Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
merge: cli/rad issue ls --output json
WillForan committed 2 months ago
commit cafb42e24412c8b1834506424ba5d9c2687cc833
parent 8e96900267cb3d5c1e0d63a6f2f669afcccaeb8a
426 files changed +25362 -13497
added .codespellrc
@@ -0,0 +1,4 @@
+
[codespell]
+
skip = .git*,*.lock,.codespellrc,target,.jj
+
check-hidden = true
+
ignore-words-list = set,noes
added .git-blame-ignore-revs
@@ -0,0 +1 @@
+
b0beef4391766d245370b852b0224d1b12efd198 # rust/clippy: 1.88 → 1.90
modified .github/workflows/build.yml
@@ -11,14 +11,19 @@ jobs:
      fail-fast: false
      matrix:
        os:
-
         - macos-latest       # arm64
-
         - ubuntu-latest-arm  # arm64
-
         - ubuntu-latest      # x64
-
         - windows-latest-arm # arm64
-
         - windows-latest     # x64
+
          - macos-latest      # arm64
+
          - ubuntu-24.04-arm  # arm64
+
          - ubuntu-latest     # x64
+
          - windows-11-arm    # arm64
+
          - windows-latest    # x64
+

    runs-on: ${{ matrix.os }}
    steps:
-
      - uses: actions/checkout@v2
+
      - uses: actions/checkout@v6
+
        with:
+
          fetch-depth: 512
+
          fetch-tags: true
+
          filter: 'tree:0'
      - uses: dtolnay/rust-toolchain@stable
      - uses: actions/cache@v4
        with:
@@ -29,4 +34,22 @@ jobs:
          path: target
          key: ${{ matrix.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock') }}-cargo-target
      - run: cargo build --workspace --verbose --all-features
-
      - run: cargo test  --workspace --verbose --all-features
+
      - if: runner.os == 'Windows'
+
        working-directory: windows
+
        run: |
+
          $env:RADICLE_VERSION = "$(.\version.ps1)"
+
          echo $env:RADICLE_VERSION
+
          dotnet build
+
      - if: runner.os == 'Windows'
+
        uses: actions/upload-artifact@v4
+
        with:
+
          name: radicle-installer-${{ runner.arch }}-${{ github.sha }}-${{ github.job }}
+
          path: windows/bin/*/*.msi
+
      - if: runner.os != 'Windows'
+
        run: cargo test  --workspace --verbose --all-features -- --nocapture
+
      - if: runner.os == 'Windows'
+
        run: cargo test  --workspace --verbose --all-features -- --nocapture
+
        env:
+
          RUST_BACKTRACE: full
+
          RUST_LOG: trace
+
          RUST_TEST_THREADS: 1
modified .radicle/ambient.yaml
@@ -16,8 +16,9 @@ plan:
      export CARGO_HOME=/workspace/deps
      export HOME=/root
      export PATH="/root/.cargo/bin:$PATH"
+
      export RUSTDOCFLAGS='-D warnings'

-
      cargo doc --workspace --no-deps
+
      cargo doc --workspace --no-deps --all-features

  # Prepare source tree for building a Debian package.
  - action: shell
modified .radicle/native.yaml
@@ -1,9 +1,11 @@
shell: |
+
  export RUSTDOCFLAGS='-D warnings'
+

  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 doc --workspace --no-deps --all-features
  cargo test --workspace --no-fail-fast
added .rustfmt.toml
@@ -0,0 +1 @@
+
edition = "2021"
added .typos.toml
@@ -0,0 +1,16 @@
+
[default]
+
extend-ignore-re = [
+
    "[0-9a-f]{7}\\.\\.\\.?[0-9a-f]{7}", # Git range between two short commit IDs
+
    "[0-9a-f]{7}\\[\\.\\.\\]", # Shortened commit IDs as written in tests
+
    "did:key:z6Mk[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{44}",
+
    "rad://z[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{28}",
+
    "rad:z[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{28}",
+
    "z6Mk[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{44}",
+
]
+

+
[default.extend-identifiers]
+
"typ" = "typ" # We may write "typ" instead of "type". The latter is a Rust keyword.
+

+
[type.codespell]
+
check-file = false
+
extend-glob = [".codespellrc"]
modified CHANGELOG.md
@@ -9,15 +9,392 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Release Highlights

+
## New Features
+

+
- The block policy for `NodeId`'s is used for limiting the namespaces fetched
+
  from other nodes. It is now also extended to block connections to the blocked
+
  `NodeId`.
+
- The set of references returned by `references_of` were restricted to `heads`,
+
  `tags`, `notes`, `rad`, and `cobs`. The restriction is lifted, and the only
+
  references filtered out are `refs/tmp/heads` – used by `radicle-remote-helper`
+
  to create temporary patches.
+
- The `rad id` command will provide a better error message when a non-delegate
+
  attempts to modify the identity document.
+

+
## Fixed Bugs
+

+
- When preparing commands to execute, the `shlex` crate was used on all platforms.
+
  The semantics on Windows are different (e.g. '\' is a path separator on Windows
+
  but marks an escape sequence on Unix-like systems), which lead to issues when
+
  attempting to execute child processes.
+
  This is fixed by using `winsplit` on Windows instead.
+
- On Windows, zombie `git-upload-pack` processes are now prevented by using the
+
  "Job" API of the operating system to group child processes and their children.
+
- On Windows, signal handling was not supported. The `radicle-node` executable
+
  will now respect signal handling on Windows.
+
- Commands sent to the `Service` would never respond when it encountered errors.
+
  This would result in timeouts when commands are run from the `rad` CLI.
+
  The `Service` has now learned to return results when an error occurs which
+
  will be reported back to the user.
+
- Parsing addresses involving an IPv6 host failed if they were enclosed in
+
  square brackets, e.g. in `rad node connect z6Mk...@[::1]:8776`.
+
  Also, ambiguous addresses would parse, e.g. `::1:8776` would be
+
  indistinguishable from `[::1]:8776`. Since a port number is always required
+
  along a host when providing an address, IPv6 addresses now always require
+
  brackets to avoid confusion.
+

+
## Deprecations
+

+
- The `rad fork` command was confusing, and mislead users as to what its purpose
+
  was. Many believed it to create a hard-fork of the repository. Instead, it
+
  pushed the default branch to the local user's namespace. The command is now
+
  deprecated, and the user should use `git push` instead.
+

+
## Breaking Changes
+

+
- The `Connected` state of a peer no longer contains fetching information. This
+
  information was returned when requesting for `Seeds` on the control socket.
+
  Callers should no longer expect the `fetching` inside that JSON result.
+
- The `rad debug` information for ongoing fetches contained the number of
+
  subscribers awaiting for results, this was removed.
+
- The `TypeName` strings defined in `radicle-cob` are restricted to reflect the
+
  size limits on domain names as specified in
+
  [RFC-1035](https://www.rfc-editor.org/rfc/rfc1035#section-2.3.4).
+

+
## 1.6.1
+

+
## Fixed Bugs
+

+
### Improve Logging
+

+
The introduction of new logs in `radicle-node` caused too many log lines to be
+
output. All logs were evaluated and adjusted to a more suitable log level.
+

+
### Improve `Service::fetch_missing_repositories`
+

+
If the check for `storage.contains` failed with an error, the whole process of
+
fetching missing repositories would fail. Instead, the error is logged and it
+
continues to gather repositories to fetch.
+

+
### Surface Underlying I/O Error for `radicle-fetch`
+

+
When an I/O error would occur within the `gix-transport` crate, the underlying
+
error would become opaque. This makes it hard to debug the issue when it occurs.
+
Instead, surface the I/O error so that it can be inspected.
+

+
## 1.6.0
+

+
## Release Highlights
+

+
### Migrating `radicle-node` to `mio`
+

+
The crates [`netservices`], [`io-reactor`], and [`popol`] were crucially valuable
+
for implementing `radicle-node`. However, they are not ideal dependencies for
+
ensuring long-term health of the network I/O layer:
+

+
- [`popol`] is only intended to support Unix-like platforms, and support on other
+
  platforms, like Windows, is desired.
+
- Even though [`io-reactor`] defines the trait [`reactor::poller::Poll`] to
+
  potentially support multiple I/O polling mechanisms, there is only one single
+
  implementation wrapping [`popol`]. Issues for other polling crates are open
+
  since 2023 without tangible progress: [#10 for `mio`], [#9 for `polling`],
+
  [#8 for `epoll`]. This suggests that it is not a high priority for the maintainers
+
  to integrate with other polling abstractions which might offer better
+
  cross-platform compatibility when compared to `popol`.
+
- The trait [`reactor::poller::Poll`] can only be implemented for file
+
  descriptors which also implement [`std::os::fd::raw::AsRawFd`], which is only
+
  implemented on Unix-like platforms and WASI. It is believed that this is
+
  leaked from `popol` as the only known implementation of the trait wraps it.
+
- To benefit from network effects, it would be nice to see others maintaining crates
+
  that depend on `io-reactor`. However, according to crates.io, the
+
  [only dependent is `radicle-node`] (via `netservices`). Contrary to that,
+
  at the time of writing, `mio` has 494 dependents according to
+
  [crates.io][mio reverse dependencies], and, notably, `tokio`, which has
+
  30628 dependents on [crates.io][tokio reverse dependencies], is dependent on
+
  [`mio`]. We therefore think that even if `mio` is obsoleted, e.g. by [`a10`]
+
  (which is based on [`io_uring`] on Linux and could potentially build on top of
+
  [I/O rings on Windows]) the people behind a large network of dependent projects
+
  are expected to come up with new ideas and solutions, that Radicle would then
+
  benefit from.
+
- One downside of using `mio` is that it forces the use of [`mio::Token`] to
+
  identify sources (while a type that is `Eq + Clone` might be enough). Another
+
  downside is that it forces the use of the types in [`mio::net`] for sockets,
+
  which need to be converted to/from [`std::net`] if required. These
+
  distinctions are also [noted by cloudhead]. This is acceptable to the team, in
+
  order to leverage the benefits of a well-tested and cross-platform network I/O
+
  layer.
+

+
[`netservices`]: https://crates.io/crates/netservices
+
[`io-reactor`]: https://crates.io/crates/io-reactor
+
[`popol`]: https://crates.io/crates/popol
+
[`reactor::poller::Poll`]: https://docs.rs/io-reactor/0.5.2/reactor/poller/trait.Poll.html
+
[#10 for `mio`]: https://github.com/rust-amplify/io-reactor/issues/10
+
[#9 for `polling`]: https://github.com/rust-amplify/io-reactor/issues/9
+
[#8 for `epoll`]: https://github.com/rust-amplify/io-reactor/issues/8
+
[`std::os::fd::raw::AsRawFd`]: https://doc.rust-lang.org/nightly/std/os/fd/raw/trait.AsRawFd.html
+
[only dependent is `radicle-node`]: https://crates.io/crates/io-reactor/reverse_dependencies
+
[mio reverse dependencies]: https://crates.io/crates/mio/reverse_dependencies
+
[tokio reverse dependencies]: https://crates.io/crates/tokio/reverse_dependencies
+
[`a10`]: https://crates.io/crates/a10
+
[`io_uring`]: https://en.wikipedia.org/wiki/Io_uring
+
[I/O rings on Windows]: https://learn.microsoft.com/en-us/windows/win32/api/ioringapi/
+
[`mio::Token`]: https://docs.rs/mio/1.0.4/mio/struct.Token.html
+
[`mio::net`]: https://docs.rs/mio/1.0.4/mio/net/index.html
+
[`std::net`]: https://doc.rust-lang.org/stable/std/net/index.html
+
[noted by cloudhead]: https://cloudhead.io/popol/
+

+
### Building `radicle-node` on Windows
+

+
The efforts to migrate `radicle-node` to use `mio`, alongside changes that fixed
+
path canonicalization and supporting Windows pipes, have allowed developers to
+
build `radicle-node` on Windows.
+

+
We encourage users to try out Radicle on Windows by building from source. At the
+
time of writing, there may be undiscovered issues, since this is a nascent time
+
for `radicle-node` on Windows. Please report any issues you see via `rad issue`
+
or on our [Zulip](https://radicle.zulipchat.com).
+

+
### Rust MSRV Update to 1.85
+

+
For those who are developing on top of the `heartwood` crates, it is important
+
to note that the Minimum Supported Rust Version (MSRV) is now 1.85.
+

+
## New Features
+

+
### Argument Parsing via `clap`
+

+
`rad` now uses the `clap` crate for parsing its command-line arguments. This
+
brings a brand new look to the help output for the `rad` CLI, and ensures that
+
we do not miss documenting options when they are added. Note that this does
+
affect error reporting, as they are now reported by `clap` when parsing fails.
+

+
#### Shell Completions
+

+
With the introduction of `clap`, this helped with the introduction of a command
+
`rad completion` to emit shell completions for static information.
+

+
### systemd Credentials for `radicle-node`
+

+
`radicle-node` now supports systemd Credentials (refer to
+
<https://systemd.io/CREDENTIALS> for more information) to load:
+
    1. The secret key, in addition to the commandline argument `--secret`
+
       (higher priority than the credential) and the configuration file (lower
+
       priority than the credential). The identifier of the credential is
+
       "xyz.radicle.node.secret".
+
    2. The optional passphrase for the secret key, in addition to the
+
       environment variable `RAD_PASSPHRASE` (lower priority than the
+
       credential). The identifier of the credential is
+
       "xyz.radicle.node.passphrase".
+

+
## Fixed Bugs
+

+
### Fix Bootstrapping
+

+
The IP (both IPv4 and IPv6) and the Tor onion addresses were specified for the
+
bootstrap nodes. When a new user came to using Radicle, there was a chance that
+
their setup did not support IPv6 or Tor, resulting in a failure to connect to
+
one of those addresses. The node does not know how to try a follow-up address,
+
for the moment, so we have decided to skip Tor addresses when it is not
+
configured, and removed the IP addresses in favor of the DNS names.
+

+
## 1.5.0
+

+
## Release Highlights
+

+
### Better Support for Bare Repositories
+

+
[gitrepostiory-layout]: https://git-scm.com/docs/gitrepository-layout/2.49.0
+

+
Some improvements to supporting bare repositories have been made for `rad` and
+
`git-remote-rad`. For `rad`, the `rad clone` command has learned a new flag
+
`--bare`, which clones the repository into a bare repository, as opposed to
+
having a working tree (see [gitrepository-layout]).
+

+
`git-remote-rad` (our Git remote helper), also learned to better handle bare
+
repositories, when using `git push` and `git fetch` with a `rad://` remote.
+

+
For `jj` users, this begins to unlock being able to use `jj` without co-location
+
of the Git repository. Further improvements to interoperability with `jj` are
+
in progress and will be released in future versions.
+

+
### Introducing the `patch.branch` Option
+

+
Continuing on the theme of making `jj` users happy, `git-remote-rad` can now
+
handle the option `-o patch.branch[=<name>]`. When the option is passed without
+
a name, i.e. `-o patch.branch`, an upstream branch will be created which is
+
named after the patch being created – `patches/<PATCH ID>`. Alternatively, the
+
`<name>` value is used if supplied.
+

+
This allows you to specify if you want a tracking branch (or bookmark in `jj`)
+
for the patch. This means that you can avoid using `rad patch checkout`.
+

+
### Improved `rad patch show`
+

+
The `rad patch show` command has received some love by improving its output. The
+
`Base` of the patch is now always output, where before it was behind the
+
`--verbose` flag.
+

+
The previous output would differentiate "updates", where the original author
+
creates a new revision, and "revisions", where another author creates a
+
revision. This could be confusing since updates are also revisions. Instead, the
+
output shows a timeline of the root of the patch and each new revision, without
+
any differentiation. The revision identifiers, head commit of the revision, and
+
author are still printed as per usual.
+

+
### Structured Logging
+

+
The `radicle-node` has learned to output structure logging using the new
+
`--log-logger structured` and `--log-format json` option pairs. If they are not
+
specified, then the logging will remain the same as per usual.
+

+
### Deprecations in `rad`
+

+
It is important to note that we are now emitting deprecation and obsoletion
+
warnings for several `rad` commands and options.
+

+
For `rad diff`, the whole command is deprecated, and `git diff` should be used
+
instead. It is better to use the tools that already exist in this case.
+

+
The option `rad self --nid` was deprecated in favor of `rad node status --only nid`.
+
The reason for this is that we will be making efforts to separate the cryptographic
+
identity of user and node.
+
For this case, the node will – in a future version – read the location of the
+
secret key to use from configuration or arguments at runtime. This means that a
+
running node is required to report the correct Node ID – and the command cannot
+
rely on the default location, which is shared with the user.
+

+
The options `rad patch review [--patch | --delete]` are marked as obsolete,
+
since their functionality never worked as intended. Reviews are something that
+
requires more research and time to implement. These commands will likely be
+
removed before a next major release, since their lack of functionality is
+
confusing.
+

## Deprecations

+
- The option `rad self --nid` was deprecated in favor of `rad node status --only nid`
+
- `rad diff` was deprecated in favor of using `git diff`
+
- `rad patch review --patch` and `rad patch review --delete` are made obsolete.
+
  This functionality never worked as intended, and may be removed before the
+
  next major release.
+
- The option `radicle-node --log` was deprecated in favor of
+
  `radicle-node --log-level` to be in line with `--log-logger` and `--log-format`.
+

## New Features

-
- `rad cob log` now supports the arguments `--from` and `--to` which can be used
-
  to range over particular operations on a COB.
+
- `rad clone` now supports the flag `--bare` which works analogously to
+
  `git clone --bare`.
+
- `rad patch show` now has improved output. It does not distinguish between the
+
  original author's updates and other updated, each update is marked as
+
  `Revision`, and the general output is cleaned up. It also shows `Base` by
+
  default without the `--verbose` flag.
+
- `rad init --setup-signing` now works on bare repositories.
+
- `git-remote-rad` now correctly reports the default branch to Git by listing
+
  the symbolic reference `HEAD`.
+
- `rad status` learned a new option `--only nid` for printing the Node ID.
+
- The remote helper has learned a new server option `patch.branch[=<name>]`.
+
  This will create an upstream branch when creating the branch. This upstream
+
  can then be used for updating the patch, post creation.
+
- `radicle-node` has learned `--log-logger structured` and `--log-format json`
+
  options. The node will output its logs in a structured, JSON format when
+
  specified.
+

+
## Fixed Bugs
+

+
- The `rad` CLI now uses [indicatif](https://crates.io/crates/indicatif) for
+
  emitting progress spinners. This fixes an issue when the terminal size was
+
  too small for the spinner line. It also fixes when there is a user interrupt,
+
  the cursor would disappear.
+
- The remote helper will no longer attempt to verify Git hooks twice, when
+
  performing a `git push`.
+
- The default Git remote options, when using `rad remote`, now set `pruneTags`
+
  to prevent canonical tags from being pruned from the working copy of the
+
  repository's `refs/tags`.
+
- `rad init --setup-signing` now works in combination with `--existing`.
+

+
## 1.4.0
+

+
## Release Highlights
+

+
### systemd service hardening
+

+
Running `radicle-node` as systemd service using our service files, will now run the service with some hardening options enabled.
+
This work includes some trivial sandboxing options in the provided service files and lead users to `systemd-analyze security`.
+

+
While being a trivial change and far from a secure service it is an improvement and may push downstream packagers and / or users to add even a bit of sandboxing.
+

+
### Path to Windows
+

+
We continued working on Windows support for Radicle and made some progress on the node implementation.
+
As `std::os::unix` is obviously not available on Windows, we resorted to using the `winpipe` crate.
+
This crate implements a very similar API to `std::os::unix` but for named pipes.
+
The node has learned how to use named pipes when for the control socket when on Windows architecture.
+

+
### Bootstrapping Improvements
+

+
When you start a fresh node, it'll need to have at least one seed that it can bootstrap from.
+
We do this by using `iris.radicle.xyz` and `rosa.radicle.xyz` as bootstrap nodes.
+
With this release, a node can now connect to them when DNS is not available or a connection via Tor is desired.
+

+
### Improvements to `rad cob log`
+

+
The rad cob log command learned two new options, `--from` and `--to`.
+
These take a commit SHA that correspond to a COB operation,
+
and allows you to limit the log to start from or end the log at those operations, respectively.
+

+
### Improvements to rad sync
+

+
We now use a more suitable symbol in rad sync status for the status:
+

+
✗ Hint:
+
   ? … Status:
+
       ✓ … in sync          ✗ … out of sync
+
       ! … not announced    • … unknown
+

+
This aligns closer with the rad node status output. As well as this,
+
the Tip column was renamed to SigRefs, since the term Tip was too ambiguous.
+

+
The internal logic of rad sync --announce was improved by writing more tests and finding edge cases to fix.
+
Included in these improvements is changing the target behavior.
+
Before, the announcements would attempt to reach the preferred seeds target and the replication factor.
+
Now, it tries to reach the preferred seeds and falls back to the replication factor.
+

+
### Human Oriented Panics
+

+
The `rad` CLI now prints a more human-friendly message when it encounters a panic.
+

+
### Notable Crate Changes
+

+
- Introduce a new module that provides an API for iterating over a COB's operations, given a range of commits
+
- Remove `anyhow` from `radicle-term` and `radicle-node`
+
- BREAKING: Removed `radicle::node::DEFAULT_SOCKET_NAME`, use `radicle::profile::Home::socket` instead
+
- BREAKING: Add a node event for canonical reference updates
+

+
## Fixed Bugs
+

+
- Fix panic when reading from SQLite database fails
+

+
## 1.3.1 - 2025-09-04

## Fixed Bugs

+
### Fixed Panics
+

+
Two instances of panics were fixed in this release.
+

+
The first, and most important, was a panic around serializing wire messages.
+
There is a strict size limit on the protocol messages that we control. However,
+
this size limit is not intended to be imposed on Git streams, for example during
+
fetching from other nodes. We incorrectly placed a check for this size limit in
+
the `serialize` function, which meant it would panic for some Git fetches. This
+
was fixed by moving the check elsewhere, while also improving the code so we do
+
not make that mistake again.
+

+
The second involved using the `read` method from the `sqlite` crate. This method
+
calls `try_read` and `unwrap`s the `Result`, which would cause a panic. We have
+
replaced the calls to `read` with `try_read` to more gracefully handle the
+
error.
+

+

## 1.3.0 - 2025-08-12

## Release Highlights
modified Cargo.lock
@@ -4,9 +4,9 @@ version = 4

[[package]]
name = "addr2line"
-
version = "0.24.2"
+
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
+
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
 "gimli",
]
@@ -136,23 +136,24 @@ dependencies = [

[[package]]
name = "anstream"
-
version = "0.6.13"
+
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb"
+
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
 "anstyle",
 "anstyle-parse",
 "anstyle-query",
 "anstyle-wincon",
 "colorchoice",
+
 "is_terminal_polyfill",
 "utf8parse",
]

[[package]]
name = "anstyle"
-
version = "1.0.6"
+
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc"
+
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"

[[package]]
name = "anstyle-parse"
@@ -174,12 +175,13 @@ dependencies = [

[[package]]
name = "anstyle-wincon"
-
version = "3.0.2"
+
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7"
+
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
 "anstyle",
-
 "windows-sys 0.52.0",
+
 "once_cell_polyfill",
+
 "windows-sys 0.60.2",
]

[[package]]
@@ -190,9 +192,12 @@ checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519"

[[package]]
name = "arc-swap"
-
version = "1.7.1"
+
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
+
checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5"
+
dependencies = [
+
 "rustversion",
+
]

[[package]]
name = "ascii"
@@ -208,9 +213,9 @@ checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80"

[[package]]
name = "backtrace"
-
version = "0.3.75"
+
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
+
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
 "addr2line",
 "cfg-if",
@@ -218,7 +223,7 @@ dependencies = [
 "miniz_oxide",
 "object",
 "rustc-demangle",
-
 "windows-targets 0.52.6",
+
 "windows-link 0.2.1",
]

[[package]]
@@ -241,12 +246,6 @@ checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"

[[package]]
name = "base64"
-
version = "0.13.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
-

-
[[package]]
-
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
@@ -346,12 +345,12 @@ checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32"

[[package]]
name = "bstr"
-
version = "1.9.1"
+
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706"
+
checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4"
dependencies = [
 "memchr",
-
 "regex-automata 0.4.9",
+
 "regex-automata",
 "serde",
]

@@ -375,9 +374,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"

[[package]]
name = "bytes"
-
version = "1.10.1"
+
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
+
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"

[[package]]
name = "bytesize"
@@ -462,6 +461,55 @@ dependencies = [
]

[[package]]
+
name = "clap"
+
version = "4.5.44"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1c1f056bae57e3e54c3375c41ff79619ddd13460a17d7438712bd0d83fda4ff8"
+
dependencies = [
+
 "clap_builder",
+
 "clap_derive",
+
]
+

+
[[package]]
+
name = "clap_builder"
+
version = "4.5.44"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8"
+
dependencies = [
+
 "anstream",
+
 "anstyle",
+
 "clap_lex",
+
 "strsim",
+
]
+

+
[[package]]
+
name = "clap_complete"
+
version = "4.5.60"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8e602857739c5a4291dfa33b5a298aeac9006185229a700e5810a3ef7272d971"
+
dependencies = [
+
 "clap",
+
]
+

+
[[package]]
+
name = "clap_derive"
+
version = "4.5.41"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491"
+
dependencies = [
+
 "heck",
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.106",
+
]
+

+
[[package]]
+
name = "clap_lex"
+
version = "0.7.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
+

+
[[package]]
name = "colorchoice"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -478,6 +526,19 @@ dependencies = [
]

[[package]]
+
name = "console"
+
version = "0.16.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2e09ced7ebbccb63b4c65413d821f2e00ce54c5ca4514ddc6b3c892fdbcbc69d"
+
dependencies = [
+
 "encode_unicode",
+
 "libc",
+
 "once_cell",
+
 "unicode-width 0.2.1",
+
 "windows-sys 0.60.2",
+
]
+

+
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -509,9 +570,9 @@ dependencies = [

[[package]]
name = "crc32fast"
-
version = "1.4.0"
+
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa"
+
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
 "cfg-if",
]
@@ -559,7 +620,7 @@ dependencies = [
 "document-features",
 "mio 1.0.4",
 "parking_lot",
-
 "rustix 1.0.7",
+
 "rustix 1.1.3",
 "signal-hook",
 "signal-hook-mio",
 "winapi",
@@ -581,7 +642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
 "generic-array",
-
 "rand_core",
+
 "rand_core 0.6.4",
 "subtle",
 "zeroize",
]
@@ -593,7 +654,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
 "generic-array",
-
 "rand_core",
+
 "rand_core 0.6.4",
 "typenum",
]

@@ -621,6 +682,7 @@ dependencies = [
 "amplify",
 "base32",
 "cyphergraphy",
+
 "serde",
 "sha3",
]

@@ -632,7 +694,6 @@ checksum = "b67c16c8ef5ddcdab57aab83fd8e770540ea3682ccdae09642c63575b0da2184"
dependencies = [
 "amplify",
 "ec25519",
-
 "multibase",
 "sha2",
]

@@ -691,15 +752,6 @@ dependencies = [
]

[[package]]
-
name = "deranged"
-
version = "0.4.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
-
dependencies = [
-
 "powerfmt",
-
]
-

-
[[package]]
name = "derive_more"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -717,7 +769,7 @@ dependencies = [
 "convert_case",
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
]

[[package]]
@@ -746,7 +798,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
]

[[package]]
@@ -823,7 +875,7 @@ dependencies = [
 "generic-array",
 "group",
 "pkcs8",
-
 "rand_core",
+
 "rand_core 0.6.4",
 "sec1",
 "subtle",
 "zeroize",
@@ -848,6 +900,12 @@ dependencies = [
]

[[package]]
+
name = "encode_unicode"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+

+
[[package]]
name = "env_filter"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -891,7 +949,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
 "libc",
-
 "windows-sys 0.59.0",
+
 "windows-sys 0.60.2",
]

[[package]]
@@ -913,8 +971,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
dependencies = [
 "bit-set",
-
 "regex-automata 0.4.9",
-
 "regex-syntax 0.8.5",
+
 "regex-automata",
+
 "regex-syntax",
]

[[package]]
@@ -925,18 +983,19 @@ checksum = "3afcf4effa2c44390b9912544582d5af29e10dc4c816c5dbebf748e1c7416faa"

[[package]]
name = "faster-hex"
-
version = "0.9.0"
+
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a2a2b11eda1d40935b26cf18f6833c526845ae8c41e58d09af6adeb6f0269183"
+
checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73"
dependencies = [
+
 "heapless",
 "serde",
]

[[package]]
name = "fastrand"
-
version = "2.1.0"
+
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
+
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"

[[package]]
name = "ff"
@@ -944,7 +1003,7 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449"
dependencies = [
-
 "rand_core",
+
 "rand_core 0.6.4",
 "subtle",
]

@@ -956,7 +1015,7 @@ checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd"
dependencies = [
 "cfg-if",
 "libc",
-
 "redox_syscall",
+
 "redox_syscall 0.4.1",
 "windows-sys 0.52.0",
]

@@ -982,6 +1041,12 @@ dependencies = [
]

[[package]]
+
name = "fnv"
+
version = "1.0.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+

+
[[package]]
name = "form_urlencoded"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1055,15 +1120,15 @@ dependencies = [

[[package]]
name = "gimli"
-
version = "0.31.1"
+
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
+
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"

[[package]]
name = "git-ref-format"
-
version = "0.3.1"
+
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "7428e0d6e549a9a613d6f019b839a0f5142c331295b79e119ca8f4faac145da1"
+
checksum = "ed6913a77cee9e231cab93577c9a5eea84a1344ab39294d91dc075b3c24499d0"
dependencies = [
 "git-ref-format-core",
 "git-ref-format-macro",
@@ -1071,9 +1136,9 @@ dependencies = [

[[package]]
name = "git-ref-format-core"
-
version = "0.3.1"
+
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "bbaeb9672a55e9e32cb6d3ef781e7526b25ab97d499fae71615649340b143424"
+
checksum = "1955ef3abddd586f24c834d28f959d610157ab5cb71f2200911932a0018d0364"
dependencies = [
 "bstr",
 "serde",
@@ -1082,21 +1147,21 @@ dependencies = [

[[package]]
name = "git-ref-format-macro"
-
version = "0.3.1"
+
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "3b6ca5353accc201f6324dff744ba4660099546d4daf187ba868f07562e36ca4"
+
checksum = "4e730f09c82961c28f5465b83da0aa5c2716156ce57da33a1fa51bbd560aa5f7"
dependencies = [
 "git-ref-format-core",
-
 "proc-macro-error",
+
 "proc-macro-error2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
]

[[package]]
name = "git2"
-
version = "0.19.0"
+
version = "0.20.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724"
+
checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b"
dependencies = [
 "bitflags 2.9.1",
 "libc",
@@ -1107,193 +1172,297 @@ dependencies = [

[[package]]
name = "gix-actor"
-
version = "0.33.2"
+
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "20018a1a6332e065f1fcc8305c1c932c6b8c9985edea2284b3c79dc6fa3ee4b2"
+
checksum = "b50ce5433eaa46187349e59089eea71b0397caa71991b2fa3e124120426d7d15"
dependencies = [
 "bstr",
-
 "gix-date",
+
 "gix-date 0.13.0",
 "gix-utils",
 "itoa",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
+
 "winnow",
+
]
+

+
[[package]]
+
name = "gix-actor"
+
version = "0.39.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0c44f13049925e8dc3955c20ecec5391cedfb041e0952416b583ecc57bc68325"
+
dependencies = [
+
 "bstr",
+
 "gix-date 0.14.0",
+
 "gix-error 0.1.0",
 "winnow",
]

[[package]]
name = "gix-chunk"
-
version = "0.4.11"
+
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "0b1f1d8764958699dc764e3f727cef280ff4d1bd92c107bbf8acd85b30c1bd6f"
+
checksum = "63e516efaac951ed21115b11d5514b120c26ccb493d0c0b9ea6cc10edf4fdf44"
dependencies = [
-
 "thiserror 2.0.12",
+
 "gix-error 0.0.0",
+
]
+

+
[[package]]
+
name = "gix-chunk"
+
version = "0.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d14ee09ab454481a91fe969ca5afbd41c8a9b05680197b6554ebb69bdcf7d571"
+
dependencies = [
+
 "gix-error 0.1.0",
]

[[package]]
name = "gix-command"
-
version = "0.4.1"
+
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "cb410b84d6575db45e62025a9118bdbf4d4b099ce7575a76161e898d9ca98df1"
+
checksum = "2962172c6f78731e2b7773bf762f7b8d1746a342a4c0a8914a612206e1295953"
dependencies = [
 "bstr",
 "gix-path",
+
 "gix-quote",
 "gix-trace",
 "shell-words",
]

[[package]]
name = "gix-commitgraph"
-
version = "0.25.1"
+
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a8da6591a7868fb2b6dabddea6b09988b0b05e0213f938dbaa11a03dd7a48d85"
+
checksum = "d0dda2e4d5a61d4a16a780f61f2b7e9406ad1f8da97c35c09ef501f3fdf74de0"
dependencies = [
 "bstr",
-
 "gix-chunk",
-
 "gix-features",
+
 "gix-chunk 0.5.0",
+
 "gix-error 0.0.0",
+
 "gix-hash",
+
 "memmap2",
+
]
+

+
[[package]]
+
name = "gix-commitgraph"
+
version = "0.33.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f9dc2a550978b510b4e58b0bf5a15481433c3b21981330f3898d93f25b07d9a5"
+
dependencies = [
+
 "bstr",
+
 "gix-chunk 0.6.0",
+
 "gix-error 0.1.0",
 "gix-hash",
 "memmap2",
-
 "thiserror 2.0.12",
]

[[package]]
name = "gix-config-value"
-
version = "0.14.12"
+
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "8dc2c844c4cf141884678cabef736fd91dd73068b9146e6f004ba1a0457944b6"
+
checksum = "441a300bc3645a1f45cba495b9175f90f47256ce43f2ee161da0031e3ac77c92"
dependencies = [
 "bitflags 2.9.1",
 "bstr",
 "gix-path",
 "libc",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-credentials"
-
version = "0.26.0"
+
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "82a50c56b785c29a151ab4ccf74a83fe4e21d2feda0d30549504b4baed353e0a"
+
checksum = "64b5ef8d1d86b9598df695fd61989e535dc7d139040ed9f31944bc7dcd81b713"
dependencies = [
 "bstr",
 "gix-command",
 "gix-config-value",
+
 "gix-date 0.14.0",
 "gix-path",
 "gix-prompt",
 "gix-sec",
 "gix-trace",
 "gix-url",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-date"
-
version = "0.9.4"
+
version = "0.13.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "12553b32d1da25671f31c0b084bf1e5cb6d5ef529254d04ec33cdc890bd7f687"
+
dependencies = [
+
 "bstr",
+
 "gix-error 0.0.0",
+
 "itoa",
+
 "jiff",
+
 "smallvec",
+
]
+

+
[[package]]
+
name = "gix-date"
+
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "daa30058ec7d3511fbc229e4f9e696a35abd07ec5b82e635eff864a2726217e4"
+
checksum = "e66a5117b22495fe7cb4b443777cf3f024a1b1db0009771db440fc8b38a0a6fd"
dependencies = [
 "bstr",
+
 "gix-error 0.1.0",
 "itoa",
 "jiff",
-
 "thiserror 2.0.12",
+
 "smallvec",
]

[[package]]
name = "gix-diff"
-
version = "0.49.0"
+
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a8e92566eccbca205a0a0f96ffb0327c061e85bc5c95abbcddfe177498aa04f6"
+
checksum = "26bcd367b2c5dbf6bec9ce02ca59eab179fc82cf39f15ec83549ee25c255c99f"
dependencies = [
 "bstr",
 "gix-hash",
-
 "gix-object",
-
 "thiserror 2.0.12",
+
 "gix-object 0.55.0",
+
 "thiserror 2.0.18",
+
]
+

+
[[package]]
+
name = "gix-error"
+
version = "0.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7dffc9ca4dfa4f519a3d2cf1c038919160544923577ac60f45bcb602a24d82c6"
+
dependencies = [
+
 "bstr",
+
]
+

+
[[package]]
+
name = "gix-error"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "92e37b10e97c822fc17550fc1a187283ad3ce79617e920bf179f301ee12abcbc"
+
dependencies = [
+
 "bstr",
]

[[package]]
name = "gix-features"
-
version = "0.39.1"
+
version = "0.46.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "7d85d673f2e022a340dba4713bed77ef2cf4cd737d2f3e0f159d45e0935fd81f"
+
checksum = "a83a5fe8927de3bb02b0cfb87165dbfb49f04d4c297767443f2e1011ecc15bdd"
dependencies = [
 "crc32fast",
-
 "flate2",
-
 "gix-hash",
+
 "gix-path",
 "gix-trace",
 "gix-utils",
 "libc",
 "prodash",
-
 "sha1_smol",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
 "walkdir",
+
 "zlib-rs",
]

[[package]]
name = "gix-fs"
-
version = "0.12.1"
+
version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "3b3d4fac505a621f97e5ce2c69fdc425742af00c0920363ca4074f0eb48b1db9"
+
checksum = "de4bd0d8e6c6ef03485205f8eecc0359042a866d26dba569075db1ebcc005970"
dependencies = [
+
 "bstr",
 "fastrand",
 "gix-features",
+
 "gix-path",
 "gix-utils",
+
 "thiserror 2.0.18",
+
]
+

+
[[package]]
+
name = "gix-glob"
+
version = "0.24.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b03e6cd88cc0dc1eafa1fddac0fb719e4e74b6ea58dd016e71125fde4a326bee"
+
dependencies = [
+
 "bitflags 2.9.1",
+
 "bstr",
+
 "gix-features",
+
 "gix-path",
]

[[package]]
name = "gix-hash"
-
version = "0.15.1"
+
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "0b5eccc17194ed0e67d49285e4853307e4147e95407f91c1c3e4a13ba9f4e4ce"
+
checksum = "d8ced05d2d7b13bff08b2f7eb4e47cfeaf00b974c2ddce08377c4fe1f706b3eb"
dependencies = [
 "faster-hex",
-
 "thiserror 2.0.12",
+
 "gix-features",
+
 "sha1-checked",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-hashtable"
-
version = "0.6.0"
+
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "0ef65b256631078ef733bc5530c4e6b1c2e7d5c2830b75d4e9034ab3997d18fe"
+
checksum = "52f1eecdd006390cbed81f105417dbf82a6fe40842022006550f2e32484101da"
dependencies = [
 "gix-hash",
-
 "hashbrown",
+
 "hashbrown 0.16.1",
 "parking_lot",
]

[[package]]
name = "gix-lock"
-
version = "15.0.1"
+
version = "21.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "1cd3ab68a452db63d9f3ebdacb10f30dba1fa0d31ac64f4203d395ed1102d940"
+
checksum = "cbe09cf05ba7c679bba189acc29eeea137f643e7fff1b5dff879dfd45248be31"
dependencies = [
 "gix-tempfile",
 "gix-utils",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-negotiate"
-
version = "0.17.0"
+
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d27f830a16405386e9c83b9d5be8261fe32bbd6b3caf15bd1b284c6b2b7ef1a8"
+
checksum = "68d01c3303ed336b72eb3a48543c028be4650279ff3c3b561f13fa9d2b1979e7"
dependencies = [
 "bitflags 2.9.1",
-
 "gix-commitgraph",
-
 "gix-date",
+
 "gix-commitgraph 0.33.0",
+
 "gix-date 0.14.0",
 "gix-hash",
-
 "gix-object",
-
 "gix-revwalk",
+
 "gix-object 0.56.0",
+
 "gix-revwalk 0.27.0",
 "smallvec",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-object"
-
version = "0.46.1"
+
version = "0.55.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4d3f705c977d90ace597049252ae1d7fec907edc0fa7616cc91bf5508d0f4006"
+
dependencies = [
+
 "bstr",
+
 "gix-actor 0.38.0",
+
 "gix-date 0.13.0",
+
 "gix-features",
+
 "gix-hash",
+
 "gix-hashtable",
+
 "gix-path",
+
 "gix-utils",
+
 "gix-validate",
+
 "itoa",
+
 "smallvec",
+
 "thiserror 2.0.18",
+
 "winnow",
+
]
+

+
[[package]]
+
name = "gix-object"
+
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "e42d58010183ef033f31088479b4eb92b44fe341b35b62d39eb8b185573d77ea"
+
checksum = "227e12fad42022ff08d6fbcc4bae34d6e2aa180576e9e1106d9a29a06798e750"
dependencies = [
 "bstr",
-
 "gix-actor",
-
 "gix-date",
+
 "gix-actor 0.39.0",
+
 "gix-date 0.14.0",
 "gix-features",
 "gix-hash",
 "gix-hashtable",
@@ -1302,240 +1471,257 @@ dependencies = [
 "gix-validate",
 "itoa",
 "smallvec",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
 "winnow",
]

[[package]]
name = "gix-odb"
-
version = "0.66.0"
+
version = "0.75.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "cb780eceb3372ee204469478de02eaa34f6ba98247df0186337e0333de97d0ae"
+
checksum = "1d59882d2fdab5e609b0c452a6ef9a3bd12ef6b694be4f82ab8f126ad0969864"
dependencies = [
 "arc-swap",
-
 "gix-date",
 "gix-features",
 "gix-fs",
 "gix-hash",
 "gix-hashtable",
-
 "gix-object",
+
 "gix-object 0.55.0",
 "gix-pack",
 "gix-path",
 "gix-quote",
 "parking_lot",
 "tempfile",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-pack"
-
version = "0.56.0"
+
version = "0.65.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "4158928929be29cae7ab97afc8e820a932071a7f39d8ba388eed2380c12c566c"
+
checksum = "8c44db57ebbbeaad9972c2a60662142660427a1f0a7529314d53fefb4fedad24"
dependencies = [
-
 "gix-chunk",
+
 "gix-chunk 0.5.0",
 "gix-diff",
+
 "gix-error 0.0.0",
 "gix-features",
 "gix-hash",
 "gix-hashtable",
-
 "gix-object",
+
 "gix-object 0.55.0",
 "gix-path",
 "gix-tempfile",
 "gix-traverse",
 "memmap2",
 "parking_lot",
 "smallvec",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-packetline"
-
version = "0.18.4"
+
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "123844a70cf4d5352441dc06bab0da8aef61be94ec239cb631e0ba01dc6d3a04"
+
checksum = "25429ee1ef792d9b653ee5de09bb525489fc8e6908334cfd5d5824269f0b7073"
dependencies = [
 "bstr",
 "faster-hex",
 "gix-trace",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-path"
-
version = "0.10.15"
+
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "f910668e2f6b2a55ff35a1f04df88a1a049f7b868507f4cbeeaa220eaba7be87"
+
checksum = "7163b1633d35846a52ef8093f390cec240e2d55da99b60151883035e5169cd85"
dependencies = [
 "bstr",
 "gix-trace",
-
 "home",
-
 "once_cell",
-
 "thiserror 2.0.12",
+
 "gix-validate",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-prompt"
-
version = "0.9.1"
+
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "79f2185958e1512b989a007509df8d61dca014aa759a22bee80cfa6c594c3b6d"
+
checksum = "4806f1ebf969cd54d178ccd975911ef1829aeccea0b27630e63c9d26c8347d7f"
dependencies = [
 "gix-command",
 "gix-config-value",
 "parking_lot",
-
 "rustix 0.38.34",
-
 "thiserror 2.0.12",
+
 "rustix 1.1.3",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-protocol"
-
version = "0.47.0"
+
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "c84642e8b6fed7035ce9cc449593019c55b0ec1af7a5dce1ab8a0636eaaeb067"
+
checksum = "a13680c03e847d8f32a59cf1511dd05334d429da33f5af08891367680f15cbca"
dependencies = [
 "bstr",
 "gix-credentials",
-
 "gix-date",
+
 "gix-date 0.14.0",
 "gix-features",
 "gix-hash",
 "gix-lock",
 "gix-negotiate",
-
 "gix-object",
+
 "gix-object 0.56.0",
 "gix-ref",
 "gix-refspec",
-
 "gix-revwalk",
+
 "gix-revwalk 0.27.0",
 "gix-shallow",
 "gix-trace",
 "gix-transport",
 "gix-utils",
 "maybe-async",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
 "winnow",
]

[[package]]
name = "gix-quote"
-
version = "0.4.15"
+
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "e49357fccdb0c85c0d3a3292a9f6db32d9b3535959b5471bb9624908f4a066c6"
+
checksum = "96fc2ff2ec8cc0c92807f02eab1f00eb02619fc2810d13dc42679492fcc36757"
dependencies = [
 "bstr",
 "gix-utils",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-ref"
-
version = "0.49.1"
+
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a91b61776c839d0f1b7114901179afb0947aa7f4d30793ca1c56d335dfef485f"
+
checksum = "c92fd2e86d65efe972a5226f303ed72cfb49a8ad03740e5cc2b27c07970a5d78"
dependencies = [
-
 "gix-actor",
+
 "gix-actor 0.39.0",
 "gix-features",
 "gix-fs",
 "gix-hash",
 "gix-lock",
-
 "gix-object",
+
 "gix-object 0.56.0",
 "gix-path",
 "gix-tempfile",
 "gix-utils",
 "gix-validate",
 "memmap2",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
 "winnow",
]

[[package]]
name = "gix-refspec"
-
version = "0.27.0"
+
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "00c056bb747868c7eb0aeb352c9f9181ab8ca3d0a2550f16470803500c6c413d"
+
checksum = "40adba15f8099159d37d0a21e1cfb6602c2e49b6c05f6f8a57a47d0fb21611af"
dependencies = [
 "bstr",
+
 "gix-error 0.1.0",
+
 "gix-glob",
 "gix-hash",
 "gix-revision",
 "gix-validate",
 "smallvec",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-revision"
-
version = "0.31.1"
+
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "61e1ddc474405a68d2ce8485705dd72fe6ce959f2f5fe718601ead5da2c8f9e7"
+
checksum = "a75ef94c9d76de0765e429ae22a01c512c0d50459269195a90ea11099a5fc752"
dependencies = [
 "bstr",
-
 "gix-commitgraph",
-
 "gix-date",
+
 "gix-commitgraph 0.33.0",
+
 "gix-date 0.14.0",
+
 "gix-error 0.1.0",
 "gix-hash",
-
 "gix-object",
-
 "gix-revwalk",
-
 "thiserror 2.0.12",
+
 "gix-object 0.56.0",
+
 "gix-revwalk 0.27.0",
]

[[package]]
name = "gix-revwalk"
-
version = "0.17.0"
+
version = "0.26.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "194a50b30aa0c6e6de43c723359c5809a96275a3aa92d323ef7f58b1cdd60f16"
+
dependencies = [
+
 "gix-commitgraph 0.32.0",
+
 "gix-date 0.13.0",
+
 "gix-error 0.0.0",
+
 "gix-hash",
+
 "gix-hashtable",
+
 "gix-object 0.55.0",
+
 "smallvec",
+
 "thiserror 2.0.18",
+
]
+

+
[[package]]
+
name = "gix-revwalk"
+
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "510026fc32f456f8f067d8f37c34088b97a36b2229d88a6a5023ef179fcb109d"
+
checksum = "07efcad1d064abdac3e79dfc6e23e05accb579559d015e944c9dcf64be95eb49"
dependencies = [
-
 "gix-commitgraph",
-
 "gix-date",
+
 "gix-commitgraph 0.33.0",
+
 "gix-date 0.14.0",
+
 "gix-error 0.1.0",
 "gix-hash",
 "gix-hashtable",
-
 "gix-object",
+
 "gix-object 0.56.0",
 "smallvec",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-sec"
-
version = "0.10.12"
+
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "47aeb0f13de9ef2f3033f5ff218de30f44db827ac9f1286f9ef050aacddd5888"
+
checksum = "e014df75f3d7f5c98b18b45c202422da6236a1c0c0a50997c3f41e601f3ad511"
dependencies = [
 "bitflags 2.9.1",
 "gix-path",
 "libc",
-
 "windows-sys 0.52.0",
+
 "windows-sys 0.61.2",
]

[[package]]
name = "gix-shallow"
-
version = "0.1.0"
+
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "88d2673242e87492cb6ff671f0c01f689061ca306c4020f137197f3abc84ce01"
+
checksum = "189386b5da5285216cc0ede89eff5a943d5261fc794241ee6ec5360b77df15ad"
dependencies = [
 "bstr",
 "gix-hash",
 "gix-lock",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-tempfile"
-
version = "15.0.0"
+
version = "21.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "2feb86ef094cc77a4a9a5afbfe5de626897351bbbd0de3cb9314baf3049adb82"
+
checksum = "9d9ab2c89fe4bfd4f1d8700aa4516534c170d8a21ae2c554167374607c2eaf16"
dependencies = [
 "gix-fs",
 "libc",
-
 "once_cell",
 "parking_lot",
 "tempfile",
]

[[package]]
name = "gix-trace"
-
version = "0.1.12"
+
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "7c396a2036920c69695f760a65e7f2677267ccf483f25046977d87e4cb2665f7"
+
checksum = "f69a13643b8437d4ca6845e08143e847a36ca82903eed13303475d0ae8b162e0"

[[package]]
name = "gix-transport"
-
version = "0.44.0"
+
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "dd04d91e507a8713cfa2318d5a85d75b36e53a40379cc7eb7634ce400ecacbaf"
+
checksum = "9561a98f4f1cada03b565121c475c95d060998082bdb1277044fa6e11fe2aa17"
dependencies = [
 "bstr",
 "gix-command",
@@ -1544,45 +1730,43 @@ dependencies = [
 "gix-quote",
 "gix-sec",
 "gix-url",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-traverse"
-
version = "0.43.1"
+
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "6ed47d648619e23e93f971d2bba0d10c1100e54ef95d2981d609907a8cabac89"
+
checksum = "37f8b53b4c56b01c43a4491c4edfe2ce66c654eb86232205172ceb1650d21c55"
dependencies = [
 "bitflags 2.9.1",
-
 "gix-commitgraph",
-
 "gix-date",
+
 "gix-commitgraph 0.32.0",
+
 "gix-date 0.13.0",
 "gix-hash",
 "gix-hashtable",
-
 "gix-object",
-
 "gix-revwalk",
+
 "gix-object 0.55.0",
+
 "gix-revwalk 0.26.0",
 "smallvec",
-
 "thiserror 2.0.12",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-url"
-
version = "0.28.2"
+
version = "0.35.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d096fb733ba6bd3f5403dba8bd72bdd8809fe2b347b57844040b8f49c93492d9"
+
checksum = "507752d41afcdf5961ab494eb062c3bf21f68b2ee67e45568e9028cccdd00c34"
dependencies = [
 "bstr",
-
 "gix-features",
 "gix-path",
 "percent-encoding",
-
 "thiserror 2.0.12",
-
 "url",
+
 "thiserror 2.0.18",
]

[[package]]
name = "gix-utils"
-
version = "0.1.14"
+
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "ff08f24e03ac8916c478c8419d7d3c33393da9bb41fa4c24455d5406aeefd35f"
+
checksum = "befcdbdfb1238d2854591f760a48711bed85e72d80a10e8f2f93f656746ef7c5"
dependencies = [
 "fastrand",
 "unicode-normalization",
@@ -1590,12 +1774,11 @@ dependencies = [

[[package]]
name = "gix-validate"
-
version = "0.9.4"
+
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "34b5f1253109da6c79ed7cf6e1e38437080bb6d704c76af14c93e2f255234084"
+
checksum = "0ec1eff98d91941f47766367cba1be746bab662bad761d9891ae6f7882f7840b"
dependencies = [
 "bstr",
-
 "thiserror 2.0.12",
]

[[package]]
@@ -1605,46 +1788,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
 "ff",
-
 "rand_core",
+
 "rand_core 0.6.4",
 "subtle",
]

[[package]]
+
name = "hash32"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
+
dependencies = [
+
 "byteorder",
+
]
+

+
[[package]]
name = "hashbrown"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"

[[package]]
-
name = "hmac"
-
version = "0.12.1"
+
name = "hashbrown"
+
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+

+
[[package]]
+
name = "heapless"
+
version = "0.8.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
dependencies = [
-
 "digest",
+
 "hash32",
+
 "stable_deref_trait",
]

[[package]]
-
name = "home"
-
version = "0.5.9"
+
name = "heck"
+
version = "0.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+

+
[[package]]
+
name = "hmac"
+
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5"
+
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
-
 "windows-sys 0.52.0",
+
 "digest",
]

[[package]]
name = "human-panic"
-
version = "2.0.3"
+
version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "ac63a746b187e95d51fe16850eb04d1cfef203f6af98e6c405a6f262ad3df00a"
+
checksum = "075e8747af11abcff07d55d98297c9c6c70eb5d6365b25e7b12f02e484935191"
dependencies = [
 "anstream",
 "anstyle",
 "backtrace",
-
 "os_info",
 "serde",
 "serde_derive",
+
 "sysinfo",
 "toml",
 "uuid",
]
@@ -1787,7 +1992,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
]

[[package]]
@@ -1818,11 +2023,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
dependencies = [
 "equivalent",
-
 "hashbrown",
+
 "hashbrown 0.14.3",
 "serde",
]

[[package]]
+
name = "indicatif"
+
version = "0.18.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "70a646d946d06bedbbc4cac4c218acf4bbf2d87757a784857025f4d447e4e1cd"
+
dependencies = [
+
 "console",
+
 "portable-atomic",
+
 "unicode-width 0.2.1",
+
 "unit-prefix",
+
 "web-time",
+
]
+

+
[[package]]
name = "inout"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1846,39 +2064,65 @@ dependencies = [
 "once_cell",
 "tempfile",
 "unicode-segmentation",
-
 "unicode-width",
+
 "unicode-width 0.1.11",
]

[[package]]
-
name = "io-reactor"
-
version = "0.5.2"
+
name = "io-uring"
+
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "77d78c3e630f04a61ec86ba171c0bbd161434a7f2e8e4a67728320d4ce7c6c79"
+
checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
dependencies = [
-
 "amplify",
-
 "crossbeam-channel",
+
 "bitflags 2.9.1",
+
 "cfg-if",
 "libc",
-
 "popol",
]

[[package]]
-
name = "itoa"
-
version = "1.0.11"
+
name = "is_terminal_polyfill"
+
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
+
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"

[[package]]
-
name = "jiff"
-
version = "0.2.1"
+
name = "itertools"
+
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "3590fea8e9e22d449600c9bbd481a8163bef223e4ff938e5f55899f8cf1adb93"
+
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
-
 "jiff-tzdb-platform",
-
 "log",
-
 "portable-atomic",
+
 "either",
+
]
+

+
[[package]]
+
name = "itoa"
+
version = "1.0.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+

+
[[package]]
+
name = "jiff"
+
version = "0.2.20"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543"
+
dependencies = [
+
 "jiff-static",
+
 "jiff-tzdb-platform",
+
 "log",
+
 "portable-atomic",
 "portable-atomic-util",
-
 "serde",
-
 "windows-sys 0.59.0",
+
 "serde_core",
+
 "windows-sys 0.60.2",
+
]
+

+
[[package]]
+
name = "jiff-static"
+
version = "0.2.20"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.106",
]

[[package]]
@@ -1935,7 +2179,7 @@ dependencies = [
 "percent-encoding",
 "referencing",
 "regex",
-
 "regex-syntax 0.8.5",
+
 "regex-syntax",
 "serde",
 "serde_json",
 "uuid-simd",
@@ -1943,9 +2187,9 @@ dependencies = [

[[package]]
name = "keccak"
-
version = "0.1.5"
+
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654"
+
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
 "cpufeatures",
]
@@ -1967,15 +2211,15 @@ checksum = "baff4b617f7df3d896f97fe922b64817f6cd9a756bb81d40f8883f2f66dcb401"

[[package]]
name = "libc"
-
version = "0.2.174"
+
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
+
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"

[[package]]
name = "libgit2-sys"
-
version = "0.17.0+1.8.1"
+
version = "0.18.3+1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224"
+
checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487"
dependencies = [
 "cc",
 "libc",
@@ -2009,9 +2253,9 @@ checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"

[[package]]
name = "linux-raw-sys"
-
version = "0.9.4"
+
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
+
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"

[[package]]
name = "litemap"
@@ -2026,21 +2270,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"

[[package]]
-
name = "localtime"
-
version = "1.3.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "016a009e0bb8ba6e3229fb74bf11a8fe6ef24542cc6ef35ef38863ac13f96d87"
-
dependencies = [
-
 "serde",
-
]
-

-
[[package]]
name = "lock_api"
-
version = "0.4.11"
+
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45"
+
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
-
 "autocfg",
 "scopeguard",
]

@@ -2049,14 +2283,18 @@ name = "log"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
+
dependencies = [
+
 "serde",
+
 "value-bag",
+
]

[[package]]
name = "matchers"
-
version = "0.1.0"
+
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
+
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
-
 "regex-automata 0.1.10",
+
 "regex-automata",
]

[[package]]
@@ -2067,7 +2305,7 @@ checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
]

[[package]]
@@ -2078,14 +2316,23 @@ checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d"

[[package]]
name = "memmap2"
-
version = "0.9.4"
+
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "fe751422e4a8caa417e13c3ea66452215d7d63e19e604f4980461212f3ae1322"
+
checksum = "843a98750cd611cc2965a8213b53b43e715f13c37a9e096c6408e69990961db7"
dependencies = [
 "libc",
]

[[package]]
+
name = "memoffset"
+
version = "0.9.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+
dependencies = [
+
 "autocfg",
+
]
+

+
[[package]]
name = "miniz_oxide"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2130,20 +2377,6 @@ dependencies = [
]

[[package]]
-
name = "netservices"
-
version = "0.8.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "af0f91a10aaddcc3b76770c3bf5c17680829aa0828e5ffc69c62d58bfbe9c48c"
-
dependencies = [
-
 "amplify",
-
 "cyphernet",
-
 "io-reactor",
-
 "libc",
-
 "rand",
-
 "socket2",
-
]
-

-
[[package]]
name = "newline-converter"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2165,12 +2398,6 @@ dependencies = [

[[package]]
name = "nonempty"
-
version = "0.5.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "9ff7ac1e5ea23db6d61ad103e91864675049644bf47c35912336352fa4e9c109"
-

-
[[package]]
-
name = "nonempty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "995defdca0a589acfdd1bd2e8e3b896b4d4f7675a31fd14c32611440c7f608e6"
@@ -2185,16 +2412,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"

[[package]]
-
name = "nu-ansi-term"
-
version = "0.46.0"
+
name = "ntapi"
+
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
+
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
-
 "overload",
 "winapi",
]

[[package]]
+
name = "nu-ansi-term"
+
version = "0.50.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+
dependencies = [
+
 "windows-sys 0.59.0",
+
]
+

+
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2230,7 +2465,7 @@ dependencies = [
 "num-integer",
 "num-iter",
 "num-traits",
-
 "rand",
+
 "rand 0.8.5",
 "smallvec",
 "zeroize",
]
@@ -2251,12 +2486,6 @@ dependencies = [
]

[[package]]
-
name = "num-conv"
-
version = "0.1.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
-

-
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2298,10 +2527,29 @@ dependencies = [
]

[[package]]
+
name = "objc2-core-foundation"
+
version = "0.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+
dependencies = [
+
 "bitflags 2.9.1",
+
]
+

+
[[package]]
+
name = "objc2-io-kit"
+
version = "0.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
+
dependencies = [
+
 "libc",
+
 "objc2-core-foundation",
+
]
+

+
[[package]]
name = "object"
-
version = "0.36.7"
+
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
+
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
 "memchr",
]
@@ -2313,22 +2561,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"

[[package]]
-
name = "opaque-debug"
-
version = "0.3.1"
+
name = "once_cell_polyfill"
+
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"

[[package]]
-
name = "os_info"
-
version = "3.12.0"
+
name = "opaque-debug"
+
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3"
-
dependencies = [
-
 "log",
-
 "plist",
-
 "serde",
-
 "windows-sys 0.52.0",
-
]
+
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"

[[package]]
name = "outref"
@@ -2337,12 +2579,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"

[[package]]
-
name = "overload"
-
version = "0.1.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
-

-
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2376,15 +2612,15 @@ dependencies = [
 "ecdsa",
 "elliptic-curve",
 "primeorder",
-
 "rand_core",
+
 "rand_core 0.6.4",
 "sha2",
]

[[package]]
name = "parking_lot"
-
version = "0.12.3"
+
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
+
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
 "lock_api",
 "parking_lot_core",
@@ -2392,15 +2628,15 @@ dependencies = [

[[package]]
name = "parking_lot_core"
-
version = "0.9.9"
+
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e"
+
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
 "cfg-if",
 "libc",
-
 "redox_syscall",
+
 "redox_syscall 0.5.18",
 "smallvec",
-
 "windows-targets 0.48.5",
+
 "windows-link 0.2.1",
]

[[package]]
@@ -2486,19 +2722,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"

[[package]]
-
name = "plist"
-
version = "1.7.4"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1"
-
dependencies = [
-
 "base64 0.22.1",
-
 "indexmap",
-
 "quick-xml",
-
 "serde",
-
 "time",
-
]
-

-
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2522,15 +2745,6 @@ dependencies = [
]

[[package]]
-
name = "popol"
-
version = "3.0.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "93406933502e4446250941cf95d5e62851feb62a25b742acf7ffce96755c53e3"
-
dependencies = [
-
 "libc",
-
]
-

-
[[package]]
name = "portable-atomic"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2546,12 +2760,6 @@ dependencies = [
]

[[package]]
-
name = "powerfmt"
-
version = "0.2.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
-

-
[[package]]
name = "ppv-lite86"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2577,55 +2785,71 @@ dependencies = [
]

[[package]]
-
name = "proc-macro-error"
-
version = "1.0.4"
+
name = "proc-macro-error-attr2"
+
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
-
 "proc-macro-error-attr",
 "proc-macro2",
 "quote",
-
 "syn 1.0.109",
-
 "version_check",
]

[[package]]
-
name = "proc-macro-error-attr"
-
version = "1.0.4"
+
name = "proc-macro-error2"
+
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
+
 "proc-macro-error-attr2",
 "proc-macro2",
 "quote",
-
 "version_check",
+
 "syn 2.0.106",
]

[[package]]
name = "proc-macro2"
-
version = "1.0.92"
+
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0"
+
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
dependencies = [
 "unicode-ident",
]

[[package]]
name = "prodash"
-
version = "29.0.2"
+
version = "31.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "f04bb108f648884c23b98a0e940ebc2c93c0c3b89f04dbaf7eb8256ce617d1bc"
+
checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c"
dependencies = [
-
 "log",
 "parking_lot",
]

[[package]]
+
name = "proptest"
+
version = "1.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40"
+
dependencies = [
+
 "bit-set",
+
 "bit-vec",
+
 "bitflags 2.9.1",
+
 "num-traits",
+
 "rand 0.9.2",
+
 "rand_chacha 0.9.0",
+
 "rand_xorshift",
+
 "regex-syntax",
+
 "rusty-fork",
+
 "tempfile",
+
 "unarray",
+
]
+

+
[[package]]
name = "qcheck"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b439bd4242da51d62d18c95e6a6add749346756b0d1a587dfd0cc22fa6b5f3f0"
dependencies = [
-
 "rand",
+
 "rand 0.8.5",
]

[[package]]
@@ -2640,19 +2864,16 @@ dependencies = [
]

[[package]]
-
name = "quick-xml"
-
version = "0.38.1"
+
name = "quick-error"
+
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "9845d9dccf565065824e69f9f235fafba1587031eda353c1f1561cd6a6be78f4"
-
dependencies = [
-
 "memchr",
-
]
+
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"

[[package]]
name = "quote"
-
version = "1.0.36"
+
version = "1.0.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
+
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
dependencies = [
 "proc-macro2",
]
@@ -2665,7 +2886,7 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"

[[package]]
name = "radicle"
-
version = "0.19.0"
+
version = "0.21.0"
dependencies = [
 "amplify",
 "base64 0.21.7",
@@ -2682,16 +2903,19 @@ dependencies = [
 "indexmap",
 "jsonschema",
 "libc",
-
 "localtime",
 "log",
 "multibase",
-
 "nonempty 0.9.0",
+
 "nonempty",
 "pretty_assertions",
 "qcheck",
 "qcheck-macros",
 "radicle-cob",
+
 "radicle-core",
 "radicle-crypto",
-
 "radicle-git-ext",
+
 "radicle-git-metadata",
+
 "radicle-git-ref-format",
+
 "radicle-localtime",
+
 "radicle-oid",
 "radicle-ssh",
 "schemars",
 "serde",
@@ -2700,30 +2924,31 @@ dependencies = [
 "siphasher 1.0.1",
 "sqlite",
 "tempfile",
-
 "thiserror 1.0.69",
+
 "thiserror 2.0.18",
+
 "uds_windows",
 "unicode-normalization",
-
 "winpipe",
]

[[package]]
name = "radicle-cli"
-
version = "0.16.0"
+
version = "0.18.0"
dependencies = [
 "anyhow",
 "chrono",
+
 "clap",
+
 "clap_complete",
 "dunce",
-
 "git-ref-format",
 "human-panic",
-
 "lexopt",
-
 "localtime",
+
 "itertools",
 "log",
-
 "nonempty 0.9.0",
+
 "nonempty",
 "pretty_assertions",
 "radicle",
 "radicle-cli-test",
 "radicle-cob",
 "radicle-crypto",
-
 "radicle-git-ext",
+
 "radicle-git-ref-format",
+
 "radicle-localtime",
 "radicle-node",
 "radicle-surf",
 "radicle-term",
@@ -2732,7 +2957,7 @@ dependencies = [
 "serde_json",
 "shlex",
 "tempfile",
-
 "thiserror 1.0.69",
+
 "thiserror 2.0.18",
 "timeago",
 "tree-sitter",
 "tree-sitter-bash",
@@ -2748,6 +2973,7 @@ dependencies = [
 "tree-sitter-rust",
 "tree-sitter-toml-ng",
 "tree-sitter-typescript",
+
 "winsplit",
 "zeroize",
]

@@ -2761,48 +2987,72 @@ dependencies = [
 "radicle",
 "shlex",
 "snapbox",
-
 "thiserror 1.0.69",
+
 "thiserror 2.0.18",
+
 "winsplit",
]

[[package]]
name = "radicle-cob"
-
version = "0.16.0"
+
version = "0.18.0"
dependencies = [
 "fastrand",
+
 "git-ref-format-core",
 "git2",
 "log",
-
 "nonempty 0.9.0",
+
 "nonempty",
 "qcheck",
 "qcheck-macros",
 "radicle-crypto",
 "radicle-dag",
-
 "radicle-git-ext",
+
 "radicle-git-metadata",
+
 "radicle-git-ref-format",
+
 "radicle-oid",
 "serde",
 "serde_json",
 "signature 2.2.0",
 "tempfile",
-
 "thiserror 1.0.69",
+
 "thiserror 2.0.18",
+
]
+

+
[[package]]
+
name = "radicle-core"
+
version = "0.1.0"
+
dependencies = [
+
 "git2",
+
 "gix-hash",
+
 "multibase",
+
 "proptest",
+
 "qcheck",
+
 "radicle-crypto",
+
 "radicle-git-ref-format",
+
 "radicle-oid",
+
 "schemars",
+
 "serde",
+
 "serde_json",
+
 "sqlite",
+
 "thiserror 2.0.18",
]

[[package]]
name = "radicle-crypto"
-
version = "0.13.0"
+
version = "0.15.0"
dependencies = [
 "amplify",
 "cyphernet",
 "ec25519",
 "fastrand",
+
 "git-ref-format-core",
 "multibase",
 "qcheck",
 "qcheck-macros",
-
 "radicle-git-ext",
 "radicle-ssh",
+
 "schemars",
 "serde",
 "signature 2.2.0",
 "sqlite",
 "ssh-key",
 "tempfile",
-
 "thiserror 1.0.69",
+
 "thiserror 2.0.18",
 "zeroize",
]

@@ -2815,7 +3065,7 @@ dependencies = [

[[package]]
name = "radicle-fetch"
-
version = "0.15.0"
+
version = "0.17.0"
dependencies = [
 "bstr",
 "either",
@@ -2824,19 +3074,21 @@ dependencies = [
 "gix-odb",
 "gix-pack",
 "gix-protocol",
+
 "gix-refspec",
 "gix-transport",
 "log",
-
 "nonempty 0.9.0",
+
 "nonempty",
 "radicle",
-
 "radicle-git-ext",
-
 "thiserror 1.0.69",
+
 "radicle-git-ref-format",
+
 "radicle-oid",
+
 "thiserror 2.0.18",
]

[[package]]
name = "radicle-git-ext"
-
version = "0.8.1"
+
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "4b78c26e67d1712ad5a0c602ae3b236609461372ac04e200bda359fe4a1c6650"
+
checksum = "db68f47aaf6b8352a733da684f7e24f89aeb03131598628f147ff1bcc633670d"
dependencies = [
 "git-ref-format",
 "git2",
@@ -2847,10 +3099,33 @@ dependencies = [
]

[[package]]
+
name = "radicle-git-metadata"
+
version = "0.1.0"
+
dependencies = [
+
 "thiserror 2.0.18",
+
]
+

+
[[package]]
+
name = "radicle-git-ref-format"
+
version = "0.1.0"
+
dependencies = [
+
 "git-ref-format-core",
+
]
+

+
[[package]]
+
name = "radicle-localtime"
+
version = "0.1.0"
+
dependencies = [
+
 "schemars",
+
 "serde",
+
 "serde_json",
+
]
+

+
[[package]]
name = "radicle-node"
-
version = "0.15.0"
+
version = "0.17.0"
dependencies = [
-
 "amplify",
+
 "backtrace",
 "bloomy",
 "bytes",
 "chrono",
@@ -2858,76 +3133,88 @@ dependencies = [
 "crossbeam-channel",
 "cyphernet",
 "fastrand",
-
 "io-reactor",
+
 "gix-packetline",
 "lexopt",
-
 "libc",
-
 "localtime",
 "log",
-
 "netservices",
-
 "nonempty 0.9.0",
+
 "mio 1.0.4",
+
 "nonempty",
 "qcheck",
 "qcheck-macros",
 "radicle",
 "radicle-crypto",
 "radicle-fetch",
-
 "radicle-git-ext",
+
 "radicle-localtime",
 "radicle-protocol",
 "radicle-signals",
 "radicle-systemd",
+
 "radicle-windows",
 "scrypt",
 "serde",
 "serde_json",
 "snapbox",
 "socket2",
 "sqlite",
+
 "structured-logger",
 "tempfile",
 "test-log",
-
 "thiserror 1.0.69",
-
 "winpipe",
+
 "thiserror 2.0.18",
+
 "uds_windows",
+
]
+

+
[[package]]
+
name = "radicle-oid"
+
version = "0.1.0"
+
dependencies = [
+
 "git2",
+
 "gix-hash",
+
 "qcheck",
+
 "qcheck-macros",
+
 "radicle-git-ref-format",
+
 "schemars",
+
 "serde",
]

[[package]]
name = "radicle-protocol"
-
version = "0.3.0"
+
version = "0.5.0"
dependencies = [
 "bloomy",
 "bytes",
 "crossbeam-channel",
-
 "cyphernet",
+
 "cypheraddr",
 "fastrand",
-
 "localtime",
 "log",
-
 "nonempty 0.9.0",
+
 "nonempty",
 "paste",
 "qcheck",
 "qcheck-macros",
 "radicle",
+
 "radicle-core",
 "radicle-crypto",
 "radicle-fetch",
-
 "radicle-git-ext",
+
 "radicle-localtime",
 "scrypt",
 "serde",
 "serde_json",
 "sqlite",
-
 "thiserror 1.0.69",
+
 "thiserror 2.0.18",
]

[[package]]
name = "radicle-remote-helper"
-
version = "0.13.0"
+
version = "0.14.0"
dependencies = [
 "dunce",
 "log",
 "radicle",
 "radicle-cli",
 "radicle-crypto",
-
 "radicle-git-ext",
-
 "thiserror 1.0.69",
+
 "thiserror 2.0.18",
]

[[package]]
name = "radicle-schemars"
-
version = "0.5.0"
+
version = "0.6.0"
dependencies = [
 "radicle",
 "schemars",
@@ -2942,35 +3229,36 @@ dependencies = [
 "crossbeam-channel",
 "libc",
 "signals_receipts",
+
 "windows 0.62.2",
]

[[package]]
name = "radicle-ssh"
version = "0.10.0"
dependencies = [
-
 "thiserror 1.0.69",
+
 "thiserror 2.0.18",
 "winpipe",
 "zeroize",
]

[[package]]
name = "radicle-std-ext"
-
version = "0.1.0"
+
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "db20136bbc9ae63f3fec8e5a6c369f4902fac2244501b5dfc6d668e43475aaa4"
+
checksum = "fb935931bdd2a2966f3b584f3031d9d54ec0713ddbc563a0193d54e62a88ec73"

[[package]]
name = "radicle-surf"
-
version = "0.22.0"
+
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "fb308c3989087f71e43d8c7a2737273fdc7fbcd3e6628af81a42f601ae64f314"
+
checksum = "e2c6a29eac2e55a6d0632c8faeb3cbe91afb1e07be60d157a27fdadb8cc4c508"
dependencies = [
 "anyhow",
-
 "base64 0.13.1",
+
 "base64 0.21.7",
 "flate2",
 "git2",
 "log",
-
 "nonempty 0.5.0",
+
 "nonempty",
 "radicle-git-ext",
 "radicle-std-ext",
 "tar",
@@ -2980,7 +3268,7 @@ dependencies = [

[[package]]
name = "radicle-systemd"
-
version = "0.10.0"
+
version = "0.12.0"
dependencies = [
 "log",
 "systemd-journal-logger",
@@ -2988,43 +3276,73 @@ dependencies = [

[[package]]
name = "radicle-term"
-
version = "0.15.0"
+
version = "0.17.0"
dependencies = [
 "anstyle-query",
 "crossbeam-channel",
 "crossterm 0.29.0",
 "git2",
+
 "indicatif",
 "inquire",
 "libc",
 "pretty_assertions",
 "radicle-signals",
 "shlex",
 "tempfile",
-
 "thiserror 1.0.69",
+
 "thiserror 2.0.18",
 "unicode-display-width",
 "unicode-segmentation",
+
 "winsplit",
 "zeroize",
]

[[package]]
+
name = "radicle-windows"
+
version = "0.1.0"
+
dependencies = [
+
 "thiserror 2.0.18",
+
 "windows 0.62.2",
+
]
+

+
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
 "libc",
-
 "rand_chacha",
-
 "rand_core",
+
 "rand_chacha 0.3.1",
+
 "rand_core 0.6.4",
]

[[package]]
-
name = "rand_chacha"
-
version = "0.3.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
+
name = "rand"
+
version = "0.9.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+
dependencies = [
+
 "rand_chacha 0.9.0",
+
 "rand_core 0.9.3",
+
]
+

+
[[package]]
+
name = "rand_chacha"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
 "ppv-lite86",
-
 "rand_core",
+
 "rand_core 0.6.4",
+
]
+

+
[[package]]
+
name = "rand_chacha"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+
dependencies = [
+
 "ppv-lite86",
+
 "rand_core 0.9.3",
]

[[package]]
@@ -3037,6 +3355,24 @@ dependencies = [
]

[[package]]
+
name = "rand_core"
+
version = "0.9.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
+
dependencies = [
+
 "getrandom 0.3.3",
+
]
+

+
[[package]]
+
name = "rand_xorshift"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+
dependencies = [
+
 "rand_core 0.9.3",
+
]
+

+
[[package]]
name = "redox_syscall"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3046,6 +3382,15 @@ dependencies = [
]

[[package]]
+
name = "redox_syscall"
+
version = "0.5.18"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+
dependencies = [
+
 "bitflags 2.9.1",
+
]
+

+
[[package]]
name = "ref-cast"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3062,7 +3407,7 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
]

[[package]]
@@ -3087,17 +3432,8 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
 "aho-corasick",
 "memchr",
-
 "regex-automata 0.4.9",
-
 "regex-syntax 0.8.5",
-
]
-

-
[[package]]
-
name = "regex-automata"
-
version = "0.1.10"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
-
dependencies = [
-
 "regex-syntax 0.6.29",
+
 "regex-automata",
+
 "regex-syntax",
]

[[package]]
@@ -3108,17 +3444,11 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
 "aho-corasick",
 "memchr",
-
 "regex-syntax 0.8.5",
+
 "regex-syntax",
]

[[package]]
name = "regex-syntax"
-
version = "0.6.29"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
-

-
[[package]]
-
name = "regex-syntax"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
@@ -3146,7 +3476,7 @@ dependencies = [
 "num-traits",
 "pkcs1",
 "pkcs8",
-
 "rand_core",
+
 "rand_core 0.6.4",
 "sha2",
 "signature 2.2.0",
 "spki",
@@ -3175,15 +3505,15 @@ dependencies = [

[[package]]
name = "rustix"
-
version = "1.0.7"
+
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
+
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
 "bitflags 2.9.1",
 "errno",
 "libc",
-
 "linux-raw-sys 0.9.4",
-
 "windows-sys 0.59.0",
+
 "linux-raw-sys 0.11.0",
+
 "windows-sys 0.60.2",
]

[[package]]
@@ -3193,6 +3523,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"

[[package]]
+
name = "rusty-fork"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
+
dependencies = [
+
 "fnv",
+
 "quick-error",
+
 "tempfile",
+
 "wait-timeout",
+
]
+

+
[[package]]
name = "ryu"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3238,7 +3580,7 @@ dependencies = [
 "proc-macro2",
 "quote",
 "serde_derive_internals",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
]

[[package]]
@@ -3286,10 +3628,11 @@ dependencies = [

[[package]]
name = "serde"
-
version = "1.0.219"
+
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
+
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
+
 "serde_core",
 "serde_derive",
]

@@ -3305,14 +3648,23 @@ dependencies = [
]

[[package]]
+
name = "serde_core"
+
version = "1.0.228"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+
dependencies = [
+
 "serde_derive",
+
]
+

+
[[package]]
name = "serde_derive"
-
version = "1.0.219"
+
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
+
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
]

[[package]]
@@ -3323,7 +3675,16 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
+
]
+

+
[[package]]
+
name = "serde_fmt"
+
version = "1.0.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e1d4ddca14104cd60529e8c7f7ba71a2c8acd8f7f5cfcdc2faf97eeb7c3010a4"
+
dependencies = [
+
 "serde",
]

[[package]]
@@ -3341,18 +3702,33 @@ dependencies = [

[[package]]
name = "serde_spanned"
-
version = "1.0.0"
+
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83"
+
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
dependencies = [
-
 "serde",
+
 "serde_core",
]

[[package]]
-
name = "sha1_smol"
-
version = "1.0.0"
+
name = "sha1"
+
version = "0.10.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+
dependencies = [
+
 "cfg-if",
+
 "cpufeatures",
+
 "digest",
+
]
+

+
[[package]]
+
name = "sha1-checked"
+
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012"
+
checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423"
+
dependencies = [
+
 "digest",
+
 "sha1",
+
]

[[package]]
name = "sha2"
@@ -3452,7 +3828,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
 "digest",
-
 "rand_core",
+
 "rand_core 0.6.4",
]

[[package]]
@@ -3474,10 +3850,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"

[[package]]
+
name = "slab"
+
version = "0.4.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
+

+
[[package]]
name = "smallvec"
-
version = "1.13.2"
+
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
+
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"

[[package]]
name = "snapbox"
@@ -3605,7 +3987,7 @@ dependencies = [
 "p256",
 "p384",
 "p521",
-
 "rand_core",
+
 "rand_core 0.6.4",
 "rsa",
 "sec1",
 "sha2",
@@ -3629,12 +4011,109 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"

[[package]]
+
name = "strsim"
+
version = "0.11.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+

+
[[package]]
+
name = "structured-logger"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f41647ab1dfedac6dccb4622ded5f3bea80ade9257a9ddcc89e36a43e1769cdf"
+
dependencies = [
+
 "log",
+
 "parking_lot",
+
 "serde",
+
 "serde_json",
+
 "tokio",
+
]
+

+
[[package]]
name = "subtle"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"

[[package]]
+
name = "sval"
+
version = "2.14.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7cc9739f56c5d0c44a5ed45473ec868af02eb896af8c05f616673a31e1d1bb09"
+

+
[[package]]
+
name = "sval_buffer"
+
version = "2.14.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f39b07436a8c271b34dad5070c634d1d3d76d6776e938ee97b4a66a5e8003d0b"
+
dependencies = [
+
 "sval",
+
 "sval_ref",
+
]
+

+
[[package]]
+
name = "sval_dynamic"
+
version = "2.14.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ffcb072d857431bf885580dacecf05ed987bac931230736739a79051dbf3499b"
+
dependencies = [
+
 "sval",
+
]
+

+
[[package]]
+
name = "sval_fmt"
+
version = "2.14.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3f214f427ad94a553e5ca5514c95c6be84667cbc5568cce957f03f3477d03d5c"
+
dependencies = [
+
 "itoa",
+
 "ryu",
+
 "sval",
+
]
+

+
[[package]]
+
name = "sval_json"
+
version = "2.14.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "389ed34b32e638dec9a99c8ac92d0aa1220d40041026b625474c2b6a4d6f4feb"
+
dependencies = [
+
 "itoa",
+
 "ryu",
+
 "sval",
+
]
+

+
[[package]]
+
name = "sval_nested"
+
version = "2.14.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "14bae8fcb2f24fee2c42c1f19037707f7c9a29a0cda936d2188d48a961c4bb2a"
+
dependencies = [
+
 "sval",
+
 "sval_buffer",
+
 "sval_ref",
+
]
+

+
[[package]]
+
name = "sval_ref"
+
version = "2.14.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2a4eaea3821d3046dcba81d4b8489421da42961889902342691fb7eab491d79e"
+
dependencies = [
+
 "sval",
+
]
+

+
[[package]]
+
name = "sval_serde"
+
version = "2.14.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "172dd4aa8cb3b45c8ac8f3b4111d644cd26938b0643ede8f93070812b87fb339"
+
dependencies = [
+
 "serde",
+
 "sval",
+
 "sval_nested",
+
]
+

+
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3647,9 +4126,9 @@ dependencies = [

[[package]]
name = "syn"
-
version = "2.0.89"
+
version = "2.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e"
+
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
dependencies = [
 "proc-macro2",
 "quote",
@@ -3670,7 +4149,21 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
+
]
+

+
[[package]]
+
name = "sysinfo"
+
version = "0.37.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f"
+
dependencies = [
+
 "libc",
+
 "memchr",
+
 "ntapi",
+
 "objc2-core-foundation",
+
 "objc2-io-kit",
+
 "windows 0.61.3",
]

[[package]]
@@ -3680,7 +4173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7266304d24ca5a4b230545fc558c80e18bd3e1d2eb1be149b6bcd04398d3e79c"
dependencies = [
 "log",
-
 "rustix 1.0.7",
+
 "rustix 1.1.3",
]

[[package]]
@@ -3696,21 +4189,22 @@ dependencies = [

[[package]]
name = "tempfile"
-
version = "3.10.1"
+
version = "3.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1"
+
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
dependencies = [
-
 "cfg-if",
 "fastrand",
-
 "rustix 0.38.34",
-
 "windows-sys 0.52.0",
+
 "getrandom 0.3.3",
+
 "once_cell",
+
 "rustix 1.1.3",
+
 "windows-sys 0.60.2",
]

[[package]]
name = "test-log"
-
version = "0.2.18"
+
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "1e33b98a582ea0be1168eba097538ee8dd4bbe0f2b01b22ac92ea30054e5be7b"
+
checksum = "37d53ac171c92a39e4769491c4b4dde7022c60042254b5fc044ae409d34a24d4"
dependencies = [
 "env_logger",
 "test-log-macros",
@@ -3719,13 +4213,13 @@ dependencies = [

[[package]]
name = "test-log-macros"
-
version = "0.2.18"
+
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36"
+
checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
]

[[package]]
@@ -3739,11 +4233,11 @@ dependencies = [

[[package]]
name = "thiserror"
-
version = "2.0.12"
+
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
+
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
-
 "thiserror-impl 2.0.12",
+
 "thiserror-impl 2.0.18",
]

[[package]]
@@ -3754,18 +4248,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
]

[[package]]
name = "thiserror-impl"
-
version = "2.0.12"
+
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
+
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
]

[[package]]
@@ -3778,37 +4272,6 @@ dependencies = [
]

[[package]]
-
name = "time"
-
version = "0.3.41"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
-
dependencies = [
-
 "deranged",
-
 "itoa",
-
 "num-conv",
-
 "powerfmt",
-
 "serde",
-
 "time-core",
-
 "time-macros",
-
]
-

-
[[package]]
-
name = "time-core"
-
version = "0.1.4"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
-

-
[[package]]
-
name = "time-macros"
-
version = "0.2.22"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
-
dependencies = [
-
 "num-conv",
-
 "time-core",
-
]
-

-
[[package]]
name = "timeago"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3840,12 +4303,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"

[[package]]
+
name = "tokio"
+
version = "1.47.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
+
dependencies = [
+
 "backtrace",
+
 "bytes",
+
 "io-uring",
+
 "libc",
+
 "mio 1.0.4",
+
 "parking_lot",
+
 "pin-project-lite",
+
 "slab",
+
]
+

+
[[package]]
name = "toml"
-
version = "0.9.5"
+
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8"
+
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
-
 "serde",
+
 "serde_core",
 "serde_spanned",
 "toml_datetime",
 "toml_writer",
@@ -3853,24 +4332,24 @@ dependencies = [

[[package]]
name = "toml_datetime"
-
version = "0.7.0"
+
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3"
+
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
-
 "serde",
+
 "serde_core",
]

[[package]]
name = "toml_writer"
-
version = "1.0.2"
+
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64"
+
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"

[[package]]
name = "tracing"
-
version = "0.1.41"
+
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
+
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
 "pin-project-lite",
 "tracing-core",
@@ -3878,9 +4357,9 @@ dependencies = [

[[package]]
name = "tracing-core"
-
version = "0.1.34"
+
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
+
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
 "once_cell",
 "valuable",
@@ -3899,14 +4378,14 @@ dependencies = [

[[package]]
name = "tracing-subscriber"
-
version = "0.3.19"
+
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
+
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
 "matchers",
 "nu-ansi-term",
 "once_cell",
-
 "regex",
+
 "regex-automata",
 "sharded-slab",
 "thread_local",
 "tracing",
@@ -3922,7 +4401,7 @@ checksum = "b67baf55e7e1b6806063b1e51041069c90afff16afcbbccd278d899f9d84bca4"
dependencies = [
 "cc",
 "regex",
-
 "regex-syntax 0.8.5",
+
 "regex-syntax",
 "streaming-iterator",
 "tree-sitter-language",
]
@@ -4079,6 +4558,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"

[[package]]
+
name = "uds_windows"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
+
dependencies = [
+
 "memoffset",
+
 "tempfile",
+
 "winapi",
+
]
+

+
[[package]]
+
name = "unarray"
+
version = "0.1.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
+

+
[[package]]
name = "unicode-display-width"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4115,6 +4611,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85"

[[package]]
+
name = "unicode-width"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
+

+
[[package]]
+
name = "unit-prefix"
+
version = "0.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817"
+

+
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4149,17 +4657,19 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"

[[package]]
name = "utf8parse"
-
version = "0.2.1"
+
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
+
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"

[[package]]
name = "uuid"
-
version = "1.16.0"
+
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
+
checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
dependencies = [
 "getrandom 0.3.3",
+
 "js-sys",
+
 "wasm-bindgen",
]

[[package]]
@@ -4180,6 +4690,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"

[[package]]
+
name = "value-bag"
+
version = "1.11.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5"
+
dependencies = [
+
 "value-bag-serde1",
+
 "value-bag-sval2",
+
]
+

+
[[package]]
+
name = "value-bag-serde1"
+
version = "1.11.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "35540706617d373b118d550d41f5dfe0b78a0c195dc13c6815e92e2638432306"
+
dependencies = [
+
 "erased-serde",
+
 "serde",
+
 "serde_fmt",
+
]
+

+
[[package]]
+
name = "value-bag-sval2"
+
version = "1.11.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6fe7e140a2658cc16f7ee7a86e413e803fc8f9b5127adc8755c19f9fefa63a52"
+
dependencies = [
+
 "sval",
+
 "sval_buffer",
+
 "sval_dynamic",
+
 "sval_fmt",
+
 "sval_json",
+
 "sval_ref",
+
 "sval_serde",
+
]
+

+
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4198,6 +4744,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"

[[package]]
+
name = "wait-timeout"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
+
dependencies = [
+
 "libc",
+
]
+

+
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4244,7 +4799,7 @@ dependencies = [
 "log",
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
 "wasm-bindgen-shared",
]

@@ -4266,7 +4821,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
 "wasm-bindgen-backend",
 "wasm-bindgen-shared",
]
@@ -4281,6 +4836,16 @@ dependencies = [
]

[[package]]
+
name = "web-time"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+
dependencies = [
+
 "js-sys",
+
 "wasm-bindgen",
+
]
+

+
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4322,6 +4887,49 @@ dependencies = [
]

[[package]]
+
name = "windows"
+
version = "0.61.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
+
dependencies = [
+
 "windows-collections 0.2.0",
+
 "windows-core 0.61.2",
+
 "windows-future 0.2.1",
+
 "windows-link 0.1.3",
+
 "windows-numerics 0.2.0",
+
]
+

+
[[package]]
+
name = "windows"
+
version = "0.62.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+
dependencies = [
+
 "windows-collections 0.3.2",
+
 "windows-core 0.62.2",
+
 "windows-future 0.3.2",
+
 "windows-numerics 0.3.1",
+
]
+

+
[[package]]
+
name = "windows-collections"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
+
dependencies = [
+
 "windows-core 0.61.2",
+
]
+

+
[[package]]
+
name = "windows-collections"
+
version = "0.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+
dependencies = [
+
 "windows-core 0.62.2",
+
]
+

+
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4336,14 +4944,62 @@ version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
-
 "windows-implement",
-
 "windows-interface",
-
 "windows-result",
-
 "windows-strings",
+
 "windows-implement 0.58.0",
+
 "windows-interface 0.58.0",
+
 "windows-result 0.2.0",
+
 "windows-strings 0.1.0",
 "windows-targets 0.52.6",
]

[[package]]
+
name = "windows-core"
+
version = "0.61.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
+
dependencies = [
+
 "windows-implement 0.60.2",
+
 "windows-interface 0.59.3",
+
 "windows-link 0.1.3",
+
 "windows-result 0.3.4",
+
 "windows-strings 0.4.2",
+
]
+

+
[[package]]
+
name = "windows-core"
+
version = "0.62.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+
dependencies = [
+
 "windows-implement 0.60.2",
+
 "windows-interface 0.59.3",
+
 "windows-link 0.2.1",
+
 "windows-result 0.4.1",
+
 "windows-strings 0.5.1",
+
]
+

+
[[package]]
+
name = "windows-future"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
+
dependencies = [
+
 "windows-core 0.61.2",
+
 "windows-link 0.1.3",
+
 "windows-threading 0.1.0",
+
]
+

+
[[package]]
+
name = "windows-future"
+
version = "0.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+
dependencies = [
+
 "windows-core 0.62.2",
+
 "windows-link 0.2.1",
+
 "windows-threading 0.2.1",
+
]
+

+
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4351,7 +5007,18 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
+
]
+

+
[[package]]
+
name = "windows-implement"
+
version = "0.60.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.106",
]

[[package]]
@@ -4362,7 +5029,50 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
+
]
+

+
[[package]]
+
name = "windows-interface"
+
version = "0.59.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.106",
+
]
+

+
[[package]]
+
name = "windows-link"
+
version = "0.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+

+
[[package]]
+
name = "windows-link"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+

+
[[package]]
+
name = "windows-numerics"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
+
dependencies = [
+
 "windows-core 0.61.2",
+
 "windows-link 0.1.3",
+
]
+

+
[[package]]
+
name = "windows-numerics"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+
dependencies = [
+
 "windows-core 0.62.2",
+
 "windows-link 0.2.1",
]

[[package]]
@@ -4375,16 +5085,52 @@ dependencies = [
]

[[package]]
+
name = "windows-result"
+
version = "0.3.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+
dependencies = [
+
 "windows-link 0.1.3",
+
]
+

+
[[package]]
+
name = "windows-result"
+
version = "0.4.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+
dependencies = [
+
 "windows-link 0.2.1",
+
]
+

+
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
-
 "windows-result",
+
 "windows-result 0.2.0",
 "windows-targets 0.52.6",
]

[[package]]
+
name = "windows-strings"
+
version = "0.4.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+
dependencies = [
+
 "windows-link 0.1.3",
+
]
+

+
[[package]]
+
name = "windows-strings"
+
version = "0.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+
dependencies = [
+
 "windows-link 0.2.1",
+
]
+

+
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4412,6 +5158,24 @@ dependencies = [
]

[[package]]
+
name = "windows-sys"
+
version = "0.60.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+
dependencies = [
+
 "windows-targets 0.53.2",
+
]
+

+
[[package]]
+
name = "windows-sys"
+
version = "0.61.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+
dependencies = [
+
 "windows-link 0.2.1",
+
]
+

+
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4435,7 +5199,7 @@ dependencies = [
 "windows_aarch64_gnullvm 0.52.6",
 "windows_aarch64_msvc 0.52.6",
 "windows_i686_gnu 0.52.6",
-
 "windows_i686_gnullvm",
+
 "windows_i686_gnullvm 0.52.6",
 "windows_i686_msvc 0.52.6",
 "windows_x86_64_gnu 0.52.6",
 "windows_x86_64_gnullvm 0.52.6",
@@ -4443,6 +5207,40 @@ dependencies = [
]

[[package]]
+
name = "windows-targets"
+
version = "0.53.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef"
+
dependencies = [
+
 "windows_aarch64_gnullvm 0.53.0",
+
 "windows_aarch64_msvc 0.53.0",
+
 "windows_i686_gnu 0.53.0",
+
 "windows_i686_gnullvm 0.53.0",
+
 "windows_i686_msvc 0.53.0",
+
 "windows_x86_64_gnu 0.53.0",
+
 "windows_x86_64_gnullvm 0.53.0",
+
 "windows_x86_64_msvc 0.53.0",
+
]
+

+
[[package]]
+
name = "windows-threading"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
+
dependencies = [
+
 "windows-link 0.1.3",
+
]
+

+
[[package]]
+
name = "windows-threading"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+
dependencies = [
+
 "windows-link 0.2.1",
+
]
+

+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4455,6 +5253,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"

[[package]]
+
name = "windows_aarch64_gnullvm"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
+

+
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4467,6 +5271,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"

[[package]]
+
name = "windows_aarch64_msvc"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
+

+
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4479,12 +5289,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"

[[package]]
+
name = "windows_i686_gnu"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
+

+
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"

[[package]]
+
name = "windows_i686_gnullvm"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
+

+
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4497,6 +5319,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"

[[package]]
+
name = "windows_i686_msvc"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
+

+
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4509,6 +5337,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"

[[package]]
+
name = "windows_x86_64_gnu"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
+

+
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4521,6 +5355,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"

[[package]]
+
name = "windows_x86_64_gnullvm"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
+

+
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4533,10 +5373,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"

[[package]]
+
name = "windows_x86_64_msvc"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
+

+
[[package]]
name = "winnow"
-
version = "0.6.26"
+
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"
+
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
 "memchr",
]
@@ -4549,12 +5395,18 @@ checksum = "1ccf671d62d1bd0c913d9059e69bb4a6b51f7a4c899ab83c62d921e35f206053"
dependencies = [
 "defer-heavy",
 "log",
-
 "rand",
+
 "rand 0.8.5",
 "sync-ptr",
-
 "windows",
+
 "windows 0.58.0",
]

[[package]]
+
name = "winsplit"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3ab703352da6a72f35c39a533526393725640575bb211f61987a2748323ad956"
+

+
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4612,7 +5464,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
 "synstructure",
]

@@ -4633,7 +5485,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
]

[[package]]
@@ -4653,7 +5505,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
 "synstructure",
]

@@ -4682,5 +5534,11 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
dependencies = [
 "proc-macro2",
 "quote",
-
 "syn 2.0.89",
+
 "syn 2.0.106",
]
+

+
[[package]]
+
name = "zlib-rs"
+
version = "0.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c"
modified Cargo.toml
@@ -16,58 +16,83 @@ version = "0.9.0"
# *per crate*. If anyone ever wants to set it to a different
# value per crate, this is of course possible. We're waiting
# for the day it makes a difference…
-
rust-version = "1.81.0"
+
rust-version = "1.85.0"

[workspace.dependencies]
amplify = { version = "4.0.0", default-features = false }
bstr = "1.3"
-
bytes = "1"
+
bytes = "1.11.1"
chrono = { version = "0.4.26", default-features = false }
colored = "2.1.0"
crossbeam-channel = "0.5.6"
+
cypheraddr = "0.4.0"
cyphernet = "0.5.2"
dunce = "1.0.5"
fastrand = { version = "2.0.0", default-features = false }
-
git2 = { version = "0.19.0", default-features = false }
-
human-panic = "2"
+
git2 = { version = "0.20.4", default-features = false, features = ["vendored-libgit2"] }
+
gix-hash = { version = "0.22.1", default-features = false, features = ["sha1"] }
+
gix-packetline = { version = "0.21.1", default-features = false }
+
human-panic = "2.0.6"
+
itertools = "0.14"
lexopt = "0.3.0"
libc = "0.2.137"
-
localtime = "1.2.0"
log = "0.4.17"
multibase = "0.9.1"
nonempty = "0.9.0"
pretty_assertions = "1.3.0"
+
proptest = "1.9"
qcheck = { version = "1", default-features = false }
qcheck-macros = { version = "1", default-features = false }
-
radicle = { version = "0.19", path = "crates/radicle" }
-
radicle-cli = { version = "0.16", path = "crates/radicle-cli" }
+
radicle = { version = "0.21", path = "crates/radicle" }
+
radicle-cli = { version = "0.18", path = "crates/radicle-cli" }
radicle-cli-test = { path = "crates/radicle-cli-test" }
-
radicle-cob = { version = "0.16", path = "crates/radicle-cob" }
-
radicle-crypto = { version = "0.13", path = "crates/radicle-crypto" }
+
radicle-cob = { version = "0.18", path = "crates/radicle-cob" }
+
radicle-core = { version = "0.1", path = "crates/radicle-core" }
+
radicle-crypto = { version = "0.15", path = "crates/radicle-crypto" }
radicle-dag = { version = "0.10", path = "crates/radicle-dag" }
-
radicle-fetch = { version = "0.15", path = "crates/radicle-fetch" }
-
radicle-git-ext = { version = "0.8", default-features = false }
-
radicle-node = { version = "0.15", path = "crates/radicle-node" }
-
radicle-protocol = { version = "0.3", path = "crates/radicle-protocol" }
+
radicle-fetch = { version = "0.17", path = "crates/radicle-fetch" }
+
radicle-git-metadata = { version = "0.1.0", path = "crates/radicle-git-metadata", default-features = false }
+
radicle-git-ref-format = { version = "0.1.0", path = "crates/radicle-git-ref-format", default-features = false }
+
radicle-localtime = { version = "0.1", path = "crates/radicle-localtime" }
+
radicle-node = { version = "0.17", path = "crates/radicle-node" }
+
radicle-oid = { version = "0.1.0", path = "crates/radicle-oid", default-features = false }
+
radicle-protocol = { version = "0.5", path = "crates/radicle-protocol" }
radicle-signals = { version = "0.11", path = "crates/radicle-signals" }
radicle-ssh = { version = "0.10", path = "crates/radicle-ssh", default-features = false }
-
radicle-systemd = { version = "0.10", path = "crates/radicle-systemd" }
-
radicle-term = { version = "0.15", path = "crates/radicle-term" }
-
schemars = { version = "1.0.4" }
-
serde = "1.0"
+
radicle-systemd = { version = "0.12", path = "crates/radicle-systemd" }
+
radicle-term = { version = "0.17", path = "crates/radicle-term" }
+
radicle-windows = { version = "0.1", path = "crates/radicle-windows" }
+
schemars = { version = "1.0.4", default-features = false }
+
serde = { version = "1.0", default-features = false }
serde_json = "1.0"
shlex = "1.1.0"
signature = "2.2"
snapbox = "0.4.3"
sqlite = "0.32.0"
tempfile = "3.3.0"
-
thiserror = "1.0"
+
thiserror = { version = "2", default-features = false }
+
uds_windows = "1.1.0"
+
windows = "0.62"
winpipe = "0.1.1"
+
winsplit = "0.1.0"
zeroize = "1.5.7"

+
# Crates from the "radicle-git" workspace. These should be synced manually.
+
# When updating, start from `radicle-surf`:
+
# `radicle-surf` → `radicle-git-ext` → `git-ref-format` → `git-ref-format-core`
+
# Also note that `radicle-surf → git2` so try to also sync with `git2`.
+
git-ref-format-core = { version = "0.6.0", default-features = false }
+
radicle-surf = "0.27.0"
+

[workspace.lints]
clippy.type_complexity = "allow"
clippy.enum_variant_names = "allow"
+
clippy.indexing_slicing = "deny"
+
clippy.fallible_impl_from = "deny"
+
clippy.wildcard_enum_match_arm = "deny"
+
clippy.unneeded_field_pattern = "deny"
+
clippy.fn_params_excessive_bools = "deny"
+
clippy.must_use_candidate = "deny"

[profile.container]
inherits = "release"
added RELEASE.md
@@ -0,0 +1,253 @@
+
Release Process
+
===============
+
In this document, we describe the release process for the Radicle binaries. It
+
is expected to be a living document as we refine our build and release process.
+

+
Pre-Release Process
+
-------------------
+
Before cutting a proper release, we first aim to cut a pre-release so that we
+
can test the binaries on a smaller scale, usually internally. To do this, we
+
follow the following steps, outlined in each subsection.
+

+
### Tag Version
+
The first action required is to create a release tag. All tags that start with a
+
`releases/` are considered release tags, e.g. `releases/1.0.0`, `releases/1.1.0`,
+
`releases/1.1.0-rc`, etc.
+
Before creating the tag, we must decide which commit we are choosing for the
+
release. In general, this will be the latest commit of the `master` branch. We
+
checkout this commit:
+

+
```
+
git checkout <commit>
+
```
+

+
The tag name that is being chosen for the release candidate is the next semantic
+
version, followed by `-rc.1`. If it is a follow-up release candidate for any
+
fixes, we increase digit, e.g. `releases/1.1.0-rc.2`, `releases/1.1.0-rc.3`,
+
etc.
+

+
Note that, for the next part, `git config user.signingKey` must match the key
+
you are using as your Radicle signing key, and it must be using the `ssh`
+
format. In your working copy of `heartwood` you can set this up with the
+
following commands:
+

+
```
+
git config set gpg.format ssh
+
```
+

+
```
+
git config set user.signingKey "key::$(rad self --ssh-key)""
+
```
+

+
We provide a script for performing the tagging related options, `build/tag`.
+
The input to this script does not require the `releases/` prefix. For example,
+
if we want to cut a release for `releases/1.3.0-rc.3`, we would call the
+
script like the following:
+

+
```
+
build/tag 1.3.0-rc.3
+
```
+

+
The script will ask you to confirm the creation of the tag, showing you the
+
commit that you're tagging, respond with `y` if it all looks good.
+

+
### Run Build
+
The next thing we do is to build the binaries based on the latest tag. We
+
provide a `build/build` script that performs the build through a Docker
+
container. The following requirements are needed for running the build script:
+

+
* `rad`
+
* `podman`
+
* `sha256sum`
+

+
Running `build/build` will find the latest tag and perform the build, this will
+
take some time, so grab a coffee ☕.
+

+
---
+

+
**Note**: the script currently outputs warnings about the `strip` command for
+
MacOS builds. These are ok, and can be ignored.
+

+
---
+

+
### Verify Artifacts
+
All artifacts constructed from the `build/build` script will be placed under
+
`build/artifacts`. Any existing, old artifacts can be removed.
+

+
We can then verify the artifacts are present via the `build/checksums` script,
+
which prints the checksum values of all the binaries that were built, noting
+
that there is a binary for different architectures.
+

+
We also check that `build/artifacts/radicle.json` file to see that the metadata
+
matches what we expected. For example, the output may look something like:
+

+
```json
+
{"name":"rad","version":"1.3.0-rc.3","commit":"3296de8323b5782ff2af9d3a0fe2309a9bf1d3d6","timestamp":"1756131991"}
+
```
+

+
Making careful note of the `version` and `commit`.
+

+
### Upload Artifacts
+
The next step is to upload the artifacts to our servers, allowing others to
+
install the binaries, as well as launching the new binaries on our team seed
+
node.
+

+
This is achieved through the `build/upload` script, which requires SSH access to
+
`files.radicle.xyz`, for example:
+

+
```
+
SSH_LOGIN=<user> build/upload 1.3.0-rc.3
+
```
+

+
Once the files are released we can install the binaries via:
+

+
```
+
curl -O -L https://files.radicle.xyz/releases/latest/radicle-$TARGET.tar.xz
+
```
+

+
where `$TARGET` is the relevant architecture and version.
+

+
### Release on Team Node
+
To help with testing the pre-release internally, we upgrade our team node,
+
`seed.radicle.xyz`, which is restricted to only replicate from our team's Node
+
IDs.
+

+
We do this using NixOS and the [`radicle-nix`][radicle-nix] and
+
[`radicle-infra`][radicle-infra] repositories.
+

+
### Post Changelog
+

+
<!-- The examples will obviously need a bit of rework, and probably based on -->
+
<!-- an upcoming pre-release rather than something historical.  /RL -->
+

+
Once all these steps are completed, we can generate the changelog, by first
+
checking out the relevant tag, and running `scripts/changelog` – you can also
+
pass a previous version as `--from-version`. This will output something like the
+
following:
+

+
~~~
+
# 👾 Radicle 1.5.0-rc.2
+

+
Radicle 1.5.0-rc.2 (7b00bf2e3) is released.
+

+
## Installation
+

+
```
+
curl -sSf https://radicle.xyz/install | sh -s -- --no-modify-path --version=1.5.0-rc.2
+
```
+

+
## Notes
+

+
* Properly deprecate `rad self --nid` and introduce `rad status --only nid`
+
* Deprecates `rad diff`
+
* Obsolete warning for `rad patch review [--patch | --delete]`
+

+
## Changelog
+

+
This release contains 69 commit(s) by 5 contributor(s).
+

+
* `7b00bf2e3` **cli/patch/review: Obsoletion Warning** *<lorenz.leutgeb@radicle.xyz>*
+
* `8dd17e2a6` **cli/warning: Add `fn obsolete`** *<lorenz.leutgeb@radicle.xyz>*
+
* `7d1db6a01` **cli/diff: Deprecation Warning** *<lorenz.leutgeb@radicle.xyz>*
+
* `8558cc223` **cli/self: `--nid` deprecation warning to stderr** *<lorenz.leutgeb@radicle.xyz>*
+
* `3fb04623a` **cli/warning: Add `fn deprecate`** *<lorenz.leutgeb@radicle.xyz>*
+
* `2635562c9` **cli/node/status: Add `--only nid`** *<lorenz.leutgeb@radicle.xyz>*
+
* `8afd55ff6` **build: update release files location** *<fintan.halpenny@gmail.com>*
+
* `d2e10fdef` **cli/tests/commands: Clean up test `rad_patch`** *<erik@zirkular.io>*
+
* `19210faab` **protocol/service: Change `Routing table updated..` from info to debug** *<me@sebastinez.dev>*
+
* `86472fdcc` **remote-helper/fetch: Improve error handling** *<lorenz.leutgeb@radicle.xyz>*
+
[..]
+

+
## Checksums
+

+
```
+
675c9d9731751de9c81f8be5445ac80a5bd6dcc7c5d1718d4d8671b7bdfa69e6  radicle-1.5.0-rc.2-aarch64-unknown-linux-musl.tar.xz
+
583921069b031789debbd64de86635f0e3e705d742e1e8e619659181b2933c60  radicle-1.5.0-rc.2-aarch64-apple-darwin.tar.xz
+
fc6ee5d764941aaf21d33547e837f3908fbddba533a5b17675ae04e1ab68a664  radicle-1.5.0-rc.2-x86_64-unknown-linux-musl.tar.xz
+
166bd82760ac4acf68dc7ba7cfe5f32c490311184def9a387b8e47fd39e28b34  radicle-1.5.0-rc.2-x86_64-apple-darwin.tar.xz
+
```
+
~~~
+

+
Once we have the output from `scripts/changelog`, we can post to the internal or
+
release candidate topic in Zulip – naming the topic after the release version name.
+
Remember to `@all` so that everyone is notified. Issues that are encountered
+
should be reported in the Zulip topic, so that they can be resolved for the
+
final release.
+

+
In the `Notes` section we make note of any major or breaking changes that were
+
made in this release.
+

+
Here we can define a grace period of how long we wait for the release to be
+
running until we decide to cut the final release, given that there are no issues
+
with the pre-release. This grace period can depend on the size and complexity of
+
the changes.
+

+
Release Process
+
---------------
+
Once the team feels that the release is ready, the final release can be made.
+
The `build/tag` step should be repeated for the tag, without the `-rc` suffix.
+
The `build/build` and `build/upload` steps are repeated.
+

+
Finally, `SSH_LOGIN=<user> build/release <version>` is used to create a symlink
+
from version release to the `latest` release – which is used in our install
+
script linked to on [Get Started][website].
+

+
### Release Branch
+

+
At this point, a release branch should be created. This branch will used for
+
*patch releases*, e.g. `1.5.1`, `1.5.2`, etc.
+

+
The branch must be named `releases/x.y`, similar to the tagged release, where
+
`x` is the major version, and `y` is the minor version, e.g `releases/1.5`,
+
`releases/1.6`, etc.
+

+
### CHANGELOG
+

+
The `heartwood/CHANGELOG.md` must be updated to reflect the latest changes that
+
were made with regards to the binaries. Many of them should have been included
+
during the development process, such as new features, breaking changes, or fixed
+
bugs. It is still worth checking `scripts/changelog` to see if there were any
+
missed notes.
+

+
Once the change log is finalized with a header using the version number, e.g.
+
`## 1.5.0`, it should be committed to the `releases/x.y` branch and a patch must
+
be made to port the changes to the `master` branch.
+

+
### Announcement
+

+
The announcement post is prepared using the [`radicle.xyz`
+
repository][radicle-xyz], and should appear in the [Updates][updates] section of
+
the website. The announcement is essentially the same as the
+
`heartwood/CHANGELOG.md` entry, but should include some preamble about the
+
effort of the release – have fun with it!
+

+
We then announce on [Zulip][zulip], [Mastodon][mastodon], and [Bluesky][bsk].
+

+
Patch Releases
+
--------------
+

+
After the `x.y.0` release is made, it may be beneficial, or even necessary, to
+
release patch releases of the binaries. These patch releases must be compatible
+
with minor version that was released, otherwise the commits should not be
+
included.
+

+
These changes may have been made on `master` and back-ported to the
+
`releases/x.y` branch. Note that is not the job of the maintainer to ensure that
+
the change applies cleanly to both branches – it is the job of the person
+
contributing the changes.
+
Alternatively, it may be the case that changes are made on the `releases/x.y`
+
branch and forward-ported to `master`. The burden of ensuring changes apply
+
remains the same as above.
+

+
Remember that `heartwood/CHANGELOG.md` must be updated to include the latest
+
changes in the patch release. These must be forward-ported to the `master`
+
branch.
+

+
[radicle-infra]: https://app.radicle.xyz/nodes/seed.radicle.xyz/rad:z254T5p17bdFPmzfDojsdjo4HjpoZ
+
[radicle-nix]: https://github.com/radicle-nix/radicle-nix
+
[get-started]: https://radicle.xyz/#get-started
+
[radicle-xyz]: https://app.radicle.xyz/nodes/seed.radicle.xyz/rad:z371PVmDHdjJucejRoRYJcDEvD5pp
+
[updates]: https://radicle.xyz/#updates
+
[zulip]: https://radicle.zulipchat.com/#narrow/channel/409174-announcements
+
[mastodon]: https://toot.radicle.xyz/explore
+
[bsky]: https://bsky.app/profile/radicle.xyz
modified build.rs
@@ -21,10 +21,44 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
            .unwrap_or("unknown".into())
    });

-
    let version = if let Ok(version) = env::var("RADICLE_VERSION") {
-
        version
+
    let version = env::var("RADICLE_VERSION").unwrap_or_else(|_| {
+
        // If `RADICLE_VERSION` is not set, we still try our best to
+
        // describe this version by asking git. The result will in many
+
        // cases be a reference to the last released version, and how
+
        // many commits we are ahead, plus a short version of the
+
        // object ID of `HEAD`, e.g. `releases/x.y.z-80-gefe10f95be-dirty`
+
        // which would mean that we built 80 commits ahead of release
+
        // x.y.z, with efe10f95be being a unique prefix of the OID of
+
        // `HEAD`, and the working directory was dirty.
+
        // If this is a build pointing to a commit that has release tag, this
+
        // will just return the tag name itself, e.g. `releases/x.y.z`.
+
        // If all fails, we just use `hash`, which, in the worst case is
+
        // still "unknown" (see above) but in most cases will just be
+
        // the short OID of `HEAD`.
+
        Command::new("git")
+
            .arg("describe")
+
            .arg("--always")
+
            .arg("--broken")
+
            .arg("--dirty")
+
            .output()
+
            .ok()
+
            .and_then(|output| {
+
                if output.status.success() {
+
                    String::from_utf8(output.stdout).ok()
+
                } else {
+
                    None
+
                }
+
            })
+
            .unwrap_or(hash.clone())
+
    });
+

+
    // Since in the previous step we are likely to almost always end up with
+
    // a prefix of `releases/`, as this is the scheme we use in this
+
    // repository, we remove this common prefix, to get nice version numbers.
+
    let version = if let Some(stripped) = version.strip_prefix("releases/") {
+
        stripped.to_owned()
    } else {
-
        "pre-release".to_owned()
+
        version
    };

    // Set a build-time `SOURCE_DATE_EPOCH` env var which includes the commit time.
modified build/Dockerfile
@@ -16,7 +16,16 @@ ARG GIT_HEAD
# Copy cargo configuration we're going to use to specify compiler options.
RUN mkdir -p .cargo && cp build/config.toml .cargo/config.toml
# Install dependencies.
-
RUN apk update && apk add --no-cache git musl-dev xz asciidoctor zig
+
RUN apk update && apk add --no-cache git musl-dev xz asciidoctor
+

+
# Install a pinned zig version with MacOS TBD v4 support
+
ARG ZIG_VERSION="0.13.0"
+
RUN wget -q https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz && \
+
    tar -xf zig-linux-x86_64-${ZIG_VERSION}.tar.xz && \
+
    mv zig-linux-x86_64-${ZIG_VERSION} /usr/local/zig && \
+
    ln -s /usr/local/zig/zig /usr/local/bin/zig && \
+
    rm zig-linux-x86_64-${ZIG_VERSION}.tar.xz
+

# Build man pages and strip metadata. Removes all comments, since they include
# non-reproducible information, such as version numbers.
RUN asciidoctor --doctype manpage --backend manpage --destination-dir . *.1.adoc && \
@@ -32,7 +41,7 @@ RUN rustup target add \
# We use Zig as the linker to perform the compilation from a Linux host.
# Zig is not yet available on Debian, so we download the official binary.
# Compilation is done via `cargo-zigbuild` which is a wrapper around `zig`.
-
RUN cargo install --locked cargo-zigbuild@0.20.0
+
RUN cargo install --locked cargo-zigbuild@0.22.1


# Parts of the macOS SDK are required to build Radicle, we make these available
modified build/macos-sdk-11.3.tar.xz
modified build/release
@@ -12,6 +12,7 @@ SSH_KEY="$(rad path)/keys/radicle"

main() {
  version="$1"
+
  destination="/var/www/files.radicle.xyz/releases"

  if [ -z "$version" ]; then
    echo "fatal: empty version number" >&2 ; exit 1
@@ -28,7 +29,7 @@ main() {
  case "$confirmation" in
    [Yy]*)
      echo "Creating 'latest' symlink.."
-
      ssh -i "$SSH_KEY" "$SSH_ADDRESS" ln -snf "/mnt/radicle/files/releases/$version" /mnt/radicle/files/releases/latest ;;
+
      ssh -i "$SSH_KEY" "$SSH_ADDRESS" ln -snf "$destination/$version" "$destination/latest" ;;
    *)
      echo "Operation aborted."
      exit 1 ;;
modified build/upload
@@ -17,7 +17,7 @@ main() {
    echo "fatal: empty version number" >&2 ; exit 1
  fi

-
  destination="/mnt/radicle/files/releases/$version"
+
  destination="/var/www/files.radicle.xyz/releases/$version"

  # Create remote folder.
  ssh -i "$SSH_KEY" "$SSH_ADDRESS" mkdir -p "$destination"
@@ -52,7 +52,6 @@ main() {
    fi
  done < build/TARGETS

-
  # TODO(cloudhead): Don't pass `--tags` when we have canonical refs.
  # Pushes tags without assuming the remote the user is using. It does this by
  # using the pushurl directly, i.e.
  # `rad://z3gqcJUoA1n9HaHKufZs5FCSGazv5/<nid>`, where `<nid>` is the local Node
modified crates/radicle-cli-test/Cargo.toml
@@ -16,6 +16,11 @@ escargot = "0.5.7"
log = { workspace = true, features = ["std"] }
pretty_assertions = { workspace = true }
radicle = { workspace = true, features = ["logger", "test"]}
-
shlex = { workspace = true }
snapbox = { workspace = true }
-
thiserror = { workspace = true }

\ No newline at end of file
+
thiserror = { workspace = true, default-features = true }
+

+
[target.'cfg(unix)'.dependencies]
+
shlex = { workspace = true }
+

+
[target.'cfg(windows)'.dependencies]
+
winsplit = { workspace = true }
modified crates/radicle-cli-test/README.md
@@ -18,7 +18,7 @@ ohai
`````

Say this is placed in `kind-echo.md`, this is what the corresponding test case
-
would look lke:
+
would look like:

``` rust
use std::path::Path;
modified crates/radicle-cli-test/src/lib.rs
@@ -4,12 +4,16 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync;
-
use std::{env, ffi, fs, io, mem};
+
use std::{env, fs, io, mem};

use snapbox::cmd::{Command, OutputAssert};
use snapbox::{Assert, Substitutions};
use thiserror::Error;

+
const CARGO_TARGET_DIR_DIRNAME: &str = "target";
+

+
const CARGO_PROFILE: &str = "debug";
+

/// Used to ensure the build task is only run once.
static BUILD: sync::Once = sync::Once::new();

@@ -65,6 +69,8 @@ pub struct Assertion {
    expected: String,
    /// Expected exit status.
    exit: ExitStatus,
+
    /// Line number in the test file where this assertion is defined.
+
    line: usize,
}

#[derive(Debug, Default, PartialEq, Eq, Clone)]
@@ -124,6 +130,7 @@ impl<'a> TestRunner<'a> {

        if let Some(ref h) = test.home {
            if let Some(home) = self.homes.get(h) {
+
                env.insert("USER".to_owned(), h.to_owned());
                return TestRun {
                    home: home.clone(),
                    env,
@@ -163,8 +170,6 @@ pub struct TestFormula {
    tests: Vec<Test>,
    /// Output substitutions.
    subs: Substitutions,
-
    /// Binaries path.
-
    bins: Vec<PathBuf>,
}

impl TestFormula {
@@ -175,35 +180,11 @@ impl TestFormula {
            homes: HashMap::new(),
            tests: Vec::new(),
            subs: Substitutions::new(),
-
            bins: env::var("PATH")
-
                .map(|env_path| {
-
                    let mut bins: Vec<PathBuf> = env_path.split(':').map(PathBuf::from).collect();
-
                    // Add current working directory to `$PATH`,
-
                    // this makes it more convenient to execute scripts during testing.
-
                    bins.push(cwd);
-
                    bins
-
                })
-
                .unwrap_or_default(),
        }
    }

    pub fn build(&mut self, binaries: &[(&str, &str)]) -> &mut Self {
-
        let manifest = env::var("CARGO_MANIFEST_DIR").expect(
-
            "TestFormula::build: cannot build binaries: variable `CARGO_MANIFEST_DIR` is not set",
-
        );
-
        let profile = if cfg!(debug_assertions) {
-
            "debug"
-
        } else {
-
            "release"
-
        };
-
        let target_dir = env::var("CARGO_TARGET_DIR").unwrap_or("target".to_string());
-
        let manifest = Path::new(manifest.as_str());
-
        let bins = manifest.join(&target_dir).join(profile);
-

-
        // Add the target dir to the beginning of the list we will use as `PATH`.
-
        self.bins.insert(0, bins);
-

-
        // We don't need to re-build everytime the `build` function is called. Once is enough.
+
        // We don't need to re-build every time the `build` function is called. Once is enough.
        BUILD.call_once(|| {
            use escargot::format::Message;
            use radicle::logger::env_level;
@@ -218,11 +199,13 @@ impl TestFormula {
            for (package, binary) in binaries {
                log::debug!(target: "test", "Building binaries for package `{package}`..");

+
                let cargo_manifest_dir = cargo_manifest_dir();
+

                let results = escargot::CargoBuild::new()
                    .package(package)
                    .bin(binary)
-
                    .manifest_path(manifest.join("Cargo.toml"))
-
                    .target_dir(&target_dir)
+
                    .manifest_path(cargo_manifest_dir.clone().join("Cargo.toml"))
+
                    .target_dir(cargo_manifest_dir.join(CARGO_TARGET_DIR_DIRNAME))
                    .exec()
                    .unwrap();

@@ -297,7 +280,7 @@ impl TestFormula {
        let mut fenced = false; // Whether we're inside a fenced code block.
        let mut file: Option<(PathBuf, String)> = None; // Path and content of file created by this test block.

-
        for line in r.lines() {
+
        for (row, line) in r.lines().enumerate() {
            let line = line?;

            if line.starts_with("```") {
@@ -346,7 +329,13 @@ impl TestFormula {
                    content.push('\n');
                } else if let Some(line) = line.strip_prefix('$') {
                    let line = line.trim();
+

+
                    #[cfg(unix)]
                    let parts = shlex::split(line).ok_or(Error::Parse)?;
+

+
                    #[cfg(windows)]
+
                    let parts = winsplit::split(line);
+

                    let (cmd, args) = parts.split_first().ok_or(Error::Parse)?;

                    test.assertions.push(Assertion {
@@ -359,6 +348,7 @@ impl TestFormula {
                        } else {
                            ExitStatus::Success
                        },
+
                        line: row + 1,
                    });
                } else if let Some(a) = test.assertions.last_mut() {
                    a.expected.push_str(line.as_str());
@@ -413,29 +403,22 @@ impl TestFormula {
        let mut runner = TestRunner::new(self);

        fs::create_dir_all(&self.cwd)?;
-
        log::debug!(target: "test", "Using PATH {:?}", self.bins);

        // For each code block.
        for test in &self.tests {
            let mut run = runner.run(test);

            // For each command.
-
            for assertion in &test.assertions {
-
                // Expand environment variables.
-
                let mut args = assertion.args.clone();
-
                for arg in &mut args {
-
                    for (k, v) in run.envs() {
-
                        *arg = arg.replace(format!("${k}").as_str(), &v);
-
                    }
-
                }
-
                let path = assertion
+
            for (i, assertion) in test.assertions.iter().enumerate() {
+
                let location = assertion
                    .path
                    .file_name()
                    .map(|f| f.to_string_lossy().to_string())
+
                    .map(|f| f.strip_suffix(".md").unwrap_or(&f).to_owned())
+
                    .map(|f| f + ":" + assertion.line.to_string().as_str())
                    .unwrap_or(String::from("<none>"));
-
                let cmd = if assertion.command == "rad" {
-
                    snapbox::cmd::cargo_bin("rad")
-
                } else if assertion.command == "cd" {
+

+
                if assertion.command == "cd" {
                    let arg = assertion.args.first().unwrap();
                    let dir: PathBuf = arg.into();
                    let dir = run.path().join(dir);
@@ -443,7 +426,7 @@ impl TestFormula {
                    // TODO: Add support for `..` and `/`
                    // TODO: Error if more than one args are given.

-
                    log::debug!(target: "test", "{path}: Running `cd {}`..", dir.display());
+
                    log::debug!(target: "test", "{location}: `cd {}`..", dir.display());

                    if !dir.exists() {
                        return Err(io::Error::new(
@@ -454,37 +437,65 @@ impl TestFormula {
                    run.cd(dir);

                    continue;
-
                } else {
-
                    PathBuf::from(&assertion.command)
-
                };
-
                log::debug!(target: "test", "{path}: Running `{}` with {:?} in `{}`..", cmd.display(), assertion.args, run.path().display());
+
                }
+

+
                // Expand environment variables.
+
                let mut args = assertion.args.clone();
+
                for arg in &mut args {
+
                    for (k, v) in run.envs() {
+
                        *arg = arg.replace(format!("${k}").as_str(), &v);
+
                    }
+
                }

                if !run.path().exists() {
-
                    log::warn!(target: "test", "{path}: Directory {} does not exist. Creating..", run.path().display());
+
                    log::warn!(target: "test", "{location}: Directory {} does not exist. Creating..", run.path().display());
                    fs::create_dir_all(run.path())?;
                }

-
                let bins = self
-
                    .bins
-
                    .iter()
-
                    .map(|p| p.as_os_str())
-
                    .collect::<Vec<_>>()
-
                    .join(ffi::OsStr::new(":"));
-
                let result = Command::new(cmd.clone())
+
                let jj_envs = if assertion.command == "jj" {
+
                    vec![
+
                        ("JJ_RANDOMNESS_SEED", i.to_string()),
+
                        ("JJ_TIMESTAMP", "2001-02-03T04:05:06+07:00".to_string()),
+
                        ("JJ_OP_TIMESTAMP", "2001-02-03T04:05:06+07:00".to_string()),
+
                    ]
+
                } else {
+
                    vec![]
+
                };
+

+
                let bins = std::env::join_paths(bins(self.cwd.clone())).unwrap();
+

+
                let command = Command::new(assertion.command.clone())
                    .env_clear()
                    .env("PATH", &bins)
                    .env("RUST_BACKTRACE", "1")
+
                    .envs(jj_envs)
                    .envs(run.envs())
                    .current_dir(run.path())
-
                    .args(args)
-
                    .with_assert(assert.clone())
-
                    .output();
+
                    .args(args.clone())
+
                    .with_assert(assert.clone());
+

+
                log::debug!(target: "test", "{location}: `{} {}` @ {}", assertion.command, args.join(" "), run.path().display());
+
                log::trace!(target: "test", "{location}: {}", run.envs().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join(", "));
+
                log::logger().flush();

-
                match result {
+
                // Even though it would be possible to use `Command::assert` to directly obtain
+
                // `OutputAssert`, we use `Command::output` to be able to handle `io::ErrorKind::NotFound`
+
                // separately and provide a more helpful error message in that case.
+
                match command.output() {
                    Ok(output) => {
                        let assert = OutputAssert::new(output).with_assert(assert.clone());
                        let expected = Self::map_spaced_brackets(&assertion.expected);

+
                        let expected = {
+
                            #[cfg(windows)]
+
                            const EXE: &str = ".exe";
+

+
                            #[cfg(unix)]
+
                            const EXE: &str = "";
+

+
                            expected.replace("[EXE]", EXE)
+
                        };
+

                        let matches = if test.stderr {
                            assert.stderr_matches(&expected)
                        } else {
@@ -501,11 +512,11 @@ impl TestFormula {
                    }
                    Err(err) => {
                        if err.kind() == io::ErrorKind::NotFound {
-
                            log::error!(target: "test", "{path}: Command `{}` does not exist..", cmd.display());
+
                            log::error!(target: "test", "{location}: Command `{}` does not exist..", assertion.command);
                        }
                        return Err(io::Error::new(
                            err.kind(),
-
                            format!("{path}: {err}: `{}`", cmd.display()),
+
                            format!("{location}: {err}: `{}`", assertion.command),
                        ));
                    }
                }
@@ -516,6 +527,44 @@ impl TestFormula {
    }
}

+
fn cargo_manifest_dir() -> PathBuf {
+
    env::var("CARGO_MANIFEST_DIR").map(PathBuf::from).unwrap()
+
}
+

+
/// Get the list of binary paths to use as `$PATH` for the tests,
+
/// starting with the current working directory.
+
fn bins(cwd: PathBuf) -> Vec<PathBuf> {
+
    let mut bins: Vec<PathBuf> = Vec::new();
+

+
    // Add current working directory to `$PATH`,
+
    // this makes it more convenient to execute scripts during testing.
+
    bins.push(cwd);
+

+
    bins.push(
+
        cargo_manifest_dir()
+
            .join(CARGO_TARGET_DIR_DIRNAME)
+
            .join(CARGO_PROFILE),
+
    );
+

+
    // Add the "real" `$PATH`.
+
    if let Ok(path) = env::var("PATH") {
+
        bins.extend(env::split_paths(&path));
+
    }
+

+
    #[cfg(windows)]
+
    {
+
        // Radicle CLI tests rely on various Unix coreutils
+
        // (such as `ls` and `touch`) being available.
+
        // On Windows, it is very likely that we can find them in the
+
        // following location.
+
        // Note that adding this path to the end of `$PATH` causes
+
        // no harm, even if the directory does not exist.
+
        bins.push(PathBuf::from(r#"C:\Program Files\Git\usr\bin"#));
+
    }
+

+
    bins
+
}
+

#[cfg(test)]
mod tests {
    use super::*;
@@ -557,21 +606,13 @@ $ rad sync
            cwd: cwd.clone(),
            env: HashMap::new(),
            subs: Substitutions::new(),
-
            bins: {
-
                let mut bins: Vec<_> = env::var("PATH")
-
                    .unwrap_or_default()
-
                    .split(':')
-
                    .map(PathBuf::from)
-
                    .collect();
-
                bins.push(cwd);
-
                bins
-
            },
            tests: vec![
                Test {
                    context: vec![String::from("Let's try to track @dave and @sean:")],
                    home: None,
                    assertions: vec![
                        Assertion {
+
                            line: 3,
                            path: path.clone(),
                            command: String::from("rad"),
                            args: vec![String::from("track"), String::from("@dave")],
@@ -581,6 +622,7 @@ $ rad sync
                            exit: ExitStatus::Success,
                        },
                        Assertion {
+
                            line: 7,
                            path: path.clone(),
                            command: String::from("rad"),
                            args: vec![String::from("track"), String::from("@sean")],
@@ -600,6 +642,7 @@ $ rad sync
                    context: vec![String::from("Super, now let's move on to the next step.")],
                    home: Some("alice".to_owned()),
                    assertions: vec![Assertion {
+
                        line: 13,
                        path: path.clone(),
                        command: String::from("rad"),
                        args: vec![String::from("sync")],
added crates/radicle-cli/CHANGELOG.md
@@ -0,0 +1,64 @@
+
# Changelog
+

+
All notable changes to this project will be documented in this file.
+

+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+

+
## [Unreleased]
+

+
### Added
+

+
### Changed
+

+
### Removed
+

+
### Security
+

+
## 0.18.0
+

+
### Changed
+

+
- `radicle_cli::terminal::fail` now takes 1 parameter, the `anyhow::Error`
+
  instead of 2.
+
- `radicle_cli::commands::diff::run` now delegates to the `git diff` process,
+
  and only accepts the `Vec<OsString>` args.
+

+
### Removed
+

+
The `radicle-cli` crate is refactored to use the `clap` crate, and as a result
+
many things were removed from the public API.
+

+
- The `radicle_cli::terminal::args` module was removed, including the `Args`
+
  trait, the `Help` struct, the `Error` enum, and all parsing functions
+
  (`parse_value`, `finish`, `format`, `refstring`, `did`, `nid`, `rid`,
+
  `pubkey`, `addr`, `socket_addr`, `number`, `seconds`, `milliseconds`,
+
  `string`, `rev`, `oid`, `alias`, `issue`, `patch`, `cob`).
+
- The `radicle_cli::terminal::Command` trait was removed.
+
- `radicle_cli::terminal::run_command`, `radicle_cli::terminal::run_command_args`,
+
  and `radicle_cli::terminal::run_command_fn` were removed.
+
- The `radicle_cli::commands::help` module was removed, including
+
  `help::Options` and `help::run`.
+
- `radicle_cli::git::parse_remote` was removed.
+
- The `HELP` constant was removed from the following command modules: `auth`,
+
  `block`, `checkout`, `clone`, `cob`, `config`, `debug`, `diff`, `follow`,
+
  `fork`, `help`, `id`, `inbox`, `init`, `inspect`, `ls`, `node`, `patch`,
+
  `publish`, `remote`, `seed`, `self`, `sync`, `unblock`, `watch`.
+
- The `Options` struct was removed from the following command modules: `auth`,
+
  `block`, `checkout`, `clone`, `cob`, `config`, `debug`, `diff`, `follow`,
+
  `fork`, `help`, `id`, `inbox`, `init`, `inspect`, `ls`, `node`, `patch`,
+
  `publish`, `remote`, `seed`, `rad_self`, `sync`, `unblock`, `watch`.
+
- `radicle_cli::commands::patch::AssignOptions`,
+
  `radicle_cli::commands::patch::LabelOptions`, and
+
  `radicle_cli::commands::patch::CommentOperation` were removed.
+
- The following enums were removed from their respective command modules:
+
  `follow::Operation`, `follow::OperationName`, `id::Operation`,
+
  `id::OperationName`, `inspect::Target`, `node::Addr`, `node::Operation`,
+
  `node::OperationName`, `patch::Operation`, `patch::OperationName`,
+
  `remote::Operation`, `remote::OperationName`, `remote::ListOption`,
+
  `seed::Operation`, `sync::Operation`, `sync::SyncDirection`,
+
  `sync::SyncMode`, `sync::SortBy`.
+

+
### Security
+

+
*No security updates.*
modified crates/radicle-cli/Cargo.toml
@@ -3,7 +3,7 @@ name = "radicle-cli"
description = "Radicle CLI"
homepage.workspace = true
license.workspace = true
-
version = "0.16.0"
+
version = "0.18.0"
authors = ["cloudhead <cloudhead@radicle.xyz>"]
edition.workspace = true
build = "build.rs"
@@ -16,27 +16,25 @@ path = "src/main.rs"
[dependencies]
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 }
-
localtime = { workspace = true }
+
itertools.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-localtime = { workspace = true }
+
radicle-surf = { workspace = true }
radicle-term = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
-
shlex = { workspace = true }
tempfile = { workspace = true }
-
thiserror = { workspace = true }
+
thiserror = { workspace = true, default-features = true }
timeago = { version = "0.4.2", default-features = false }
tree-sitter = "0.24.4"
tree-sitter-bash = "0.23.3"
@@ -54,10 +52,17 @@ tree-sitter-toml-ng = "0.6.0"
tree-sitter-typescript = "0.23.2"
zeroize = { workspace = true }

+
[target.'cfg(unix)'.dependencies]
+
shlex = { workspace = true }
+

+
[target.'cfg(windows)'.dependencies]
+
winsplit = { workspace = true }
+

[dev-dependencies]
pretty_assertions = { workspace = true }
radicle = { workspace = true, features = ["test"] }
radicle-cli-test = { workspace = true }
+
radicle-localtime = { workspace = true }
radicle-node = { workspace = true, features = ["test"] }

[lints]
modified crates/radicle-cli/examples/framework/home.md
@@ -8,23 +8,23 @@ $ touch file.bin
$ rad self --did
did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
$ pwd
-
[..]/home/bob/.radicle
+
[..]/bob/.radicle
$ mkdir src
$ cd src
$ pwd
-
[..]/home/bob/.radicle/src
+
[..]/bob/.radicle/src
```

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+
For more information, try '--help'.
```
modified crates/radicle-cli/examples/rad-block.md
@@ -38,7 +38,7 @@ $ rad seed
╭───────────────────────────────────────────────────────────╮
│ Repository                          Name   Policy   Scope │
├───────────────────────────────────────────────────────────┤
-
│ rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji          block    all   │
+
│ rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji          block          │
╰───────────────────────────────────────────────────────────╯
```

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

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

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

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

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

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

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

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

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

+
Let's check the last commit!
+

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

+
    Second commit
+
```
+

+
Cloned repositories show up in `rad ls`:
+
```
+
$ rad ls --seeded
+
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ Name        RID                                 Visibility   Head      Description                        │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ heartwood   rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji   public       f2de534   Radicle Heartwood Protocol & Stack │
+
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
```
modified crates/radicle-cli/examples/rad-clone-connect.md
@@ -3,7 +3,7 @@ automatically connect to the necessary seeds.

```
$ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'all'
+
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'followed'
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 2 potential seed(s).
✓ Target met: 1 seed(s)
✓ Creating checkout in ./heartwood..
modified crates/radicle-cli/examples/rad-clone-partial-fail.md
@@ -10,12 +10,13 @@ $ rad node routing
│ rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji   z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk │
╰──────────────────────────────────────────────────────────────────────────────────────╯
```
+

When she tries to clone, one of those will fail to fetch. But the clone command
still returns successfully.

```
$ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --timeout 3
-
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'all'
+
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'followed'
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 3 potential seed(s).
✓ Target met: 1 seed(s)
✓ Creating checkout in ./heartwood..
modified crates/radicle-cli/examples/rad-clone.md
@@ -2,7 +2,7 @@ To create a local 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
+
$ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'followed'
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found [..] potential seed(s).
✓ Target met: [..] seed(s)
modified crates/radicle-cli/examples/rad-cob-update.md
@@ -46,6 +46,7 @@ $ rad patch show 89f7afb
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2                  │
│ Author    alice (you)                                               │
│ Head      8945f6189adf027892c85ac57f7e9341049c2537                  │
+
│ Base      [..                                                     ] │
│ Branches  changes                                                   │
│ Commits   ahead 2, behind 0                                         │
│ Status    open                                                      │
@@ -69,6 +70,7 @@ $ rad patch show 89f7afb
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2                  │
│ Author    alice (you)                                               │
│ Head      8945f6189adf027892c85ac57f7e9341049c2537                  │
+
│ Base      [..                                                     ] │
│ Branches  changes                                                   │
│ Commits   ahead 2, behind 0                                         │
│ Status    open                                                      │
@@ -161,6 +163,7 @@ $ rad patch show 89f7afb
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2                  │
│ Author    alice (you)                                               │
│ Head      f1339dd109e538c6b3a7fed3e72403e1b4db08c9                  │
+
│ Base      [..                                                     ] │
│ Branches  changes                                                   │
│ Commits   ahead 3, behind 0                                         │
│ Status    open                                                      │
@@ -175,4 +178,4 @@ $ rad patch show 89f7afb
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
│ ↑ updated to 2da9c025a1d14d93c4f2cec60a7878afbc5e2a3c (f1339dd) now │
╰─────────────────────────────────────────────────────────────────────╯
-
```

\ No newline at end of file
+
```
modified crates/radicle-cli/examples/rad-config.md
@@ -57,6 +57,614 @@ $ rad config
}
```

+
The `rad config schema` command provides the JSON schema that can be used to
+
validate the JSON of the user configuration.
+

+
```
+
$ rad config schema
+
{
+
  "$schema": "https://json-schema.org/draft/2020-12/schema",
+
  "title": "Config",
+
  "description": "Local radicle configuration.",
+
  "type": "object",
+
  "properties": {
+
    "publicExplorer": {
+
      "description": "Public explorer. This is used for generating links.",
+
      "$ref": "#/$defs/Explorer",
+
      "default": "https://app.radicle.xyz/nodes/$host/$rid$path"
+
    },
+
    "preferredSeeds": {
+
      "description": "Preferred seeds. These seeds will be used for explorer links/nand in other situations when a seed needs to be chosen.",
+
      "type": "array",
+
      "items": {
+
        "$ref": "#/$defs/ConnectAddress"
+
      },
+
      "default": []
+
    },
+
    "web": {
+
      "description": "Web configuration.",
+
      "$ref": "#/$defs/WebConfig",
+
      "default": {
+
        "pinned": {
+
          "repositories": []
+
        }
+
      }
+
    },
+
    "cli": {
+
      "description": "CLI configuration.",
+
      "$ref": "#/$defs/CliConfig",
+
      "default": {
+
        "hints": true
+
      }
+
    },
+
    "node": {
+
      "description": "Node configuration.",
+
      "$ref": "#/$defs/NodeConfig"
+
    }
+
  },
+
  "required": [
+
    "node"
+
  ],
+
  "$defs": {
+
    "Explorer": {
+
      "description": "A public explorer.",
+
      "type": "string"
+
    },
+
    "ConnectAddress": {
+
      "description": "A node address to connect to. Format: An Ed25519 public key in multibase encoding, followed by the symbol '@', followed by an IP address, or a DNS name, or a Tor onion name, followed by the symbol ':', followed by a TCP port number.",
+
      "type": "string",
+
      "examples": [
+
        "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@rosa.radicle.xyz:8776",
+
        "z6MkvUJtYD9dHDJfpevWRT98mzDDpdAtmUjwyDSkyqksUr7C@xmrhfasfg5suueegrnc4gsgyi2tyclcy5oz7f5drnrodmdtob6t2ioyd.onion:8776",
+
        "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi@seed.example.com:8776",
+
        "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5@192.0.2.0:31337"
+
      ],
+
      "pattern": "^.+@.+:((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4}))$"
+
    },
+
    "WebConfig": {
+
      "description": "Web configuration.",
+
      "type": "object",
+
      "properties": {
+
        "pinned": {
+
          "description": "Pinned content.",
+
          "$ref": "#/$defs/Pinned"
+
        },
+
        "bannerUrl": {
+
          "description": "URL pointing to an image used in the header of a node page.",
+
          "type": [
+
            "string",
+
            "null"
+
          ],
+
          "format": "uri"
+
        },
+
        "avatarUrl": {
+
          "description": "URL pointing to an image used as the node avatar.",
+
          "type": [
+
            "string",
+
            "null"
+
          ],
+
          "format": "uri"
+
        },
+
        "description": {
+
          "description": "Node description.",
+
          "type": [
+
            "string",
+
            "null"
+
          ],
+
          "format": "uri"
+
        }
+
      },
+
      "required": [
+
        "pinned"
+
      ]
+
    },
+
    "Pinned": {
+
      "description": "Pinned content. This can be used to pin certain content when/nlisting, e.g. pin repositories on a web client.",
+
      "type": "object",
+
      "properties": {
+
        "repositories": {
+
          "description": "Pinned repositories.",
+
          "type": "array",
+
          "uniqueItems": true,
+
          "items": {
+
            "$ref": "#/$defs/RepoId"
+
          }
+
        }
+
      },
+
      "required": [
+
        "repositories"
+
      ]
+
    },
+
    "RepoId": {
+
      "description": "A repository identifier.",
+
      "type": "string",
+
      "examples": [
+
        "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5"
+
      ],
+
      "minLength": 5,
+
      "pattern": "rad:z[1-9a-km-zA-HJ-NP-Z]+"
+
    },
+
    "CliConfig": {
+
      "description": "CLI configuration.",
+
      "type": "object",
+
      "properties": {
+
        "hints": {
+
          "description": "Whether to show hints or not in the CLI.",
+
          "type": "boolean",
+
          "default": false
+
        }
+
      }
+
    },
+
    "NodeConfig": {
+
      "description": "Service configuration.",
+
      "type": "object",
+
      "properties": {
+
        "alias": {
+
          "description": "Node alias.",
+
          "$ref": "#/$defs/Alias"
+
        },
+
        "listen": {
+
          "description": "Socket address (a combination of IPv4 or IPv6 address and TCP port) to listen on.",
+
          "type": "array",
+
          "items": {
+
            "type": "string"
+
          },
+
          "examples": [
+
            "127.0.0.1:8776"
+
          ],
+
          "default": []
+
        },
+
        "peers": {
+
          "description": "Peer configuration.",
+
          "$ref": "#/$defs/PeerConfig",
+
          "default": {
+
            "type": "dynamic"
+
          }
+
        },
+
        "connect": {
+
          "description": "Peers to connect to on startup./nConnections to these peers will be maintained.",
+
          "type": "array",
+
          "uniqueItems": true,
+
          "items": {
+
            "$ref": "#/$defs/ConnectAddress"
+
          },
+
          "default": []
+
        },
+
        "externalAddresses": {
+
          "description": "Specify the node's public addresses",
+
          "type": "array",
+
          "items": {
+
            "$ref": "#/$defs/Address"
+
          },
+
          "default": []
+
        },
+
        "proxy": {
+
          "description": "Global proxy.",
+
          "type": [
+
            "string",
+
            "null"
+
          ]
+
        },
+
        "onion": {
+
          "description": "Onion address config.",
+
          "anyOf": [
+
            {
+
              "$ref": "#/$defs/AddressConfig"
+
            },
+
            {
+
              "type": "null"
+
            }
+
          ]
+
        },
+
        "network": {
+
          "description": "Peer-to-peer network.",
+
          "$ref": "#/$defs/Network",
+
          "default": "main"
+
        },
+
        "log": {
+
          "description": "Log level.",
+
          "$ref": "#/$defs/LogLevel",
+
          "default": "INFO"
+
        },
+
        "relay": {
+
          "description": "Whether or not our node should relay messages.",
+
          "$ref": "#/$defs/Relay",
+
          "default": "auto"
+
        },
+
        "limits": {
+
          "description": "Configured service limits.",
+
          "$ref": "#/$defs/Limits",
+
          "default": {
+
            "routingMaxSize": 1000,
+
            "routingMaxAge": 604800,
+
            "gossipMaxAge": 1209600,
+
            "fetchConcurrency": 1,
+
            "maxOpenFiles": 4096,
+
            "rate": {
+
              "inbound": {
+
                "fillRate": 5.0,
+
                "capacity": 1024
+
              },
+
              "outbound": {
+
                "fillRate": 10.0,
+
                "capacity": 2048
+
              }
+
            },
+
            "connection": {
+
              "inbound": 128,
+
              "outbound": 16
+
            },
+
            "fetchPackReceive": "500.0 MiB"
+
          }
+
        },
+
        "workers": {
+
          "description": "Number of worker threads to spawn.",
+
          "type": "integer",
+
          "format": "uint",
+
          "minimum": 0,
+
          "default": 8
+
        },
+
        "seedingPolicy": {
+
          "description": "Default seeding policy.",
+
          "$ref": "#/$defs/DefaultSeedingPolicy",
+
          "default": {
+
            "default": "block"
+
          }
+
        },
+
        "secret": {
+
          "description": "Path to a file containing an Ed25519 secret key, in OpenSSH format, i.e./nwith the `-----BEGIN OPENSSH PRIVATE KEY-----` header. The corresponding/npublic key will be used as the Node ID./n/nA decryption password cannot be configured, but passed at runtime via/nthe environment variable `RAD_PASSPHRASE`.",
+
          "type": [
+
            "string",
+
            "null"
+
          ]
+
        }
+
      },
+
      "required": [
+
        "alias"
+
      ],
+
      "additionalProperties": true
+
    },
+
    "Alias": {
+
      "description": "Node alias, i.e. a short and memorable name for it.",
+
      "type": "string"
+
    },
+
    "PeerConfig": {
+
      "description": "Peer configuration.",
+
      "oneOf": [
+
        {
+
          "description": "Static peer set. Connect to the configured peers and maintain the connections.",
+
          "type": "object",
+
          "properties": {
+
            "type": {
+
              "type": "string",
+
              "const": "static"
+
            }
+
          },
+
          "required": [
+
            "type"
+
          ]
+
        },
+
        {
+
          "description": "Dynamic peer set.",
+
          "type": "object",
+
          "properties": {
+
            "type": {
+
              "type": "string",
+
              "const": "dynamic"
+
            }
+
          },
+
          "required": [
+
            "type"
+
          ]
+
        }
+
      ]
+
    },
+
    "Address": {
+
      "description": "An IP address, or a DNS name, or a Tor onion name, followed by the symbol ':', followed by a TCP port number.",
+
      "type": "string",
+
      "examples": [
+
        "xmrhfasfg5suueegrnc4gsgyi2tyclcy5oz7f5drnrodmdtob6t2ioyd.onion:8776",
+
        "seed.example.com:8776",
+
        "192.0.2.0:31337"
+
      ],
+
      "pattern": "^.+:((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4}))$"
+
    },
+
    "AddressConfig": {
+
      "description": "Proxy configuration.",
+
      "oneOf": [
+
        {
+
          "description": "Proxy connections to this address type.",
+
          "type": "object",
+
          "properties": {
+
            "address": {
+
              "description": "Proxy address.",
+
              "type": "string"
+
            },
+
            "mode": {
+
              "type": "string",
+
              "const": "proxy"
+
            }
+
          },
+
          "required": [
+
            "mode",
+
            "address"
+
          ]
+
        },
+
        {
+
          "description": "Forward address to the next layer. Either this is the global proxy,/nor the operating system, via DNS.",
+
          "type": "object",
+
          "properties": {
+
            "mode": {
+
              "type": "string",
+
              "const": "forward"
+
            }
+
          },
+
          "required": [
+
            "mode"
+
          ]
+
        }
+
      ]
+
    },
+
    "Network": {
+
      "description": "Peer-to-peer network.",
+
      "type": "string",
+
      "enum": [
+
        "main",
+
        "test"
+
      ]
+
    },
+
    "LogLevel": {
+
      "$ref": "#/$defs/Level"
+
    },
+
    "Level": {
+
      "description": "A log level.",
+
      "oneOf": [
+
        {
+
          "description": "Designates very serious errors.",
+
          "type": "string",
+
          "const": "ERROR"
+
        },
+
        {
+
          "description": "Designates hazardous situations.",
+
          "type": "string",
+
          "const": "WARN"
+
        },
+
        {
+
          "description": "Designates useful information.",
+
          "type": "string",
+
          "const": "INFO"
+
        },
+
        {
+
          "description": "Designates lower priority information.",
+
          "type": "string",
+
          "const": "DEBUG"
+
        },
+
        {
+
          "description": "Designates very low priority, often extremely verbose, information.",
+
          "type": "string",
+
          "const": "TRACE"
+
        }
+
      ]
+
    },
+
    "Relay": {
+
      "description": "Relay configuration.",
+
      "oneOf": [
+
        {
+
          "description": "Always relay messages.",
+
          "type": "string",
+
          "const": "always"
+
        },
+
        {
+
          "description": "Never relay messages.",
+
          "type": "string",
+
          "const": "never"
+
        },
+
        {
+
          "description": "Relay messages when applicable.",
+
          "type": "string",
+
          "const": "auto"
+
        }
+
      ]
+
    },
+
    "Limits": {
+
      "description": "Configuration parameters defining attributes of minima and maxima.",
+
      "type": "object",
+
      "properties": {
+
        "routingMaxSize": {
+
          "description": "Number of routing table entries before we start pruning.",
+
          "type": "integer",
+
          "format": "uint",
+
          "minimum": 0,
+
          "default": 1000
+
        },
+
        "routingMaxAge": {
+
          "description": "How long to keep a routing table entry before being pruned.",
+
          "$ref": "#/$defs/LocalDuration",
+
          "default": 604800
+
        },
+
        "gossipMaxAge": {
+
          "description": "How long to keep a gossip message entry before pruning it.",
+
          "$ref": "#/$defs/LocalDuration",
+
          "default": 1209600
+
        },
+
        "fetchConcurrency": {
+
          "description": "Maximum number of concurrent fetches per peer connection.",
+
          "type": "integer",
+
          "format": "uint",
+
          "minimum": 0,
+
          "default": 1
+
        },
+
        "maxOpenFiles": {
+
          "description": "Maximum number of open files.",
+
          "type": "integer",
+
          "format": "uint",
+
          "minimum": 0,
+
          "default": 4096
+
        },
+
        "rate": {
+
          "description": "Rate limiter settings.",
+
          "$ref": "#/$defs/RateLimits",
+
          "default": {
+
            "inbound": {
+
              "fillRate": 5.0,
+
              "capacity": 1024
+
            },
+
            "outbound": {
+
              "fillRate": 10.0,
+
              "capacity": 2048
+
            }
+
          }
+
        },
+
        "connection": {
+
          "description": "Connection limits.",
+
          "$ref": "#/$defs/ConnectionLimits",
+
          "default": {
+
            "inbound": 128,
+
            "outbound": 16
+
          }
+
        },
+
        "fetchPackReceive": {
+
          "description": "Channel limits.",
+
          "$ref": "#/$defs/FetchPackSizeLimit",
+
          "default": "500.0 MiB"
+
        }
+
      }
+
    },
+
    "LocalDuration": {
+
      "description": "A time duration measured locally in seconds.",
+
      "type": "integer",
+
      "format": "uint128",
+
      "minimum": 0
+
    },
+
    "RateLimits": {
+
      "description": "Rate limits for inbound and outbound connections.",
+
      "type": "object",
+
      "properties": {
+
        "inbound": {
+
          "$ref": "#/$defs/RateLimit"
+
        },
+
        "outbound": {
+
          "$ref": "#/$defs/RateLimit"
+
        }
+
      },
+
      "required": [
+
        "inbound",
+
        "outbound"
+
      ]
+
    },
+
    "RateLimit": {
+
      "description": "Rate limits for a single connection.",
+
      "type": "object",
+
      "properties": {
+
        "fillRate": {
+
          "type": "number",
+
          "format": "double"
+
        },
+
        "capacity": {
+
          "type": "integer",
+
          "format": "uint",
+
          "minimum": 0
+
        }
+
      },
+
      "required": [
+
        "fillRate",
+
        "capacity"
+
      ]
+
    },
+
    "ConnectionLimits": {
+
      "description": "Connection limits.",
+
      "type": "object",
+
      "properties": {
+
        "inbound": {
+
          "description": "Max inbound connections.",
+
          "type": "integer",
+
          "format": "uint",
+
          "minimum": 0,
+
          "default": 128
+
        },
+
        "outbound": {
+
          "description": "Max outbound connections. Note that this can be higher than the *target* number.",
+
          "type": "integer",
+
          "format": "uint",
+
          "minimum": 0,
+
          "default": 16
+
        }
+
      }
+
    },
+
    "FetchPackSizeLimit": {
+
      "description": "Limiter for byte streams./n/nDefault: 500MiB",
+
      "$ref": "#/$defs/ByteSize"
+
    },
+
    "ByteSize": {
+
      "description": "Byte quantities using unit prefixes according to SI or ISO/IEC 80000-13.",
+
      "type": "string",
+
      "pattern": "^//d+(//.//d+)? ((K|M|G|T|P)i?B?|B)$",
+
      "examples": [
+
        "7 G",
+
        "50.3 TiB",
+
        "200 B",
+
        "4 Ki",
+
        "10 MB"
+
      ]
+
    },
+
    "DefaultSeedingPolicy": {
+
      "description": "Default seeding policy. Applies when no repository policies for the given repo are found.",
+
      "oneOf": [
+
        {
+
          "description": "Allow seeding.",
+
          "type": "object",
+
          "properties": {
+
            "default": {
+
              "type": "string",
+
              "const": "allow"
+
            }
+
          },
+
          "anyOf": [
+
            {
+
              "$ref": "#/$defs/Scope"
+
            },
+
            {
+
              "type": "null"
+
            }
+
          ],
+
          "required": [
+
            "default"
+
          ]
+
        },
+
        {
+
          "description": "Block seeding.",
+
          "type": "object",
+
          "properties": {
+
            "default": {
+
              "type": "string",
+
              "const": "block"
+
            }
+
          },
+
          "required": [
+
            "default"
+
          ]
+
        }
+
      ]
+
    },
+
    "Scope": {
+
      "description": "Follow scope of a seeded repository.",
+
      "oneOf": [
+
        {
+
          "description": "Seed remotes that are explicitly followed.",
+
          "type": "string",
+
          "const": "followed"
+
        },
+
        {
+
          "description": "Seed all remotes.",
+
          "type": "string",
+
          "const": "all"
+
        }
+
      ]
+
    }
+
  }
+
}
+
```
+

You can also get any value in the configuration by path, eg.

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

Exploring `rad diff`.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+
diff --git a/file.bin b/file.bin
+
new file mode 100644
+
index 0000000..1b1cb4d
+
Binary files /dev/null and b/file.bin differ
```
modified crates/radicle-cli/examples/rad-fetch.md
@@ -10,7 +10,7 @@ have to update our seeding policy for the project.

```
$ rad seed rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --no-fetch
-
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'all'
+
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'followed'
```

Now that the project is seeding we can fetch it and we will have it in
added crates/radicle-cli/examples/rad-help.md
@@ -0,0 +1,51 @@
+
```
+
$ 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[EXE] <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
+
  id        Manage repository identities
+
  inbox     Manage your Radicle notifications
+
  init      Initialize a Radicle repository
+
  inspect   Inspect a Radicle repository
+
  issue     Manage issues
+
  ls        List repositories
+
  node      Control and query the Radicle Node
+
  patch     Manage patches
+
  path      Display the Radicle home path
+
  publish   Publish a repository to the network
+
  remote    Manage a repository's remotes
+
  seed      Manage repository seeding policies
+
  self      Show information about your identity and device
+
  stats     Displays aggregated repository and node metrics
+
  sync      Sync repositories to the network
+
  unblock   Unblock repositories or nodes to allow them to be seeded or followed
+
  unfollow  Unfollow a peer
+
  unseed    Remove repository seeding policies
+
  watch     Wait for some state to be updated
+
  version   Print the version information of the CLI
+
  help      Print this message or the help of the given subcommand(s)
+

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

+
  -V, --version
+
          Print version
+
```
modified crates/radicle-cli/examples/rad-id-threshold.md
@@ -170,13 +170,13 @@ sync` and fetch his references:

``` ~bob
$ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'all'
+
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'followed'
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 2 potential seed(s).
✓ Target met: 1 seed(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 │
added crates/radicle-cli/examples/rad-id-unauthorized-delegate.md
@@ -0,0 +1,8 @@
+
Alice has created a new repository `rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji` with only herself as the sole delegate. After Bob has cloned it, let's ensure he can't add himself as a delegate too:
+

+
``` ~bob (fail)
+
$ rad id update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --title "Add myself!" --delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --no-confirm
+
✗ Error: did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk is not a delegate, and only delegates are allowed to create a revision
+
✗ Hint: bob (you) is attempting to modify the identity document but is not a delegate!
+
```
+

modified crates/radicle-cli/examples/rad-id.md
@@ -6,7 +6,7 @@ project.

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -17,11 +17,11 @@ 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
-
✓ Seeding policy updated for rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK with scope 'all'
+
✓ Seeding policy updated for rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK with scope 'followed'
```
```
$ rad node inventory
modified crates/radicle-cli/examples/rad-init-private-clone-seed.md
@@ -1,6 +1,6 @@
Given a private repo `rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu` belonging to Alice,
Alice allows Bob to fetch it, and Bob, without the updated identity document
-
is able to fetch it by specifiying Alice as a seed.
+
is able to fetch it by specifying Alice as a seed.

``` ~alice
$ rad id update --title "Allow Bob" --description "" --allow did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk -q
@@ -30,7 +30,7 @@ $ rad inspect --identity
``` ~bob
$ rad ls --all --private
$ rad clone rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu --seed z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --timeout 1
-
✓ Seeding policy updated for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'all'
+
✓ Seeding policy updated for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'followed'
Fetching rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu from the network, found 1 potential seed(s).
✓ Target met: 1 preferred seed(s).
✓ Creating checkout in ./heartwood..
@@ -49,7 +49,7 @@ We can also use `rad seed` to seed and fetch without creating a checkout.

``` ~bob
$ rad seed rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu --from z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --timeout 1
-
✓ Seeding policy exists for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'all'
+
✓ Seeding policy exists for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'followed'
Fetching rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu from the network, found 1 potential seed(s).
✓ Target met: 1 seed(s)
```
modified crates/radicle-cli/examples/rad-init-private-clone.md
@@ -6,11 +6,11 @@ $ rad ls
```
``` ~bob (fail)
$ rad clone rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu --seed z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --timeout 1
-
✓ Seeding policy updated for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'all'
+
✓ Seeding policy updated for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'followed'
Fetching rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu from the network, found 1 potential seed(s).
✗ Target not met: could not fetch from [z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi], and required 1 more seed(s)
! Warning: Failed to fetch from 1 seed(s).
-
! Warning: z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi: failed to perform fetch handshake: [..]
+
! Warning: z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi: an I/O error occurred during the fetch handshake (connection reset)
✗ Error: no seeds found for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu
```

modified crates/radicle-cli/examples/rad-init-private-no-seed.md
@@ -28,7 +28,7 @@ We can decide to seed it later, so that others can fetch it from us, given
that they are part of the allow list:
```
$ rad seed rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu
-
✓ Seeding policy updated for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'all'
+
✓ Seeding policy updated for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'followed'
```

But it still won't show up in our inventory, since it's private:
modified crates/radicle-cli/examples/rad-init-private.md
@@ -17,7 +17,7 @@ To make it public, run `rad publish`.
To push changes, run `git push`.
```

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

We can verify that the remote tracking branches were also deleted:
modified crates/radicle-cli/examples/rad-node.md
@@ -108,7 +108,7 @@ up in our inventory:
```
$ rad seed rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
✓ Inventory updated with rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'all'
+
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'followed'
$ rad node inventory
rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
```
modified crates/radicle-cli/examples/rad-patch-ahead-behind.md
@@ -56,20 +56,20 @@ When showing the patch, we see that it is `ahead 1, behind 1`, since master has
diverged by one commit:
```
$ rad patch show -v -p 217f050
-
╭────────────────────────────────────────────────────╮
-
│ Title     Add Alan                                 │
-
│ Patch     217f050f8891def8fb863f7c0b4f85c89f97299d │
-
│ Author    alice (you)                              │
-
│ Head      5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7 │
-
│ Base      f64fb2c8fe28f7c458c72ec8d700373924794943 │
-
│ Branches  feature/1                                │
-
│ Commits   ahead 1, behind 1                        │
-
│ Status    open                                     │
-
├────────────────────────────────────────────────────┤
-
│ 5c88a79 Add Alan                                   │
-
├────────────────────────────────────────────────────┤
-
│ ● opened by alice (you) (5c88a79) now              │
-
╰────────────────────────────────────────────────────╯
+
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ Title     Add Alan                                                                                                        │
+
│ Patch     217f050f8891def8fb863f7c0b4f85c89f97299d                                                                        │
+
│ Author    alice (you)                                                                                                     │
+
│ Head      5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7                                                                        │
+
│ Base      f64fb2c8fe28f7c458c72ec8d700373924794943                                                                        │
+
│ Branches  feature/1                                                                                                       │
+
│ Commits   ahead 1, behind 1                                                                                               │
+
│ Status    open                                                                                                            │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ 5c88a79 Add Alan                                                                                                          │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ● Revision 217f050f8891def8fb863f7c0b4f85c89f97299d with head 5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7 by alice (you) now │
+
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+
Define power requirements
+
```
+

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

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

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

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

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

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

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

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

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

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

+
    Define power requirements
+

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

+
```

\ No newline at end of file
modified crates/radicle-cli/examples/rad-patch-pull-update.md
@@ -22,7 +22,7 @@ To push changes, run `git push`.

``` ~bob
$ rad clone rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK
-
✓ Seeding policy updated for rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK with scope 'all'
+
✓ Seeding policy updated for rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK with scope 'followed'
Fetching rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK from the network, found 1 potential seed(s).
✓ Target met: 1 seed(s)
✓ Creating checkout in ./heartwood..
@@ -97,21 +97,23 @@ Alice pulls the update.

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

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

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

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

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

@@ -61,6 +62,7 @@ And let's look at our local and remote refs:
$ git show-ref
42d894a83c9c356552a57af09ccdbd5587a99045 refs/heads/feature/1
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/master
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/remotes/rad/HEAD
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/remotes/rad/master
42d894a83c9c356552a57af09ccdbd5587a99045 refs/remotes/rad/patches/6035d2f582afbe01ff23ea87528ae523d76875b6
```
@@ -137,21 +139,22 @@ We can then see that the patch head has moved:

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

And we can check that all the refs are properly updated in our repository:
@@ -200,10 +203,10 @@ hint: See the 'Note about fast-forwards' in 'git push --help' for details.
```

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

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

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

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

+
## Detached HEAD
+

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

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

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

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

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

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

+
Or undo this operation with:
+

+
  git switch -
+

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

```
$ rad seed rad:z3Rry7rpdWuGpfjPYGzdJKQADsoNW rad:z3zTnCfi6cVSZG8eCGn6AMDypgAPm
-
✓ Seeding policy updated for rad:z3Rry7rpdWuGpfjPYGzdJKQADsoNW with scope 'all'
+
✓ Seeding policy updated for rad:z3Rry7rpdWuGpfjPYGzdJKQADsoNW with scope 'followed'
Fetching rad:z3Rry7rpdWuGpfjPYGzdJKQADsoNW from the network, found 1 potential seed(s).
✓ Target met: 1 seed(s)
-
✓ Seeding policy updated for rad:z3zTnCfi6cVSZG8eCGn6AMDypgAPm with scope 'all'
+
✓ Seeding policy updated for rad:z3zTnCfi6cVSZG8eCGn6AMDypgAPm with scope 'followed'
Fetching rad:z3zTnCfi6cVSZG8eCGn6AMDypgAPm from the network, found 1 potential seed(s).
✓ Target met: 1 seed(s)
```
added crates/radicle-cli/examples/rad-seed-policy-allow-no-scope.md
@@ -0,0 +1,7 @@
+
We want to ensure that a warning is printed when the `scope` field is missing in the `seedingPolicy`.
+

+
``` alice
+
$ rad node status
+
! Warning: Configuration option 'node.seedingPolicy.scope' is not set, and thus takes the value 'all' by default. The default value will change to 'followed' in a future release. Please edit your configuration file, and set it to one of ['all', 'followed'] explicitly.
+
[..]
+
```
modified crates/radicle-cli/examples/rad-self.md
@@ -9,11 +9,11 @@ Node not running
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
+
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
@@ -26,10 +26,14 @@ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

```
$ rad self --nid
-
! Warning: The option `--nid` is deprecated, please use `rad node status` instead.
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
@@ -37,5 +41,5 @@ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHahWSBEpuT1ESZbynOmBNkLBSnR32Ar4woZqSV2YNH1

```
$ rad self --home
-
[..]/home/alice/.radicle
+
[..]/alice/.radicle
```
modified crates/radicle-cli/examples/rad-sync-without-node.md
@@ -14,5 +14,5 @@ Note that seeding works fine without a running node:

``` ~alice
$ rad seed rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
-
✓ Seeding policy updated for rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 with scope 'all'
+
✓ Seeding policy updated for rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 with scope 'followed'
```
modified crates/radicle-cli/examples/rad-sync.md
@@ -107,7 +107,7 @@ It's also possible to receive an error if a repository is not found anywhere.

```
$ rad seed rad:z39mP9rQAaGmERfUMPULfPUi473tY --no-fetch
-
✓ Seeding policy updated for rad:z39mP9rQAaGmERfUMPULfPUi473tY with scope 'all'
+
✓ Seeding policy updated for rad:z39mP9rQAaGmERfUMPULfPUi473tY with scope 'followed'
```
``` (fail)
$ rad sync rad:z39mP9rQAaGmERfUMPULfPUi473tY
modified crates/radicle-cli/examples/rad-warn-old-nodes.md
@@ -38,8 +38,8 @@ $ rad debug
    "RAD_RNG_SEED": "0"
  },
  "warnings": [
-
    "Value of configuration option `node.connect` at index 0 mentions node with address 'ash.radicle.garden:8776', which has been renamed to 'rosa.radicle.xyz:8776'. Please update your configuration.",
-
    "Value of configuration option `preferred_seeds` at index 0 mentions node with address 'seed.radicle.garden:8776', which has been renamed to 'iris.radicle.xyz:8776'. Please update your configuration."
+
    "Value of configuration option `node.connect` at index 0 mentions node with address 'ash.radicle.garden:8776', which has been renamed to 'rosa.radicle.xyz:8776'. Please edit your configuration file to use the new address.",
+
    "Value of configuration option `preferredSeeds` at index 0 mentions node with address 'seed.radicle.garden:8776', which has been renamed to 'iris.radicle.xyz:8776'. Please edit your configuration file to use the new address."
  ]
}
```
@@ -48,8 +48,8 @@ Also, `rad node status` will warn us:

```
$ rad node status
-
! Warning: Value of configuration option `node.connect` at index 0 mentions node with address 'ash.radicle.garden:8776', which has been renamed to 'rosa.radicle.xyz:8776'. Please update your configuration.
-
! Warning: Value of configuration option `preferred_seeds` at index 0 mentions node with address 'seed.radicle.garden:8776', which has been renamed to 'iris.radicle.xyz:8776'. Please update your configuration.
+
! Warning: Value of configuration option `node.connect` at index 0 mentions node with address 'ash.radicle.garden:8776', which has been renamed to 'rosa.radicle.xyz:8776'. Please edit your configuration file to use the new address.
+
! Warning: Value of configuration option `preferredSeeds` at index 0 mentions node with address 'seed.radicle.garden:8776', which has been renamed to 'iris.radicle.xyz:8776'. Please edit your configuration file to use the new address.
Node is stopped.
To start it, run `rad node start`.
-
```

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

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

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

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

To publish our new state to the network, we simply push:
modified crates/radicle-cli/src/commands.rs
@@ -1,62 +1,32 @@
-
#[path = "commands/auth.rs"]
-
pub mod rad_auth;
-
#[path = "commands/block.rs"]
-
pub mod rad_block;
-
#[path = "commands/checkout.rs"]
-
pub mod rad_checkout;
-
#[path = "commands/clean.rs"]
-
pub mod rad_clean;
-
#[path = "commands/clone.rs"]
-
pub mod rad_clone;
-
#[path = "commands/cob.rs"]
-
pub mod rad_cob;
-
#[path = "commands/config.rs"]
-
pub mod rad_config;
-
#[path = "commands/debug.rs"]
-
pub mod rad_debug;
-
#[path = "commands/diff.rs"]
-
pub mod rad_diff;
-
#[path = "commands/follow.rs"]
-
pub mod rad_follow;
-
#[path = "commands/fork.rs"]
-
pub mod rad_fork;
-
#[path = "commands/help.rs"]
-
pub mod rad_help;
-
#[path = "commands/id.rs"]
-
pub mod rad_id;
-
#[path = "commands/inbox.rs"]
-
pub mod rad_inbox;
-
#[path = "commands/init.rs"]
-
pub mod rad_init;
-
#[path = "commands/inspect.rs"]
-
pub mod rad_inspect;
-
#[path = "commands/issue.rs"]
-
pub mod rad_issue;
-
#[path = "commands/ls.rs"]
-
pub mod rad_ls;
-
#[path = "commands/node.rs"]
-
pub mod rad_node;
-
#[path = "commands/patch.rs"]
-
pub mod rad_patch;
-
#[path = "commands/path.rs"]
-
pub mod rad_path;
-
#[path = "commands/publish.rs"]
-
pub mod rad_publish;
-
#[path = "commands/remote.rs"]
-
pub mod rad_remote;
-
#[path = "commands/seed.rs"]
-
pub mod rad_seed;
+
pub mod auth;
+
pub mod block;
+
pub mod checkout;
+
pub mod clean;
+
pub mod clone;
+
pub mod cob;
+
pub mod config;
+
pub mod debug;
+
pub mod diff;
+
pub mod follow;
+
pub mod fork;
+
pub mod id;
+
pub mod inbox;
+
pub mod init;
+
pub mod inspect;
+
pub mod issue;
+
pub mod ls;
+
pub mod node;
+
pub mod patch;
+
pub mod path;
+
pub mod publish;
+
pub mod remote;
+
pub mod seed;
+
pub mod stats;
+
pub mod sync;
+
pub mod unblock;
+
pub mod unfollow;
+
pub mod unseed;
+
pub mod watch;
+

#[path = "commands/self.rs"]
pub mod rad_self;
-
#[path = "commands/stats.rs"]
-
pub mod rad_stats;
-
#[path = "commands/sync.rs"]
-
pub mod rad_sync;
-
#[path = "commands/unblock.rs"]
-
pub mod rad_unblock;
-
#[path = "commands/unfollow.rs"]
-
pub mod rad_unfollow;
-
#[path = "commands/unseed.rs"]
-
pub mod rad_unseed;
-
#[path = "commands/watch.rs"]
-
pub mod rad_watch;
modified crates/radicle-cli/src/commands/auth.rs
@@ -1,5 +1,6 @@
#![allow(clippy::or_fun_call)]
-
use std::ffi::OsString;
+
mod args;
+

use std::str::FromStr;

use anyhow::{anyhow, Context};
@@ -11,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);

-
        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 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<()> {
    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() {
@@ -92,7 +37,7 @@ pub fn init(options: Options) -> anyhow::Result<()> {
        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());
@@ -105,7 +50,7 @@ pub fn init(options: Options) -> anyhow::Result<()> {
        user.ok_or_else(|| anyhow::anyhow!("An alias is required for Radicle to run."))?
    };
    let home = profile::home()?;
-
    let passphrase = if options.stdin {
+
    let passphrase = if args.stdin {
        Some(term::passphrase_stdin()?)
    } else {
        term::passphrase_confirm("Enter a passphrase:", env::RAD_PASSPHRASE)?
@@ -165,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(());
@@ -186,7 +131,7 @@ 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 if let Some(passphrase) =
                term::io::passphrase(term::io::PassphraseValidator::new(profile.keystore.clone()))?
@@ -240,7 +185,7 @@ pub fn register(
                e.into()
            }
        })?
-
        .ok_or_else(|| anyhow!("Key not found in {:?}", profile.keystore.path()))?;
+
        .ok_or_else(|| anyhow!("Key not found in {:?}", profile.keystore.secret_key_path()))?;

    agent.register(&secret)?;

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

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

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

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

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

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

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

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

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

-
Options
-

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+
    use super::Args;
+

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

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

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

use std::path::PathBuf;

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

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

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

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

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

-
Options
-

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

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

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

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

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

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

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

    Ok(())
}

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

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

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

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

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

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

-
use anyhow::anyhow;
-

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

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

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

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

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

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

-
Options
-

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

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

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

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

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

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

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

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

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

+
use radicle::prelude::RepoId;
+

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

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

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

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

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

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

-
use anyhow::anyhow;
use radicle::issue::cache::Issues as _;
use radicle::patch::cache::Patches as _;
use thiserror::Error;
@@ -13,6 +10,7 @@ use radicle::git::raw;
use radicle::identity::doc;
use radicle::identity::doc::RepoId;
use radicle::node;
+
use radicle::node::policy;
use radicle::node::policy::Scope;
use radicle::node::{Handle as _, Node};
use radicle::prelude::*;
@@ -21,117 +19,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()?;
+
pub use args::Args;

-
                    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 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 +44,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 +61,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 +101,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!(
@@ -220,6 +122,8 @@ enum CloneError {
    NoSeeds(RepoId),
    #[error("fetch: {0}")]
    Fetch(#[from] sync::FetchError),
+
    #[error("policy store: {0}")]
+
    PolicyStore(#[from] policy::store::Error),
}

struct Checkout {
@@ -229,6 +133,7 @@ struct Checkout {
    repository: storage::git::Repository,
    doc: Doc,
    project: Project,
+
    bare: bool,
}

impl Checkout {
@@ -236,6 +141,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 +163,7 @@ impl Checkout {
            repository,
            doc: doc.doc,
            project: proj,
+
            bare,
        })
    }

@@ -274,7 +181,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 ./{}",
@@ -299,11 +206,21 @@ impl Checkout {
fn clone(
    id: RepoId,
    directory: Option<PathBuf>,
-
    scope: Scope,
+
    scope: Option<Scope>,
    settings: SyncSettings,
    node: &mut Node,
    profile: &Profile,
+
    bare: bool,
) -> Result<CloneResult, CloneError> {
+
    let scope = match scope {
+
        Some(scope) => scope,
+
        None => profile
+
            .policies()?
+
            .seed_policy(&id)?
+
            .scope()
+
            .unwrap_or(Scope::Followed),
+
    };
+

    // Seed repository.
    if node.seed(id, scope)? {
        term::success!(
@@ -322,7 +239,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 +247,7 @@ fn clone(
                }
            }
        }
-
        Ok(repository) => Ok(perform_checkout(repository, profile, directory)?),
+
        Ok(repository) => Ok(perform_checkout(repository, profile, directory, bare)?),
    }
}

@@ -338,8 +255,9 @@ fn perform_checkout(
    repository: storage::git::Repository,
    profile: &Profile,
    directory: Option<PathBuf>,
+
    bare: bool,
) -> Result<CloneResult, rad::CheckoutError> {
-
    Checkout::new(repository, profile, directory).map_or_else(
+
    Checkout::new(repository, profile, directory, bare).map_or_else(
        |failure| Ok(CloneResult::Failure(failure)),
        |checkout| checkout.run(&profile.storage),
    )
added crates/radicle-cli/src/commands/clone/args.rs
@@ -0,0 +1,104 @@
+
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,
+
        value_parser = terminal::args::ScopeParser
+
    )]
+
    pub(super) scope: Option<Scope>,
+

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

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

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

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

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

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

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

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

use anyhow::{anyhow, bail};

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

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

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

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

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

-
Commands
-

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

-
Create, Update options
-

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

-
Log options
-

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

-
Show options
-

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

-
Other options
-

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

-
            match type_name {
+
            match type_name.into() {
                Issue => operations::<cob::issue::Action>(
                    &cob::issue::TYPENAME,
                    oid,
@@ -485,12 +147,13 @@ pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<(
            }
        }
        Show {
-
            rid,
-
            oids,
+
            repo,
+
            objects,
            type_name,
+
            ..
        } => {
-
            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,
+
            ..
+
        }) => {
            let signer = &profile.signer()?;
-
            let repo = storage.repository_mut(rid)?;
-
            let reader = io::BufReader::new(fs::File::open(actions)?);
-
            let oid = &oid.resolve(&repo.backend)?;
-
            let embeds = embeds
-
                .into_iter()
-
                .map(|embed| embed.try_into_bytes(&repo))
-
                .collect::<anyhow::Result<Vec<_>>>()?;
+
            let repo = storage.repository_mut(repo)?;
+
            let oid = object.resolve::<radicle::git::Oid>(&repo.backend)?.into();
+
            let embeds = embeds(&repo, operation.embed_files, operation.embed_hashes)?;

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

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

    NonEmpty::from_vec(read_jsonl(reader)?)
        .ok_or_else(|| anyhow!("at least one action is required"))
}
added crates/radicle-cli/src/commands/cob/args.rs
@@ -0,0 +1,418 @@
+
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)`.
+
        #[allow(clippy::indexing_slicing)]
+
        Embed {
+
            name: chunk[0].to_string(),
+
            content: EmbedContent::from(T::from(chunk[1].clone())),
+
        }
+
    })
+
}
+

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

-
Options
-

-
    --help    Print help
-

-
"#,
-
};
-

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+
pub use args::Args;

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

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

-
    rad debug
-

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

-
"#,
-
};
-

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

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

-
pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(_args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    match ctx.profile() {
        Ok(profile) => debug(Some(&profile)),
        Err(e) => {
@@ -156,7 +131,7 @@ fn stderr_of(bin: &str, args: &[&str]) -> anyhow::Result<String> {

fn collect_warnings(profile: Option<&Profile>) -> Vec<String> {
    match profile {
-
        Some(profile) => crate::warning::nodes_renamed(&profile.config),
+
        Some(profile) => crate::warning::config_warnings(&profile.config),
        None => vec!["No Radicle profile found.".to_string()],
    }
}
added crates/radicle-cli/src/commands/debug/args.rs
@@ -0,0 +1,13 @@
+
use clap::Parser;
+

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

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

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

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

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

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

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

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

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

-
Options
-

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

-
    Ok(())
+
    process::exit(exit_status.code().unwrap_or(1));
}
modified crates/radicle-cli/src/commands/follow.rs
@@ -1,106 +1,21 @@
-
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, .. } => follow(nid, alias, &mut node, &profile)?,
+
        Operation::List { alias, .. } => following(&profile, alias)?,
    }

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

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

+
use crate::terminal as term;
+

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

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

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

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

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

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

+
impl From<Args> for Operation {
+
    fn from(
+
        Args {
+
            nid,
+
            alias,
+
            verbose,
+
        }: Args,
+
    ) -> Self {
+
        match nid {
+
            Some(nid) => Self::Follow {
+
                nid,
+
                alias,
+
                verbose,
+
            },
+
            None => Self::List { alias, verbose },
+
        }
+
    }
+
}
modified crates/radicle-cli/src/commands/fork.rs
@@ -1,66 +1,23 @@
-
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};
+
use crate::{terminal as term, warning};

-
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<()> {
+
    warning::deprecated("rad fork", "git push");
    let profile = ctx.profile()?;
    let signer = profile.signer()?;
    let storage = &profile.storage;

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

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

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

+
This command is deprecated and will be removed.
+

+
Instead of using `rad fork`, use `git push` to push any references to
+
your own namespace of a Radicle repository. Usually
+

+
    git push rad main
+

+
would suffice to push the default branch (here named 'main').
+
";
+

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

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

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

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

+
    #[test]
+
    fn should_not_parse_rid_url() {
+
        let err =
+
            Args::try_parse_from(["fork", "rad://z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]).unwrap_err();
+
        assert_eq!(err.kind(), ErrorKind::ValueValidation);
+
    }
+
}
deleted crates/radicle-cli/src/commands/help.rs
@@ -1,102 +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,
-
];
-

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

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

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

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

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

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

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

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

-
    Ok(())
-
}
modified crates/radicle-cli/src/commands/id.rs
@@ -1,285 +1,34 @@
+
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::format::Author;
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 use args::Args;
+
use args::Command;

-
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 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 +40,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 +53,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 +63,14 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                    }
                    // TODO: Different output if canonical changed?

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

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

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

-
                if !options.quiet {
+
                if !args.quiet {
                    term::success!("Revision {} rejected", revision.id);
                    print_meta(&revision, &current, &profile)?;
                }
            }
        }
-
        Operation::EditRevision {
+
        Command::Edit {
            revision,
            title,
            description,
@@ -357,11 +106,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 +124,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 {
@@ -384,12 +136,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                let proposal = match update::privacy_allow_list(proposal, allow, disallow) {
                    Ok(proposal) => proposal,
                    Err(e) => match e {
-
                        update::error::PrivacyAllowList::Overlapping(overlap) =>                     anyhow::bail!("`--allow` and `--disallow` must not overlap: {overlap:?}"),
-
                        update::error::PrivacyAllowList::PublicVisibility =>                         return Err(Error::WithHint {
-
                            err:
+
                        update::error::PrivacyAllowList::Overlapping(overlap) =>anyhow::bail!("`--allow` and `--disallow` must not overlap: {overlap:?}"),
+
                        update::error::PrivacyAllowList::PublicVisibility => return Err(Error::with_hint(
                            anyhow!("`--allow` and `--disallow` should only be used for private repositories"),
-
                            hint: "use `--visibility private` to make the repository private, or perhaps you meant to use `--delegate`/`--rescind`",
-
                        }.into())
+
                            "use `--visibility private` to make the repository private, or perhaps you meant to use `--delegate`/`--rescind`")
+
                        .into())
                    }
                };
                let threshold = proposal.threshold;
@@ -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`.",
                    ));
@@ -439,13 +194,20 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                return Ok(());
            }
            let signer = term::signer(&profile)?;
-
            let revision = update(title, description, proposal, &mut identity, &signer)?;
+
            let revision = update(
+
                title,
+
                description,
+
                proposal,
+
                &mut identity,
+
                &signer,
+
                &profile,
+
            )?;

            if revision.is_accepted() && revision.parent == Some(current.id) {
                // 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 +217,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                print(&revision, &current, &repo, &profile)?;
            }
        }
-
        Operation::ListRevisions => {
+
        Command::List => {
            let mut revisions =
                term::Table::<7, term::Label>::new(term::table::TableOptions::bordered());

@@ -489,25 +251,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
@@ -666,13 +428,16 @@ fn update<R, G>(
    doc: Doc,
    current: &mut IdentityMut<R>,
    signer: &Device<G>,
+
    profile: &Profile,
) -> anyhow::Result<Revision>
where
    R: WriteRepository + cob::Store<Namespace = NodeId>,
    G: crypto::signature::Signer<crypto::Signature>,
{
    if let Some((title, description)) = edit_title_description(title, description)? {
-
        let id = current.update(title, description, &doc, signer)?;
+
        let id = current
+
            .update(title, description, &doc, signer)
+
            .map_err(|e| on_identity_err(e, profile))?;
        let revision = current
            .revision(&id)
            .ok_or(anyhow!("update failed: revision {id} is missing"))?;
@@ -683,6 +448,47 @@ where
    }
}

+
fn on_identity_err(e: identity::Error, profile: &Profile) -> anyhow::Error {
+
    let e = anyhow::Error::from(e);
+

+
    e.chain()
+
        .find_map(|c| c.downcast_ref::<identity::ApplyError>())
+
        .map(|e| on_apply_err(e, profile))
+
        .unwrap_or(e)
+
}
+

+
fn on_apply_err(e: &identity::ApplyError, profile: &Profile) -> anyhow::Error {
+
    match e {
+
        e @ identity::ApplyError::NonDelegateUnauthorized { author, .. } => {
+
            let nid = NodeId::from(*author);
+
            let labels = Author::new(&nid, profile, false).labels();
+

+
            Error::with_hint(
+
                anyhow!(e.to_string()),
+
                format!(
+
                    "{} {} is attempting to modify the identity document but is not a delegate!",
+
                    labels.0, labels.1
+
                ),
+
            )
+
            .into()
+
        }
+
        e @ radicle::cob::identity::ApplyError::Missing(_)
+
        | e @ radicle::cob::identity::ApplyError::Init(_)
+
        | e @ radicle::cob::identity::ApplyError::InvalidSignature(..)
+
        | e @ radicle::cob::identity::ApplyError::NotAuthorized
+
        | e @ radicle::cob::identity::ApplyError::MissingParent
+
        | e @ radicle::cob::identity::ApplyError::DuplicateVerdict
+
        | e @ radicle::cob::identity::ApplyError::UnexpectedState
+
        | e @ radicle::cob::identity::ApplyError::Redacted
+
        | e @ radicle::cob::identity::ApplyError::DocUnchanged
+
        | e @ radicle::cob::identity::ApplyError::Git(_)
+
        | e @ radicle::cob::identity::ApplyError::Doc(_)
+
        | e => {
+
            anyhow!(e.to_string())
+
        }
+
    }
+
}
+

fn print_diff(
    previous: Option<&RevisionId>,
    current: &RevisionId,
added crates/radicle-cli/src/commands/id/args.rs
@@ -0,0 +1,327 @@
+
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)`.
+
        #[allow(clippy::indexing_slicing)]
+
        Ok(PayloadUpsert {
+
            id: PayloadId::from_str(&chunk[0])?,
+
            key: chunk[1].to_owned(),
+
            value: json::from_str(&chunk[2].to_owned())?,
+
        })
+
    })
+
}
+

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+
pub use args::Args;
+

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

use anyhow::anyhow;

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

use term::Element as _;

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

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

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

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

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

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

-
Options
-

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -572,7 +453,8 @@ fn show(
                .spawn()?
                .wait()?;
        }
-
        notification => {
+
        notification @ NotificationKind::Cob { .. }
+
        | notification @ NotificationKind::Unknown { .. } => {
            term::json::to_pretty(&notification, Path::new("notification.json"))?.print();
        }
    }
added crates/radicle-cli/src/commands/inbox/args.rs
@@ -0,0 +1,224 @@
+
use std::{fmt::Display, str::FromStr};
+

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+
        Self::Contextual
+
    }
+
}
+

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

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

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

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

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

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

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

+
        Self::Contextual
+
    }
+
}
+

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

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

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

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

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

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

+
mod args;
+

+
pub use args::Args;
+

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

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

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

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

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

-
Options
-

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    let default_branch = match find_default_branch(&repo) {
        Err(err @ DefaultBranchError::Head) => {
@@ -232,21 +77,22 @@ 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()
                .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,
@@ -256,13 +102,13 @@ pub fn init(
            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"))?.unwrap_or_default()
        }
    };
-
    let branch = match options.branch {
+
    let branch = match args.branch {
        Some(branch) => branch,
        None if interactive.yes() => term::input(
            "Default branch",
@@ -274,9 +120,11 @@ pub fn init(
    };
    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"],
@@ -308,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,
@@ -333,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)?;
            }
@@ -344,7 +192,7 @@ pub fn init(
                term::format::dim("(RID)"),
                term::format::highlight(rid.urn())
            );
-
            let directory = if path == env::current_dir()? {
+
            let directory = if path == dunce::canonicalize(env::current_dir()?)? {
                "this directory".to_owned()
            } else {
                term::format::tertiary(path.display()).to_string()
@@ -378,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(
@@ -393,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,
@@ -403,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),
@@ -448,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(),
@@ -633,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)
@@ -645,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())?;
@@ -661,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");
@@ -677,13 +544,10 @@ pub fn setup_signing(
                return Err(err.into());
            }
        }
-
        git::configure_signing(repo, node_id)?;
-

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

    Ok(())
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+
mod args;
+

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

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

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

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

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

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

-
Options
-

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

-
use std::collections::BTreeSet;
-
use std::ffi::OsString;
-
use std::str::FromStr;
-

-
use anyhow::{anyhow, Context as _};
+
use anyhow::Context as _;
use serde_json as json;

-
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::cob::Timestamp;
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};
use radicle_cob::ObjectId;
-
use radicle::cob::Timestamp;
+

+
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] [--output {table,json}] [<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
-

-
List options
-

-
    --output              'table' or 'json'. Use 'json' with e.g. 'jq' to customize output
-
                             rad issue list --output json |
-
                             jq -r '.[]|[(.author+" "+.did), (.opened/1000|todate), .title]|@tsv'
-

-
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,
-
}
-

-
#[derive(Default, Debug, PartialEq, Eq)]
-
pub enum OutputFormat {
-
    #[default]
-
    Table,
-
    Json,
-
}
-

-

-
/// 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>,
-
        output: Option<OutputFormat>,
-
    },
-
    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 output = Some(OutputFormat::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,
-
                    });
-
                }
-
                Long("output") if op == Some(OperationName::List) => {
-
                    let val = parser.value()?;
-
                    let val = term::args::string(&val);
-

-
                    match val.as_str() {
-
                        "table" => output = Some(OutputFormat::Table),
-
                        "json" => output = Some(OutputFormat::Json),
-
                        _ => anyhow::bail!("unknown output '{val}' not 'table' or 'json'."),
-
                    }
-
                }
-

-
                // 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)?;
-

-
                    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),
+
use crate::commands::issue::args::OutputFormat;

-
                    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()));
-
                }
-
            }
-
        }
+
const ABOUT: &str = "Manage issues";

-
        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, output
-
            },
-
            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)
-
                .map_err(|e| Error::WithHint {
-
                    err: e.into(),
-
                    hint: "reset the cache with `rad issue cache` and try again",
+
                .map_err(|e| {
+
                    Error::with_hint(e, "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 {
@@ -637,7 +153,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                };
            }
        }
-
        Operation::React {
+
        Command::React {
            id,
            reaction,
            comment_id,
@@ -646,39 +162,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 {
@@ -692,11 +186,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");
@@ -707,17 +197,25 @@ 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, output } => {
-
            list(issues, &assigned, &state, &output, &profile)?;
+
        Command::List(list_args) => {
+
            list(
+
                issues,
+
                &list_args.assigned,
+
                &((&list_args.state).into()),
+
                &list_args.output,
+
                &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 {
@@ -765,6 +263,7 @@ fn list<C>(
    state: &Option<State>,
    output: &Option<OutputFormat>,
    profile: &profile::Profile,
+
    verbose: bool,
) -> anyhow::Result<()>
where
    C: issue::cache::Issues,
@@ -841,16 +340,21 @@ where
    });

    match output {
-
        Some(OutputFormat::Table) => {print_table(all); }
-
        Some(OutputFormat::Json) => {println!("{}",json::to_string_pretty(&all)?);},
-
        &None => {println!("Unknown ouptput format!");}
+
        Some(OutputFormat::Table) => {
+
            print_table(all);
+
        }
+
        Some(OutputFormat::Json) => {
+
            println!("{}", json::to_string_pretty(&all)?);
+
        }
+
        &None => {
+
            println!("Unknown ouptput format!");
+
        }
    }

    Ok(())
}

-

-
fn print_table(issues: Vec<IssueSummary>){
+
fn print_table(issues: Vec<IssueSummary>) {
    let mut table = term::Table::new(term::table::TableOptions::bordered());
    table.header([
        term::format::dim(String::from("●")).into(),
@@ -864,45 +368,47 @@ fn print_table(issues: Vec<IssueSummary>){
    ]);
    table.divider();

-
    for issue in issues {
-
        table.push([
-
            match issue.state {
-
                State::Open => term::format::positive("●").into(),
-
                State::Closed { .. } => term::format::negative("●").into(),
-
            },
-
            term::format::tertiary(term::format::cob(&issue.id))
-
                .to_owned()
-
                .into(),
-
            term::format::default(issue.title).into(),
-
            issue.author.into(),
-
            issue.did.into(),
-
            term::format::secondary(issue.labels.join(", ")).into(),
-
            if issue.assignees.is_empty() {
-
                term::format::dim(String::default()).into()
-
            } else {
-
                term::format::primary(issue.assignees.join(", ")).dim().into()
-
            },
-
            term::format::timestamp(issue.opened)
-
                .dim()
-
                .italic()
-
                .into(),
-
        ]);
-
    }
+
    table.extend(issues.into_iter().map(|issue| mk_issue_row(issue)));
    table.print();
}

+
fn mk_issue_row(issue: IssueSummary) -> [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(&issue.id))
+
            .to_owned()
+
            .into(),
+
        term::format::default(issue.title).into(),
+
        issue.author.into(),
+
        issue.did.into(),
+
        term::format::secondary(issue.labels.join(", ")).into(),
+
        if issue.assignees.is_empty() {
+
            term::format::dim(String::default()).into()
+
        } else {
+
            term::format::primary(issue.assignees.join(", "))
+
                .dim()
+
                .into()
+
        },
+
        term::format::timestamp(issue.opened).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()) {
@@ -921,8 +427,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(())
}
@@ -936,7 +442,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)?;
@@ -960,7 +466,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 {
@@ -976,94 +482,3 @@ where

    Ok(issue)
}
-

-
/// Get a comment from the user, by prompting.
-
pub fn prompt_comment(
-
    message: Message,
-
    thread: &thread::Thread,
-
    mut reply_to: Option<Oid>,
-
    edit: Option<&str>,
-
) -> anyhow::Result<String> {
-
    let (chase, missing) = {
-
        let mut chase = Vec::with_capacity(thread.len());
-
        let mut missing = None;
-

-
        while let Some(id) = reply_to {
-
            if let Some(comment) = thread.comment(&id) {
-
                chase.push(comment);
-
                reply_to = comment.reply_to();
-
            } else {
-
                missing = reply_to;
-
                break;
-
            }
-
        }
-

-
        (chase, missing)
-
    };
-

-
    let quotes = if chase.is_empty() {
-
        ""
-
    } else {
-
        "Quotes (lines starting with '>') will be preserved. Please remove those that you do not intend to keep.\n"
-
    };
-

-
    let mut buffer = term::format::html::commented(format!("HTML comments, such as this one, are deleted before posting.\n{quotes}Saving an empty file aborts the operation.").as_str());
-
    buffer.push('\n');
-

-
    for comment in chase.iter().rev() {
-
        buffer.reserve(2);
-
        buffer.push('\n');
-
        comment_quoted(comment, &mut buffer);
-
    }
-

-
    if let Some(id) = missing {
-
        buffer.push('\n');
-
        buffer.push_str(
-
            term::format::html::commented(
-
                format!("The comment with ID {id} that was replied to could not be found.")
-
                    .as_str(),
-
            )
-
            .as_str(),
-
        );
-
    }
-

-
    if let Some(edit) = edit {
-
        if !chase.is_empty() {
-
            buffer.push_str(
-
                "\n<!-- The contents of the comment you are editing follow below this line. -->\n",
-
            );
-
        }
-
        buffer.reserve(2 + edit.len());
-
        buffer.push('\n');
-
        buffer.push_str(edit);
-
    }
-

-
    let body = message.get(&buffer)?;
-

-
    if body.is_empty() {
-
        anyhow::bail!("aborting operation due to empty comment");
-
    }
-
    Ok(body)
-
}
-

-
fn comment_quoted(comment: &thread::Comment, buffer: &mut String) {
-
    let body = comment.body();
-
    let lines = body.lines();
-

-
    let hint = {
-
        let (lower, upper) = lines.size_hint();
-
        upper.unwrap_or(lower)
-
    };
-

-
    buffer.push_str(format!("{} wrote:\n", comment.author()).as_str());
-
    buffer.reserve(body.len() + hint * 2);
-

-
    for line in lines {
-
        buffer.push('>');
-
        if !line.is_empty() {
-
            buffer.push(' ');
-
        }
-
        buffer.push_str(line);
-
        buffer.push('\n');
-
    }
-
}
added crates/radicle-cli/src/commands/issue/args.rs
@@ -0,0 +1,476 @@
+
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(Default, Debug, Clone, PartialEq, Eq, clap::ValueEnum)]
+
pub enum OutputFormat {
+
    #[default]
+
    Table,
+
    Json,
+
}
+

+
#[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(),
+
            Command::Cache{..} | Command::Show { .. } | Command::List(_) => 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>,
+

+
    /// How to display results
+
    #[arg(long, name = "OUTPUT")]
+
    #[arg(default_missing_value = "table")]
+
    #[arg(num_args = 0..=1)]
+
    pub(crate) output: Option<OutputFormat>,
+

+
    #[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),
+
            output: Some(OutputFormat::Table),
+
        }
+
    }
+
}
+

+
/// 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: Option<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 {
+
        let message = message.unwrap_or(Message::Edit);
+
        match (reply_to, edit) {
+
            (Some(_), Some(_)) => {
+
                unreachable!("the argument '--reply-to' cannot be used with '--edit'")
+
            }
+
            (Some(reply_to), None) => Self::Reply {
+
                id,
+
                message,
+
                reply_to,
+
            },
+
            (None, Some(to_edit)) => Self::Edit {
+
                id,
+
                message,
+
                to_edit,
+
            },
+
            (None, None) => Self::Comment { id, message },
+
        }
+
    }
+
}
added crates/radicle-cli/src/commands/issue/comment.rs
@@ -0,0 +1,166 @@
+
use radicle::cob::thread;
+
use radicle::storage::WriteRepository;
+
use radicle::Profile;
+
use radicle::{cob, git, issue, storage};
+

+
use crate::git::Rev;
+
use crate::terminal as term;
+
use crate::terminal::patch::Message;
+
use crate::terminal::Element as _;
+

+
pub(super) fn comment(
+
    profile: &Profile,
+
    repo: &storage::git::Repository,
+
    issues: &mut issue::Cache<
+
        issue::Issues<'_, storage::git::Repository>,
+
        cob::cache::Store<cob::cache::Write>,
+
    >,
+
    id: Rev,
+
    message: Message,
+
    reply_to: Option<Rev>,
+
    quiet: bool,
+
) -> Result<(), anyhow::Error> {
+
    let reply_to = reply_to
+
        .map(|rev| rev.resolve::<git::Oid>(repo.raw()))
+
        .transpose()?;
+
    let signer = term::signer(profile)?;
+
    let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
+
    let mut issue = issues.get_mut(&issue_id)?;
+
    let (root_comment_id, _) = issue.root();
+
    let body = prompt_comment(message, issue.thread(), reply_to, None)?;
+
    let comment_id = issue.comment(body, reply_to.unwrap_or(*root_comment_id), vec![], &signer)?;
+
    if quiet {
+
        term::print(comment_id);
+
    } else {
+
        let comment = issue.thread().comment(&comment_id).unwrap();
+
        term::comment::widget(&comment_id, comment, profile).print();
+
    }
+
    Ok(())
+
}
+

+
pub(super) fn edit(
+
    profile: &Profile,
+
    repo: &storage::git::Repository,
+
    issues: &mut issue::Cache<
+
        issue::Issues<'_, storage::git::Repository>,
+
        cob::cache::Store<cob::cache::Write>,
+
    >,
+
    id: Rev,
+
    message: Message,
+
    comment_id: Rev,
+
    quiet: bool,
+
) -> Result<(), anyhow::Error> {
+
    let signer = term::signer(profile)?;
+
    let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
+
    let comment_id = comment_id.resolve(&repo.backend)?;
+
    let mut issue = issues.get_mut(&issue_id)?;
+
    let comment = issue
+
        .thread()
+
        .comment(&comment_id)
+
        .ok_or(anyhow::anyhow!("comment '{comment_id}' not found"))?;
+
    let body = prompt_comment(
+
        message,
+
        issue.thread(),
+
        comment.reply_to(),
+
        Some(comment.body()),
+
    )?;
+
    issue.edit_comment(comment_id, body, vec![], &signer)?;
+
    if quiet {
+
        term::print(comment_id);
+
    } else {
+
        let comment = issue.thread().comment(&comment_id).unwrap();
+
        term::comment::widget(&comment_id, comment, profile).print();
+
    }
+
    Ok(())
+
}
+

+
/// Get a comment from the user, by prompting.
+
fn prompt_comment(
+
    message: Message,
+
    thread: &thread::Thread,
+
    mut reply_to: Option<git::Oid>,
+
    edit: Option<&str>,
+
) -> anyhow::Result<String> {
+
    let (chase, missing) = {
+
        let mut chase = Vec::with_capacity(thread.len());
+
        let mut missing = None;
+
        while let Some(id) = reply_to {
+
            if let Some(comment) = thread.comment(&id) {
+
                chase.push(comment);
+
                reply_to = comment.reply_to();
+
            } else {
+
                missing = reply_to;
+
                break;
+
            }
+
        }
+

+
        (chase, missing)
+
    };
+

+
    let quotes = if chase.is_empty() {
+
        ""
+
    } else {
+
        "Quotes (lines starting with '>') will be preserved. Please remove those that you do not intend to keep.\n"
+
    };
+

+
    let mut buffer = term::format::html::commented(format!("HTML comments, such as this one, are deleted before posting.\n{quotes}Saving an empty file aborts the operation.").as_str());
+
    buffer.push('\n');
+

+
    for comment in chase.iter().rev() {
+
        buffer.reserve(2);
+
        buffer.push('\n');
+
        comment_quoted(comment, &mut buffer);
+
    }
+

+
    if let Some(id) = missing {
+
        buffer.push('\n');
+
        buffer.push_str(
+
            term::format::html::commented(
+
                format!("The comment with ID {id} that was replied to could not be found.")
+
                    .as_str(),
+
            )
+
            .as_str(),
+
        );
+
    }
+

+
    if let Some(edit) = edit {
+
        if !chase.is_empty() {
+
            buffer.push_str(
+
                "\n<!-- The contents of the comment you are editing follow below this line. -->\n",
+
            );
+
        }
+

+
        buffer.reserve(2 + edit.len());
+
        buffer.push('\n');
+
        buffer.push_str(edit);
+
    }
+

+
    let body = message.get(&buffer)?;
+
    if body.is_empty() {
+
        anyhow::bail!("aborting operation due to empty comment");
+
    }
+

+
    Ok(body)
+
}
+

+
fn comment_quoted(comment: &thread::Comment, buffer: &mut String) {
+
    let body = comment.body();
+
    let lines = body.lines();
+
    let hint = {
+
        let (lower, upper) = lines.size_hint();
+
        upper.unwrap_or(lower)
+
    };
+

+
    buffer.push_str(format!("{} wrote:\n", comment.author()).as_str());
+
    buffer.reserve(body.len() + hint * 2);
+

+
    for line in lines {
+
        buffer.push('>');
+
        if !line.is_empty() {
+
            buffer.push(' ');
+
        }
+

+
        buffer.push_str(line);
+
        buffer.push('\n');
+
    }
+
}
added crates/radicle-cli/src/commands/issue/comment.txt
@@ -0,0 +1,9 @@
+
Comment on an issue, or comment in reply to an earlier comment on the issue.
+

+
Every issue can be viewed as a tree of comments, with the initial issue description at the root.
+

+
Discussions about the issue can be organized in sub-trees, by using `--reply-to`.
+

+
As a fallback, when `--reply-to` is not used, the comment will be in response to the issue description itself.
+

+
Using `--edit` preserves this structure of replies.

\ No newline at end of file
modified crates/radicle-cli/src/commands/ls.rs
@@ -1,91 +1,14 @@
-
use std::ffi::OsString;
+
mod args;
+

+
pub use args::Args;

use radicle::storage::{ReadStorage, RepositoryInfo};

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

use term::Element;

-
pub const HELP: Help = Help {
-
    name: "ls",
-
    description: "List repositories",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

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

-
    By default, this command shows you all repositories that you have forked or initialized.
-
    If you wish to see all seeded repositories, use the `--seeded` option.
-

-
Options
-

-
    --private       Show only private repositories
-
    --public        Show only public repositories
-
    --seeded, -s    Show all seeded repositories
-
    --all, -a       Show all repositories in storage
-
    --verbose, -v   Verbose output
-
    --help          Print help
-
"#,
-
};
-

-
pub struct Options {
-
    #[allow(dead_code)]
-
    verbose: bool,
-
    public: bool,
-
    private: bool,
-
    all: bool,
-
    seeded: bool,
-
}
-

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

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut verbose = false;
-
        let mut private = false;
-
        let mut public = false;
-
        let mut all = false;
-
        let mut seeded = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Long("all") | Short('a') => {
-
                    all = true;
-
                }
-
                Long("seeded") | Short('s') => {
-
                    seeded = true;
-
                }
-
                Long("private") => {
-
                    private = true;
-
                }
-
                Long("public") => {
-
                    public = true;
-
                }
-
                Long("verbose") | Short('v') => verbose = true,
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                verbose,
-
                private,
-
                public,
-
                all,
-
                seeded,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let storage = &profile.storage;
    let repos = storage.repositories()?;
@@ -105,21 +28,21 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        ..
    } in repos
    {
-
        if doc.is_public() && options.private && !options.public {
+
        if doc.is_public() && args.private {
            continue;
        }
-
        if !doc.is_public() && !options.private && options.public {
+
        if !doc.is_public() && args.public {
            continue;
        }
-
        if refs.is_none() && !options.all && !options.seeded {
+
        if refs.is_none() && !args.all && !args.seeded {
            continue;
        }
        let seeded = policy.is_seeding(&rid)?;

-
        if !seeded && !options.all {
+
        if !seeded && !args.all {
            continue;
        }
-
        if !seeded && options.seeded {
+
        if !seeded && args.seeded {
            continue;
        }
        let proj = match doc.project() {
added crates/radicle-cli/src/commands/ls/args.rs
@@ -0,0 +1,27 @@
+
use clap::Parser;
+

+
const ABOUT: &str = "List repositories";
+
const LONG_ABOUT: &str = r#"
+
By default, this command shows you all repositories that you have forked or initialized.
+
If you wish to see all seeded repositories, use the `--seeded` option.
+
"#;
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// Show only private repositories
+
    #[arg(long, conflicts_with = "public")]
+
    pub(super) private: bool,
+
    /// Show only public repositories
+
    #[arg(long)]
+
    pub(super) public: bool,
+
    /// Show all seeded repositories
+
    #[arg(short, long)]
+
    pub(super) seeded: bool,
+
    /// Show all repositories in storage
+
    #[arg(short, long)]
+
    pub(super) all: bool,
+
    /// Verbose output
+
    #[arg(short, long)]
+
    pub(super) verbose: bool,
+
}
modified crates/radicle-cli/src/commands/node.rs
@@ -1,298 +1,51 @@
-
use std::ffi::OsString;
-
use std::path::PathBuf;
-
use std::str::FromStr;
-
use std::time;
+
mod args;
+
mod commands;
+
pub mod control;
+
mod events;
+
mod logs;
+
pub mod routing;

-
use anyhow::anyhow;
+
use std::{process, time};

use radicle::node::address::Store as AddressStore;
use radicle::node::config::ConnectAddress;
use radicle::node::routing::Store;
use radicle::node::Handle as _;
-
use radicle::node::{Address, Node, NodeId, PeerAddr};
-
use radicle::prelude::RepoId;
+
use radicle::node::Node;

+
use crate::commands::node::args::Only;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::Element as _;
+
use crate::warning;

-
#[path = "node/commands.rs"]
-
mod commands;
-
#[path = "node/control.rs"]
-
pub mod control;
-
#[path = "node/events.rs"]
-
mod events;
-
#[path = "node/routing.rs"]
-
pub mod routing;
-

-
pub const HELP: Help = Help {
-
    name: "node",
-
    description: "Control and query the Radicle Node",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad node status [<option>...]
-
    rad node start [--foreground] [--verbose] [<option>...] [-- <node-option>...]
-
    rad node stop [<option>...]
-
    rad node logs [-n <lines>]
-
    rad node debug [<option>...]
-
    rad node connect <nid>[@<addr>] [<option>...]
-
    rad node routing [--rid <rid>] [--nid <nid>] [--json] [<option>...]
-
    rad node inventory [--nid <nid>] [<option>...]
-
    rad node events [--timeout <secs>] [-n <count>] [<option>...]
-
    rad node config [--addresses]
-
    rad node db <command> [<option>..]
-

-
    For `<node-option>` see `radicle-node --help`.
-

-
Start options
-

-
    --foreground         Start the node in the foreground
-
    --path <path>        Start node binary at path (default: radicle-node)
-
    --verbose, -v        Verbose output
-

-
Routing options
-

-
    --rid <rid>          Show the routing table entries for the given RID
-
    --nid <nid>          Show the routing table entries for the given NID
-
    --json               Output the routing table as json
-

-
Inventory options
-

-
    --nid <nid>          List the inventory of the given NID (default: self)
-

-
Events options
-

-
    --timeout <secs>     How long to wait to receive an event before giving up
-
    --count, -n <count>  Exit after <count> events
-

-
General options
-

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

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

-
/// Address used for the [`Operation::Connect`]
-
pub enum Addr {
-
    /// Fully-specified address of the form `<NID>@<Address>`
-
    Peer(PeerAddr<NodeId, Address>),
-
    /// Just the `NID`, to be used for address lookups.
-
    Node(NodeId),
-
}
-

-
impl FromStr for Addr {
-
    type Err = anyhow::Error;
-

-
    fn from_str(s: &str) -> Result<Self, Self::Err> {
-
        if s.contains("@") {
-
            PeerAddr::from_str(s)
-
                .map(Self::Peer)
-
                .map_err(|e| anyhow!("expected <nid> or <nid>@<addr>: {e}"))
-
        } else {
-
            NodeId::from_str(s)
-
                .map(Self::Node)
-
                .map_err(|e| anyhow!("expected <nid> or <nid>@<addr>: {e}"))
-
        }
-
    }
-
}
-

-
pub enum Operation {
-
    Connect {
-
        addr: Addr,
-
        timeout: time::Duration,
-
    },
-
    Config {
-
        addresses: bool,
-
    },
-
    Db {
-
        args: Vec<OsString>,
-
    },
-
    Events {
-
        timeout: time::Duration,
-
        count: usize,
-
    },
-
    Routing {
-
        json: bool,
-
        rid: Option<RepoId>,
-
        nid: Option<NodeId>,
-
    },
-
    Start {
-
        foreground: bool,
-
        verbose: bool,
-
        path: PathBuf,
-
        options: Vec<OsString>,
-
    },
-
    Logs {
-
        lines: usize,
-
    },
-
    Status,
-
    Inventory {
-
        nid: Option<NodeId>,
-
    },
-
    Debug,
-
    Sessions,
-
    Stop,
-
}
+
pub use args::Args;
+
use args::{Addr, Command};

-
#[derive(Default, PartialEq, Eq)]
-
pub enum OperationName {
-
    Connect,
-
    Config,
-
    Db,
-
    Events,
-
    Routing,
-
    Logs,
-
    Start,
-
    #[default]
-
    Status,
-
    Inventory,
-
    Debug,
-
    Sessions,
-
    Stop,
-
}
-

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

-
        let mut foreground = false;
-
        let mut options = vec![];
-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
-
        let mut nid: Option<NodeId> = None;
-
        let mut rid: Option<RepoId> = None;
-
        let mut json: bool = false;
-
        let mut addr: Option<Addr> = None;
-
        let mut lines: usize = 60;
-
        let mut count: usize = usize::MAX;
-
        let mut timeout = time::Duration::MAX;
-
        let mut addresses = false;
-
        let mut path = None;
-
        let mut verbose = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "connect" => op = Some(OperationName::Connect),
-
                    "db" => op = Some(OperationName::Db),
-
                    "events" => op = Some(OperationName::Events),
-
                    "logs" => op = Some(OperationName::Logs),
-
                    "config" => op = Some(OperationName::Config),
-
                    "routing" => op = Some(OperationName::Routing),
-
                    "inventory" => op = Some(OperationName::Inventory),
-
                    "start" => op = Some(OperationName::Start),
-
                    "status" => op = Some(OperationName::Status),
-
                    "stop" => op = Some(OperationName::Stop),
-
                    "sessions" => op = Some(OperationName::Sessions),
-
                    "debug" => op = Some(OperationName::Debug),
-

-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
-
                Value(val) if matches!(op, Some(OperationName::Connect)) => {
-
                    addr = Some(val.parse()?);
-
                }
-
                Long("rid") if matches!(op, Some(OperationName::Routing)) => {
-
                    let val = parser.value()?;
-
                    rid = term::args::rid(&val).ok();
-
                }
-
                Long("nid")
-
                    if matches!(op, Some(OperationName::Routing))
-
                        || matches!(op, Some(OperationName::Inventory)) =>
-
                {
-
                    let val = parser.value()?;
-
                    nid = term::args::nid(&val).ok();
-
                }
-
                Long("json") if matches!(op, Some(OperationName::Routing)) => json = true,
-
                Long("timeout")
-
                    if op == Some(OperationName::Events) || op == Some(OperationName::Connect) =>
-
                {
-
                    let val = parser.value()?;
-
                    timeout = term::args::seconds(&val)?;
-
                }
-
                Long("count") | Short('n') if matches!(op, Some(OperationName::Events)) => {
-
                    let val = parser.value()?;
-
                    count = term::args::number(&val)?;
-
                }
-
                Long("foreground") if matches!(op, Some(OperationName::Start)) => {
-
                    foreground = true;
-
                }
-
                Long("addresses") if matches!(op, Some(OperationName::Config)) => {
-
                    addresses = true;
-
                }
-
                Long("verbose") | Short('v') if matches!(op, Some(OperationName::Start)) => {
-
                    verbose = true;
-
                }
-
                Long("path") if matches!(op, Some(OperationName::Start)) => {
-
                    let val = parser.value()?;
-
                    path = Some(PathBuf::from(val));
-
                }
-
                Short('n') if matches!(op, Some(OperationName::Logs)) => {
-
                    lines = parser.value()?.parse()?;
-
                }
-
                Value(val) if matches!(op, Some(OperationName::Start)) => {
-
                    options.push(val);
-
                }
-
                Value(val) if matches!(op, Some(OperationName::Db)) => {
-
                    options.push(val);
-
                }
-
                _ => return Err(anyhow!(arg.unexpected())),
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::Connect => Operation::Connect {
-
                addr: addr.ok_or_else(|| {
-
                    anyhow!("an `<nid>` or an address of the form `<nid>@<host>:<port>` must be provided")
-
                })?,
-
                timeout,
-
            },
-
            OperationName::Config => Operation::Config { addresses },
-
            OperationName::Db => Operation::Db { args: options },
-
            OperationName::Events => Operation::Events { timeout, count },
-
            OperationName::Routing => Operation::Routing { rid, nid, json },
-
            OperationName::Logs => Operation::Logs { lines },
-
            OperationName::Start => Operation::Start {
-
                foreground,
-
                verbose,
-
                options,
-
                path: path.unwrap_or(PathBuf::from("radicle-node")),
-
            },
-
            OperationName::Inventory => Operation::Inventory { nid },
-
            OperationName::Status => Operation::Status,
-
            OperationName::Debug => Operation::Debug,
-
            OperationName::Sessions => Operation::Sessions,
-
            OperationName::Stop => Operation::Stop,
-
        };
-
        Ok((Options { op }, vec![]))
-
    }
-
}
-

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

-
    match options.op {
-
        Operation::Connect { addr, timeout } => match addr {
-
            Addr::Peer(addr) => control::connect(&mut node, addr.id, addr.addr, timeout)?,
-
            Addr::Node(nid) => {
-
                let db = profile.database()?;
-
                let addresses = db
-
                    .addresses_of(&nid)?
-
                    .into_iter()
-
                    .map(|ka| ka.addr)
-
                    .collect();
-
                control::connect_many(&mut node, nid, addresses, timeout)?;
+
    let command = args.command.unwrap_or_default();
+

+
    match command {
+
        Command::Connect { addr, timeout } => {
+
            let timeout = timeout
+
                .map(time::Duration::from_secs)
+
                .unwrap_or(time::Duration::MAX);
+
            match addr {
+
                Addr::Peer(addr) => control::connect(&mut node, addr.id, addr.addr, timeout)?,
+
                Addr::Node(nid) => {
+
                    let db = profile.database()?;
+
                    let addresses = db
+
                        .addresses_of(&nid)?
+
                        .into_iter()
+
                        .map(|ka| ka.addr)
+
                        .collect();
+
                    control::connect_many(&mut node, nid, addresses, timeout)?;
+
                }
            }
-
        },
-
        Operation::Config { addresses } => {
+
        }
+
        Command::Config { addresses } => {
            if addresses {
                let cfg = node.config()?;
                for addr in cfg.external_addresses {
@@ -302,27 +55,33 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                control::config(&node)?;
            }
        }
-
        Operation::Db { args } => {
-
            commands::db(&profile, args)?;
+
        Command::Db(op) => {
+
            commands::db(&profile, op)?;
        }
-
        Operation::Debug => {
+
        Command::Debug => {
            control::debug(&mut node)?;
        }
-
        Operation::Sessions => {
+
        Command::Sessions => {
+
            warning::deprecated("rad node sessions", "rad node status");
            let sessions = control::sessions(&node)?;
            if let Some(table) = sessions {
                table.print();
            }
        }
-
        Operation::Events { timeout, count } => {
+
        Command::Events { timeout, count } => {
+
            let count = count.unwrap_or(usize::MAX);
+
            let timeout = timeout
+
                .map(time::Duration::from_secs)
+
                .unwrap_or(time::Duration::MAX);
+

            events::run(node, count, timeout)?;
        }
-
        Operation::Routing { rid, nid, json } => {
+
        Command::Routing { rid, nid, json } => {
            let store = profile.database()?;
            routing::run(&store, rid, nid, json)?;
        }
-
        Operation::Logs { lines } => control::logs(lines, Some(time::Duration::MAX), &profile)?,
-
        Operation::Start {
+
        Command::Logs { lines } => control::logs(lines, Some(time::Duration::MAX), &profile)?,
+
        Command::Start {
            foreground,
            options,
            path,
@@ -330,16 +89,25 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        } => {
            control::start(node, !foreground, verbose, options, &path, &profile)?;
        }
-
        Operation::Inventory { nid } => {
+
        Command::Inventory { nid } => {
            let nid = nid.as_ref().unwrap_or(profile.id());
            for rid in profile.routing()?.get_inventory(nid)? {
                println!("{}", term::format::tertiary(rid));
            }
        }
-
        Operation::Status => {
+
        Command::Status {
+
            only: Some(Only::Nid),
+
        } => {
+
            if node.is_running() {
+
                term::print(term::format::node_id_human(&node.nid()?));
+
            } else {
+
                process::exit(2);
+
            }
+
        }
+
        Command::Status { only: None } => {
            control::status(&node, &profile)?;
        }
-
        Operation::Stop => {
+
        Command::Stop => {
            control::stop(node, &profile);
        }
    }
added crates/radicle-cli/src/commands/node/args.rs
@@ -0,0 +1,231 @@
+
use std::ffi::OsString;
+
use std::fmt::Debug;
+
use std::path::PathBuf;
+
use std::str::FromStr;
+

+
use thiserror::Error;
+

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

+
use radicle::crypto::{PublicKey, PublicKeyError};
+
use radicle::node::{Address, NodeId, PeerAddr, PeerAddrParseError};
+
use radicle::prelude::RepoId;
+

+
const ABOUT: &str = "Control and query the Radicle Node";
+

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

+
/// Address used for the [`Operation::Connect`]
+
#[derive(Clone, Debug)]
+
pub(super) enum Addr {
+
    /// Fully-specified address of the form `<NID>@<ADDR>`
+
    Peer(PeerAddr<NodeId, Address>),
+
    /// Just the `NID`, to be used for address lookups.
+
    Node(NodeId),
+
}
+

+
#[derive(Error, Debug)]
+
pub(super) enum AddrParseError {
+
    #[error("{0}, expected <NID> or <NID>@<ADDR>")]
+
    PeerAddr(#[from] PeerAddrParseError<PublicKey>),
+
    #[error(transparent)]
+
    NodeId(#[from] PublicKeyError),
+
}
+

+
impl FromStr for Addr {
+
    type Err = AddrParseError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        if s.contains("@") {
+
            PeerAddr::from_str(s)
+
                .map(Self::Peer)
+
                .map_err(AddrParseError::PeerAddr)
+
        } else {
+
            NodeId::from_str(s)
+
                .map(Self::Node)
+
                .map_err(AddrParseError::NodeId)
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Only {
+
    Nid,
+
}
+

+
#[derive(Error, Debug)]
+
#[error("could not parse value `{0}`")]
+
pub struct OnlyParseError(String);
+

+
impl FromStr for Only {
+
    type Err = OnlyParseError;
+

+
    fn from_str(value: &str) -> Result<Self, Self::Err> {
+
        match value {
+
            "nid" => Ok(Only::Nid),
+
            _ => Err(OnlyParseError(value.to_string())),
+
        }
+
    }
+
}
+

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

+
impl clap::builder::TypedValueParser for OnlyParser {
+
    type Value = Only;
+

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

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

+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    /// Instruct the node to connect to another node
+
    Connect {
+
        /// The Node ID, and optionally the address and port, of the node to connect to
+
        #[arg(value_name = "NID[@ADDR]")]
+
        addr: Addr,
+

+
        /// How long to wait for the connection to be established
+
        #[arg(long, value_name = "SECS")]
+
        timeout: Option<u64>,
+
    },
+

+
    /// Show the config
+
    Config {
+
        /// Only show external addresses from the node's config
+
        #[arg(long)]
+
        addresses: bool,
+
    },
+

+
    /// Interact with the node database
+
    #[command(subcommand, hide = true)]
+
    Db(DbOperation),
+

+
    /// Watch and print events.
+
    ///
+
    /// This command will connect to the node and print events to
+
    /// standard output as they occur.
+
    ///
+
    /// If no timeout or count is specified, it will run indefinitely.
+
    Events {
+
        /// How long to wait to receive an event before giving up
+
        #[arg(long, value_name = "SECS")]
+
        timeout: Option<u64>,
+

+
        /// Exit after <COUNT> events
+
        #[arg(long, short = 'n')]
+
        count: Option<usize>,
+
    },
+

+
    /// Show the routing table
+
    Routing {
+
        /// Output the routing table as json
+
        #[arg(long)]
+
        json: bool,
+

+
        /// Show the routing table entries for the given RID
+
        #[arg(long)]
+
        rid: Option<RepoId>,
+

+
        /// Show the routing table entries for the given NID
+
        #[arg(long)]
+
        nid: Option<NodeId>,
+
    },
+

+
    /// Start the node
+
    Start {
+
        /// Start the node in the foreground
+
        #[arg(long)]
+
        foreground: bool,
+

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

+
        /// Start node binary at path
+
        #[arg(long, default_value = "radicle-node")]
+
        path: PathBuf,
+

+
        /// Additional options to pass to the binary
+
        ///
+
        /// See `radicle-node --help` for additional options
+
        #[arg(value_name = "NODE_OPTIONS", last = true, num_args = 1..)]
+
        options: Vec<OsString>,
+
    },
+

+
    /// Show the log
+
    Logs {
+
        /// Only show <COUNT> lines of the log
+
        #[arg(long, value_name = "COUNT", default_value_t = 60)]
+
        lines: usize,
+
    },
+

+
    /// Show the status
+
    Status {
+
        /// If node is running, only print the Node ID and exit, otherwise exit with a non-zero exit status.
+
        #[arg(long, value_parser = OnlyParser)]
+
        only: Option<Only>,
+
    },
+

+
    /// Manage the inventory
+
    Inventory {
+
        /// List the inventory of the given NID, defaults to `self`
+
        #[arg(long)]
+
        nid: Option<NodeId>,
+
    },
+

+
    /// Show debug information related to the running node.
+
    ///
+
    /// This includes metrics fetching, peer connections, rate limiting, etc.
+
    Debug,
+

+
    /// Show the active sessions of the running node.
+
    ///
+
    /// Deprecated, use `status` instead.
+
    #[command(hide = true)]
+
    Sessions,
+

+
    /// Stop the node
+
    Stop,
+
}
+

+
impl Default for Command {
+
    fn default() -> Self {
+
        Command::Status { only: None }
+
    }
+
}
+

+
/// Operations related to the [`Command::Db`]
+
#[derive(Debug, Subcommand)]
+
pub(super) enum DbOperation {
+
    /// Execute an SQL operation on the local node database.
+
    ///
+
    /// The command only returns the number of rows that are affected by the
+
    /// query. This means that `SELECT` queries will not return their output.
+
    ///
+
    /// The command should only be used for executing queries given you know
+
    /// what you are doing.
+
    Exec {
+
        #[arg(value_name = "SQL")]
+
        query: String,
+
    },
+
}
modified crates/radicle-cli/src/commands/node/commands.rs
@@ -1,39 +1,11 @@
-
use std::ffi::OsString;
-

-
use anyhow::anyhow;
use radicle::Profile;
use radicle_term as term;

-
#[derive(PartialEq, Eq)]
-
pub enum Operation {
-
    Exec { query: String },
-
}
-

-
pub fn db(profile: &Profile, args: Vec<OsString>) -> anyhow::Result<()> {
-
    use lexopt::prelude::*;
-

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

-
    while let Some(arg) = parser.next()? {
-
        match arg {
-
            Value(cmd) if op.is_none() => match cmd.to_string_lossy().as_ref() {
-
                "exec" => {
-
                    let val = parser
-
                        .value()
-
                        .map_err(|_| anyhow!("a query to execute must be provided for `exec`"))?;
-
                    op = Some(Operation::Exec {
-
                        query: val.to_string_lossy().to_string(),
-
                    });
-
                }
-
                unknown => anyhow::bail!("unknown operation '{unknown}'"),
-
            },
-
            _ => return Err(anyhow!(arg.unexpected())),
-
        }
-
    }
+
use super::args::DbOperation;

-
    match op.ok_or_else(|| anyhow!("a command must be provided, eg. `rad node db exec`"))? {
-
        Operation::Exec { query } => {
+
pub fn db(profile: &Profile, op: DbOperation) -> anyhow::Result<()> {
+
    match op {
+
        DbOperation::Exec { query } => {
            let db = profile.database_mut()?;
            db.execute(query)?;

modified crates/radicle-cli/src/commands/node/control.rs
@@ -1,6 +1,3 @@
-
mod logs;
-
use logs::{LogRotatorFileSystem, Rotated};
-

use std::collections::HashMap;
use std::ffi::OsString;
use std::fs::File;
@@ -16,6 +13,7 @@ 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 _;

@@ -107,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)
@@ -259,7 +257,7 @@ pub fn connect_many(
}

pub fn status(node: &Node, profile: &Profile) -> anyhow::Result<()> {
-
    for warning in crate::warning::nodes_renamed(&profile.config) {
+
    for warning in crate::warning::config_warnings(&profile.config) {
        term::warning(warning);
    }

@@ -377,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 => (
@@ -386,17 +384,17 @@ pub fn sessions(node: &Node) -> Result<Option<term::Table<5, term::Label>>, node
                term::Label::blank(),
            ),
            node::State::Attempted => (
-
                term::format::addr_compact(&sess.addr).into(),
+
                sess.addr.display_compact().to_string().into(),
                term::Label::from(state_attempted()),
                term::Label::blank(),
            ),
            node::State::Connected { since, .. } => (
-
                term::format::addr_compact(&sess.addr).into(),
+
                sess.addr.display_compact().to_string().into(),
                term::Label::from(state_connected()),
                term::format::dim(now - since).into(),
            ),
            node::State::Disconnected { since, .. } => (
-
                term::format::addr_compact(&sess.addr).into(),
+
                sess.addr.display_compact().to_string().into(),
                term::Label::from(state_disconnected()),
                term::format::dim(now - since).into(),
            ),
@@ -405,8 +403,10 @@ pub fn sessions(node: &Node) -> Result<Option<term::Table<5, term::Label>>, node
            node::Link::Inbound => term::Label::from(link_direction_inbound()),
            node::Link::Outbound => term::Label::from(link_direction_outbound()),
        };
-
        table.push([nid, addr, state, direction, time]);
-
    }
+

+
        [nid, addr, state, direction, time]
+
    }));
+

    Ok(Some(table))
}

modified crates/radicle-cli/src/commands/patch.rs
@@ -1,47 +1,28 @@
-
#[path = "patch/archive.rs"]
mod archive;
-
#[path = "patch/assign.rs"]
+
mod args;
mod assign;
-
#[path = "patch/cache.rs"]
mod cache;
-
#[path = "patch/checkout.rs"]
mod checkout;
-
#[path = "patch/comment.rs"]
mod comment;
-
#[path = "patch/delete.rs"]
mod delete;
-
#[path = "patch/diff.rs"]
mod diff;
-
#[path = "patch/edit.rs"]
mod edit;
-
#[path = "patch/label.rs"]
mod label;
-
#[path = "patch/list.rs"]
mod list;
-
#[path = "patch/react.rs"]
mod react;
-
#[path = "patch/ready.rs"]
mod ready;
-
#[path = "patch/redact.rs"]
mod redact;
-
#[path = "patch/resolve.rs"]
mod resolve;
-
#[path = "patch/review.rs"]
mod review;
-
#[path = "patch/show.rs"]
mod show;
-
#[path = "patch/update.rs"]
mod update;

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

use anyhow::anyhow;

use radicle::cob::patch::PatchId;
-
use radicle::cob::{patch, Label, Reaction};
-
use radicle::git::RefString;
+
use radicle::cob::{patch, Label};
use radicle::patch::cache::Patches as _;
use radicle::storage::git::transport;
use radicle::{prelude::*, Node};
@@ -49,811 +30,14 @@ use radicle::{prelude::*, Node};
use crate::git::Rev;
use crate::node;
use crate::terminal as term;
-
use crate::terminal::args::{string, Args, Error, Help};
use crate::terminal::patch::Message;

-
pub const HELP: Help = Help {
-
    name: "patch",
-
    description: "Manage patches",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
+
pub use args::Args;

-
    rad patch [<option>...]
-
    rad patch list [--all|--merged|--open|--archived|--draft|--authored] [--author <did>]... [<option>...]
-
    rad patch show <patch-id> [<option>...]
-
    rad patch diff <patch-id> [<option>...]
-
    rad patch archive <patch-id> [--undo] [<option>...]
-
    rad patch update <patch-id> [<option>...]
-
    rad patch checkout <patch-id> [<option>...]
-
    rad patch review <patch-id> [--accept | --reject] [-m [<string>]] [-d | --delete] [<option>...]
-
    rad patch resolve <patch-id> [--review <review-id>] [--comment <comment-id>] [--unresolve] [<option>...]
-
    rad patch delete <patch-id> [<option>...]
-
    rad patch redact <revision-id> [<option>...]
-
    rad patch react <patch-id | revision-id> [--react <emoji>] [<option>...]
-
    rad patch assign <revision-id> [--add <did>] [--delete <did>] [<option>...]
-
    rad patch label <revision-id> [--add <label>] [--delete <label>] [<option>...]
-
    rad patch ready <patch-id> [--undo] [<option>...]
-
    rad patch edit <patch-id> [<option>...]
-
    rad patch set <patch-id> [<option>...]
-
    rad patch comment <patch-id | revision-id> [<option>...]
-
    rad patch cache [<patch-id>] [--storage] [<option>...]
+
use args::{AssignArgs, Command, CommentAction, LabelArgs};

-
Show options
-

-
    -p, --patch                Show the actual patch diff
-
    -v, --verbose              Show additional information about the patch
-

-
Diff options
-

-
    -r, --revision <id>        The revision to diff (default: latest)
-

-
Comment options
-

-
    -m, --message <string>     Provide a comment message via the command-line
-
        --reply-to <comment>   The comment to reply to
-
        --edit <comment>       The comment to edit (use --message to edit with the provided message)
-
        --react <comment>      The comment to react to
-
        --emoji <char>         The emoji to react with when --react is used
-
        --redact <comment>     The comment to redact
-

-
Edit options
-

-
    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)
-

-
Review options
-

-
    -r, --revision <id>        Review the given revision of the patch
-
    -p, --patch                Review by patch hunks
-
        --hunk <index>         Only review a specific hunk
-
        --accept               Accept a patch or set of hunks
-
        --reject               Reject a patch or set of hunks
-
    -U, --unified <n>          Generate diffs with <n> lines of context instead of the usual three
-
    -d, --delete               Delete a review draft
-
    -m, --message [<string>]   Provide a comment with the review (default: prompt)
-

-
Resolve options
-

-
    --review <id>              The review id which the comment is under
-
    --comment <id>             The comment to (un)resolve
-
    --undo                     Unresolve the comment
-

-
Assign options
-

-
    -a, --add    <did>         Add an assignee to the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
    -d, --delete <did>         Delete an assignee from the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
Archive options
-

-
        --undo                 Unarchive a patch
-

-
Label options
-

-
    -a, --add    <label>       Add a label to the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
    -d, --delete <label>       Delete a label from the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
Update options
-

-
    -b, --base <revspec>       Provide a Git revision as the base commit
-
    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)
-
        --no-message           Leave the patch or revision comment message blank
-

-
List options
-

-
        --all                  Show all patches, including merged and archived patches
-
        --archived             Show only archived patches
-
        --merged               Show only merged patches
-
        --open                 Show only open patches (default)
-
        --draft                Show only draft patches
-
        --authored             Show only patches that you have authored
-
        --author <did>         Show only patched where the given user is an author
-
                               (may be specified multiple times)
-

-
Ready options
-

-
        --undo                 Convert a patch back to a draft
-

-
Checkout options
-

-
        --revision <id>        Checkout the given revision of the patch
-
        --name <string>        Provide a name for the branch to checkout
-
        --remote <string>      Provide the git remote to use as the upstream
-
    -f, --force                Checkout the head of the revision, even if the branch already exists
-

-
Set options
-

-
        --remote <string>      Provide the git remote to use as the upstream
-

-
React options
-

-
        --emoji <char>         The emoji to react to the patch or revision with
-

-
Other options
-

-
        --repo <rid>           Operate on the given repository (default: cwd)
-
        --[no-]announce        Announce changes made to the network
-
    -q, --quiet                Quiet output
-
        --help                 Print help
-
"#,
-
};
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub enum OperationName {
-
    Assign,
-
    Show,
-
    Diff,
-
    Update,
-
    Archive,
-
    Delete,
-
    Checkout,
-
    Comment,
-
    React,
-
    Ready,
-
    Review,
-
    Resolve,
-
    Label,
-
    #[default]
-
    List,
-
    Edit,
-
    Redact,
-
    Set,
-
    Cache,
-
}
-

-
#[derive(Debug, PartialEq, Eq)]
-
pub enum CommentOperation {
-
    Edit,
-
    React,
-
    Redact,
-
}
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub struct AssignOptions {
-
    pub add: BTreeSet<Did>,
-
    pub delete: BTreeSet<Did>,
-
}
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub struct LabelOptions {
-
    pub add: BTreeSet<Label>,
-
    pub delete: BTreeSet<Label>,
-
}
-

-
#[derive(Debug)]
-
pub enum Operation {
-
    Show {
-
        patch_id: Rev,
-
        diff: bool,
-
        verbose: bool,
-
    },
-
    Diff {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
    },
-
    Update {
-
        patch_id: Rev,
-
        base_id: Option<Rev>,
-
        message: Message,
-
    },
-
    Archive {
-
        patch_id: Rev,
-
        undo: bool,
-
    },
-
    Ready {
-
        patch_id: Rev,
-
        undo: bool,
-
    },
-
    Delete {
-
        patch_id: Rev,
-
    },
-
    Checkout {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
        opts: checkout::Options,
-
    },
-
    Comment {
-
        revision_id: Rev,
-
        message: Message,
-
        reply_to: Option<Rev>,
-
    },
-
    CommentEdit {
-
        revision_id: Rev,
-
        comment_id: Rev,
-
        message: Message,
-
    },
-
    CommentRedact {
-
        revision_id: Rev,
-
        comment_id: Rev,
-
    },
-
    CommentReact {
-
        revision_id: Rev,
-
        comment_id: Rev,
-
        reaction: Reaction,
-
        undo: bool,
-
    },
-
    React {
-
        revision_id: Rev,
-
        reaction: Reaction,
-
        undo: bool,
-
    },
-
    Review {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
        opts: review::Options,
-
    },
-
    Resolve {
-
        patch_id: Rev,
-
        review_id: Rev,
-
        comment_id: Rev,
-
        undo: bool,
-
    },
-
    Assign {
-
        patch_id: Rev,
-
        opts: AssignOptions,
-
    },
-
    Label {
-
        patch_id: Rev,
-
        opts: LabelOptions,
-
    },
-
    List {
-
        filter: Option<patch::Status>,
-
    },
-
    Edit {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
        message: Message,
-
    },
-
    Redact {
-
        revision_id: Rev,
-
    },
-
    Set {
-
        patch_id: Rev,
-
        remote: Option<RefString>,
-
    },
-
    Cache {
-
        patch_id: Option<Rev>,
-
        storage: bool,
-
    },
-
}
-

-
impl Operation {
-
    fn is_announce(&self) -> bool {
-
        match self {
-
            Operation::Update { .. }
-
            | Operation::Archive { .. }
-
            | Operation::Ready { .. }
-
            | Operation::Delete { .. }
-
            | Operation::Comment { .. }
-
            | Operation::CommentEdit { .. }
-
            | Operation::CommentRedact { .. }
-
            | Operation::CommentReact { .. }
-
            | Operation::Review { .. }
-
            | Operation::Resolve { .. }
-
            | Operation::Assign { .. }
-
            | Operation::Label { .. }
-
            | Operation::Edit { .. }
-
            | Operation::Redact { .. }
-
            | Operation::React { .. }
-
            | Operation::Set { .. } => true,
-
            Operation::Show { .. }
-
            | Operation::Diff { .. }
-
            | Operation::Checkout { .. }
-
            | Operation::List { .. }
-
            | Operation::Cache { .. } => false,
-
        }
-
    }
-
}
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub op: Operation,
-
    pub repo: Option<RepoId>,
-
    pub announce: bool,
-
    pub quiet: bool,
-
    pub authored: bool,
-
    pub authors: Vec<Did>,
-
}
-

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

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
-
        let mut verbose = false;
-
        let mut quiet = false;
-
        let mut authored = false;
-
        let mut authors = vec![];
-
        let mut announce = true;
-
        let mut patch_id = None;
-
        let mut revision_id = None;
-
        let mut review_id = None;
-
        let mut comment_id = None;
-
        let mut message = Message::default();
-
        let mut filter = Some(patch::Status::Open);
-
        let mut diff = false;
-
        let mut undo = false;
-
        let mut reaction: Option<Reaction> = None;
-
        let mut reply_to: Option<Rev> = None;
-
        let mut comment_op: Option<(CommentOperation, Rev)> = None;
-
        let mut checkout_opts = checkout::Options::default();
-
        let mut remote: Option<RefString> = None;
-
        let mut assign_opts = AssignOptions::default();
-
        let mut label_opts = LabelOptions::default();
-
        let mut review_op = review::Operation::default();
-
        let mut base_id = None;
-
        let mut repo = None;
-
        let mut cache_storage = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                // Options.
-
                Long("message") | Short('m') => {
-
                    if message != Message::Blank {
-
                        // We skip this code when `no-message` is specified.
-
                        let txt: String = term::args::string(&parser.value()?);
-
                        message.append(&txt);
-
                    }
-
                }
-
                Long("no-message") => {
-
                    message = Message::Blank;
-
                }
-
                Long("announce") => {
-
                    announce = true;
-
                }
-
                Long("no-announce") => {
-
                    announce = false;
-
                }
-

-
                // Show options.
-
                Long("patch") | Short('p') if op == Some(OperationName::Show) => {
-
                    diff = true;
-
                }
-
                Long("verbose") | Short('v') if op == Some(OperationName::Show) => {
-
                    verbose = true;
-
                }
-

-
                // Ready options.
-
                Long("undo") if op == Some(OperationName::Ready) => {
-
                    undo = true;
-
                }
-

-
                // Archive options.
-
                Long("undo") if op == Some(OperationName::Archive) => {
-
                    undo = true;
-
                }
-

-
                // Update options.
-
                Short('b') | Long("base") if op == Some(OperationName::Update) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    base_id = Some(rev);
-
                }
-

-
                // React options.
-
                Long("emoji") if op == Some(OperationName::React) => {
-
                    if let Some(emoji) = parser.value()?.to_str() {
-
                        reaction =
-
                            Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?);
-
                    }
-
                }
-
                Long("undo") if op == Some(OperationName::React) => {
-
                    undo = true;
-
                }
-

-
                // Comment options.
-
                Long("reply-to") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    reply_to = Some(rev);
-
                }
-

-
                Long("edit") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_op = Some((CommentOperation::Edit, rev));
-
                }
-

-
                Long("react") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_op = Some((CommentOperation::React, rev));
-
                }
-
                Long("emoji")
-
                    if op == Some(OperationName::Comment)
-
                        && matches!(comment_op, Some((CommentOperation::React, _))) =>
-
                {
-
                    if let Some(emoji) = parser.value()?.to_str() {
-
                        reaction =
-
                            Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?);
-
                    }
-
                }
-
                Long("undo")
-
                    if op == Some(OperationName::Comment)
-
                        && matches!(comment_op, Some((CommentOperation::React, _))) =>
-
                {
-
                    undo = true;
-
                }
-

-
                Long("redact") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_op = Some((CommentOperation::Redact, rev));
-
                }
-

-
                // Edit options.
-
                Long("revision") | Short('r') if op == Some(OperationName::Edit) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    revision_id = Some(rev);
-
                }
-

-
                // Review/diff options.
-
                Long("revision") | Short('r')
-
                    if op == Some(OperationName::Review) || op == Some(OperationName::Diff) =>
-
                {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    revision_id = Some(rev);
-
                }
-
                Long("patch") | Short('p') if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review { by_hunk, .. } = &mut review_op {
-
                        *by_hunk = true;
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("unified") | Short('U') if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review { unified, .. } = &mut review_op {
-
                        let val = parser.value()?;
-
                        *unified = term::args::number(&val)?;
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("hunk") if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review { hunk, .. } = &mut review_op {
-
                        let val = parser.value()?;
-
                        let val = term::args::number(&val)
-
                            .map_err(|e| anyhow!("invalid hunk value: {e}"))?;
-

-
                        *hunk = Some(val);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("delete") | Short('d') if op == Some(OperationName::Review) => {
-
                    review_op = review::Operation::Delete;
-
                }
-
                Long("accept") if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review {
-
                        verdict: verdict @ None,
-
                        ..
-
                    } = &mut review_op
-
                    {
-
                        *verdict = Some(patch::Verdict::Accept);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("reject") if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review {
-
                        verdict: verdict @ None,
-
                        ..
-
                    } = &mut review_op
-
                    {
-
                        *verdict = Some(patch::Verdict::Reject);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-

-
                // Resolve options
-
                Long("undo") if op == Some(OperationName::Resolve) => {
-
                    undo = true;
-
                }
-
                Long("review") if op == Some(OperationName::Resolve) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    review_id = Some(rev);
-
                }
-
                Long("comment") if op == Some(OperationName::Resolve) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_id = Some(rev);
-
                }
-

-
                // Checkout options
-
                Long("revision") if op == Some(OperationName::Checkout) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    revision_id = Some(rev);
-
                }
-

-
                Long("force") | Short('f') if op == Some(OperationName::Checkout) => {
-
                    checkout_opts.force = true;
-
                }
-

-
                Long("name") if op == Some(OperationName::Checkout) => {
-
                    let val = parser.value()?;
-
                    checkout_opts.name = Some(term::args::refstring("name", val)?);
-
                }
-

-
                Long("remote") if op == Some(OperationName::Checkout) => {
-
                    let val = parser.value()?;
-
                    checkout_opts.remote = Some(term::args::refstring("remote", val)?);
-
                }
-

-
                // Assign options.
-
                Short('a') | Long("add") if matches!(op, Some(OperationName::Assign)) => {
-
                    assign_opts.add.insert(term::args::did(&parser.value()?)?);
-
                }
-

-
                Short('d') | Long("delete") if matches!(op, Some(OperationName::Assign)) => {
-
                    assign_opts
-
                        .delete
-
                        .insert(term::args::did(&parser.value()?)?);
-
                }
-

-
                // Label options.
-
                Short('a') | Long("add") if matches!(op, Some(OperationName::Label)) => {
-
                    let val = parser.value()?;
-
                    let name = term::args::string(&val);
-
                    let label = Label::new(name)?;
-

-
                    label_opts.add.insert(label);
-
                }
-

-
                Short('d') | Long("delete") if matches!(op, Some(OperationName::Label)) => {
-
                    let val = parser.value()?;
-
                    let name = term::args::string(&val);
-
                    let label = Label::new(name)?;
-

-
                    label_opts.delete.insert(label);
-
                }
-

-
                // Set options.
-
                Long("remote") if op == Some(OperationName::Set) => {
-
                    let val = parser.value()?;
-
                    remote = Some(term::args::refstring("remote", val)?);
-
                }
-

-
                // List options.
-
                Long("all") => {
-
                    filter = None;
-
                }
-
                Long("draft") => {
-
                    filter = Some(patch::Status::Draft);
-
                }
-
                Long("archived") => {
-
                    filter = Some(patch::Status::Archived);
-
                }
-
                Long("merged") => {
-
                    filter = Some(patch::Status::Merged);
-
                }
-
                Long("open") => {
-
                    filter = Some(patch::Status::Open);
-
                }
-
                Long("authored") => {
-
                    authored = true;
-
                }
-
                Long("author") if op == Some(OperationName::List) => {
-
                    authors.push(term::args::did(&parser.value()?)?);
-
                }
-

-
                // Cache options.
-
                Long("storage") if op == Some(OperationName::Cache) => {
-
                    cache_storage = true;
-
                }
-

-
                // Common.
-
                Long("quiet") | Short('q') => {
-
                    quiet = true;
-
                }
-
                Long("repo") => {
-
                    let val = parser.value()?;
-
                    let rid = term::args::rid(&val)?;
-

-
                    repo = Some(rid);
-
                }
-
                Long("help") => {
-
                    return Err(Error::HelpManual { name: "rad-patch" }.into());
-
                }
-
                Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-

-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "l" | "list" => op = Some(OperationName::List),
-
                    "s" | "show" => op = Some(OperationName::Show),
-
                    "u" | "update" => op = Some(OperationName::Update),
-
                    "d" | "delete" => op = Some(OperationName::Delete),
-
                    "c" | "checkout" => op = Some(OperationName::Checkout),
-
                    "a" | "archive" => op = Some(OperationName::Archive),
-
                    "y" | "ready" => op = Some(OperationName::Ready),
-
                    "e" | "edit" => op = Some(OperationName::Edit),
-
                    "r" | "redact" => op = Some(OperationName::Redact),
-
                    "diff" => op = Some(OperationName::Diff),
-
                    "assign" => op = Some(OperationName::Assign),
-
                    "label" => op = Some(OperationName::Label),
-
                    "comment" => op = Some(OperationName::Comment),
-
                    "review" => op = Some(OperationName::Review),
-
                    "resolve" => op = Some(OperationName::Resolve),
-
                    "set" => op = Some(OperationName::Set),
-
                    "cache" => op = Some(OperationName::Cache),
-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
-
                Value(val) if op == Some(OperationName::Redact) => {
-
                    let rev = term::args::rev(&val)?;
-
                    revision_id = Some(rev);
-
                }
-
                Value(val)
-
                    if patch_id.is_none()
-
                        && [
-
                            Some(OperationName::Show),
-
                            Some(OperationName::Diff),
-
                            Some(OperationName::Update),
-
                            Some(OperationName::Delete),
-
                            Some(OperationName::Archive),
-
                            Some(OperationName::Ready),
-
                            Some(OperationName::Checkout),
-
                            Some(OperationName::Comment),
-
                            Some(OperationName::Review),
-
                            Some(OperationName::Resolve),
-
                            Some(OperationName::Edit),
-
                            Some(OperationName::Set),
-
                            Some(OperationName::Assign),
-
                            Some(OperationName::Label),
-
                            Some(OperationName::Cache),
-
                        ]
-
                        .contains(&op) =>
-
                {
-
                    let val = string(&val);
-
                    patch_id = Some(Rev::from(val));
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::List => Operation::List { filter },
-
            OperationName::Show => Operation::Show {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                diff,
-
                verbose,
-
            },
-
            OperationName::Diff => Operation::Diff {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                revision_id,
-
            },
-
            OperationName::Delete => Operation::Delete {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
            },
-
            OperationName::Update => Operation::Update {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                base_id,
-
                message,
-
            },
-
            OperationName::Archive => Operation::Archive {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch id must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Checkout => Operation::Checkout {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                revision_id,
-
                opts: checkout_opts,
-
            },
-
            OperationName::Comment => match comment_op {
-
                Some((CommentOperation::Edit, comment)) => Operation::CommentEdit {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    comment_id: comment,
-
                    message,
-
                },
-
                Some((CommentOperation::React, comment)) => Operation::CommentReact {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    comment_id: comment,
-
                    reaction: reaction
-
                        .ok_or_else(|| anyhow!("a reaction emoji must be provided"))?,
-
                    undo,
-
                },
-
                Some((CommentOperation::Redact, comment)) => Operation::CommentRedact {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    comment_id: comment,
-
                },
-
                None => Operation::Comment {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    message,
-
                    reply_to,
-
                },
-
            },
-
            OperationName::React => Operation::React {
-
                revision_id: patch_id
-
                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                reaction: reaction.ok_or_else(|| anyhow!("a reaction emoji must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Review => Operation::Review {
-
                patch_id: patch_id
-
                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                revision_id,
-
                opts: review::Options {
-
                    message,
-
                    op: review_op,
-
                },
-
            },
-
            OperationName::Resolve => Operation::Resolve {
-
                patch_id: patch_id
-
                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                review_id: review_id.ok_or_else(|| anyhow!("a review must be provided"))?,
-
                comment_id: comment_id.ok_or_else(|| anyhow!("a comment must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Ready => Operation::Ready {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Edit => Operation::Edit {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                revision_id,
-
                message,
-
            },
-
            OperationName::Redact => Operation::Redact {
-
                revision_id: revision_id.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Assign => Operation::Assign {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                opts: assign_opts,
-
            },
-
            OperationName::Label => Operation::Label {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                opts: label_opts,
-
            },
-
            OperationName::Set => Operation::Set {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                remote,
-
            },
-
            OperationName::Cache => Operation::Cache {
-
                patch_id,
-
                storage: cache_storage,
-
            },
-
        };
-

-
        Ok((
-
            Options {
-
                op,
-
                repo,
-
                quiet,
-
                announce,
-
                authored,
-
                authors,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
-
    let (workdir, rid) = if let Some(rid) = options.repo {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
+
    let (workdir, rid) = if let Some(rid) = args.repo {
        (None, rid)
    } else {
        radicle::rad::cwd()
@@ -863,51 +47,51 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {

    let profile = ctx.profile()?;
    let repository = profile.storage.repository(rid)?;
-
    let announce = options.announce && options.op.is_announce();
+

+
    // Fallback to [`Command::List`] if no subcommand is provided.
+
    // Construct it using the [`EmptyArgs`] in `args.empty`.
+
    let mut announce = args.should_announce();
+
    let command = args
+
        .command
+
        .unwrap_or_else(|| Command::List(args.empty.into()));
+
    announce &= command.should_announce();

    transport::local::register(profile.storage.clone());

-
    match options.op {
-
        Operation::List { filter } => {
-
            let mut authors: BTreeSet<Did> = options.authors.iter().cloned().collect();
-
            if options.authored {
+
    match command {
+
        Command::List(args) => {
+
            let mut authors: BTreeSet<Did> = args.authors.iter().cloned().collect();
+
            if args.authored {
                authors.insert(profile.did());
            }
-
            list::run(filter.as_ref(), authors, &repository, &profile)?;
+
            list::run((&args.state).into(), authors, &repository, &profile)?;
        }
-
        Operation::Show {
-
            patch_id,
-
            diff,
-
            verbose,
-
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
+

+
        Command::Show { id, patch, verbose } => {
+
            let patch_id = id.resolve(&repository.backend)?;
            show::run(
                &patch_id,
-
                diff,
+
                patch,
                verbose,
                &profile,
                &repository,
                workdir.as_ref(),
            )?;
        }
-
        Operation::Diff {
-
            patch_id,
-
            revision_id,
-
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let revision_id = revision_id
+

+
        Command::Diff { id, revision } => {
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let revision_id = revision
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
            diff::run(&patch_id, revision_id, &repository, &profile)?;
        }
-
        Operation::Update {
-
            ref patch_id,
-
            ref base_id,
-
            ref message,
-
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let base_id = base_id
+

+
        Command::Update { id, base, message } => {
+
            let message = Message::from(message);
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let base_id = base
                .as_ref()
                .map(|base| base.resolve(&repository.backend))
                .transpose()?;
@@ -915,21 +99,16 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                "this command must be run from a repository checkout"
            ))?;

-
            update::run(
-
                patch_id,
-
                base_id,
-
                message.clone(),
-
                &profile,
-
                &repository,
-
                &workdir,
-
            )?;
+
            update::run(patch_id, base_id, message, &profile, &repository, &workdir)?;
        }
-
        Operation::Archive { ref patch_id, undo } => {
-
            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+

+
        Command::Archive { id, undo } => {
+
            let patch_id = id.resolve::<PatchId>(&repository.backend)?;
            archive::run(&patch_id, undo, &profile, &repository)?;
        }
-
        Operation::Ready { ref patch_id, undo } => {
-
            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+

+
        Command::Ready { id, undo } => {
+
            let patch_id = id.resolve::<PatchId>(&repository.backend)?;

            if !ready::run(&patch_id, undo, &profile, &repository)? {
                if undo {
@@ -939,17 +118,15 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                }
            }
        }
-
        Operation::Delete { patch_id } => {
-
            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+

+
        Command::Delete { id } => {
+
            let patch_id = id.resolve::<PatchId>(&repository.backend)?;
            delete::run(&patch_id, &profile, &repository)?;
        }
-
        Operation::Checkout {
-
            patch_id,
-
            revision_id,
-
            opts,
-
        } => {
-
            let patch_id = patch_id.resolve::<radicle::git::Oid>(&repository.backend)?;
-
            let revision_id = revision_id
+

+
        Command::Checkout { id, revision, opts } => {
+
            let patch_id = id.resolve::<radicle::git::Oid>(&repository.backend)?;
+
            let revision_id = revision
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
@@ -962,86 +139,136 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                &repository,
                &workdir,
                &profile,
-
                opts,
+
                opts.into(),
            )?;
        }
-
        Operation::Comment {
-
            revision_id,
-
            message,
-
            reply_to,
-
        } => {
-
            comment::run(
-
                revision_id,
+

+
        Command::Comment(c) => match CommentAction::from(c) {
+
            CommentAction::Comment {
+
                revision,
                message,
                reply_to,
-
                options.quiet,
-
                &repository,
-
                &profile,
-
            )?;
-
        }
-
        Operation::Review {
-
            patch_id,
-
            revision_id,
-
            opts,
+
            } => {
+
                comment::run(
+
                    revision,
+
                    message,
+
                    reply_to,
+
                    args.quiet,
+
                    &repository,
+
                    &profile,
+
                )?;
+
            }
+
            CommentAction::Edit {
+
                revision,
+
                comment,
+
                message,
+
            } => {
+
                let comment = comment.resolve(&repository.backend)?;
+
                comment::edit::run(
+
                    revision,
+
                    comment,
+
                    message,
+
                    args.quiet,
+
                    &repository,
+
                    &profile,
+
                )?;
+
            }
+
            CommentAction::Redact { revision, comment } => {
+
                let comment = comment.resolve(&repository.backend)?;
+
                comment::redact::run(revision, comment, &repository, &profile)?;
+
            }
+
            CommentAction::React {
+
                revision,
+
                comment,
+
                emoji,
+
                undo,
+
            } => {
+
                let comment = comment.resolve(&repository.backend)?;
+
                if undo {
+
                    comment::react::run(revision, comment, emoji, false, &repository, &profile)?;
+
                } else {
+
                    comment::react::run(revision, comment, emoji, true, &repository, &profile)?;
+
                }
+
            }
+
        },
+

+
        Command::Review {
+
            id,
+
            revision,
+
            options,
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let revision_id = revision_id
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let revision_id = revision
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
-
            review::run(patch_id, revision_id, opts, &profile, &repository)?;
+
            review::run(patch_id, revision_id, options.into(), &profile, &repository)?;
        }
-
        Operation::Resolve {
-
            ref patch_id,
-
            ref review_id,
-
            ref comment_id,
-
            undo,
+

+
        Command::Resolve {
+
            id,
+
            review,
+
            comment,
+
            unresolve,
        } => {
-
            let patch = patch_id.resolve(&repository.backend)?;
+
            let patch = id.resolve(&repository.backend)?;
            let review = patch::ReviewId::from(
-
                review_id.resolve::<radicle::cob::EntryId>(&repository.backend)?,
+
                review.resolve::<radicle::cob::EntryId>(&repository.backend)?,
            );
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            if undo {
+
            let comment = comment.resolve(&repository.backend)?;
+
            if unresolve {
                resolve::unresolve(patch, review, comment, &repository, &profile)?;
-
                term::success!("Unresolved comment {comment_id}");
+
                term::success!("Unresolved comment {comment}");
            } else {
                resolve::resolve(patch, review, comment, &repository, &profile)?;
-
                term::success!("Resolved comment {comment_id}");
+
                term::success!("Resolved comment {comment}");
            }
        }
-
        Operation::Edit {
-
            patch_id,
-
            revision_id,
+
        Command::Edit {
+
            id,
+
            revision,
            message,
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let revision_id = revision_id
+
            let message = Message::from(message);
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let revision_id = revision
                .map(|id| id.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
            edit::run(&patch_id, revision_id, message, &profile, &repository)?;
        }
-
        Operation::Redact { revision_id } => {
-
            redact::run(&revision_id, &profile, &repository)?;
+
        Command::Redact { id } => {
+
            redact::run(&id, &profile, &repository)?;
        }
-
        Operation::Assign {
-
            patch_id,
-
            opts: AssignOptions { add, delete },
+
        Command::Assign {
+
            id,
+
            args: AssignArgs { add, delete },
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            assign::run(&patch_id, add, delete, &profile, &repository)?;
+
            let patch_id = id.resolve(&repository.backend)?;
+
            assign::run(
+
                &patch_id,
+
                add.into_iter().collect(),
+
                delete.into_iter().collect(),
+
                &profile,
+
                &repository,
+
            )?;
        }
-
        Operation::Label {
-
            patch_id,
-
            opts: LabelOptions { add, delete },
+
        Command::Label {
+
            id,
+
            args: LabelArgs { add, delete },
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            label::run(&patch_id, add, delete, &profile, &repository)?;
+
            let patch_id = id.resolve(&repository.backend)?;
+
            label::run(
+
                &patch_id,
+
                add.into_iter().collect(),
+
                delete.into_iter().collect(),
+
                &profile,
+
                &repository,
+
            )?;
        }
-
        Operation::Set { patch_id, remote } => {
+
        Command::Set { id, remote } => {
            let patches = term::cob::patches(&profile, &repository)?;
-
            let patch_id = patch_id.resolve(&repository.backend)?;
+
            let patch_id = id.resolve(&repository.backend)?;
            let patch = patches
                .get(&patch_id)?
                .ok_or_else(|| anyhow!("patch {patch_id} not found"))?;
@@ -1056,13 +283,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                true,
            )?;
        }
-
        Operation::Cache { patch_id, storage } => {
+
        Command::Cache { id, storage } => {
            let mode = if storage {
                cache::CacheMode::Storage
            } else {
-
                let patch_id = patch_id
-
                    .map(|id| id.resolve(&repository.backend))
-
                    .transpose()?;
+
                let patch_id = id.map(|id| id.resolve(&repository.backend)).transpose()?;
                patch_id.map_or(
                    cache::CacheMode::Repository {
                        repository: &repository,
@@ -1075,50 +300,15 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            };
            cache::run(mode, &profile)?;
        }
-
        Operation::CommentEdit {
-
            revision_id,
-
            comment_id,
-
            message,
-
        } => {
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            comment::edit::run(
-
                revision_id,
-
                comment,
-
                message,
-
                options.quiet,
-
                &repository,
-
                &profile,
-
            )?;
-
        }
-
        Operation::CommentRedact {
-
            revision_id,
-
            comment_id,
-
        } => {
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            comment::redact::run(revision_id, comment, &repository, &profile)?;
-
        }
-
        Operation::CommentReact {
-
            revision_id,
-
            comment_id,
-
            reaction,
-
            undo,
-
        } => {
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            if undo {
-
                comment::react::run(revision_id, comment, reaction, false, &repository, &profile)?;
-
            } else {
-
                comment::react::run(revision_id, comment, reaction, true, &repository, &profile)?;
-
            }
-
        }
-
        Operation::React {
-
            revision_id,
-
            reaction,
+
        Command::React {
+
            id,
+
            emoji: react,
            undo,
        } => {
            if undo {
-
                react::run(&revision_id, reaction, false, &repository, &profile)?;
+
                react::run(&id, react, false, &repository, &profile)?;
            } else {
-
                react::run(&revision_id, reaction, true, &repository, &profile)?;
+
                react::run(&id, react, true, &repository, &profile)?;
            }
        }
    }
added crates/radicle-cli/src/commands/patch/args.rs
@@ -0,0 +1,755 @@
+
use clap::{Parser, Subcommand};
+

+
use radicle::cob::Label;
+
use radicle::git;
+
use radicle::git::fmt::RefString;
+
use radicle::patch::Status;
+
use radicle::patch::Verdict;
+
use radicle::prelude::Did;
+
use radicle::prelude::RepoId;
+

+
use crate::commands::patch::checkout;
+
use crate::commands::patch::review;
+

+
use crate::git::Rev;
+
use crate::terminal::patch::Message;
+

+
const ABOUT: &str = "Manage patches";
+

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

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

+
    /// Announce changes made to the network
+
    #[arg(long, global = true, conflicts_with = "no_announce")]
+
    announce: bool,
+

+
    /// Do not announce changes made to the network
+
    #[arg(long, global = true, conflicts_with = "announce")]
+
    no_announce: bool,
+

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

+
    /// Verbose output
+
    #[arg(long, short, global = true)]
+
    pub(super) verbose: bool,
+

+
    /// Arguments for the empty subcommand.
+
    /// Will fall back to [`Command::List`].
+
    #[clap(flatten)]
+
    pub(super) empty: EmptyArgs,
+
}
+

+
impl Args {
+
    pub(super) fn should_announce(&self) -> bool {
+
        self.announce || !self.no_announce
+
    }
+
}
+

+
/// Commands to create, view, and edit Radicle patches
+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    /// List the patches of a repository
+
    #[command(alias = "l")]
+
    List(ListArgs),
+

+
    /// Show a specific patch
+
    #[command(alias = "s")]
+
    Show {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Show the diff of the changes in the patch
+
        #[arg(long, short)]
+
        patch: bool,
+

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

+
    /// Show the diff of a specific patch
+
    ///
+
    /// The `git diff` of the revision's base and head will be shown
+
    Diff {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// The revision to diff
+
        ///
+
        /// If not specified, the latest revision of the original author
+
        /// will be used
+
        #[arg(long, short)]
+
        revision: Option<Rev>,
+
    },
+

+
    /// Mark a patch as archived
+
    #[command(alias = "a")]
+
    Archive {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Unarchive a patch
+
        ///
+
        /// The patch will be marked as open
+
        #[arg(long)]
+
        undo: bool,
+
    },
+

+
    /// Update the metadata of a patch
+
    #[command(alias = "u")]
+
    Update {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Provide a Git revision as the base commit
+
        #[arg(long, short, value_name = "REVSPEC")]
+
        base: Option<Rev>,
+

+
        /// Change the message of the original revision of the patch
+
        #[clap(flatten)]
+
        message: MessageArgs,
+
    },
+

+
    /// Checkout a Git branch pointing to the head of a patch revision
+
    ///
+
    /// If no revision is specified, the latest revision of the original author
+
    /// is chosen
+
    #[command(alias = "c")]
+
    Checkout {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Checkout the given revision of the patch
+
        #[arg(long)]
+
        revision: Option<Rev>,
+

+
        #[clap(flatten)]
+
        opts: CheckoutArgs,
+
    },
+

+
    /// Create a review of a patch revision
+
    Review {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// The particular revision to review
+
        ///
+
        /// If none is specified, the initial revision will be reviewed
+
        #[arg(long, short)]
+
        revision: Option<Rev>,
+

+
        #[clap(flatten)]
+
        options: ReviewArgs,
+
    },
+

+
    /// Mark a comment of a review as resolved or unresolved
+
    Resolve {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// The review id which the comment is under
+
        #[arg(long, value_name = "REVIEW_ID")]
+
        review: Rev,
+

+
        /// The comment to (un)resolve
+
        #[arg(long, value_name = "COMMENT_ID")]
+
        comment: Rev,
+

+
        /// Unresolve the comment
+
        #[arg(long)]
+
        unresolve: bool,
+
    },
+

+
    /// Delete a patch
+
    ///
+
    /// This will delete any patch data associated with this user. Note that
+
    /// other user's data will remain, meaning the patch will remain until all
+
    /// other data is also deleted.
+
    #[command(alias = "d")]
+
    Delete {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+
    },
+

+
    /// Redact a patch revision
+
    #[command(alias = "r")]
+
    Redact {
+
        /// ID of the patch revision
+
        #[arg(value_name = "REVISION_ID")]
+
        id: Rev,
+
    },
+

+
    /// React to a patch or patch revision
+
    React {
+
        /// ID of the patch or patch revision
+
        #[arg(value_name = "PATCH_ID|REVISION_ID")]
+
        id: Rev,
+

+
        /// The reaction being used
+
        #[arg(long, value_name = "CHAR")]
+
        emoji: radicle::cob::Reaction,
+

+
        /// Remove the reaction
+
        #[arg(long)]
+
        undo: bool,
+
    },
+

+
    /// Add or remove assignees to/from a patch
+
    Assign {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[clap(flatten)]
+
        args: AssignArgs,
+
    },
+

+
    /// Add or remove labels to/from a patch
+
    Label {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[clap(flatten)]
+
        args: LabelArgs,
+
    },
+

+
    /// If the patch is marked as a draft, then mark it as open
+
    #[command(alias = "y")]
+
    Ready {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Convert a patch back to a draft
+
        #[arg(long)]
+
        undo: bool,
+
    },
+

+
    #[command(alias = "e")]
+
    Edit {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// ID of the patch revision
+
        #[arg(long, value_name = "REVISION_ID")]
+
        revision: Option<Rev>,
+

+
        #[clap(flatten)]
+
        message: MessageArgs,
+
    },
+

+
    /// Set an upstream branch for a patch
+
    Set {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Provide the git remote to use as the upstream
+
        #[arg(long, value_name = "REF", value_parser = parse_refstr)]
+
        remote: Option<RefString>,
+
    },
+

+
    /// Comment on, reply to, edit, or react to a comment
+
    Comment(CommentArgs),
+

+
    /// Re-cache the patches
+
    Cache {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Option<Rev>,
+

+
        /// Re-cache all patches in storage, as opposed to the current repository
+
        #[arg(long)]
+
        storage: bool,
+
    },
+
}
+

+
impl Command {
+
    pub(super) fn should_announce(&self) -> bool {
+
        match self {
+
            Self::Update { .. }
+
            | Self::Archive { .. }
+
            | Self::Ready { .. }
+
            | Self::Delete { .. }
+
            | Self::Comment { .. }
+
            | Self::Review { .. }
+
            | Self::Resolve { .. }
+
            | Self::Assign { .. }
+
            | Self::Label { .. }
+
            | Self::Edit { .. }
+
            | Self::Redact { .. }
+
            | Self::React { .. }
+
            | Self::Set { .. } => true,
+
            Self::Show { .. }
+
            | Self::Diff { .. }
+
            | Self::Checkout { .. }
+
            | Self::List { .. }
+
            | Self::Cache { .. } => false,
+
        }
+
    }
+
}
+

+
#[derive(Parser, Debug)]
+
pub(super) struct CommentArgs {
+
    /// ID of the revision to comment on
+
    #[arg(value_name = "REVISION_ID")]
+
    revision: Rev,
+

+
    #[clap(flatten)]
+
    message: MessageArgs,
+

+
    /// The comment to edit
+
    ///
+
    /// Use `--message` to edit with the provided message
+
    #[arg(
+
        long,
+
        value_name = "COMMENT_ID",
+
        conflicts_with = "react",
+
        conflicts_with = "redact"
+
    )]
+
    edit: Option<Rev>,
+

+
    /// The comment to react to
+
    ///
+
    /// Use `--emoji` for the character to react with
+
    ///
+
    /// Use `--undo` with `--emoji` to remove the reaction
+
    #[arg(
+
        long,
+
        value_name = "COMMENT_ID",
+
        conflicts_with = "edit",
+
        conflicts_with = "redact",
+
        requires = "emoji",
+
        group = "reaction"
+
    )]
+
    react: Option<Rev>,
+

+
    /// The comment to redact
+
    #[arg(
+
        long,
+
        value_name = "COMMENT_ID",
+
        conflicts_with = "react",
+
        conflicts_with = "edit"
+
    )]
+
    redact: Option<Rev>,
+

+
    /// The emoji to react with
+
    ///
+
    /// Requires using `--react <COMMENT_ID>`
+
    #[arg(long, requires = "reaction")]
+
    emoji: Option<radicle::cob::Reaction>,
+

+
    /// The comment to reply to
+
    #[arg(long, value_name = "COMMENT_ID")]
+
    reply_to: Option<Rev>,
+

+
    /// Remove the reaction
+
    ///
+
    /// Requires using `--react <COMMENT_ID> --emoji <EMOJI>`
+
    #[arg(long, requires = "reaction")]
+
    undo: bool,
+
}
+

+
#[derive(Debug)]
+
pub(super) enum CommentAction {
+
    Comment {
+
        revision: Rev,
+
        message: Message,
+
        reply_to: Option<Rev>,
+
    },
+
    Edit {
+
        revision: Rev,
+
        comment: Rev,
+
        message: Message,
+
    },
+
    Redact {
+
        revision: Rev,
+
        comment: Rev,
+
    },
+
    React {
+
        revision: Rev,
+
        comment: Rev,
+
        emoji: radicle::cob::Reaction,
+
        undo: bool,
+
    },
+
}
+

+
impl From<CommentArgs> for CommentAction {
+
    fn from(
+
        CommentArgs {
+
            revision,
+
            message,
+
            edit,
+
            react,
+
            redact,
+
            reply_to,
+
            emoji,
+
            undo,
+
        }: CommentArgs,
+
    ) -> Self {
+
        match (edit, react, redact) {
+
            (Some(edit), None, None) => CommentAction::Edit {
+
                revision,
+
                comment: edit,
+
                message: Message::from(message),
+
            },
+
            (None, Some(react), None) => CommentAction::React {
+
                revision,
+
                comment: react,
+
                emoji: emoji.expect("emoji must be Some when react is Some"),
+
                undo,
+
            },
+
            (None, None, Some(redact)) => CommentAction::Redact {
+
                revision,
+
                comment: redact,
+
            },
+
            (None, None, None) => Self::Comment {
+
                revision,
+
                message: Message::from(message),
+
                reply_to,
+
            },
+
            _ => unreachable!("`--edit`, `--react`, and `--redact` cannot be used together"),
+
        }
+
    }
+
}
+

+
#[derive(Parser, Debug, Default)]
+
pub(super) struct EmptyArgs {
+
    #[arg(long, hide = true, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
+
    authors: Vec<Did>,
+

+
    #[arg(long, hide = true)]
+
    authored: bool,
+

+
    #[clap(flatten)]
+
    state: EmptyStateArgs,
+
}
+

+
#[derive(Parser, Debug, Default)]
+
#[group(multiple = false)]
+
pub(super) struct EmptyStateArgs {
+
    #[arg(long, hide = true)]
+
    all: bool,
+

+
    #[arg(long, hide = true)]
+
    draft: bool,
+

+
    #[arg(long, hide = true)]
+
    open: bool,
+

+
    #[arg(long, hide = true)]
+
    merged: bool,
+

+
    #[arg(long, hide = true)]
+
    archived: bool,
+
}
+

+
#[derive(Parser, Debug, Default)]
+
pub(super) struct ListArgs {
+
    /// Show only patched where the given user is an author (may be specified
+
    /// multiple times)
+
    #[arg(
+
        long = "author",
+
        value_name = "DID",
+
        num_args = 1..,
+
        action = clap::ArgAction::Append,
+
    )]
+
    pub(super) authors: Vec<Did>,
+

+
    /// Show only patches that you have authored
+
    #[arg(long)]
+
    pub(super) authored: bool,
+

+
    #[clap(flatten)]
+
    pub(super) state: ListStateArgs,
+
}
+

+
impl From<EmptyArgs> for ListArgs {
+
    fn from(args: EmptyArgs) -> Self {
+
        Self {
+
            authors: args.authors,
+
            authored: args.authored,
+
            state: ListStateArgs::from(args.state),
+
        }
+
    }
+
}
+

+
#[derive(Parser, Debug, Default)]
+
#[group(multiple = false)]
+
pub(crate) struct ListStateArgs {
+
    /// Show all patches, including draft, merged, and archived patches
+
    #[arg(long)]
+
    pub(crate) all: bool,
+

+
    /// Show only draft patches
+
    #[arg(long)]
+
    pub(crate) draft: bool,
+

+
    /// Show only open patches (default)
+
    #[arg(long)]
+
    pub(crate) open: bool,
+

+
    /// Show only merged patches
+
    #[arg(long)]
+
    pub(crate) merged: bool,
+

+
    /// Show only archived patches
+
    #[arg(long)]
+
    pub(crate) archived: bool,
+
}
+

+
impl From<EmptyStateArgs> for ListStateArgs {
+
    fn from(args: EmptyStateArgs) -> Self {
+
        Self {
+
            all: args.all,
+
            draft: args.draft,
+
            open: args.open,
+
            merged: args.merged,
+
            archived: args.archived,
+
        }
+
    }
+
}
+

+
impl From<&ListStateArgs> for Option<&Status> {
+
    fn from(args: &ListStateArgs) -> Self {
+
        match (args.all, args.draft, args.open, args.merged, args.archived) {
+
            (true, false, false, false, false) => None,
+
            (false, true, false, false, false) => Some(&Status::Draft),
+
            (false, false, true, false, false) | (false, false, false, false, false) => {
+
                Some(&Status::Open)
+
            }
+
            (false, false, false, true, false) => Some(&Status::Merged),
+
            (false, false, false, false, true) => Some(&Status::Archived),
+
            _ => unreachable!(),
+
        }
+
    }
+
}
+

+
#[derive(Debug, Parser)]
+
pub(super) struct ReviewArgs {
+
    /// Review by patch hunks
+
    ///
+
    /// This operation is obsolete
+
    #[arg(long, short, group = "by-hunk", conflicts_with = "delete")]
+
    patch: bool,
+

+
    /// Generate diffs with <N> lines of context
+
    ///
+
    /// This operation is obsolete
+
    #[arg(
+
        long,
+
        short = 'U',
+
        value_name = "N",
+
        requires = "by-hunk",
+
        default_value_t = 3
+
    )]
+
    unified: usize,
+

+
    /// Only review a specific hunk
+
    ///
+
    /// This operation is obsolete
+
    #[arg(long, value_name = "INDEX", requires = "by-hunk")]
+
    hunk: Option<usize>,
+

+
    /// Accept a patch revision
+
    #[arg(long, conflicts_with = "reject", conflicts_with = "delete")]
+
    accept: bool,
+

+
    /// Reject a patch revision
+
    #[arg(long, conflicts_with = "delete")]
+
    reject: bool,
+

+
    /// Delete a review draft
+
    ///
+
    /// This operation is obsolete
+
    #[arg(long, short)]
+
    delete: bool,
+

+
    #[clap(flatten)]
+
    message_args: MessageArgs,
+
}
+

+
impl ReviewArgs {
+
    fn as_operation(&self) -> review::Operation {
+
        let Self {
+
            patch,
+
            accept,
+
            reject,
+
            delete,
+
            ..
+
        } = self;
+

+
        if *patch {
+
            let verdict = if *accept {
+
                Some(Verdict::Accept)
+
            } else if *reject {
+
                Some(Verdict::Reject)
+
            } else {
+
                None
+
            };
+
            return review::Operation::Review(review::ReviewOptions {
+
                by_hunk: true,
+
                unified: self.unified,
+
                hunk: self.hunk,
+
                verdict,
+
            });
+
        }
+

+
        if *delete {
+
            return review::Operation::Delete;
+
        }
+

+
        if *accept {
+
            return review::Operation::Review(review::ReviewOptions {
+
                by_hunk: false,
+
                unified: 3,
+
                hunk: None,
+
                verdict: Some(Verdict::Accept),
+
            });
+
        }
+

+
        if *reject {
+
            return review::Operation::Review(review::ReviewOptions {
+
                by_hunk: false,
+
                unified: 3,
+
                hunk: None,
+
                verdict: Some(Verdict::Reject),
+
            });
+
        }
+

+
        panic!("expected one of `--patch`, `--delete`, `--accept`, or `--reject`");
+
    }
+
}
+

+
impl From<ReviewArgs> for review::Options {
+
    fn from(args: ReviewArgs) -> Self {
+
        let op = args.as_operation();
+
        Self {
+
            message: Message::from(args.message_args),
+
            op,
+
        }
+
    }
+
}
+

+
#[derive(Debug, clap::Args)]
+
#[group(required = false, multiple = false)]
+
pub(super) struct MessageArgs {
+
    /// Provide a message (default: prompt)
+
    ///
+
    /// This can be specified multiple times. This will result in newlines
+
    /// between the specified messages.
+
    #[clap(
+
        long,
+
        short,
+
        value_name = "MESSAGE",
+
        num_args = 1..,
+
        action = clap::ArgAction::Append
+
    )]
+
    pub(super) message: Option<Vec<String>>,
+

+
    /// Do not provide a message
+
    #[arg(long, conflicts_with = "message")]
+
    pub(super) no_message: bool,
+
}
+

+
impl From<MessageArgs> for Message {
+
    fn from(
+
        MessageArgs {
+
            message,
+
            no_message,
+
        }: MessageArgs,
+
    ) -> Self {
+
        if no_message {
+
            assert!(message.is_none());
+
            return Self::Blank;
+
        }
+

+
        match message {
+
            Some(messages) => messages.into_iter().fold(Self::Blank, |mut result, m| {
+
                result.append(&m);
+
                result
+
            }),
+
            None => Self::Edit,
+
        }
+
    }
+
}
+

+
#[derive(Debug, clap::Args)]
+
pub(super) struct CheckoutArgs {
+
    /// Provide a name for the branch to checkout
+
    #[arg(long, value_name = "BRANCH", value_parser = parse_refstr)]
+
    pub(super) name: Option<RefString>,
+

+
    /// Provide the git remote to use as the upstream
+
    #[arg(long, value_parser = parse_refstr)]
+
    pub(super) remote: Option<RefString>,
+

+
    /// Checkout the head of the revision, even if the branch already exists
+
    #[arg(long, short)]
+
    pub(super) force: bool,
+
}
+

+
impl From<CheckoutArgs> for checkout::Options {
+
    fn from(value: CheckoutArgs) -> Self {
+
        Self {
+
            name: value.name,
+
            remote: value.remote,
+
            force: value.force,
+
        }
+
    }
+
}
+

+
#[derive(Parser, Debug)]
+
#[group(required = true)]
+
pub(super) struct AssignArgs {
+
    /// Add an assignee to the patch (may be specified multiple times).
+
    ///
+
    /// Note: `--add` takes precedence over `--delete`
+
    #[arg(long, short, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) add: Vec<Did>,
+

+
    /// Remove an assignee from the patch (may be specified multiple times).
+
    ///
+
    /// Note: `--add` takes precedence over `--delete`
+
    #[clap(long, short, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) delete: Vec<Did>,
+
}
+

+
#[derive(Parser, Debug)]
+
#[group(required = true)]
+
pub(super) struct LabelArgs {
+
    /// Add a label to the patch (may be specified multiple times).
+
    ///
+
    /// Note: `--add` takes precedence over `--delete`
+
    #[arg(long, short, value_name = "LABEL", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) add: Vec<Label>,
+

+
    /// Remove a label from the patch (may be specified multiple times).
+
    ///
+
    /// Note: `--add` takes precedence over `--delete`
+
    #[clap(long, short, value_name = "LABEL", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) delete: Vec<Label>,
+
}
+

+
fn parse_refstr(refstr: &str) -> Result<RefString, git::fmt::Error> {
+
    RefString::try_from(refstr)
+
}
modified crates/radicle-cli/src/commands/patch/checkout.rs
@@ -1,9 +1,10 @@
use anyhow::anyhow;

-
use git_ref_format::Qualified;
use radicle::cob::patch;
use radicle::cob::patch::RevisionId;
-
use radicle::git::RefString;
+
use radicle::git::fmt::Qualified;
+
use radicle::git::fmt::RefString;
+
use radicle::git::raw::ErrorExt as _;
use radicle::patch::cache::Patches as _;
use radicle::patch::PatchId;
use radicle::storage::git::Repository;
@@ -24,7 +25,7 @@ impl Options {
            Some(refname) => Ok(Qualified::from_refstr(refname)
                .map_or_else(|| refname.clone(), |q| q.to_ref_string())),
            // SAFETY: Patch IDs are valid refstrings.
-
            None => Ok(git::refname!("patch")
+
            None => Ok(git::fmt::refname!("patch")
                .join(RefString::try_from(term::format::cob(id).item).unwrap())),
        }
    }
@@ -56,34 +57,33 @@ pub fn run(
    let mut spinner = term::spinner("Performing checkout...");
    let patch_branch = opts.branch(patch_id)?;

-
    let commit =
-
        match working.find_branch(patch_branch.as_str(), radicle::git::raw::BranchType::Local) {
-
            Ok(branch) if opts.force => {
-
                let commit = find_patch_commit(revision, stored, working)?;
-
                let mut r = branch.into_reference();
-
                r.set_target(commit.id(), &format!("force update '{patch_branch}'"))?;
-
                commit
-
            }
-
            Ok(branch) => {
-
                let head = branch.get().peel_to_commit()?;
-
                if head.id() != *revision.head() {
-
                    anyhow::bail!(
-
                        "branch '{patch_branch}' already exists (use `--force` to overwrite)"
-
                    );
-
                }
-
                head
-
            }
-
            Err(e) if radicle::git::is_not_found_err(&e) => {
-
                let commit = find_patch_commit(revision, stored, working)?;
-
                // Create patch branch and switch to it.
-
                working.branch(patch_branch.as_str(), &commit, true)?;
-
                commit
+
    let commit = match working.find_branch(patch_branch.as_str(), git::raw::BranchType::Local) {
+
        Ok(branch) if opts.force => {
+
            let commit = find_patch_commit(revision, stored, working)?;
+
            let mut r = branch.into_reference();
+
            r.set_target(commit.id(), &format!("force update '{patch_branch}'"))?;
+
            commit
+
        }
+
        Ok(branch) => {
+
            let head = branch.get().peel_to_commit()?;
+
            if revision.head() != head.id() {
+
                anyhow::bail!(
+
                    "branch '{patch_branch}' already exists (use `--force` to overwrite)"
+
                );
            }
-
            Err(e) => return Err(e.into()),
-
        };
+
            head
+
        }
+
        Err(e) if e.is_not_found() => {
+
            let commit = find_patch_commit(revision, stored, working)?;
+
            // Create patch branch and switch to it.
+
            working.branch(patch_branch.as_str(), &commit, true)?;
+
            commit
+
        }
+
        Err(e) => return Err(e.into()),
+
    };

    if opts.force {
-
        let mut builder = radicle::git::raw::build::CheckoutBuilder::new();
+
        let mut builder = git::raw::build::CheckoutBuilder::new();
        builder.force();
        working.checkout_tree(commit.as_object(), Some(&mut builder))?;
    } else {
@@ -124,15 +124,27 @@ fn find_patch_commit<'a>(
    stored: &Repository,
    working: &'a git::raw::Repository,
) -> anyhow::Result<git::raw::Commit<'a>> {
-
    let head = *revision.head();
-
    let workdir = working
-
        .workdir()
-
        .ok_or(anyhow::anyhow!("repository is a bare git repository "))?;
+
    let head = revision.head().into();

    match working.find_commit(head) {
        Ok(commit) => Ok(commit),
-
        Err(e) if git::ext::is_not_found_err(&e) => {
-
            git::process::fetch_local(workdir, stored, [head.into()])?;
+
        Err(e) if e.is_not_found() => {
+
            let output = git::process::fetch_pack(
+
                Some(working.path()),
+
                stored,
+
                [head.into()],
+
                git::Verbosity::default(),
+
            )?;
+

+
            if !output.status.success() {
+
                anyhow::bail!(
+
                    "`git fetch` exited with status {}, stderr and stdout follow:\n{}\n{}\n",
+
                    output.status,
+
                    String::from_utf8_lossy(&output.stderr),
+
                    String::from_utf8_lossy(&output.stdout)
+
                );
+
            }
+

            working.find_commit(head).map_err(|e| e.into())
        }
        Err(e) => Err(e.into()),
modified crates/radicle-cli/src/commands/patch/comment.rs
@@ -1,8 +1,5 @@
-
#[path = "comment/edit.rs"]
pub mod edit;
-
#[path = "comment/react.rs"]
pub mod react;
-
#[path = "comment/redact.rs"]
pub mod redact;

use super::*;
modified crates/radicle-cli/src/commands/patch/list.rs
@@ -14,6 +14,8 @@ use term::Element as _;
use crate::terminal as term;
use crate::terminal::patch as common;

+
use itertools::Itertools as _;
+

/// List patches.
pub fn run(
    filter: Option<&patch::Status>,
@@ -79,13 +81,14 @@ pub fn run(
        is_me.then(by_rev_time).then(by_id)
    });

-
    let mut errors = Vec::new();
-
    for (id, patch) in &mut all {
-
        match row(id, patch, repository, profile) {
-
            Ok(r) => table.push(r),
-
            Err(e) => errors.push((patch.title(), id, e.to_string())),
-
        }
-
    }
+
    let (rows, errors): (Vec<_>, Vec<_>) = all
+
        .iter()
+
        .map(|(id, patch)| {
+
            row(id, patch, repository, profile).map_err(|e| (patch.title(), id, e.to_string()))
+
        })
+
        .partition_result();
+

+
    table.extend(rows);
    table.print();

    if !errors.is_empty() {
modified crates/radicle-cli/src/commands/patch/review.rs
@@ -1,4 +1,3 @@
-
#[path = "review/builder.rs"]
mod builder;

use anyhow::{anyhow, Context};
@@ -22,19 +21,16 @@ Markdown supported.
"#;

#[derive(Debug, PartialEq, Eq)]
-
pub enum Operation {
-
    Delete,
-
    Review {
-
        by_hunk: bool,
-
        unified: usize,
-
        hunk: Option<usize>,
-
        verdict: Option<Verdict>,
-
    },
+
pub(super) struct ReviewOptions {
+
    pub(super) by_hunk: bool,
+
    pub(super) unified: usize,
+
    pub(super) hunk: Option<usize>,
+
    pub(super) verdict: Option<Verdict>,
}

-
impl Default for Operation {
+
impl Default for ReviewOptions {
    fn default() -> Self {
-
        Self::Review {
+
        Self {
            by_hunk: false,
            unified: 3,
            hunk: None,
@@ -43,6 +39,18 @@ impl Default for Operation {
    }
}

+
#[derive(Debug, PartialEq, Eq)]
+
pub(super) enum Operation {
+
    Delete,
+
    Review(ReviewOptions),
+
}
+

+
impl Default for Operation {
+
    fn default() -> Self {
+
        Operation::Review(ReviewOptions::default())
+
    }
+
}
+

#[derive(Debug, Default)]
pub struct Options {
    pub message: Message,
@@ -78,12 +86,13 @@ pub fn run(

    let patch_id_pretty = term::format::tertiary(term::format::cob(&patch_id));
    match options.op {
-
        Operation::Review {
-
            verdict,
+
        Operation::Review(ReviewOptions {
            by_hunk,
            unified,
            hunk,
-
        } if by_hunk => {
+
            verdict,
+
        }) if by_hunk => {
+
            crate::warning::obsolete("rad patch review --patch");
            let mut opts = git::raw::DiffOptions::new();
            opts.patience(true)
                .minimal(true)
@@ -94,7 +103,7 @@ pub fn run(
                .verdict(verdict)
                .run(revision, &mut opts, &signer)?;
        }
-
        Operation::Review { verdict, .. } => {
+
        Operation::Review(ReviewOptions { verdict, .. }) => {
            let message = options.message.get(REVIEW_HELP_MSG)?;
            let message = message.replace(REVIEW_HELP_MSG.trim(), "");
            let message = if message.is_empty() {
@@ -125,6 +134,7 @@ pub fn run(
            }
        }
        Operation::Delete => {
+
            crate::warning::obsolete("rad patch review --delete");
            let name = git::refs::storage::draft::review(profile.id(), &patch_id);

            match repository.backend.find_reference(&name) {
modified crates/radicle-cli/src/commands/patch/review/builder.rs
@@ -23,10 +23,10 @@ use radicle::cob::patch::{PatchId, Revision, Verdict};
use radicle::cob::{CodeLocation, CodeRange};
use radicle::crypto;
use radicle::git;
+
use radicle::git::Oid;
use radicle::node::device::Device;
use radicle::prelude::*;
use radicle::storage::git::{cob::DraftStore, Repository};
-
use radicle_git_ext::Oid;
use radicle_surf::diff::*;
use radicle_term::{Element, VStack};

@@ -186,7 +186,10 @@ impl ReviewItem {
            Self::FileAdded { hunk, .. } => hunk.as_ref(),
            Self::FileDeleted { hunk, .. } => hunk.as_ref(),
            Self::FileModified { hunk, .. } => hunk.as_ref(),
-
            _ => None,
+
            Self::FileMoved { .. }
+
            | Self::FileCopied { .. }
+
            | Self::FileEofChanged { .. }
+
            | Self::FileModeChanged { .. } => None,
        }
    }

@@ -196,25 +199,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)))
-
            }
        }
    }

@@ -274,7 +280,7 @@ impl ReviewItem {
                EofNewLine::OldMissing => {
                    VStack::default().child(term::Label::new("`\\n` added at end-of-file"))
                }
-
                _ => VStack::default(),
+
                EofNewLine::BothMissing | EofNewLine::NoneMissing => VStack::default(),
            },
            Self::FileModeChanged { .. } => VStack::default(),
        }
@@ -452,7 +458,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 +485,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 +571,7 @@ impl<'a> Brain<'a> {
    }

    /// Get the brain's refname given the patch and remote.
-
    fn refname(patch: &PatchId, remote: &NodeId) -> git::Namespaced<'a> {
+
    fn refname(patch: &PatchId, remote: &NodeId) -> git::fmt::Namespaced<'a> {
        git::refs::storage::draft::review(remote, patch)
    }
}
modified crates/radicle-cli/src/commands/patch/show.rs
@@ -5,6 +5,7 @@ use radicle::git;
use radicle::storage::git::Repository;

use crate::terminal as term;
+
use crate::terminal::Error;

use super::*;

@@ -32,10 +33,9 @@ pub fn run(
    workdir: Option<&git::raw::Repository>,
) -> anyhow::Result<()> {
    let patches = term::cob::patches(profile, stored)?;
-
    let Some(patch) = patches.get(patch_id).map_err(|e| Error::WithHint {
-
        err: e.into(),
-
        hint: "reset the cache with `rad patch cache` and try again",
-
    })?
+
    let Some(patch) = patches
+
        .get(patch_id)
+
        .map_err(|e| Error::with_hint(e, "reset the cache with `rad patch cache` and try again"))?
    else {
        anyhow::bail!("Patch `{patch_id}` not found");
    };
modified crates/radicle-cli/src/commands/patch/update.rs
@@ -1,5 +1,6 @@
use radicle::cob::patch;
use radicle::git;
+
use radicle::git::Oid;
use radicle::prelude::*;
use radicle::storage::git::Repository;

@@ -9,7 +10,7 @@ use crate::terminal::patch::*;
/// Run patch update.
pub fn run(
    patch_id: patch::PatchId,
-
    base_id: Option<git::raw::Oid>,
+
    base_id: Option<Oid>,
    message: term::patch::Message,
    profile: &Profile,
    repository: &Repository,
@@ -27,22 +28,25 @@ pub fn run(
    let head_oid = branch_oid(&head_branch)?;
    let base_oid = match base_id {
        Some(oid) => oid,
-
        None => repository.backend.merge_base(*target_oid, *head_oid)?,
+
        None => repository
+
            .backend
+
            .merge_base(target_oid.into(), head_oid.into())?
+
            .into(),
    };

    // N.b. we don't update if both the head and base are the same as
    // any previous revision
    if patch
        .revisions()
-
        .any(|(_, revision)| revision.head() == head_oid && **revision.base() == base_oid)
+
        .any(|(_, revision)| revision.head() == head_oid && *revision.base() == base_oid)
    {
        return Ok(());
    }

    let (_, revision) = patch.latest();
-
    let message = term::patch::get_update_message(message, workdir, revision, &head_oid)?;
+
    let message = term::patch::get_update_message(message, workdir, revision, &head_oid.into())?;
    let signer = term::signer(profile)?;
-
    let revision = patch.update(message, base_oid, *head_oid, &signer)?;
+
    let revision = patch.update(message, base_oid, head_oid, &signer)?;

    term::print(revision);

modified crates/radicle-cli/src/commands/path.rs
@@ -1,54 +1,12 @@
-
#![allow(clippy::or_fun_call)]
-
use std::ffi::OsString;
-

-
use anyhow::anyhow;
+
mod args;

use radicle::profile;

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

-
pub const HELP: Help = Help {
-
    name: "path",
-
    description: "Display the Radicle home path",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

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

-
    If no argument is specified, the Radicle home path is displayed.
-

-
Options
-

-
    --help    Print help

-
"#,
-
};
-

-
pub struct Options {}
-

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

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

-
        #[allow(clippy::never_loop)]
-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                _ => return Err(anyhow!(arg.unexpected())),
-
            }
-
        }
-

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

-
pub fn run(_options: Options, _ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(_args: Args, _ctx: impl term::Context) -> anyhow::Result<()> {
    let home = profile::home()?;

    println!("{}", home.path().display());
added crates/radicle-cli/src/commands/path/args.rs
@@ -0,0 +1,7 @@
+
use clap::Parser;
+

+
const ABOUT: &str = "Display the Radicle home path";
+

+
#[derive(Parser, Debug)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
pub struct Args {}
modified crates/radicle-cli/src/commands/publish.rs
@@ -1,75 +1,19 @@
-
use std::ffi::OsString;
+
mod args;

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

use radicle::cob;
use radicle::identity::{Identity, Visibility};
use radicle::node::Handle as _;
-
use radicle::prelude::RepoId;
use radicle::storage::{SignRepository, ValidateRepository, WriteRepository, WriteStorage};

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

-
pub const HELP: Help = Help {
-
    name: "publish",
-
    description: "Publish a repository to the network",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
+
pub use args::Args;

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

-
    Publishing a private repository makes it public and discoverable
-
    on the network.
-

-
    By default, this command will publish the current repository.
-
    If an `<rid>` is specified, that repository will be published instead.
-

-
    Note that this command can only be run for repositories with a
-
    single delegate. The delegate must be the currently authenticated
-
    user. For repositories with more than one delegate, the `rad id`
-
    command must be used.
-

-
Options
-

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

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

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

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

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

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

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
-
    let rid = match options.rid {
+
    let rid = match args.rid {
        Some(rid) => rid,
        None => radicle::rad::cwd()
            .map(|(_, rid)| rid)
@@ -81,22 +25,20 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    let doc = identity.doc();

    if doc.is_public() {
-
        return Err(Error::WithHint {
-
            err: anyhow!("repository is already public"),
-
            hint: "to announce the repository to the network, run `rad sync --inventory`",
-
        }
+
        return Err(term::Error::with_hint(
+
            anyhow!("repository is already public"),
+
            "to announce the repository to the network, run `rad sync --inventory`",
+
        )
        .into());
    }
    if !doc.is_delegate(&profile.id().into()) {
        return Err(anyhow!("only the repository delegate can publish it"));
    }
    if doc.delegates().len() > 1 {
-
        return Err(Error::WithHint {
-
            err: anyhow!(
-
                "only repositories with a single delegate can be published with this command"
-
            ),
-
            hint: "see `rad id --help` to publish repositories with more than one delegate",
-
        }
+
        return Err(term::Error::with_hint(
+
            anyhow!("only repositories with a single delegate can be published with this command"),
+
            "see `rad id --help` to publish repositories with more than one delegate",
+
        )
        .into());
    }
    let signer = profile.signer()?;
added crates/radicle-cli/src/commands/publish/args.rs
@@ -0,0 +1,51 @@
+
use radicle::identity::RepoId;
+

+
const ABOUT: &str = "Publish a repository to the network";
+

+
const LONG_ABOUT: &str = r#"
+
Publishing a private repository makes it public and discoverable
+
on the network.
+

+
By default, this command will publish the current repository.
+
If an `<rid>` is specified, that repository will be published instead.
+

+
Note that this command can only be run for repositories with a
+
single delegate. The delegate must be the currently authenticated
+
user. For repositories with more than one delegate, the `rad id`
+
command must be used."#;
+

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

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

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

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

+
    #[test]
+
    fn should_not_parse_rid_url() {
+
        let err =
+
            Args::try_parse_from(["publish", "rad://z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]).unwrap_err();
+
        assert_eq!(err.kind(), ErrorKind::ValueValidation);
+
    }
+
}
modified crates/radicle-cli/src/commands/remote.rs
@@ -1,208 +1,50 @@
//! Remote Command implementation
-
#[path = "remote/add.rs"]
+

pub mod add;
-
#[path = "remote/list.rs"]
pub mod list;
-
#[path = "remote/rm.rs"]
pub mod rm;

-
use std::ffi::OsString;
+
mod args;

use anyhow::anyhow;

-
use radicle::git::RefString;
-
use radicle::prelude::NodeId;
use radicle::storage::ReadStorage;

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

-
pub const HELP: Help = Help {
-
    name: "remote",
-
    description: "Manage a repository's remotes",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad remote [<option>...]
-
    rad remote list [--tracked | --untracked | --all] [<option>...]
-
    rad remote add (<did> | <nid>) [--name <string>] [<option>...]
-
    rad remote rm <name> [<option>...]
-

-
List options
-

-
    --tracked     Show all remotes that are listed in the working copy
-
    --untracked   Show all remotes that are listed in the Radicle storage
-
    --all         Show all remotes in both the Radicle storage and the working copy
-

-
Add options
-

-
    --name        Override the name of the remote that by default is set to the node alias
-
    --[no-]fetch  Fetch the remote from local storage (default: fetch)
-
    --[no-]sync   Sync the remote refs from the network (default: sync)
-

-
Options
-

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

-
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-
pub enum OperationName {
-
    Add,
-
    Rm,
-
    #[default]
-
    List,
-
}
+
use crate::terminal::Context;

-
#[derive(Debug)]
-
pub enum Operation {
-
    Add {
-
        id: NodeId,
-
        name: Option<RefString>,
-
        fetch: bool,
-
        sync: bool,
-
    },
-
    Rm {
-
        name: RefString,
-
    },
-
    List {
-
        option: ListOption,
-
    },
-
}
-

-
#[derive(Debug, Default)]
-
pub enum ListOption {
-
    All,
-
    #[default]
-
    Tracked,
-
    Untracked,
-
}
-

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

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

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
-
        let mut id: Option<NodeId> = None;
-
        let mut name: Option<RefString> = None;
-
        let mut list_op: ListOption = ListOption::default();
-
        let mut fetch = true;
-
        let mut sync = true;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(args::Error::Help.into());
-
                }
-
                Long("name") | Short('n') => {
-
                    let value = parser.value()?;
-
                    let value = args::refstring("name", value)?;
-

-
                    name = Some(value);
-
                }
-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "a" | "add" => op = Some(OperationName::Add),
-
                    "l" | "list" => op = Some(OperationName::List),
-
                    "r" | "rm" => op = Some(OperationName::Rm),
-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
+
pub use args::Args;
+
use args::{Command, ListOption};

-
                // List options
-
                Long("all") if op.unwrap_or_default() == OperationName::List => {
-
                    list_op = ListOption::All;
-
                }
-
                Long("tracked") if op.unwrap_or_default() == OperationName::List => {
-
                    list_op = ListOption::Tracked;
-
                }
-
                Long("untracked") if op.unwrap_or_default() == OperationName::List => {
-
                    list_op = ListOption::Untracked;
-
                }
-

-
                // Add options
-
                Long("sync") if op == Some(OperationName::Add) => {
-
                    sync = true;
-
                }
-
                Long("no-sync") if op == Some(OperationName::Add) => {
-
                    sync = false;
-
                }
-
                Long("fetch") if op == Some(OperationName::Add) => {
-
                    fetch = true;
-
                }
-
                Long("no-fetch") if op == Some(OperationName::Add) => {
-
                    fetch = false;
-
                }
-
                Value(val) if op == Some(OperationName::Add) && id.is_none() => {
-
                    let nid = args::pubkey(&val)?;
-
                    id = Some(nid);
-
                }
-

-
                // Remove options
-
                Value(val) if op == Some(OperationName::Rm) && name.is_none() => {
-
                    let val = args::string(&val);
-
                    let val = RefString::try_from(val)
-
                        .map_err(|e| anyhow!("invalid remote name specified: {e}"))?;
-

-
                    name = Some(val);
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::Add => Operation::Add {
-
                id: id.ok_or(anyhow!(
-
                    "`DID` required, try running `rad remote add <did>`"
-
                ))?,
-
                name,
-
                fetch,
-
                sync,
-
            },
-
            OperationName::List => Operation::List { option: list_op },
-
            OperationName::Rm => Operation::Rm {
-
                name: name.ok_or(anyhow!("name required, see `rad remote`"))?,
-
            },
-
        };
-

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

-
pub fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl Context) -> anyhow::Result<()> {
    let (working, rid) = radicle::rad::cwd()
        .map_err(|_| anyhow!("this command must be run in the context of a repository"))?;
    let profile = ctx.profile()?;
-

-
    match options.op {
-
        Operation::Add {
-
            ref id,
+
    let command = args
+
        .command
+
        .unwrap_or_else(|| Command::List(args.empty.into()));
+
    match command {
+
        Command::Add {
+
            nid,
            name,
            fetch,
            sync,
        } => {
            let proj = profile.storage.repository(rid)?.project()?;
            let branch = proj.default_branch();
-

            self::add::run(
                rid,
-
                id,
+
                &nid,
                name,
                Some(branch.clone()),
                &profile,
                &working,
-
                fetch,
-
                sync,
+
                fetch.should_fetch(),
+
                sync.should_sync(),
            )?
        }
-
        Operation::Rm { ref name } => self::rm::run(name, &working)?,
-
        Operation::List { option } => match option {
+
        Command::Rm { ref name } => self::rm::run(name, &working)?,
+
        Command::List(args) => match ListOption::from(args) {
            ListOption::All => {
                let tracked = list::tracked(&working)?;
                let untracked = list::untracked(rid, &profile, tracked.iter())?;
modified crates/radicle-cli/src/commands/remote/add.rs
@@ -1,14 +1,14 @@
use std::str::FromStr;

use radicle::git;
-
use radicle::git::RefString;
+
use radicle::git::fmt::RefString;
use radicle::prelude::*;
use radicle::Profile;
use radicle_crypto::PublicKey;

-
use crate::commands::rad_checkout as checkout;
-
use crate::commands::rad_follow as follow;
-
use crate::commands::rad_sync as sync;
+
use crate::commands::checkout;
+
use crate::commands::follow;
+
use crate::commands::sync;
use crate::node::SyncSettings;
use crate::project::SetupRemote;

added crates/radicle-cli/src/commands/remote/args.rs
@@ -0,0 +1,161 @@
+
use clap::{Parser, Subcommand};
+

+
use radicle::git;
+
use radicle::git::fmt::RefString;
+
use radicle::node::NodeId;
+

+
use crate::terminal as term;
+

+
const ABOUT: &str = "Manage a repository's remotes";
+

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

+
    /// Arguments for the empty subcommand.
+
    /// Will fall back to [`Command::List`].
+
    #[clap(flatten)]
+
    pub(super) empty: EmptyArgs,
+
}
+

+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    /// Add a Git remote for the provided NID
+
    #[clap(alias = "a")]
+
    Add {
+
        /// The DID or NID of the remote to add
+
        #[arg(value_parser = term::args::parse_nid)]
+
        nid: NodeId,
+

+
        /// Override the name of the Git remote
+
        ///
+
        /// [default: <ALIAS>@<NID>]
+
        #[arg(long, short, value_name = "REMOTE", value_parser = parse_refstr)]
+
        name: Option<RefString>,
+

+
        #[clap(flatten)]
+
        fetch: FetchArgs,
+

+
        #[clap(flatten)]
+
        sync: SyncArgs,
+
    },
+
    /// Remove the Git remote identified by REMOTE
+
    #[clap(alias = "r")]
+
    Rm {
+
        /// The name of the remote to delete
+
        #[arg(value_name = "REMOTE", value_parser = parse_refstr)]
+
        name: RefString,
+
    },
+
    /// List the stored remotes
+
    ///
+
    /// Filter the listed remotes using the provided options
+
    #[clap(alias = "l")]
+
    List(ListArgs),
+
}
+

+
#[derive(Parser, Debug)]
+
pub(super) struct FetchArgs {
+
    /// Fetch the remote from local storage (default)
+
    #[arg(long, conflicts_with = "no_fetch")]
+
    fetch: bool,
+

+
    /// Do not fetch the remote from local storage
+
    #[arg(long)]
+
    no_fetch: bool,
+
}
+

+
impl FetchArgs {
+
    pub(super) fn should_fetch(&self) -> bool {
+
        let Self { fetch, no_fetch } = self;
+
        *fetch || !no_fetch
+
    }
+
}
+

+
#[derive(Parser, Debug)]
+
pub(super) struct SyncArgs {
+
    /// Sync the remote refs from the network (default)
+
    #[arg(long, conflicts_with = "no_sync")]
+
    sync: bool,
+

+
    /// Do not sync the remote refs from the network
+
    #[arg(long)]
+
    no_sync: bool,
+
}
+

+
impl SyncArgs {
+
    pub(super) fn should_sync(&self) -> bool {
+
        let Self { sync, no_sync } = self;
+
        *sync || !no_sync
+
    }
+
}
+

+
#[derive(Parser, Clone, Copy, Debug)]
+
#[group(multiple = false)]
+
pub struct ListArgs {
+
    /// Show all remotes in both the Radicle storage and the working copy
+
    #[arg(long)]
+
    all: bool,
+

+
    /// Show all remotes that are listed in the working copy
+
    #[arg(long)]
+
    tracked: bool,
+

+
    /// Show all remotes that are listed in the Radicle storage
+
    #[arg(long)]
+
    untracked: bool,
+
}
+

+
impl From<ListArgs> for ListOption {
+
    fn from(
+
        ListArgs {
+
            all,
+
            tracked,
+
            untracked,
+
        }: ListArgs,
+
    ) -> Self {
+
        match (all, tracked, untracked) {
+
            (true, false, false) => Self::All,
+
            (false, true, false) | (false, false, false) => Self::Tracked,
+
            (false, false, true) => Self::Untracked,
+
            _ => unreachable!(),
+
        }
+
    }
+
}
+

+
pub(super) enum ListOption {
+
    /// Show all remotes in both the Radicle storage and the working copy
+
    All,
+
    /// Show all remotes that are listed in the working copy
+
    Tracked,
+
    /// Show all remotes that are listed in the Radicle storage
+
    Untracked,
+
}
+

+
#[derive(Parser, Clone, Copy, Debug)]
+
#[group(multiple = false)]
+
pub(super) struct EmptyArgs {
+
    #[arg(long, hide = true)]
+
    all: bool,
+

+
    #[arg(long, hide = true)]
+
    tracked: bool,
+

+
    #[arg(long, hide = true)]
+
    untracked: bool,
+
}
+

+
impl From<EmptyArgs> for ListArgs {
+
    fn from(args: EmptyArgs) -> Self {
+
        Self {
+
            all: args.all,
+
            tracked: args.tracked,
+
            untracked: args.untracked,
+
        }
+
    }
+
}
+

+
fn parse_refstr(refstr: &str) -> Result<RefString, git::fmt::Error> {
+
    RefString::try_from(refstr)
+
}
modified crates/radicle-cli/src/commands/remote/list.rs
@@ -81,10 +81,9 @@ pub fn untracked<'a>(
}

pub fn print_tracked<'a>(tracked: impl Iterator<Item = &'a Tracked>) {
-
    let mut table = Table::default();
-
    for Tracked { direction, name } in tracked {
+
    Table::from_iter(tracked.into_iter().flat_map(|Tracked { direction, name }| {
        let Some(direction) = direction else {
-
            continue;
+
            return None;
        };

        let (dir, url) = match direction {
@@ -95,25 +94,24 @@ pub fn print_tracked<'a>(tracked: impl Iterator<Item = &'a Tracked>) {
            term::format::dim("(canonical upstream)".to_string()).italic(),
            |namespace| term::format::tertiary(namespace.to_string()),
        );
-
        table.push([
+
        Some([
            term::format::bold(name.clone()),
            description,
            term::format::parens(term::format::secondary(dir.to_owned())),
-
        ]);
-
    }
-
    table.print();
+
        ])
+
    }))
+
    .print();
}

pub fn print_untracked<'a>(untracked: impl Iterator<Item = &'a Untracked>) {
-
    let mut t = Table::default();
-
    for Untracked { remote, alias } in untracked {
-
        t.push([
+
    Table::from_iter(untracked.into_iter().map(|Untracked { remote, alias }| {
+
        [
            match alias {
                None => term::format::secondary("n/a".to_string()),
                Some(alias) => term::format::secondary(alias.to_string()),
            },
            term::format::highlight(Did::from(remote).to_string()),
-
        ])
-
    }
-
    t.print();
+
        ]
+
    }))
+
    .print();
}
modified crates/radicle-cli/src/commands/seed.rs
@@ -1,166 +1,39 @@
-
use std::collections::BTreeSet;
-
use std::ffi::OsString;
-
use std::time;
+
mod args;

-
use anyhow::anyhow;
-

-
use nonempty::NonEmpty;
use radicle::node::policy;
use radicle::node::policy::{Policy, Scope};
use radicle::node::Handle;
use radicle::{prelude::*, Node};
use radicle_term::Element as _;

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

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

-
    rad seed [<rid>...] [--[no-]fetch] [--from <nid>] [--scope <scope>] [<option>...]
-

-
    The `seed` command, when no Repository ID (<rid>) is provided, will list the
-
    repositories being seeded.
-

-
    When a Repository ID (<rid>) is provided it updates or creates the seeding policy for
-
    that repository. To delete a seeding policy, use the `rad unseed` command.
-

-
    When seeding a repository, a scope can be specified: this can be either `all` or
-
    `followed`. When using `all`, all remote nodes will be followed for that repository.
-
    On the other hand, with `followed`, only the repository delegates will be followed,
-
    plus any remote that is explicitly followed via `rad follow <nid>`.
-

-
Options
-

-
    --[no-]fetch           Fetch repository after updating seeding policy
-
    --from <nid>           Fetch from the given node (may be specified multiple times)
-
    --timeout <secs>       Fetch timeout in seconds (default: 9)
-
    --scope <scope>        Peer follow scope for this repository
-
    --verbose, -v          Verbose output
-
    --help                 Print help
-
"#,
-
};
-

-
#[derive(Debug)]
-
pub enum Operation {
-
    Seed {
-
        rids: NonEmpty<RepoId>,
-
        fetch: bool,
-
        seeds: BTreeSet<NodeId>,
-
        timeout: time::Duration,
-
        scope: Scope,
-
    },
-
    List,
-
}
-

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

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

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut rids: Vec<RepoId> = Vec::new();
-
        let mut scope: Option<Scope> = None;
-
        let mut fetch: Option<bool> = None;
-
        let mut timeout = time::Duration::from_secs(9);
-
        let mut seeds: BTreeSet<NodeId> = BTreeSet::new();
-
        let mut verbose = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match &arg {
-
                Value(val) => {
-
                    let rid = term::args::rid(val)?;
-
                    rids.push(rid);
-
                }
-
                Long("scope") => {
-
                    let val = parser.value()?;
-
                    scope = Some(term::args::parse_value("scope", val)?);
-
                }
-
                Long("fetch") => {
-
                    fetch = Some(true);
-
                }
-
                Long("no-fetch") => {
-
                    fetch = Some(false);
-
                }
-
                Long("from") => {
-
                    let val = parser.value()?;
-
                    let nid = term::args::nid(&val)?;

-
                    seeds.insert(nid);
-
                }
-
                Long("timeout") | Short('t') => {
-
                    let value = parser.value()?;
-
                    let secs = term::args::parse_value("timeout", value)?;
-

-
                    timeout = time::Duration::from_secs(secs);
-
                }
-
                Long("verbose") | Short('v') => verbose = true,
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        let op = match NonEmpty::from_vec(rids) {
-
            Some(rids) => Operation::Seed {
-
                rids,
-
                fetch: fetch.unwrap_or(true),
-
                scope: scope.unwrap_or(Scope::All),
-
                timeout,
-
                seeds,
-
            },
-
            None => Operation::List,
-
        };
-

-
        Ok((Options { op, verbose }, vec![]))
-
    }
-
}
+
pub use args::Args;

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

-
    match options.op {
-
        Operation::Seed {
+
    match args::Operation::from(args) {
+
        args::Operation::List => seeding(&profile)?,
+
        args::Operation::Seed {
            rids,
-
            fetch,
+
            should_fetch,
+
            settings,
            scope,
-
            timeout,
-
            seeds,
        } => {
+
            let settings = settings.with_profile(&profile);
            for rid in rids {
                update(rid, scope, &mut node, &profile)?;

-
                if fetch && node.is_running() {
-
                    if let Err(e) = sync::fetch(
-
                        rid,
-
                        SyncSettings::default()
-
                            .seeds(seeds.clone())
-
                            .timeout(timeout)
-
                            .with_profile(&profile),
-
                        &mut node,
-
                        &profile,
-
                    ) {
+
                if should_fetch && node.is_running() {
+
                    if let Err(e) = sync::fetch(rid, settings.clone(), &mut node, &profile) {
                        term::error(e);
                    }
                }
            }
        }
-
        Operation::List => seeding(&profile)?,
    }

    Ok(())
@@ -211,7 +84,9 @@ pub fn seeding(profile: &Profile) -> anyhow::Result<()> {
                    .repository(rid)
                    .and_then(|repo| repo.project().map(|proj| proj.name().to_string()))
                    .unwrap_or_default();
-
                let scope = policy.scope().unwrap_or_default().to_string();
+
                let scope = policy
+
                    .scope()
+
                    .map_or(String::new(), |scope| scope.to_string());
                let policy = term::format::policy(&Policy::from(policy));

                t.push([
added crates/radicle-cli/src/commands/seed/args.rs
@@ -0,0 +1,104 @@
+
use std::time;
+

+
use clap::Parser;
+

+
use nonempty::NonEmpty;
+
use radicle::node::policy::Scope;
+
use radicle::prelude::*;
+

+
use crate::node::SyncSettings;
+
use crate::terminal;
+

+
const ABOUT: &str = "Manage repository seeding policies";
+

+
const LONG_ABOUT: &str = r#"
+
The `seed` command, when no Repository ID is provided, will list the
+
repositories being seeded.
+

+
When a Repository ID is provided it updates or creates the seeding policy for
+
that repository. To delete a seeding policy, use the `rad unseed` command.
+

+
When seeding a repository, a scope can be specified: this can be either `all` or
+
`followed`. When using `all`, all remote nodes will be followed for that repository.
+
On the other hand, with `followed`, only the repository delegates will be followed,
+
plus any remote that is explicitly followed via `rad follow <nid>`.
+
"#;
+

+
#[derive(Parser, Debug)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    #[arg(value_name = "RID", num_args = 1..)]
+
    pub(super) rids: Option<Vec<RepoId>>,
+

+
    /// Fetch repository after updating seeding policy
+
    #[arg(long, overrides_with("no_fetch"), hide(true))]
+
    fetch: bool,
+

+
    /// Do not fetch repository after updating seeding policy
+
    #[arg(long, overrides_with("fetch"))]
+
    no_fetch: bool,
+

+
    /// Fetch from the given node (may be specified multiple times)
+
    #[arg(long, value_name = "NID", action = clap::ArgAction::Append)]
+
    pub(super) from: Vec<NodeId>,
+

+
    /// Fetch timeout in seconds
+
    #[arg(long, short, value_name = "SECS", default_value_t = 9)]
+
    timeout: u64,
+

+
    /// Peer follow scope for this repository
+
    #[arg(
+
        long,
+
        default_value_t = Scope::Followed,
+
        value_parser = terminal::args::ScopeParser
+
    )]
+
    pub(super) scope: Scope,
+

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

+
pub(super) enum Operation {
+
    List,
+
    Seed {
+
        rids: NonEmpty<RepoId>,
+
        should_fetch: bool,
+
        settings: SyncSettings,
+
        scope: Scope,
+
    },
+
}
+

+
impl From<Args> for Operation {
+
    fn from(args: Args) -> Self {
+
        let should_fetch = args.should_fetch();
+
        let timeout = args.timeout();
+
        let Args {
+
            rids, from, scope, ..
+
        } = args;
+
        match rids.and_then(NonEmpty::from_vec) {
+
            Some(rids) => Operation::Seed {
+
                rids,
+
                should_fetch,
+
                settings: SyncSettings::default().seeds(from).timeout(timeout),
+
                scope,
+
            },
+
            None => Self::List,
+
        }
+
    }
+
}
+

+
impl Args {
+
    fn timeout(&self) -> time::Duration {
+
        time::Duration::from_secs(self.timeout)
+
    }
+

+
    fn should_fetch(&self) -> bool {
+
        match (self.fetch, self.no_fetch) {
+
            (true, false) => true,
+
            (false, true) => false,
+
            // Default it to fetch
+
            (_, _) => true,
+
        }
+
    }
+
}
modified crates/radicle-cli/src/commands/self.rs
@@ -1,131 +1,40 @@
-
use std::ffi::OsString;
+
#[path = "self/args.rs"]
+
mod args;
+

+
pub use args::Args;

use radicle::crypto::ssh;
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
-
    --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::warning(
-
                "The option `--nid` is deprecated, please use `rad node status` instead.",
-
            );
-
            term::print(
-
                Node::new(profile.socket())
-
                    .nid()
-
                    .ok()
-
                    .unwrap_or_else(|| *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(())
added crates/radicle-cli/src/commands/self/args.rs
@@ -0,0 +1,30 @@
+
use clap::Parser;
+

+
const ABOUT: &str = "Show information about your identity and device";
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
#[group(multiple = false)]
+
pub struct Args {
+
    /// Show your DID
+
    #[arg(long)]
+
    pub(super) did: bool,
+
    /// Show your Node alias
+
    #[arg(long)]
+
    pub(super) alias: bool,
+
    /// Show your Node identifier
+
    #[arg(long, hide(true))]
+
    pub(super) nid: bool,
+
    /// Show your Radicle home
+
    #[arg(long)]
+
    pub(super) home: bool,
+
    /// Show the location of your configuration file
+
    #[arg(long)]
+
    pub(super) config: bool,
+
    /// Show your public key in OpenSSH format
+
    #[arg(long)]
+
    pub(super) ssh_key: bool,
+
    /// Show your public key fingerprint in OpenSSH format
+
    #[arg(long)]
+
    pub(super) ssh_fingerprint: bool,
+
}
modified crates/radicle-cli/src/commands/stats.rs
@@ -1,4 +1,5 @@
-
use std::ffi::OsString;
+
mod args;
+

use std::path::Path;

use localtime::LocalDuration;
@@ -13,22 +14,8 @@ use radicle_term::Element;
use serde::Serialize;

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

-
pub const HELP: Help = Help {
-
    name: "stats",
-
    description: "Displays aggregated repository and node metrics",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad stats [<option>...]

-
Options
-

-
    --help       Print help
-
"#,
-
};
+
pub use args::Args;

#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -65,30 +52,7 @@ struct Stats {
    nodes: NodeStats,
}

-
#[derive(Default, Debug, Eq, PartialEq)]
-
pub struct Options {}
-

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

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

-
        #[allow(clippy::never_loop)]
-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

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

-
pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(_args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let storage = &profile.storage;
    let mut stats = Stats::default();
@@ -106,7 +70,7 @@ pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            let remote = remote?;
            let sigrefs = repo.reference_oid(&remote, &git::refs::storage::SIGREFS_BRANCH)?;
            let mut walk = repo.raw().revwalk()?;
-
            walk.push(*sigrefs)?;
+
            walk.push(sigrefs.into())?;

            stats.local.pushes += walk.count();
            stats.local.forks += 1;
added crates/radicle-cli/src/commands/stats/args.rs
@@ -0,0 +1,7 @@
+
use clap::Parser;
+

+
const ABOUT: &str = "Displays aggregated repository and node metrics";
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
pub struct Args {}
modified crates/radicle-cli/src/commands/sync.rs
@@ -1,9 +1,8 @@
+
mod args;
+

use std::cmp::Ordering;
use std::collections::BTreeMap;
-
use std::collections::BTreeSet;
use std::collections::HashSet;
-
use std::ffi::OsString;
-
use std::str::FromStr;
use std::time;

use anyhow::{anyhow, Context as _};
@@ -23,266 +22,13 @@ use radicle_term::Element;
use crate::node::SyncReporting;
use crate::node::SyncSettings;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::format::Author;
use crate::terminal::{Table, TableOptions};

-
pub const HELP: Help = Help {
-
    name: "sync",
-
    description: "Sync repositories to the network",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad sync [--fetch | --announce] [<rid>] [<option>...]
-
    rad sync --inventory [<option>...]
-
    rad sync status [<rid>] [<option>...]
-

-
    By default, the current repository is synchronized both ways.
-
    If an <rid> is specified, that repository is synced instead.
-

-
    The process begins by fetching changes from connected seeds,
-
    followed by announcing local refs to peers, thereby prompting
-
    them to fetch from us.
-

-
    When `--fetch` is specified, any number of seeds may be given
-
    using the `--seed` option, eg. `--seed <nid>@<addr>:<port>`.
-

-
    When `--replicas` is specified, the given replication factor will try
-
    to be matched. For example, `--replicas 5` will sync with 5 seeds.
-

-
    The synchronization process can be configured using `--replicas <min>` and
-
    `--replicas-max <max>`. If these options are used independently, then the
-
    replication factor is taken as the given `<min>`/`<max>` value. If the
-
    options are used together, then the replication factor has a minimum and
-
    maximum bound.
-

-
    For fetching, the synchronization process will be considered successful if
-
    at least `<min>` seeds were fetched from *or* all preferred seeds were
-
    fetched from. If `<max>` is specified then the process will continue and
-
    attempt to sync with `<max>` seeds.
-

-
    For reference announcing, the synchronization process will be considered
-
    successful if at least `<min>` seeds were pushed to *and* all preferred
-
    seeds were pushed to.
-

-
    When `--fetch` or `--announce` are specified on their own, this command
-
    will only fetch or announce.
-

-
    If `--inventory` is specified, the node's inventory is announced to
-
    the network. This mode does not take an `<rid>`.
-

-
Commands
-

-
    status                    Display the sync status of a repository
-

-
Options
-

-
        --sort-by       <field>   Sort the table by column (options: nid, alias, status)
-
    -f, --fetch                   Turn on fetching (default: true)
-
    -a, --announce                Turn on ref announcing (default: true)
-
    -i, --inventory               Turn on inventory announcing (default: false)
-
        --timeout       <secs>    How many seconds to wait while syncing
-
        --seed          <nid>     Sync with the given node (may be specified multiple times)
-
    -r, --replicas      <count>   Sync with a specific number of seeds
-
        --replicas-max  <count>   Sync with an upper bound number of seeds
-
    -v, --verbose                 Verbose output
-
        --debug                   Print debug information afer sync
-
        --help                    Print help
-
"#,
-
};
-

-
#[derive(Debug, Clone, PartialEq, Eq, Default)]
-
pub enum Operation {
-
    Synchronize(SyncMode),
-
    #[default]
-
    Status,
-
}
-

-
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-
pub enum SortBy {
-
    Nid,
-
    Alias,
-
    #[default]
-
    Status,
-
}
-

-
impl FromStr for SortBy {
-
    type Err = &'static str;
-

-
    fn from_str(s: &str) -> Result<Self, Self::Err> {
-
        match s {
-
            "nid" => Ok(Self::Nid),
-
            "alias" => Ok(Self::Alias),
-
            "status" => Ok(Self::Status),
-
            _ => Err("invalid `--sort-by` field"),
-
        }
-
    }
-
}
-

-
#[derive(Debug, Clone, PartialEq, Eq)]
-
pub enum SyncMode {
-
    Repo {
-
        settings: SyncSettings,
-
        direction: SyncDirection,
-
    },
-
    Inventory,
-
}
-

-
impl Default for SyncMode {
-
    fn default() -> Self {
-
        Self::Repo {
-
            settings: SyncSettings::default(),
-
            direction: SyncDirection::default(),
-
        }
-
    }
-
}
-

-
#[derive(Debug, Default, PartialEq, Eq, Clone)]
-
pub enum SyncDirection {
-
    Fetch,
-
    Announce,
-
    #[default]
-
    Both,
-
}
-

-
#[derive(Default, Debug)]
-
pub struct Options {
-
    pub rid: Option<RepoId>,
-
    pub debug: bool,
-
    pub verbose: bool,
-
    pub sort_by: SortBy,
-
    pub op: Operation,
-
}
-

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

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut verbose = false;
-
        let mut timeout = time::Duration::from_secs(9);
-
        let mut rid = None;
-
        let mut fetch = false;
-
        let mut announce = false;
-
        let mut inventory = false;
-
        let mut debug = false;
-
        let mut replicas = None;
-
        let mut max_replicas = None;
-
        let mut seeds = BTreeSet::new();
-
        let mut sort_by = SortBy::default();
-
        let mut op: Option<Operation> = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("debug") => {
-
                    debug = true;
-
                }
-
                Long("verbose") | Short('v') => {
-
                    verbose = true;
-
                }
-
                Long("fetch") | Short('f') => {
-
                    fetch = true;
-
                }
-
                Long("replicas") | Short('r') => {
-
                    let val = parser.value()?;
-
                    let count = term::args::number(&val)?;
-

-
                    if count == 0 {
-
                        anyhow::bail!("value for `--replicas` must be greater than zero");
-
                    }
-
                    replicas = Some(count);
-
                }
-
                Long("replicas-max") => {
-
                    let val = parser.value()?;
-
                    let count = term::args::number(&val)?;
-

-
                    if count == 0 {
-
                        anyhow::bail!("value for `--replicas-max` must be greater than zero");
-
                    }
-
                    max_replicas = Some(count);
-
                }
-
                Long("seed") => {
-
                    let val = parser.value()?;
-
                    let nid = term::args::nid(&val)?;
-

-
                    seeds.insert(nid);
-
                }
-
                Long("announce") | Short('a') => {
-
                    announce = true;
-
                }
-
                Long("inventory") | Short('i') => {
-
                    inventory = true;
-
                }
-
                Long("sort-by") if matches!(op, Some(Operation::Status)) => {
-
                    let value = parser.value()?;
-
                    sort_by = value.parse()?;
-
                }
-
                Long("timeout") | Short('t') => {
-
                    let value = parser.value()?;
-
                    let secs = term::args::parse_value("timeout", value)?;
-

-
                    timeout = time::Duration::from_secs(secs);
-
                }
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(val) if rid.is_none() => match val.to_string_lossy().as_ref() {
-
                    "s" | "status" => {
-
                        op = Some(Operation::Status);
-
                    }
-
                    _ => {
-
                        rid = Some(term::args::rid(&val)?);
-
                    }
-
                },
-
                arg => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
+
pub use args::Args;
+
use args::{Command, SortBy, SyncDirection, SyncMode};

-
        let sync = if inventory && fetch {
-
            anyhow::bail!("`--inventory` cannot be used with `--fetch`");
-
        } else if inventory {
-
            SyncMode::Inventory
-
        } else {
-
            let direction = match (fetch, announce) {
-
                (true, true) | (false, false) => SyncDirection::Both,
-
                (true, false) => SyncDirection::Fetch,
-
                (false, true) => SyncDirection::Announce,
-
            };
-
            let mut settings = SyncSettings::default().timeout(timeout);
-

-
            let replicas = match (replicas, max_replicas) {
-
                (None, None) => sync::ReplicationFactor::default(),
-
                (None, Some(min)) => sync::ReplicationFactor::must_reach(min),
-
                (Some(min), None) => sync::ReplicationFactor::must_reach(min),
-
                (Some(min), Some(max)) => sync::ReplicationFactor::range(min, max),
-
            };
-
            settings.replicas = replicas;
-
            if !seeds.is_empty() {
-
                settings.seeds = seeds;
-
            }
-
            SyncMode::Repo {
-
                settings,
-
                direction,
-
            }
-
        };
-

-
        Ok((
-
            Options {
-
                rid,
-
                debug,
-
                verbose,
-
                sort_by,
-
                op: op.unwrap_or(Operation::Synchronize(sync)),
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let mut node = radicle::Node::new(profile.socket());
    if !node.is_running() {
@@ -290,10 +36,12 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            "to sync a repository, your node must be running. To start it, run `rad node start`"
        );
    }
+
    let verbose = args.verbose;
+
    let debug = args.verbose;

-
    match &options.op {
-
        Operation::Status => {
-
            let rid = match options.rid {
+
    match args.command {
+
        Some(Command::Status { rid, sort_by }) => {
+
            let rid = match rid {
                Some(rid) => rid,
                None => {
                    let (_, rid) = radicle::rad::cwd()
@@ -301,37 +49,41 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                    rid
                }
            };
-
            sync_status(rid, &mut node, &profile, &options)?;
+
            sync_status(rid, &mut node, &profile, &sort_by, verbose)?;
        }
-
        Operation::Synchronize(SyncMode::Repo {
-
            settings,
-
            direction,
-
        }) => {
-
            let rid = match options.rid {
-
                Some(rid) => rid,
-
                None => {
-
                    let (_, rid) = radicle::rad::cwd()
-
                        .context("Current directory is not a Radicle repository")?;
-
                    rid
-
                }
-
            };
-
            let settings = settings.clone().with_profile(&profile);
+
        None => match SyncMode::from(args.sync) {
+
            SyncMode::Repo {
+
                rid,
+
                settings,
+
                direction,
+
            } => {
+
                let rid = match rid {
+
                    Some(rid) => rid,
+
                    None => {
+
                        let (_, rid) = radicle::rad::cwd()
+
                            .context("Current directory is not a Radicle repository")?;
+
                        rid
+
                    }
+
                };
+
                let settings = settings.clone().with_profile(&profile);

-
            if [SyncDirection::Fetch, SyncDirection::Both].contains(direction) {
-
                if !profile.policies()?.is_seeding(&rid)? {
-
                    anyhow::bail!("repository {rid} is not seeded");
+
                if matches!(direction, SyncDirection::Fetch | SyncDirection::Both) {
+
                    if !profile.policies()?.is_seeding(&rid)? {
+
                        anyhow::bail!("repository {rid} is not seeded");
+
                    }
+
                    let result = fetch(rid, settings.clone(), &mut node, &profile)?;
+
                    display_fetch_result(&result, verbose)
+
                }
+
                if matches!(direction, SyncDirection::Announce | SyncDirection::Both) {
+
                    announce_refs(rid, settings, &mut node, &profile, verbose, debug)?;
                }
-
                let result = fetch(rid, settings.clone(), &mut node, &profile)?;
-
                display_fetch_result(&result, options.verbose)
            }
-
            if [SyncDirection::Announce, SyncDirection::Both].contains(direction) {
-
                announce_refs(rid, settings, &mut node, &profile, &options)?;
+
            SyncMode::Inventory => {
+
                announce_inventory(node)?;
            }
-
        }
-
        Operation::Synchronize(SyncMode::Inventory) => {
-
            announce_inventory(node)?;
-
        }
+
        },
    }
+

    Ok(())
}

@@ -339,13 +91,14 @@ fn sync_status(
    rid: RepoId,
    node: &mut Node,
    profile: &Profile,
-
    options: &Options,
+
    sort_by: &SortBy,
+
    verbose: bool,
) -> anyhow::Result<()> {
    const SYMBOL_STATE: &str = "?";
    const SYMBOL_STATE_UNKNOWN: &str = "•";

    let mut table = Table::<5, term::Label>::new(TableOptions::bordered());
-
    let mut seeds: Vec<_> = node.seeds(rid)?.into();
+
    let mut seeds: Vec<_> = node.seeds_for(rid, [*profile.did()])?.into();
    let local_nid = node.nid()?;
    let aliases = profile.aliases();

@@ -358,9 +111,9 @@ fn sync_status(
    ]);
    table.divider();

-
    sort_seeds_by(local_nid, &mut seeds, &aliases, &options.sort_by);
+
    sort_seeds_by(local_nid, &mut seeds, &aliases, sort_by);

-
    for seed in seeds {
+
    let seeds = seeds.into_iter().flat_map(|seed| {
        let (status, head, time) = match seed.sync {
            Some(SyncStatus::Synced {
                at: SyncedAt { oid, timestamp },
@@ -386,24 +139,26 @@ fn sync_status(
                term::format::oid(oid),
                term::format::timestamp(timestamp),
            ),
-
            None if options.verbose => (
+
            None if verbose => (
                term::format::dim(SYMBOL_STATE_UNKNOWN),
                term::paint(String::new()),
                term::paint(String::new()),
            ),
-
            None => continue,
+
            None => return None,
        };

-
        let (alias, nid) = Author::new(&seed.nid, profile, options.verbose).labels();
+
        let (alias, nid) = Author::new(&seed.nid, profile, verbose).labels();

-
        table.push([
+
        Some([
            nid,
            alias,
            status.into(),
            term::format::secondary(head).into(),
            time.dim().italic().into(),
-
        ]);
-
    }
+
        ])
+
    });
+

+
    table.extend(seeds);
    table.print();

    if profile.hints() {
@@ -446,7 +201,8 @@ fn announce_refs(
    settings: SyncSettings,
    node: &mut Node,
    profile: &Profile,
-
    options: &Options,
+
    verbose: bool,
+
    debug: bool,
) -> anyhow::Result<()> {
    let Ok(repo) = profile.storage.repository(rid) else {
        return Err(anyhow!(
@@ -468,14 +224,14 @@ fn announce_refs(
        &repo,
        settings,
        SyncReporting {
-
            debug: options.debug,
+
            debug,
            ..SyncReporting::default()
        },
        node,
        profile,
    )?;
    if let Some(result) = result {
-
        print_announcer_result(&result, options.verbose)
+
        print_announcer_result(&result, verbose)
    }

    Ok(())
@@ -520,12 +276,15 @@ pub fn fetch(
        None => {
            // We push nodes that are in our seed list in attempt to fulfill the
            // replicas, if needed.
-
            let seeds = node.seeds(rid)?;
+
            let seeds = node.seeds_for(rid, [*profile.did()])?;
            let (connected, disconnected) = seeds.partition();
            let candidates = connected
                .into_iter()
                .map(|seed| seed.nid)
-
                .chain(disconnected.into_iter().map(|seed| seed.nid))
+
                .chain(disconnected.into_iter().filter_map(|seed| {
+
                    // Only consider seeds that have at least one known address.
+
                    (!seed.addrs.is_empty()).then_some(seed.nid)
+
                }))
                .map(sync::fetch::Candidate::new);
            sync::FetcherConfig::public(settings.seeds.clone(), settings.replicas, *local)
                .with_candidates(candidates)
@@ -701,7 +460,7 @@ impl FetcherSpinner {
            term::format::secondary(progress.succeeded()),
            term::format::secondary(self.replicas.lower_bound()),
            term::format::tertiary(term::format::node_id_human_compact(node)),
-
            term::format::tertiary(term::format::addr_compact(addr)),
+
            term::format::tertiary(addr.display_compact()),
        ))
    }

@@ -718,7 +477,7 @@ impl FetcherSpinner {
            term::format::secondary(progress.succeeded()),
            term::format::secondary(self.replicas.lower_bound()),
            term::format::tertiary(term::format::node_id_human_compact(node)),
-
            term::format::tertiary(term::format::addr_compact(addr)),
+
            term::format::tertiary(addr.display_compact()),
        ))
    }

added crates/radicle-cli/src/commands/sync/args.rs
@@ -0,0 +1,253 @@
+
use std::str::FromStr;
+
use std::time;
+

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

+
use radicle::{
+
    node::{sync, NodeId},
+
    prelude::RepoId,
+
};
+

+
use crate::node::SyncSettings;
+

+
const ABOUT: &str = "Sync repositories to the network";
+

+
const LONG_ABOUT: &str = r#"
+
By default, the current repository is synchronized both ways.
+
If an <RID> is specified, that repository is synced instead.
+

+
The process begins by fetching changes from connected seeds,
+
followed by announcing local refs to peers, thereby prompting
+
them to fetch from us.
+

+
When `--fetch` is specified, any number of seeds may be given
+
using the `--seed` option, eg. `--seed <NID>@<ADDR>:<PORT>`.
+

+
When `--replicas` is specified, the given replication factor will try
+
to be matched. For example, `--replicas 5` will sync with 5 seeds.
+

+
The synchronization process can be configured using `--replicas <MIN>` and
+
`--replicas-max <MAX>`. If these options are used independently, then the
+
replication factor is taken as the given `<MIN>`/`<MAX>` value. If the
+
options are used together, then the replication factor has a minimum and
+
maximum bound.
+

+
For fetching, the synchronization process will be considered successful if
+
at least `<MIN>` seeds were fetched from *or* all preferred seeds were
+
fetched from. If `<MAX>` is specified then the process will continue and
+
attempt to sync with `<MAX>` seeds.
+

+
For reference announcing, the synchronization process will be considered
+
successful if at least `<MIN>` seeds were pushed to *and* all preferred
+
seeds were pushed to.
+

+
When `--fetch` or `--announce` are specified on their own, this command
+
will only fetch or announce.
+

+
If `--inventory` is specified, the node's inventory is announced to
+
the network. This mode does not take an `<RID>`.
+
"#;
+

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

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

+
    /// Enable debug information when synchronizing
+
    #[arg(long)]
+
    pub(super) debug: bool,
+

+
    /// Enable verbose information when synchronizing
+
    #[arg(long, short)]
+
    pub(super) verbose: bool,
+
}
+

+
#[derive(Parser, Debug)]
+
pub(super) struct SyncArgs {
+
    /// Enable fetching [default: true]
+
    ///
+
    /// Providing `--announce` without `--fetch` will disable fetching
+
    #[arg(long, short, conflicts_with = "inventory")]
+
    fetch: bool,
+

+
    /// Enable announcing [default: true]
+
    ///
+
    /// Providing `--fetch` without `--announce` will disable announcing
+
    #[arg(long, short, conflicts_with = "inventory")]
+
    announce: bool,
+

+
    /// Synchronize with the given node (may be specified multiple times)
+
    #[arg(
+
        long = "seed",
+
        value_name = "NID",
+
        action = clap::ArgAction::Append,
+
        conflicts_with = "inventory",
+
    )]
+
    seeds: Vec<NodeId>,
+

+
    /// How many seconds to wait while synchronizing
+
    #[arg(
+
        long,
+
        short,
+
        default_value_t = 9,
+
        value_name = "SECS",
+
        conflicts_with = "inventory"
+
    )]
+
    timeout: u64,
+

+
    /// The repository to perform the synchronizing for [default: cwd]
+
    rid: Option<RepoId>,
+

+
    /// Synchronize with a specific number of seeds
+
    ///
+
    /// The value must be greater than zero
+
    #[arg(
+
        long,
+
        short,
+
        value_name = "COUNT",
+
        value_parser = replicas_non_zero,
+
        conflicts_with = "inventory",
+
        default_value_t = radicle::node::sync::DEFAULT_REPLICATION_FACTOR,
+
    )]
+
    replicas: usize,
+

+
    /// Synchronize with an upper bound number of seeds
+
    ///
+
    /// The value must be greater than zero
+
    #[arg(
+
        long,
+
        value_name = "COUNT",
+
        value_parser = replicas_non_zero,
+
        conflicts_with = "inventory",
+
    )]
+
    max_replicas: Option<usize>,
+

+
    /// Enable announcing inventory [default: false]
+
    ///
+
    /// `--inventory` is a standalone mode and is not compatible with the other
+
    /// options
+
    ///
+
    /// <RID> is ignored with `--inventory`
+
    #[arg(long, short)]
+
    inventory: bool,
+
}
+

+
impl SyncArgs {
+
    fn direction(&self) -> SyncDirection {
+
        match (self.fetch, self.announce) {
+
            (true, true) | (false, false) => SyncDirection::Both,
+
            (true, false) => SyncDirection::Fetch,
+
            (false, true) => SyncDirection::Announce,
+
        }
+
    }
+

+
    fn timeout(&self) -> time::Duration {
+
        time::Duration::from_secs(self.timeout)
+
    }
+

+
    fn replication(&self) -> sync::ReplicationFactor {
+
        match (self.replicas, self.max_replicas) {
+
            (min, None) => sync::ReplicationFactor::must_reach(min),
+
            (min, Some(max)) => sync::ReplicationFactor::range(min, max),
+
        }
+
    }
+
}
+

+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    /// Display the sync status of a repository
+
    #[clap(alias = "s")]
+
    Status {
+
        /// The repository to display the status for [default: cwd]
+
        rid: Option<RepoId>,
+
        /// Sort the table by column
+
        #[arg(long, value_name = "FIELD", value_enum, default_value_t)]
+
        sort_by: SortBy,
+
    },
+
}
+

+
/// Sort the status table by the provided field
+
#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
+
pub(super) enum SortBy {
+
    /// The NID of the entry
+
    Nid,
+
    /// The alias of the entry
+
    Alias,
+
    /// The status of the entry
+
    #[default]
+
    Status,
+
}
+

+
impl FromStr for SortBy {
+
    type Err = &'static str;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        match s {
+
            "nid" => Ok(Self::Nid),
+
            "alias" => Ok(Self::Alias),
+
            "status" => Ok(Self::Status),
+
            _ => Err("invalid `--sort-by` field"),
+
        }
+
    }
+
}
+

+
/// Whether we are performing a fetch/announce of a repository or only
+
/// announcing the node's inventory
+
pub(super) enum SyncMode {
+
    /// Fetch and/or announce a repositories references
+
    Repo {
+
        /// The repository being synchronized
+
        rid: Option<RepoId>,
+
        /// The settings for fetch/announce
+
        settings: SyncSettings,
+
        /// The direction of the synchronization
+
        direction: SyncDirection,
+
    },
+
    /// Announce the node's inventory
+
    Inventory,
+
}
+

+
impl From<SyncArgs> for SyncMode {
+
    fn from(args: SyncArgs) -> Self {
+
        if args.inventory {
+
            Self::Inventory
+
        } else {
+
            assert!(!args.inventory);
+
            let direction = args.direction();
+
            let mut settings = SyncSettings::default()
+
                .timeout(args.timeout())
+
                .replicas(args.replication());
+
            if !args.seeds.is_empty() {
+
                settings.seeds = args.seeds.into_iter().collect();
+
            }
+
            Self::Repo {
+
                rid: args.rid,
+
                settings,
+
                direction,
+
            }
+
        }
+
    }
+
}
+

+
/// The direction of the [`SyncMode`]
+
#[derive(Debug, PartialEq, Eq)]
+
pub(super) enum SyncDirection {
+
    /// Only fetching
+
    Fetch,
+
    /// Only announcing
+
    Announce,
+
    /// Both fetching and announcing
+
    Both,
+
}
+

+
fn replicas_non_zero(s: &str) -> Result<usize, String> {
+
    let r = usize::from_str(s).map_err(|_| format!("{s} is not a number"))?;
+
    if r == 0 {
+
        return Err(format!("{s} must be a value greater than zero"));
+
    }
+
    Ok(r)
+
}
modified crates/radicle-cli/src/commands/unblock.rs
@@ -1,98 +1,24 @@
-
use std::ffi::OsString;
-

-
use radicle::prelude::{NodeId, RepoId};
+
mod args;

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

-
pub const HELP: Help = Help {
-
    name: "unblock",
-
    description: "Unblock repositories or nodes to allow them to be seeded or followed",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad unblock <rid> [<option>...]
-
    rad unblock <nid> [<option>...]
-

-
    Unblock a repository or remote to allow it to be seeded or followed.
-

-
Options

-
    --help          Print help
-
"#,
-
};
+
use term::args::BlockTarget;

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

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

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

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

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

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

-
        Ok((
-
            Options {
-
                target: target.ok_or(anyhow::anyhow!(
-
                    "a repository or remote to unblock must be specified, see `rad unblock --help`"
-
                ))?,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
+
pub use args::Args;

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

-
    let updated = match options.target {
-
        Target::Node(nid) => policies.unblock_nid(&nid)?,
-
        Target::Repo(rid) => policies.unblock_rid(&rid)?,
+
    let updated = match args.target {
+
        BlockTarget::Node(nid) => policies.unblock_nid(&nid)?,
+
        BlockTarget::Repo(rid) => policies.unblock_rid(&rid)?,
    };

    if updated {
-
        term::success!("The 'block' policy for {} is removed", options.target);
+
        term::success!("The 'block' policy for {} is removed", args.target);
    } else {
-
        term::info!("No 'block' policy exists for {}", options.target)
+
        term::info!("No 'block' policy exists for {}", args.target)
    }
    Ok(())
}
added crates/radicle-cli/src/commands/unblock/args.rs
@@ -0,0 +1,15 @@
+
use clap::Parser;
+

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

+
const ABOUT: &str = "Unblock repositories or nodes to allow them to be seeded or followed";
+

+
#[derive(Parser, Debug)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// A Repository ID or Node ID to allow to be seeded or followed
+
    ///
+
    /// [example values: rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH, z6MkiswaKJ85vafhffCGBu2gdBsYoDAyHVBWRxL3j297fwS9]
+
    #[arg(value_name = "RID|NID")]
+
    pub(super) target: BlockTarget,
+
}
modified crates/radicle-cli/src/commands/unfollow.rs
@@ -1,80 +1,15 @@
-
use std::ffi::OsString;
+
mod args;

-
use anyhow::anyhow;
-

-
use radicle::node::{Handle, NodeId};
+
use radicle::node::Handle;

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

-
pub const HELP: Help = Help {
-
    name: "unfollow",
-
    description: "Unfollow a peer",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad unfollow <nid> [<option>...]
-

-
    The `unfollow` command takes a Node ID (<nid>), optionally in DID format,
-
    and removes the follow policy for that peer.
-

-
Options
-

-
    --verbose, -v          Verbose output
-
    --help                 Print help
-
"#,
-
};
-

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

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

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

-
        while let Some(arg) = parser.next()? {
-
            match &arg {
-
                Value(val) if nid.is_none() => {
-
                    if let Ok(did) = term::args::did(val) {
-
                        nid = Some(did.into());
-
                    } else if let Ok(val) = term::args::nid(val) {
-
                        nid = Some(val);
-
                    } else {
-
                        anyhow::bail!("invalid Node ID `{}` specified", val.to_string_lossy());
-
                    }
-
                }
-
                Long("verbose") | Short('v') => verbose = true,
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                nid: nid.ok_or_else(|| anyhow!("a Node ID must be specified"))?,
-
                verbose,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
+
pub use args::Args;

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

    let unfollowed = match node.unfollow(nid) {
        Ok(updated) => updated,
added crates/radicle-cli/src/commands/unfollow/args.rs
@@ -0,0 +1,23 @@
+
use clap::Parser;
+

+
use radicle::node::NodeId;
+

+
use crate::terminal as term;
+

+
const ABOUT: &str = "Unfollow a peer";
+

+
const LONG_ABOUT: &str = r#"
+
The `unfollow` command takes a Node ID, optionally in DID format,
+
and removes the follow policy for that peer."#;
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// Node ID (optionally in DID format) of the peer to unfollow
+
    #[arg(value_name = "NID", value_parser = term::args::parse_nid)]
+
    pub(super) nid: NodeId,
+

+
    /// Verbose output
+
    #[arg(short, long)]
+
    pub(super) verbose: bool,
+
}
modified crates/radicle-cli/src/commands/unseed.rs
@@ -1,74 +1,16 @@
-
use std::ffi::OsString;
-

-
use anyhow::anyhow;
-
use nonempty::NonEmpty;
+
pub mod args;

use radicle::{prelude::*, Node};

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

-
pub const HELP: Help = Help {
-
    name: "unseed",
-
    description: "Remove repository seeding policies",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

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

-
    The `unseed` command removes the seeding policy, if found,
-
    for the given repositories.

-
Options
-

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

-
#[derive(Debug)]
-
pub struct Options {
-
    rids: NonEmpty<RepoId>,
-
}
-

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

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut rids: Vec<RepoId> = Vec::new();
-

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

-
        Ok((
-
            Options {
-
                rids: NonEmpty::from_vec(rids).ok_or(anyhow!(
-
                    "At least one Repository ID must be provided; see `rad unseed --help`"
-
                ))?,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
+
pub use args::Args;

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

-
    for rid in options.rids {
+
    for rid in args.rids {
        delete(rid, &mut node, &profile)?;
    }

added crates/radicle-cli/src/commands/unseed/args.rs
@@ -0,0 +1,16 @@
+
use clap::Parser;
+
use radicle::prelude::RepoId;
+

+
const ABOUT: &str = "Remove repository seeding policies";
+

+
const LONG_ABOUT: &str = r#"
+
The `unseed` command removes the seeding policy, if found,
+
for the given repositories."#;
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// ID of the repository to remove the seeding policy for (may be repeated)
+
    #[arg(value_name = "RID", required = true, action = clap::ArgAction::Append)]
+
    pub rids: Vec<RepoId>,
+
}
modified crates/radicle-cli/src/commands/watch.rs
@@ -1,132 +1,27 @@
-
use std::ffi::OsString;
+
mod args;
+

use std::{thread, time};

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

use radicle::git;
-
use radicle::prelude::{NodeId, RepoId};
+
use radicle::git::raw::ErrorExt as _;
+
use radicle::prelude::NodeId;
use radicle::storage::{ReadRepository, ReadStorage};

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

-
pub const HELP: Help = Help {
-
    name: "wait",
-
    description: "Wait for some state to be updated",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad watch -r <ref> [-t <oid>] [--repo <rid>] [<option>...]
-

-
    Watches a Git reference, and optionally exits when it reaches a target value.
-
    If no target value is passed, exits when the target changes.
-

-
Options
-

-
        --repo      <rid>       The repository to watch (default: `rad .`)
-
        --node      <nid>       The namespace under which this reference exists
-
                                (default: NID of the profile)
-
    -r, --ref       <ref>       The fully-qualified Git reference (branch, tag, etc.) to watch,
-
                                eg. 'refs/heads/master'
-
    -t, --target    <oid>       The target OID (commit hash) that when reached,
-
                                will cause the command to exit
-
    -i, --interval  <millis>    How often, in milliseconds, to check the reference target
-
                                (default: 1000)
-
        --timeout   <millis>    Timeout, in milliseconds (default: none)
-
    -h, --help                  Print help
-
"#,
-
};
-

-
pub struct Options {
-
    rid: Option<RepoId>,
-
    refstr: git::RefString,
-
    target: Option<git::Oid>,
-
    nid: Option<NodeId>,
-
    interval: time::Duration,
-
    timeout: time::Duration,
-
}
-

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

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut rid = None;
-
        let mut nid: Option<NodeId> = None;
-
        let mut target: Option<git::Oid> = None;
-
        let mut refstr: Option<git::RefString> = None;
-
        let mut interval: Option<time::Duration> = None;
-
        let mut timeout: time::Duration = time::Duration::MAX;
-

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

-
                    rid = Some(value);
-
                }
-
                Long("node") => {
-
                    let value = parser.value()?;
-
                    let value = term::args::nid(&value)?;
-

-
                    nid = Some(value);
-
                }
-
                Long("ref") | Short('r') => {
-
                    let value = parser.value()?;
-
                    let value = term::args::refstring("ref", value)?;
-

-
                    refstr = Some(value);
-
                }
-
                Long("target") | Short('t') => {
-
                    let value = parser.value()?;
-
                    let value = term::args::oid(&value)?;
-

-
                    target = Some(value);
-
                }
-
                Long("interval") | Short('i') => {
-
                    let value = parser.value()?;
-
                    let value = term::args::milliseconds(&value)?;
-

-
                    interval = Some(value);
-
                }
-
                Long("timeout") => {
-
                    let value = parser.value()?;
-
                    let value = term::args::milliseconds(&value)?;
-

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

-
        Ok((
-
            Options {
-
                rid,
-
                refstr: refstr.ok_or_else(|| anyhow!("a reference must be provided"))?,
-
                nid,
-
                target,
-
                interval: interval.unwrap_or(time::Duration::from_secs(1)),
-
                timeout,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
+
pub use args::Args;

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let storage = &profile.storage;
-
    let qualified = options
+
    let qualified = args
        .refstr
        .qualified()
        .ok_or_else(|| anyhow!("reference must be fully-qualified, eg. 'refs/heads/master'"))?;
-
    let nid = options.nid.unwrap_or(profile.public_key);
-
    let rid = match options.rid {
+
    let nid = args.node.unwrap_or(profile.public_key);
+
    let rid = match args.repo {
        Some(rid) => rid,
        None => {
            let (_, rid) =
@@ -136,26 +31,28 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    };
    let repo = storage.repository(rid)?;
    let now = time::SystemTime::now();
+
    let timeout = args.timeout();
+
    let interval = args.interval();

-
    if let Some(target) = options.target {
+
    if let Some(target) = args.target {
        while reference(&repo, &nid, &qualified)? != Some(target) {
-
            thread::sleep(options.interval);
-
            if now.elapsed()? >= options.timeout {
-
                anyhow::bail!("timed out after {}ms", options.timeout.as_millis());
+
            thread::sleep(interval);
+
            if now.elapsed()? >= timeout {
+
                anyhow::bail!("timed out after {}ms", timeout.as_millis());
            }
        }
    } else {
        let initial = reference(&repo, &nid, &qualified)?;

        loop {
-
            thread::sleep(options.interval);
+
            thread::sleep(interval);
            let oid = reference(&repo, &nid, &qualified)?;
            if oid != initial {
                term::info!("{}", oid.unwrap_or(git::raw::Oid::zero().into()));
                break;
            }
-
            if now.elapsed()? >= options.timeout {
-
                anyhow::bail!("timed out after {}ms", options.timeout.as_millis());
+
            if now.elapsed()? >= timeout {
+
                anyhow::bail!("timed out after {}ms", timeout.as_millis());
            }
        }
    }
@@ -165,11 +62,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
fn reference<R: ReadRepository>(
    repo: &R,
    nid: &NodeId,
-
    qual: &git::Qualified,
+
    qual: &git::fmt::Qualified,
) -> Result<Option<git::Oid>, git::raw::Error> {
    match repo.reference_oid(nid, qual) {
        Ok(oid) => Ok(Some(oid)),
-
        Err(e) if git::ext::is_not_found_err(&e) => Ok(None),
+
        Err(e) if e.is_not_found() => Ok(None),
        Err(e) => Err(e),
    }
}
added crates/radicle-cli/src/commands/watch/args.rs
@@ -0,0 +1,72 @@
+
use std::time;
+

+
#[allow(rustdoc::broken_intra_doc_links)]
+
use clap::Parser;
+

+
use radicle::git;
+
use radicle::git::fmt::RefString;
+
use radicle::prelude::{NodeId, RepoId};
+

+
const ABOUT: &str = "Wait for some state to be updated";
+

+
const LONG_ABOUT: &str = r#"
+
Watches a Git reference, and optionally exits when it reaches a target value.
+
If no target value is passed, exits when the target changes."#;
+

+
fn parse_refstr(refstr: &str) -> Result<RefString, git::fmt::Error> {
+
    RefString::try_from(refstr)
+
}
+

+
#[derive(Parser, Debug)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT,disable_version_flag = true)]
+
pub struct Args {
+
    /// The repository to watch, defaults to `rad .`
+
    #[arg(long)]
+
    pub(super) repo: Option<RepoId>,
+

+
    /// The fully-qualified Git reference (branch, tag, etc.) to watch
+
    ///
+
    /// [example value: 'refs/heads/master']
+
    #[arg(long, short, alias = "ref", value_name = "REF", value_parser = parse_refstr)]
+
    pub(super) refstr: git::fmt::RefString,
+

+
    /// The target OID (commit hash) that when reached, will cause the command to exit
+
    #[arg(long, short, value_name = "OID")]
+
    pub(super) target: Option<git::Oid>,
+

+
    /// The namespace under which this reference exists, defaults to the profiles' NID
+
    #[arg(long, short, value_name = "NID")]
+
    pub(super) node: Option<NodeId>,
+

+
    /// How often, in milliseconds, to check the reference target
+
    #[arg(long, short, value_name = "MILLIS", default_value_t = 1000)]
+
    interval: u64,
+

+
    /// Timeout, in milliseconds
+
    #[arg(long, value_name = "MILLIS")]
+
    timeout: Option<u64>,
+
}
+

+
impl Args {
+
    /// Provide the interval duration in milliseconds.
+
    pub(super) fn interval(&self) -> time::Duration {
+
        time::Duration::from_millis(self.interval)
+
    }
+

+
    /// Provide the timeout duration in milliseconds.
+
    pub(super) fn timeout(&self) -> time::Duration {
+
        time::Duration::from_millis(self.timeout.unwrap_or(u64::MAX))
+
    }
+
}
+

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

+
    #[test]
+
    fn should_parse_ref_str() {
+
        let args = Args::try_parse_from(["watch", "--ref", "refs/heads/master"]);
+
        assert!(args.is_ok())
+
    }
+
}
modified crates/radicle-cli/src/git.rs
@@ -20,14 +20,15 @@ use thiserror::Error;

use radicle::crypto::ssh;
use radicle::git;
-
use radicle::git::raw as git2;
use radicle::git::{Version, VERSION_REQUIRED};
use radicle::prelude::{NodeId, RepoId};
use radicle::storage::git::transport;

+
pub use radicle::git::Oid;
+

pub use radicle::git::raw::{
-
    build::CheckoutBuilder, AnnotatedCommit, Commit, Direction, ErrorCode, MergeAnalysis,
-
    MergeOptions, Oid, Reference, Repository, Signature,
+
    build::CheckoutBuilder, AnnotatedCommit, Commit, Direction, ErrorCode, ErrorExt as _,
+
    MergeAnalysis, MergeOptions, Reference, Repository, Signature,
};

pub const CONFIG_COMMIT_GPG_SIGN: &str = "commit.gpgsign";
@@ -42,14 +43,15 @@ pub struct Rev(String);

impl Rev {
    /// Return the revision as a string.
+
    #[must_use]
    pub fn as_str(&self) -> &str {
        &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 +86,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 +112,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 +134,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 +252,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 +265,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!(
@@ -331,43 +345,13 @@ pub fn check_version() -> Result<Version, anyhow::Error> {
    Ok(git_version)
}

-
/// Parse a remote refspec into a peer id and ref.
-
pub fn parse_remote(refspec: &str) -> Option<(NodeId, &str)> {
-
    refspec
-
        .strip_prefix("refs/remotes/")
-
        .and_then(|s| s.split_once('/'))
-
        .and_then(|(peer, r)| NodeId::from_str(peer).ok().map(|p| (p, r)))
-
}
-

-
pub fn view_diff(
-
    repo: &git2::Repository,
-
    left: &git2::Oid,
-
    right: &git2::Oid,
-
) -> anyhow::Result<()> {
-
    // TODO(erikli): Replace with repo.diff()
-
    let workdir = repo
-
        .workdir()
-
        .ok_or_else(|| anyhow!("Could not get workdir current repository."))?;
-

-
    let left = format!("{:.7}", left.to_string());
-
    let right = format!("{:.7}", right.to_string());
-

-
    let mut git = Command::new("git")
-
        .current_dir(workdir)
-
        .args(["diff", &left, &right])
-
        .spawn()?;
-
    git.wait()?;
-

-
    Ok(())
-
}
-

pub fn add_tag(
-
    repo: &git2::Repository,
+
    repo: &Repository,
    message: &str,
    patch_tag_name: &str,
-
) -> anyhow::Result<git2::Oid> {
+
) -> anyhow::Result<git::raw::Oid> {
    let head = repo.head()?;
-
    let commit = head.peel(git2::ObjectType::Commit).unwrap();
+
    let commit = head.peel(git::raw::ObjectType::Commit).unwrap();
    let oid = repo.tag(patch_tag_name, &commit, &repo.signature()?, message, false)?;

    Ok(oid)
modified crates/radicle-cli/src/git/ddiff.rs
@@ -41,7 +41,7 @@
//! +snuffing
//! omitting
//! ```
-
//! The `DDiff` will show the what changes are being made, overlayed on to the original diff and
+
//! The `DDiff` will show the what changes are being made, overlaid on to the original diff and
//! the diff's original file as context.
//!
//! ```text
@@ -382,6 +382,7 @@ impl DDiff {
    }

    /// Returns owned files in the diff.
+
    #[must_use]
    pub fn into_files(self) -> Vec<FileDDiff> {
        self.files
    }
modified crates/radicle-cli/src/git/pretty_diff.rs
@@ -2,7 +2,7 @@ use std::fs;
use std::path::{Path, PathBuf};

use radicle::git;
-
use radicle_git_ext::Oid;
+
use radicle::git::Oid;
use radicle_surf::diff;
use radicle_surf::diff::{Added, Copied, Deleted, FileStats, Hunks, Modified, Moved};
use radicle_surf::diff::{Diff, DiffContent, FileDiff, Hunk, Modification};
@@ -33,7 +33,7 @@ pub trait Repo {

impl Repo for git::raw::Repository {
    fn blob(&self, oid: git::Oid) -> Result<Blob, git::raw::Error> {
-
        let blob = self.find_blob(*oid)?;
+
        let blob = self.find_blob(oid.into())?;

        if blob.is_binary() {
            Ok(Blob::Binary)
@@ -338,7 +338,7 @@ impl ToPretty for Added {
        repo: &R,
    ) -> Self::Output {
        let old = None;
-
        let new = Some((self.path.as_path(), self.new.oid));
+
        let new = Some((self.path.as_path(), Oid::from(*self.new.oid)));

        pretty_modification(header, &self.diff, old, new, repo, hi)
    }
@@ -354,7 +354,7 @@ impl ToPretty for Deleted {
        header: &Self::Context,
        repo: &R,
    ) -> Self::Output {
-
        let old = Some((self.path.as_path(), self.old.oid));
+
        let old = Some((self.path.as_path(), Oid::from(*self.old.oid)));
        let new = None;

        pretty_modification(header, &self.diff, old, new, repo, hi)
@@ -371,8 +371,8 @@ impl ToPretty for Modified {
        header: &Self::Context,
        repo: &R,
    ) -> Self::Output {
-
        let old = Some((self.path.as_path(), self.old.oid));
-
        let new = Some((self.path.as_path(), self.new.oid));
+
        let old = Some((self.path.as_path(), Oid::from(*self.old.oid)));
+
        let new = Some((self.path.as_path(), Oid::from(*self.new.oid)));

        pretty_modification(header, &self.diff, old, new, repo, hi)
    }
@@ -444,78 +444,86 @@ impl ToPretty for Hunk<Modification> {
        if let Ok(header) = HunkHeader::from_bytes(self.header.as_bytes()) {
            vstack.push(header.pretty(hi, &(), repo));
        }
-
        for line in &self.lines {
-
            match line {
-
                Modification::Addition(a) => {
-
                    table.push([
-
                        term::Label::space()
-
                            .pad(5)
-
                            .bg(theme.color("positive"))
-
                            .to_line()
-
                            .filled(theme.color("positive")),
-
                        term::label(a.line_no.to_string())
-
                            .pad(5)
-
                            .fg(theme.color("positive.light"))
-
                            .to_line()
-
                            .filled(theme.color("positive")),
-
                        term::label(" + ")
-
                            .fg(theme.color("positive.light"))
-
                            .to_line()
-
                            .filled(theme.color("positive.dark")),
-
                        line.pretty(hi, blobs, repo)
-
                            .filled(theme.color("positive.dark")),
-
                        term::Line::blank().filled(term::Color::default()),
-
                    ]);
-
                }
-
                Modification::Deletion(a) => {
-
                    table.push([
-
                        term::label(a.line_no.to_string())
-
                            .pad(5)
-
                            .fg(theme.color("negative.light"))
-
                            .to_line()
-
                            .filled(theme.color("negative")),
-
                        term::Label::space()
-
                            .pad(5)
-
                            .fg(theme.color("dim"))
-
                            .to_line()
-
                            .filled(theme.color("negative")),
-
                        term::label(" - ")
-
                            .fg(theme.color("negative.light"))
-
                            .to_line()
-
                            .filled(theme.color("negative.dark")),
-
                        line.pretty(hi, blobs, repo)
-
                            .filled(theme.color("negative.dark")),
-
                        term::Line::blank().filled(term::Color::default()),
-
                    ]);
-
                }
-
                Modification::Context {
-
                    line_no_old,
-
                    line_no_new,
-
                    ..
-
                } => {
-
                    table.push([
-
                        term::label(line_no_old.to_string())
-
                            .pad(5)
-
                            .fg(theme.color("dim"))
-
                            .to_line()
-
                            .filled(theme.color("faint")),
-
                        term::label(line_no_new.to_string())
-
                            .pad(5)
-
                            .fg(theme.color("dim"))
-
                            .to_line()
-
                            .filled(theme.color("faint")),
-
                        term::label("   ").to_line().filled(term::Color::default()),
-
                        line.pretty(hi, blobs, repo).filled(term::Color::default()),
-
                        term::Line::blank().filled(term::Color::default()),
-
                    ]);
-
                }
-
            }
-
        }
+

+
        table.extend(
+
            self.lines
+
                .iter()
+
                .map(|line| line_to_table_row(hi, blobs, repo, &theme, line)),
+
        );
+

        vstack.push(table);
        vstack
    }
}

+
fn line_to_table_row<R: Repo>(
+
    hi: &mut Highlighter,
+
    blobs: &Blobs<Vec<radicle_term::Line>>,
+
    repo: &R,
+
    theme: &Theme,
+
    line: &Modification,
+
) -> [radicle_term::Filled<radicle_term::Line>; 5] {
+
    match line {
+
        Modification::Addition(a) => [
+
            term::Label::space()
+
                .pad(5)
+
                .bg(theme.color("positive"))
+
                .to_line()
+
                .filled(theme.color("positive")),
+
            term::label(a.line_no.to_string())
+
                .pad(5)
+
                .fg(theme.color("positive.light"))
+
                .to_line()
+
                .filled(theme.color("positive")),
+
            term::label(" + ")
+
                .fg(theme.color("positive.light"))
+
                .to_line()
+
                .filled(theme.color("positive.dark")),
+
            line.pretty(hi, blobs, repo)
+
                .filled(theme.color("positive.dark")),
+
            term::Line::blank().filled(term::Color::default()),
+
        ],
+
        Modification::Deletion(a) => [
+
            term::label(a.line_no.to_string())
+
                .pad(5)
+
                .fg(theme.color("negative.light"))
+
                .to_line()
+
                .filled(theme.color("negative")),
+
            term::Label::space()
+
                .pad(5)
+
                .fg(theme.color("dim"))
+
                .to_line()
+
                .filled(theme.color("negative")),
+
            term::label(" - ")
+
                .fg(theme.color("negative.light"))
+
                .to_line()
+
                .filled(theme.color("negative.dark")),
+
            line.pretty(hi, blobs, repo)
+
                .filled(theme.color("negative.dark")),
+
            term::Line::blank().filled(term::Color::default()),
+
        ],
+
        Modification::Context {
+
            line_no_old,
+
            line_no_new,
+
            ..
+
        } => [
+
            term::label(line_no_old.to_string())
+
                .pad(5)
+
                .fg(theme.color("dim"))
+
                .to_line()
+
                .filled(theme.color("faint")),
+
            term::label(line_no_new.to_string())
+
                .pad(5)
+
                .fg(theme.color("dim"))
+
                .to_line()
+
                .filled(theme.color("faint")),
+
            term::label("   ").to_line().filled(term::Color::default()),
+
            line.pretty(hi, blobs, repo).filled(term::Color::default()),
+
            term::Line::blank().filled(term::Color::default()),
+
        ],
+
    }
+
}
+

impl ToPretty for Modification {
    type Output = term::Line;
    type Context = Blobs<Vec<term::Line>>;
@@ -529,14 +537,14 @@ impl ToPretty for Modification {
        match self {
            Modification::Deletion(diff::Deletion { line, line_no }) => {
                if let Some(lines) = &blobs.old.as_ref() {
-
                    lines[*line_no as usize - 1].clone()
+
                    lines.get(*line_no as usize - 1).unwrap().clone()
                } else {
                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
                }
            }
            Modification::Addition(diff::Addition { line, line_no }) => {
                if let Some(lines) = &blobs.new.as_ref() {
-
                    lines[*line_no as usize - 1].clone()
+
                    lines.get(*line_no as usize - 1).unwrap().clone()
                } else {
                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
                }
@@ -546,7 +554,7 @@ impl ToPretty for Modification {
            } => {
                // Nb. we can check in the old or the new blob, we choose the new.
                if let Some(lines) = &blobs.new.as_ref() {
-
                    lines[*line_no_new as usize - 1].clone()
+
                    lines.get(*line_no_new as usize - 1).unwrap().clone()
                } else {
                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
                }
@@ -587,8 +595,8 @@ mod test {
    use term::Element;

    use super::*;
-
    use radicle::git::raw::RepositoryOpenFlags;
-
    use radicle::git::raw::{Oid, Repository};
+
    use git::raw::RepositoryOpenFlags;
+
    use git::raw::{Oid, Repository};

    #[test]
    #[ignore]
modified crates/radicle-cli/src/git/unified_diff.rs
@@ -7,7 +7,6 @@ use radicle_surf::diff::FileStats;
use thiserror::Error;

use radicle::git;
-
use radicle::git::raw::Oid;
use radicle_surf::diff;
use radicle_surf::diff::{Diff, DiffContent, DiffFile, FileDiff, Hunk, Hunks, Line, Modification};

@@ -34,11 +33,12 @@ impl Error {
        Self::Syntax(msg.to_string())
    }

+
    #[must_use]
    pub fn is_eof(&self) -> bool {
        match self {
            Self::UnexpectedEof => true,
            Self::Io(e) => e.kind() == io::ErrorKind::UnexpectedEof,
-
            _ => false,
+
            Self::Syntax(_) | Self::ParseInt(_) | Self::Utf8(_) => false,
        }
    }
}
@@ -138,12 +138,14 @@ impl TryFrom<&Hunk<Modification>> for HunkHeader {
}

impl HunkHeader {
+
    #[must_use]
    pub fn old_line_range(&self) -> std::ops::Range<u32> {
        let start: u32 = self.old_line_no;
        let end: u32 = self.old_line_no + self.old_size;
        start..end + 1
    }

+
    #[must_use]
    pub fn new_line_range(&self) -> std::ops::Range<u32> {
        let start: u32 = self.new_line_no;
        let end: u32 = self.new_line_no + self.new_size;
@@ -307,8 +309,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 +318,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 +336,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 +357,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()))?;
@@ -580,16 +582,27 @@ impl<'a> Writer<'a> {
        Ok(())
    }

+
    #[must_use]
    pub fn styled(mut self, value: bool) -> Self {
        self.styled = value;
        self
    }

    pub fn write(&mut self, s: impl fmt::Display, style: term::Style) -> io::Result<()> {
+
        #[cfg(windows)]
+
        const EOL: &str = "\r\n";
+

+
        #[cfg(not(windows))]
+
        const EOL: &str = "\n";
+

        if self.styled {
-
            writeln!(self.stream, "{}", term::Paint::new(s).with_style(style))
+
            write!(
+
                self.stream,
+
                "{}{EOL}",
+
                term::Paint::new(s).with_style(style)
+
            )
        } else {
-
            writeln!(self.stream, "{s}")
+
            write!(self.stream, "{s}{EOL}")
        }
    }

modified crates/radicle-cli/src/lib.rs
@@ -9,3 +9,5 @@ pub mod project;
pub mod terminal;

mod warning;
+

+
extern crate radicle_localtime as localtime;
modified crates/radicle-cli/src/main.rs
@@ -1,18 +1,35 @@
use std::ffi::OsString;
-
use std::io::{self, Write};
-
use std::{io::ErrorKind, iter, process};
+
use std::fmt::Display;
+
use std::io;
+
use std::io::Write;
+
use std::{io::ErrorKind, process};

use anyhow::anyhow;
+
use clap::builder::styling::AnsiColor;
+
use clap::builder::Styles;
+
use clap::{CommandFactory as _, Parser, Subcommand};

use radicle::version::Version;
use radicle_cli::commands::*;
use radicle_cli::terminal as term;

pub const NAME: &str = "rad";
+
pub const GIT_HEAD: &str = env!("GIT_HEAD");
pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
+
pub const RADICLE_VERSION_LONG: &str =
+
    concat!(env!("RADICLE_VERSION"), " (", env!("GIT_HEAD"), ")");
pub const DESCRIPTION: &str = "Radicle command line interface";
-
pub const GIT_HEAD: &str = env!("GIT_HEAD");
+
pub const LONG_DESCRIPTION: &str = "
+
Radicle is a sovereign code forge built on Git.
+

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

+
Do you have feedback?
+
 - Chat <\x1b]8;;https://radicle.zulipchat.com\x1b\\radicle.zulipchat.com\x1b]8;;\x1b\\>
+
 - Mail <\x1b]8;;mailto:feedback@radicle.xyz\x1b\\feedback@radicle.xyz\x1b]8;;\x1b\\>
+
   (Messages are automatically posted to the public #feedback channel on Zulip.)\
+
";
pub const TIMESTAMP: &str = env!("SOURCE_DATE_EPOCH");
pub const VERSION: Version = Version {
    name: NAME,
@@ -20,12 +37,77 @@ pub const VERSION: Version = Version {
    commit: GIT_HEAD,
    timestamp: TIMESTAMP,
};
+
const STYLES: Styles = Styles::styled()
+
    .header(AnsiColor::Magenta.on_default().bold())
+
    .usage(AnsiColor::Magenta.on_default().bold())
+
    .placeholder(AnsiColor::Cyan.on_default());

-
#[derive(Debug)]
+
/// Radicle command line interface
+
#[derive(Parser, Debug)]
+
#[command(name = NAME)]
+
#[command(version = RADICLE_VERSION)]
+
#[command(long_version = RADICLE_VERSION_LONG)]
+
#[command(about = DESCRIPTION)]
+
#[command(long_about = LONG_DESCRIPTION)]
+
#[command(propagate_version = true)]
+
#[command(styles = STYLES)]
+
struct CliArgs {
+
    #[command(subcommand)]
+
    pub command: Command,
+
}
+

+
#[derive(Subcommand, Debug)]
enum Command {
-
    Other(Vec<OsString>),
-
    Help,
-
    Version { json: bool },
+
    Auth(auth::Args),
+
    Block(block::Args),
+
    Checkout(checkout::Args),
+
    Clean(clean::Args),
+
    Clone(clone::Args),
+
    #[command(hide = true)]
+
    Cob(cob::Args),
+
    Config(config::Args),
+
    Debug(debug::Args),
+
    Follow(follow::Args),
+
    #[command(hide = true)] // `rad fork` command is deprecated
+
    Fork(fork::Args),
+
    Id(id::Args),
+
    Inbox(inbox::Args),
+
    Init(init::Args),
+
    #[command(alias = ".")]
+
    Inspect(inspect::Args),
+
    Issue(issue::Args),
+
    Ls(ls::Args),
+
    Node(node::Args),
+
    Patch(patch::Args),
+
    Path(path::Args),
+
    Publish(publish::Args),
+
    Remote(remote::Args),
+
    Seed(seed::Args),
+
    #[command(name = "self")]
+
    RadSelf(rad_self::Args),
+
    Stats(stats::Args),
+
    Sync(sync::Args),
+
    Unblock(unblock::Args),
+
    Unfollow(unfollow::Args),
+
    Unseed(unseed::Args),
+
    Watch(watch::Args),
+

+
    /// Print the version information of the CLI
+
    Version {
+
        /// Print the version information in JSON format
+
        #[arg(long)]
+
        json: bool,
+
    },
+

+
    /// Print static completion information for a given shell
+
    #[command(hide = true)]
+
    Completion {
+
        /// The type of shell to output a static completion script for.
+
        shell: clap_complete::Shell,
+
    },
+

+
    #[command(external_subcommand)]
+
    External(Vec<OsString>),
}

fn main() {
@@ -44,319 +126,138 @@ fn main() {
    if let Err(e) = radicle::io::set_file_limit(4096) {
        log::warn!(target: "cli", "Unable to set open file limit: {e}");
    }
-
    match parse_args().map_err(Some).and_then(run) {
-
        Ok(_) => process::exit(0),
+
    let CliArgs { command } = CliArgs::parse();
+
    run(command, term::DefaultContext)
+
}
+

+
fn write_version(as_json: bool) -> anyhow::Result<()> {
+
    let mut stdout = io::stdout();
+
    if as_json {
+
        VERSION.write_json(&mut stdout)?;
+
        writeln!(&mut stdout)?;
+
        Ok(())
+
    } else {
+
        VERSION.write(&mut stdout)?;
+
        Ok(())
+
    }
+
}
+

+
fn run(command: Command, ctx: impl term::Context) -> ! {
+
    match run_command(command, ctx) {
+
        Ok(()) => process::exit(0),
        Err(err) => {
-
            if let Some(err) = err {
-
                term::error(format!("rad: {err}"));
-
            }
+
            term::fail(&err);
            process::exit(1);
        }
    }
}

-
fn parse_args() -> anyhow::Result<Command> {
-
    use lexopt::prelude::*;
-

-
    let mut parser = lexopt::Parser::from_env();
-
    let mut command = None;
-
    let mut json = false;
-

-
    while let Some(arg) = parser.next()? {
-
        match arg {
-
            Long("json") => {
-
                json = true;
-
            }
-
            Long("help") | Short('h') => {
-
                command = Some(Command::Help);
-
            }
-
            Long("version") => {
-
                command = Some(Command::Version { json: false });
-
            }
-
            Value(val) if command.is_none() => {
-
                if val == *"." {
-
                    command = Some(Command::Other(vec![OsString::from("inspect")]));
-
                } else if val == "version" {
-
                    command = Some(Command::Version { json: false });
-
                } else {
-
                    let args = iter::once(val)
-
                        .chain(iter::from_fn(|| parser.value().ok()))
-
                        .collect();
-

-
                    command = Some(Command::Other(args))
-
                }
-
            }
-
            _ => anyhow::bail!(arg.unexpected()),
-
        }
-
    }
-
    if let Some(Command::Version { json: j }) = &mut command {
-
        *j = json;
+
fn run_command(command: Command, ctx: impl term::Context) -> Result<(), anyhow::Error> {
+
    match command {
+
        Command::Auth(args) => auth::run(args, ctx),
+
        Command::Block(args) => block::run(args, ctx),
+
        Command::Checkout(args) => checkout::run(args, ctx),
+
        Command::Clean(args) => clean::run(args, ctx),
+
        Command::Clone(args) => clone::run(args, ctx),
+
        Command::Cob(args) => cob::run(args, ctx),
+
        Command::Config(args) => config::run(args, ctx),
+
        Command::Debug(args) => debug::run(args, ctx),
+
        Command::Follow(args) => follow::run(args, ctx),
+
        Command::Fork(args) => fork::run(args, ctx),
+
        Command::Id(args) => id::run(args, ctx),
+
        Command::Inbox(args) => inbox::run(args, ctx),
+
        Command::Init(args) => init::run(args, ctx),
+
        Command::Inspect(args) => inspect::run(args, ctx),
+
        Command::Issue(args) => issue::run(args, ctx),
+
        Command::Ls(args) => ls::run(args, ctx),
+
        Command::Node(args) => node::run(args, ctx),
+
        Command::Patch(args) => patch::run(args, ctx),
+
        Command::Path(args) => path::run(args, ctx),
+
        Command::Publish(args) => publish::run(args, ctx),
+
        Command::Remote(args) => remote::run(args, ctx),
+
        Command::Seed(args) => seed::run(args, ctx),
+
        Command::RadSelf(args) => rad_self::run(args, ctx),
+
        Command::Stats(args) => stats::run(args, ctx),
+
        Command::Sync(args) => sync::run(args, ctx),
+
        Command::Unblock(args) => unblock::run(args, ctx),
+
        Command::Unfollow(args) => unfollow::run(args, ctx),
+
        Command::Unseed(args) => unseed::run(args, ctx),
+
        Command::Watch(args) => watch::run(args, ctx),
+
        Command::Version { json } => write_version(json),
+
        Command::Completion { shell } => {
+
            print_completion(shell, &mut CliArgs::command());
+
            Ok(())
+
        }
+
        Command::External(args) => ExternalCommand::new(args).run(),
    }
-
    Ok(command.unwrap_or_else(|| Command::Other(vec![])))
}

-
fn print_help() -> anyhow::Result<()> {
-
    VERSION.write(&mut io::stdout())?;
-
    println!("{DESCRIPTION}");
-
    println!();
+
fn print_completion<G: clap_complete::Generator>(generator: G, cmd: &mut clap::Command) {
+
    clap_complete::generate(
+
        generator,
+
        cmd,
+
        cmd.get_name().to_string(),
+
        &mut io::stdout(),
+
    );
+
}

-
    rad_help::run(Default::default(), term::DefaultContext)
+
struct ExternalCommand {
+
    command: OsString,
+
    args: Vec<OsString>,
}

-
fn run(command: Command) -> Result<(), Option<anyhow::Error>> {
-
    match command {
-
        Command::Version { json } => {
-
            let mut stdout = io::stdout();
-
            if json {
-
                VERSION
-
                    .write_json(&mut stdout)
-
                    .map_err(|e| Some(e.into()))?;
-
                writeln!(&mut stdout).ok();
-
            } else {
-
                VERSION.write(&mut stdout).map_err(|e| Some(e.into()))?;
-
            }
-
        }
-
        Command::Help => {
-
            print_help()?;
-
        }
-
        Command::Other(args) => {
-
            let exe = args.first();
+
impl ExternalCommand {
+
    fn new(mut args: Vec<OsString>) -> Self {
+
        let command = args.remove(0);
+
        Self { command, args }
+
    }

-
            if let Some(Some(exe)) = exe.map(|s| s.to_str()) {
-
                run_other(exe, &args[1..])?;
-
            } else {
-
                print_help()?;
-
            }
-
        }
+
    fn is_diff(&self) -> bool {
+
        self.command == "diff"
    }

-
    Ok(())
-
}
+
    fn exe(&self) -> OsString {
+
        let mut exe = OsString::from(NAME);
+
        exe.push("-");
+
        exe.push(self.command.clone());
+
        exe
+
    }

-
fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>> {
-
    match exe {
-
        "auth" => {
-
            term::run_command_args::<rad_auth::Options, _>(
-
                rad_auth::HELP,
-
                rad_auth::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "block" => {
-
            term::run_command_args::<rad_block::Options, _>(
-
                rad_block::HELP,
-
                rad_block::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "checkout" => {
-
            term::run_command_args::<rad_checkout::Options, _>(
-
                rad_checkout::HELP,
-
                rad_checkout::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "clone" => {
-
            term::run_command_args::<rad_clone::Options, _>(
-
                rad_clone::HELP,
-
                rad_clone::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "cob" => {
-
            term::run_command_args::<rad_cob::Options, _>(
-
                rad_cob::HELP,
-
                rad_cob::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "config" => {
-
            term::run_command_args::<rad_config::Options, _>(
-
                rad_config::HELP,
-
                rad_config::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "diff" => {
-
            term::run_command_args::<rad_diff::Options, _>(
-
                rad_diff::HELP,
-
                rad_diff::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "debug" => {
-
            term::run_command_args::<rad_debug::Options, _>(
-
                rad_debug::HELP,
-
                rad_debug::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "follow" => {
-
            term::run_command_args::<rad_follow::Options, _>(
-
                rad_follow::HELP,
-
                rad_follow::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "fork" => {
-
            term::run_command_args::<rad_fork::Options, _>(
-
                rad_fork::HELP,
-
                rad_fork::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "help" => {
-
            term::run_command_args::<rad_help::Options, _>(
-
                rad_help::HELP,
-
                rad_help::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "id" => {
-
            term::run_command_args::<rad_id::Options, _>(rad_id::HELP, rad_id::run, args.to_vec());
-
        }
-
        "inbox" => term::run_command_args::<rad_inbox::Options, _>(
-
            rad_inbox::HELP,
-
            rad_inbox::run,
-
            args.to_vec(),
-
        ),
-
        "init" => {
-
            term::run_command_args::<rad_init::Options, _>(
-
                rad_init::HELP,
-
                rad_init::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "inspect" => {
-
            term::run_command_args::<rad_inspect::Options, _>(
-
                rad_inspect::HELP,
-
                rad_inspect::run,
-
                args.to_vec(),
-
            );
+
    fn display_exe(&self) -> impl Display {
+
        match self.exe().into_string() {
+
            Ok(exe) => exe,
+
            Err(exe) => format!("{exe:?}"),
        }
-
        "issue" => {
-
            term::run_command_args::<rad_issue::Options, _>(
-
                rad_issue::HELP,
-
                rad_issue::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "ls" => {
-
            term::run_command_args::<rad_ls::Options, _>(rad_ls::HELP, rad_ls::run, args.to_vec());
-
        }
-
        "node" => {
-
            term::run_command_args::<rad_node::Options, _>(
-
                rad_node::HELP,
-
                rad_node::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "patch" => {
-
            term::run_command_args::<rad_patch::Options, _>(
-
                rad_patch::HELP,
-
                rad_patch::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "path" => {
-
            term::run_command_args::<rad_path::Options, _>(
-
                rad_path::HELP,
-
                rad_path::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "publish" => {
-
            term::run_command_args::<rad_publish::Options, _>(
-
                rad_publish::HELP,
-
                rad_publish::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "clean" => {
-
            term::run_command_args::<rad_clean::Options, _>(
-
                rad_clean::HELP,
-
                rad_clean::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "self" => {
-
            term::run_command_args::<rad_self::Options, _>(
-
                rad_self::HELP,
-
                rad_self::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "sync" => {
-
            term::run_command_args::<rad_sync::Options, _>(
-
                rad_sync::HELP,
-
                rad_sync::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "seed" => {
-
            term::run_command_args::<rad_seed::Options, _>(
-
                rad_seed::HELP,
-
                rad_seed::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "unblock" => {
-
            term::run_command_args::<rad_unblock::Options, _>(
-
                rad_unblock::HELP,
-
                rad_unblock::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "unfollow" => {
-
            term::run_command_args::<rad_unfollow::Options, _>(
-
                rad_unfollow::HELP,
-
                rad_unfollow::run,
-
                args.to_vec(),
-
            );
-
        }
-
        "unseed" => {
-
            term::run_command_args::<rad_unseed::Options, _>(
-
                rad_unseed::HELP,
-
                rad_unseed::run,
-
                args.to_vec(),
-
            );
+
    }
+

+
    fn run(self) -> anyhow::Result<()> {
+
        // This command is deprecated and delegates to `git diff`.
+
        // Even before it was deprecated, it was not printed by
+
        // `rad -h`.
+
        //
+
        // Since it is external, `--help` will delegate to `git diff --help`.
+
        if self.is_diff() {
+
            return diff::run(self.args);
        }
-
        "remote" => term::run_command_args::<rad_remote::Options, _>(
-
            rad_remote::HELP,
-
            rad_remote::run,
-
            args.to_vec(),
-
        ),
-
        "stats" => term::run_command_args::<rad_stats::Options, _>(
-
            rad_stats::HELP,
-
            rad_stats::run,
-
            args.to_vec(),
-
        ),
-
        "watch" => term::run_command_args::<rad_watch::Options, _>(
-
            rad_watch::HELP,
-
            rad_watch::run,
-
            args.to_vec(),
-
        ),
-
        other => {
-
            let exe = format!("{NAME}-{exe}");
-
            let status = process::Command::new(exe).args(args).status();

-
            match status {
-
                Ok(status) => {
-
                    if !status.success() {
-
                        return Err(None);
-
                    }
+
        let status = process::Command::new(self.exe()).args(&self.args).status();
+
        match status {
+
            Ok(status) => {
+
                if !status.success() {
+
                    return Err(anyhow!("`{}` exited with an error.", self.display_exe()));
                }
-
                Err(err) => {
-
                    if let ErrorKind::NotFound = err.kind() {
-
                        return Err(Some(anyhow!(
-
                            "`{other}` is not a command. See `rad --help` for a list of commands.",
-
                        )));
-
                    } else {
-
                        return Err(Some(err.into()));
-
                    }
+
                Ok(())
+
            }
+
            Err(err) => {
+
                if let ErrorKind::NotFound = err.kind() {
+
                    Err(anyhow!(
+
                        "`{}` is not a known command. See `rad --help` for a list of commands.",
+
                        self.display_exe(),
+
                    ))
+
                } else {
+
                    Err(err.into())
                }
            }
        }
    }
-
    Ok(())
}
modified crates/radicle-cli/src/node.rs
@@ -1,6 +1,5 @@
use core::time;
use std::collections::BTreeSet;
-
use std::io;
use std::io::Write;

use radicle::node::sync;
@@ -26,12 +25,14 @@ pub struct SyncSettings {

impl SyncSettings {
    /// Set sync timeout. Defaults to [`DEFAULT_SYNC_TIMEOUT`].
+
    #[must_use]
    pub fn timeout(mut self, timeout: time::Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Set replicas.
+
    #[must_use]
    pub fn replicas(mut self, replicas: sync::ReplicationFactor) -> Self {
        self.replicas = replicas;
        self
@@ -45,6 +46,7 @@ impl SyncSettings {

    /// Use profile to populate sync settings, by adding preferred seeds if no seeds are specified,
    /// and removing the local node from the set.
+
    #[must_use]
    pub fn with_profile(mut self, profile: &Profile) -> Self {
        // If no seeds were specified, add the preferred seeds.
        if self.seeds.is_empty() {
@@ -88,46 +90,7 @@ impl SyncError {
    fn is_connection_err(&self) -> bool {
        match self {
            Self::Node(e) => e.is_connection_err(),
-
            _ => false,
-
        }
-
    }
-
}
-

-
/// 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(()),
+
            Self::Repository(_) | Self::AllSeedsTimedOut | Self::Target(_) => false,
        }
    }
}
@@ -135,9 +98,9 @@ impl io::Write for SyncWriter {
/// 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 +108,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 +136,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 +152,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 +177,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 +185,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 +198,28 @@ where
    let min_replicas = target.replicas().lower_bound();
    let mut spinner = term::spinner_to(
        format!("Found {} seed(s)..", announcer.progress().unsynced()),
-
        reporting.completion.clone(),
        reporting.progress.clone(),
+
        reporting.completion.clone(),
    );

-
    match node.announce(rid, settings.timeout, announcer, |node, progress| {
-
        spinner.message(format!(
-
            "Synced with {}, {} of {} preferred seeds, and {} of at least {} replica(s).",
-
            term::format::node_id_human_compact(node),
-
            term::format::secondary(progress.preferred()),
-
            term::format::secondary(n_preferred_seeds),
-
            term::format::secondary(progress.synced()),
-
            // N.b. the number of replicas could exceed the target if we're
-
            // waiting for preferred seeds
-
            term::format::secondary(min_replicas.max(progress.synced())),
-
        ));
-
    }) {
+
    match node.announce(
+
        rid,
+
        [profile.did().into()],
+
        settings.timeout,
+
        announcer,
+
        |node, progress| {
+
            spinner.message(format!(
+
                "Synced with {}, {} of {} preferred seeds, and {} of at least {} replica(s).",
+
                term::format::node_id_human_compact(node),
+
                term::format::secondary(progress.preferred()),
+
                term::format::secondary(n_preferred_seeds),
+
                term::format::secondary(progress.synced()),
+
                // N.b. the number of replicas could exceed the target if we're
+
                // waiting for preferred seeds
+
                term::format::secondary(min_replicas.max(progress.synced())),
+
            ));
+
        },
+
    ) {
        Ok(result) => {
            spinner.message(format!(
                "Synced with {} seed(s)",
modified crates/radicle-cli/src/pager.rs
@@ -21,9 +21,14 @@ pub fn run(elem: impl Element) -> io::Result<()> {
    let Some(pager) = radicle::profile::env::pager() else {
        return elem.write(Constraint::UNBOUNDED);
    };
+

+
    #[cfg(unix)]
    let Some(parts) = shlex::split(&pager) else {
        return elem.write(Constraint::UNBOUNDED);
    };
+
    #[cfg(windows)]
+
    let parts = winsplit::split(&pager);
+

    let Some((program, args)) = parts.split_first() else {
        return elem.write(Constraint::UNBOUNDED);
    };
modified crates/radicle-cli/src/project.rs
@@ -1,7 +1,7 @@
use radicle::prelude::*;

use crate::git;
-
use radicle::git::RefStr;
+
use radicle::git::fmt::RefStr;
use radicle::node::NodeId;

/// Setup a repository remote and tracking branch.
@@ -22,7 +22,7 @@ impl SetupRemote<'_> {
        &self,
        name: impl AsRef<RefStr>,
        node: NodeId,
-
    ) -> anyhow::Result<(git::Remote, Option<BranchName>)> {
+
    ) -> anyhow::Result<(git::Remote<'_>, Option<BranchName>)> {
        let remote_url = radicle::git::Url::from(self.rid).with_namespace(node);
        let remote_name = name.as_ref();

modified crates/radicle-cli/src/terminal.rs
@@ -1,5 +1,6 @@
-
pub mod args;
-
pub use args::{Args, Error, Help};
+
pub(crate) mod args;
+
pub(crate) use args::Error;
+

pub mod format;
pub mod io;
pub use io::signer;
@@ -11,15 +12,10 @@ pub mod json;
pub mod patch;
pub mod upload_pack;

-
use std::ffi::OsString;
-
use std::process;
-

pub use radicle_term::*;

use radicle::profile::{Home, Profile};

-
use crate::terminal;
-

/// Context passed to all commands.
pub trait Context {
    /// Return the currently active profile, or an error if no profile is active.
@@ -38,89 +34,6 @@ impl Context for Profile {
    }
}

-
/// A command that can be run.
-
pub trait Command<A: Args, C: Context> {
-
    /// Run the command, given arguments and a context.
-
    fn run(self, args: A, context: C) -> anyhow::Result<()>;
-
}
-

-
impl<F, A: Args, C: Context> Command<A, C> for F
-
where
-
    F: FnOnce(A, C) -> anyhow::Result<()>,
-
{
-
    fn run(self, args: A, context: C) -> anyhow::Result<()> {
-
        self(args, context)
-
    }
-
}
-

-
pub fn run_command<A, C>(help: Help, cmd: C) -> !
-
where
-
    A: Args,
-
    C: Command<A, DefaultContext>,
-
{
-
    let args = std::env::args_os().skip(1).collect();
-

-
    run_command_args(help, cmd, args)
-
}
-

-
pub fn run_command_args<A, C>(help: Help, cmd: C, args: Vec<OsString>) -> !
-
where
-
    A: Args,
-
    C: Command<A, DefaultContext>,
-
{
-
    use io as term;
-

-
    let options = match A::from_args(args) {
-
        Ok((opts, unparsed)) => {
-
            if let Err(err) = args::finish(unparsed) {
-
                term::error(err);
-
                process::exit(1);
-
            }
-
            opts
-
        }
-
        Err(err) => {
-
            let hint = match err.downcast_ref::<Error>() {
-
                Some(Error::Help) => {
-
                    help.print();
-
                    process::exit(0);
-
                }
-
                // Print the manual, or the regular help if there's an error.
-
                Some(Error::HelpManual { name }) => {
-
                    let Ok(status) = term::manual(name) else {
-
                        help.print();
-
                        process::exit(0);
-
                    };
-
                    if !status.success() {
-
                        help.print();
-
                        process::exit(0);
-
                    }
-
                    process::exit(status.code().unwrap_or(0));
-
                }
-
                Some(Error::Usage) => {
-
                    term::usage(help.name, help.usage);
-
                    process::exit(1);
-
                }
-
                Some(Error::WithHint { hint, .. }) => Some(hint),
-
                None => None,
-
            };
-
            io::error(format!("rad {}: {err}", help.name));
-

-
            if let Some(hint) = hint {
-
                io::hint(hint);
-
            }
-
            process::exit(1);
-
        }
-
    };
-

-
    match cmd.run(options, DefaultContext) {
-
        Ok(()) => process::exit(0),
-
        Err(err) => {
-
            terminal::fail(help.name, &err);
-
            process::exit(1);
-
        }
-
    }
-
}
-

/// Gets the default profile. Fails if there is no profile.
pub struct DefaultContext;

@@ -132,10 +45,10 @@ impl Context for DefaultContext {
    fn profile(&self) -> Result<Profile, anyhow::Error> {
        match Profile::load() {
            Ok(profile) => Ok(profile),
-
            Err(radicle::profile::Error::NotFound(path)) => Err(args::Error::WithHint {
-
                err: anyhow::anyhow!("Radicle profile not found in '{}'.", path.display()),
-
                hint: "To setup your radicle profile, run `rad auth`.",
-
            }
+
            Err(radicle::profile::Error::NotFound(path)) => Err(args::Error::with_hint(
+
                anyhow::anyhow!("Radicle profile not found in '{}'.", path.display()),
+
                "To setup your radicle profile, run `rad auth`.",
+
            )
            .into()),
            Err(radicle::profile::Error::LoadConfig(e)) => Err(e.into()),
            Err(e) => Err(anyhow::anyhow!("Could not load radicle profile: {e}")),
@@ -143,7 +56,7 @@ impl Context for DefaultContext {
    }
}

-
pub fn fail(_name: &str, error: &anyhow::Error) {
+
pub fn fail(error: &anyhow::Error) {
    let err = error.to_string();
    let err = err.trim_end();

modified crates/radicle-cli/src/terminal/args.rs
@@ -1,205 +1,128 @@
-
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 {
-
        err: anyhow::Error,
-
        hint: &'static str,
-
    },
+
    WithHint { err: anyhow::Error, hint: String },
+
}
+

+
impl Error {
+
    pub fn with_hint<E, H>(err: E, hint: H) -> Self
+
    where
+
        E: Into<anyhow::Error>,
+
        H: ToString,
+
    {
+
        Self::WithHint {
+
            err: err.into(),
+
            hint: hint.to_string(),
+
        }
+
    }
}

-
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();
-

-
        match Self::from_args(args) {
-
            Ok((opts, unparsed)) => {
-
                self::finish(unparsed)?;
+
impl std::str::FromStr for BlockTarget {
+
    type Err = BlockTargetParseError;

-
                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))
-
}
+
#[derive(Clone, Debug)]
+
pub(crate) struct ScopeParser;

-
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))
-
}
+
impl TypedValueParser for ScopeParser {
+
    type Value = Scope;

-
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))
-
}
-

-
pub fn number(val: &OsString) -> anyhow::Result<usize> {
-
    let val = val.to_string_lossy();
-
    usize::from_str(&val).map_err(|_| anyhow!("invalid number '{}'", val))
-
}
-

-
pub fn seconds(val: &OsString) -> anyhow::Result<time::Duration> {
-
    let val = val.to_string_lossy();
-
    let secs = u64::from_str(&val).map_err(|_| anyhow!("invalid number of seconds '{}'", val))?;
-

-
    Ok(time::Duration::from_secs(secs))
-
}
-

-
pub fn milliseconds(val: &OsString) -> anyhow::Result<time::Duration> {
-
    let val = val.to_string_lossy();
-
    let secs =
-
        u64::from_str(&val).map_err(|_| anyhow!("invalid number of milliseconds '{}'", val))?;
-

-
    Ok(time::Duration::from_millis(secs))
-
}
-

-
pub fn string(val: &OsString) -> String {
-
    val.to_string_lossy().to_string()
-
}
-

-
pub fn rev(val: &OsString) -> anyhow::Result<Rev> {
-
    let s = val.to_str().ok_or(anyhow!("invalid git rev {val:?}"))?;
-
    Ok(Rev::from(s.to_owned()))
-
}
-

-
pub fn oid(val: &OsString) -> anyhow::Result<Oid> {
-
    let s = string(val);
-
    let o = radicle::git::Oid::from_str(&s).map_err(|_| anyhow!("invalid git oid '{s}'"))?;
+
    fn parse_ref(
+
        &self,
+
        cmd: &clap::Command,
+
        arg: Option<&clap::Arg>,
+
        value: &std::ffi::OsStr,
+
    ) -> Result<Self::Value, clap::Error> {
+
        <Scope as std::str::FromStr>::from_str.parse_ref(cmd, arg, value)
+
    }

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

-
pub fn alias(val: &OsString) -> anyhow::Result<Alias> {
-
    let val = val.as_os_str();
-
    let val = val
-
        .to_str()
-
        .ok_or_else(|| anyhow!("alias must be valid UTF-8"))?;
+
#[cfg(test)]
+
mod test {
+
    use std::str::FromStr;

-
    Alias::from_str(val).map_err(|e| e.into())
-
}
+
    use super::BlockTarget;
+
    use super::BlockTargetParseError;

-
pub fn issue(val: &OsString) -> anyhow::Result<issue::IssueId> {
-
    let val = val.to_string_lossy();
-
    issue::IssueId::from_str(&val).map_err(|_| anyhow!("invalid Issue ID '{}'", val))
-
}
+
    #[test]
+
    fn should_parse_nid() {
+
        let target = BlockTarget::from_str("z6MkiswaKJ85vafhffCGBu2gdBsYoDAyHVBWRxL3j297fwS9");
+
        assert!(target.is_ok())
+
    }

-
pub fn patch(val: &OsString) -> anyhow::Result<patch::PatchId> {
-
    let val = val.to_string_lossy();
-
    patch::PatchId::from_str(&val).map_err(|_| anyhow!("invalid Patch ID '{}'", val))
-
}
+
    #[test]
+
    fn should_parse_rid() {
+
        let target = BlockTarget::from_str("rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH");
+
        assert!(target.is_ok())
+
    }

-
pub fn cob(val: &OsString) -> anyhow::Result<cob::ObjectId> {
-
    let val = val.to_string_lossy();
-
    cob::ObjectId::from_str(&val).map_err(|_| anyhow!("invalid Object ID '{}'", val))
+
    #[test]
+
    fn should_not_parse() {
+
        let err = BlockTarget::from_str("bee").unwrap_err();
+
        assert!(matches!(err, BlockTargetParseError { .. }));
+
    }
}
modified crates/radicle-cli/src/terminal/cob.rs
@@ -51,6 +51,7 @@ pub mod migrate {
    use super::MigrateSpinner;

    /// Display migration progress via a spinner.
+
    #[must_use]
    pub fn spinner() -> MigrateSpinner {
        MigrateSpinner::default()
    }
@@ -102,12 +103,12 @@ where

/// Adds a hint to the COB out-of-date database error.
fn with_hint(e: profile::Error) -> anyhow::Error {
+
    // There are many types that aren't `profile::Error::CobsCache`; specifying them all in an
+
    // error path seems overly verbose with little value.
+
    #[allow(clippy::wildcard_enum_match_arm)]
    match e {
        profile::Error::CobsCache(cob::cache::Error::OutOfDate) => {
-
            anyhow::Error::from(terminal::args::Error::WithHint {
-
                err: e.into(),
-
                hint: MIGRATION_HINT,
-
            })
+
            terminal::args::Error::with_hint(e, MIGRATION_HINT).into()
        }
        e => anyhow::Error::from(e),
    }
modified crates/radicle-cli/src/terminal/format.rs
@@ -8,7 +8,7 @@ pub use radicle_term::{style, Paint};
use radicle::cob::ObjectId;
use radicle::identity::Visibility;
use radicle::node::policy::Policy;
-
use radicle::node::{Address, Alias, AliasStore, HostName, NodeId};
+
use radicle::node::{Alias, AliasStore, NodeId};
use radicle::prelude::Did;
use radicle::profile::{env, Profile};
use radicle::storage::RefUpdate;
@@ -17,6 +17,7 @@ use radicle_term::element::Line;
use crate::terminal as term;

/// Format a node id to be more compact.
+
#[must_use]
pub fn node_id_human_compact(node: &NodeId) -> Paint<String> {
    let node = node.to_human();
    let start = node.chars().take(7).collect::<String>();
@@ -26,31 +27,11 @@ pub fn node_id_human_compact(node: &NodeId) -> Paint<String> {
}

/// Format a node id.
+
#[must_use]
pub fn node_id_human(node: &NodeId) -> Paint<String> {
    Paint::new(node.to_human())
}

-
pub fn addr_compact(address: &Address) -> Paint<String> {
-
    let host = match address.host() {
-
        HostName::Ip(ip) => ip.to_string(),
-
        HostName::Dns(dns) => dns.clone(),
-
        HostName::Tor(onion) => {
-
            let onion = onion.to_string();
-
            let start = onion.chars().take(8).collect::<String>();
-
            let end = onion
-
                .chars()
-
                .skip(onion.len() - 8 - ".onion".len())
-
                .collect::<String>();
-
            format!("{start}…{end}")
-
        }
-
        _ => unreachable!(),
-
    };
-

-
    let port = address.port().to_string();
-

-
    Paint::new(format!("{host}:{port}"))
-
}
-

/// Format a git Oid.
pub fn oid(oid: impl Into<radicle::git::Oid>) -> Paint<String> {
    Paint::new(format!("{:.7}", oid.into()))
@@ -72,17 +53,20 @@ pub fn command<D: fmt::Display>(cmd: D) -> Paint<String> {
}

/// Format a COB id.
+
#[must_use]
pub fn cob(id: &ObjectId) -> Paint<String> {
    Paint::new(format!("{:.7}", id.to_string()))
}

/// Format a DID.
+
#[must_use]
pub fn did(did: &Did) -> Paint<String> {
    let nid = did.as_key().to_human();
    Paint::new(format!("{}…{}", &nid[..7], &nid[nid.len() - 7..]))
}

/// Format a Visibility.
+
#[must_use]
pub fn visibility(v: &Visibility) -> Paint<&str> {
    match v {
        Visibility::Public => term::format::positive("public"),
@@ -91,6 +75,7 @@ pub fn visibility(v: &Visibility) -> Paint<&str> {
}

/// Format a policy.
+
#[must_use]
pub fn policy(p: &Policy) -> Paint<String> {
    match p {
        Policy::Allow => term::format::positive(p.to_string()),
@@ -108,6 +93,7 @@ pub fn timestamp(time: impl Into<LocalTime>) -> Paint<String> {
    Paint::new(fmt.convert(duration.into()))
}

+
#[must_use]
pub fn bytes(size: usize) -> Paint<String> {
    const KB: usize = 1024;
    const MB: usize = 1024usize.pow(2);
@@ -125,6 +111,7 @@ pub fn bytes(size: usize) -> Paint<String> {
}

/// Format a ref update.
+
#[must_use]
pub fn ref_update(update: &RefUpdate) -> Paint<&'static str> {
    match update {
        RefUpdate::Updated { .. } => term::format::tertiary("updated"),
@@ -134,6 +121,7 @@ pub fn ref_update(update: &RefUpdate) -> Paint<&'static str> {
    }
}

+
#[must_use]
pub fn ref_update_verbose(update: &RefUpdate) -> Paint<String> {
    match update {
        RefUpdate::Created { name, .. } => format!(
@@ -175,6 +163,7 @@ pub struct Identity<'a> {
}

impl<'a> Identity<'a> {
+
    #[must_use]
    pub fn new(profile: &'a Profile) -> Self {
        Self {
            profile,
@@ -183,11 +172,13 @@ impl<'a> Identity<'a> {
        }
    }

+
    #[must_use]
    pub fn short(mut self) -> Self {
        self.short = true;
        self
    }

+
    #[must_use]
    pub fn styled(mut self) -> Self {
        self.styled = true;
        self
@@ -227,6 +218,7 @@ pub struct Author<'a> {
}

impl<'a> Author<'a> {
+
    #[must_use]
    pub fn new(nid: &'a NodeId, profile: &Profile, verbose: bool) -> Author<'a> {
        let alias = profile.alias(nid);

@@ -238,10 +230,12 @@ impl<'a> Author<'a> {
        }
    }

+
    #[must_use]
    pub fn alias(&self) -> Option<term::Label> {
        self.alias.as_ref().map(|a| a.to_string().into())
    }

+
    #[must_use]
    pub fn you(&self) -> Option<term::Label> {
        if self.you {
            Some(term::format::primary("(you)").dim().italic().into())
@@ -256,6 +250,7 @@ impl<'a> Author<'a> {
    ///   * `(<did>, (you))` -- the `Author` is the local peer and has no alias
    ///   * `(<alias>, <did>)` -- the `Author` is another peer and has an alias
    ///   * `(<blank>, <did>)` -- the `Author` is another peer and has no alias
+
    #[must_use]
    pub fn labels(self) -> (term::Label, term::Label) {
        let node_id = if self.verbose {
            term::format::node_id_human(self.nid)
@@ -274,6 +269,7 @@ impl<'a> Author<'a> {
        (alias, author)
    }

+
    #[must_use]
    pub fn line(self) -> Line {
        let (alias, author) = self.labels();
        Line::spaced([alias, author])
@@ -283,6 +279,7 @@ impl<'a> Author<'a> {
/// HTML-related formatting.
pub mod html {
    /// Comment a string with HTML comments.
+
    #[must_use]
    pub fn commented(s: &str) -> String {
        format!("<!--\n{s}\n-->")
    }
@@ -290,6 +287,7 @@ pub mod html {
    /// Remove html style comments from a string.
    ///
    /// The HTML comments must start at the beginning of a line and stop at the end.
+
    #[must_use]
    pub fn strip_comments(s: &str) -> String {
        let ends_with_newline = s.ends_with('\n');
        let mut is_comment = false;
@@ -323,6 +321,7 @@ pub mod issue {
    use radicle::issue::{CloseReason, State};

    /// Format issue state.
+
    #[must_use]
    pub fn state(s: &State) -> term::Paint<String> {
        match s {
            State::Open => term::format::positive(s.to_string()),
@@ -341,6 +340,7 @@ pub mod patch {
    use super::*;
    use radicle::patch::{State, Verdict};

+
    #[must_use]
    pub fn verdict(v: Option<Verdict>) -> term::Paint<String> {
        match v {
            Some(Verdict::Accept) => term::PREFIX_SUCCESS.into(),
@@ -350,6 +350,7 @@ pub mod patch {
    }

    /// Format patch state.
+
    #[must_use]
    pub fn state(s: &State) -> term::Paint<String> {
        match s {
            State::Draft => term::format::dim(s.to_string()),
@@ -366,6 +367,7 @@ pub mod identity {
    use radicle::cob::identity::State;

    /// Format identity revision state.
+
    #[must_use]
    pub fn state(s: &State) -> term::Paint<String> {
        match s {
            State::Active => term::format::tertiary(s.to_string()),
modified crates/radicle-cli/src/terminal/highlight.rs
@@ -59,6 +59,7 @@ impl Default for Theme {

impl Theme {
    /// Get the named color.
+
    #[must_use]
    pub fn color(&self, color: &'static str) -> term::Color {
        if let Some(c) = (self.color)(color) {
            c
@@ -68,6 +69,7 @@ impl Theme {
    }

    /// Return the color of a syntax group.
+
    #[must_use]
    pub fn highlight(&self, group: &'static str) -> Option<term::Color> {
        let color = match group {
            "keyword" => self.color("red"),
@@ -145,9 +147,11 @@ impl Builder {
                    }
                }
                ts::HighlightEvent::HighlightStart(h) => {
-
                    let name = HIGHLIGHTS[h.0];
-
                    let style =
-
                        term::Style::default().fg(theme.highlight(name).unwrap_or_default());
+
                    let color = HIGHLIGHTS
+
                        .get(h.0)
+
                        .and_then(|name| theme.highlight(name))
+
                        .unwrap_or_default();
+
                    let style = term::Style::default().fg(color);

                    self.advance();
                    self.styles.push(style);
modified crates/radicle-cli/src/terminal/io.rs
@@ -21,6 +21,7 @@ pub struct PassphraseValidator {

impl PassphraseValidator {
    /// Create a new validator.
+
    #[must_use]
    pub fn new(keystore: Keystore) -> Self {
        Self { keystore }
    }
@@ -45,9 +46,15 @@ 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)? {
        Some(p) => p,
@@ -72,7 +79,7 @@ pub fn comment_select(issue: &Issue) -> anyhow::Result<(&CommentId, &Comment)> {
        (0..comments.len()).collect(),
    )
    .with_render_config(*CONFIG)
-
    .with_formatter(&|i| comments[i.index].1.body().to_owned())
+
    .with_formatter(&|i| comments.get(i.index).unwrap().1.body().to_owned())
    .prompt()?;

    comments
modified crates/radicle-cli/src/terminal/patch.rs
@@ -83,18 +83,19 @@ impl Message {
            placeholder.push_str(title.as_ref());
            placeholder.push('\n');
        }
-
        if let Some(description) = description {
+
        if let Some(description) = description
+
            .as_deref()
+
            .map(str::trim)
+
            .filter(|description| !description.is_empty())
+
        {
            placeholder.push('\n');
-
            placeholder.push_str(description.trim());
+
            placeholder.push_str(description);
            placeholder.push('\n');
        }
        placeholder.push_str(help);

        let output = Self::Edit.get(&placeholder)?;
-
        let (title, description) = match output.split_once("\n\n") {
-
            Some((x, y)) => (x, y),
-
            None => (output.as_str(), ""),
-
        };
+
        let (title, description) = output.split_once("\n\n").unwrap_or((output.as_str(), ""));

        let Ok(title) = Title::new(title) else {
            return Ok(None);
@@ -112,6 +113,12 @@ impl Message {
    }
}

+
impl From<String> for Message {
+
    fn from(value: String) -> Self {
+
        Message::Text(value)
+
    }
+
}
+

pub const PATCH_MSG: &str = r#"
<!--
Please enter a patch message for your changes. An empty
@@ -133,6 +140,7 @@ blank is also okay.

/// Combine the title and description fields to display to the user.
#[inline]
+
#[must_use]
pub fn message(title: &str, description: &str) -> String {
    format!("{title}\n\n{description}").trim().to_string()
}
@@ -181,8 +189,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 +206,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 +227,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 +280,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 +303,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 +314,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 +367,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 +399,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 +441,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 +466,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 +482,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.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 +622,6 @@ mod test {
    fn test_update_display_message() {
        let tmpdir = tempfile::tempdir().unwrap();
        let (repo, commit_0) = fixtures::repository(&tmpdir);
-
        let commit_0 = commit_0.into();

        let commit_1 = commit(&repo, &refname!("feature"), &commit_0, "commit 1\n");
        let commit_2 = commit(&repo, &refname!("feature"), &commit_1, "commit 2\n");
modified crates/radicle-cli/src/terminal/patch/common.rs
@@ -1,7 +1,7 @@
use anyhow::anyhow;

use radicle::git;
-
use radicle::git::raw::Oid;
+
use radicle::git::Oid;
use radicle::prelude::*;
use radicle::storage::git::Repository;

@@ -9,7 +9,7 @@ use crate::terminal as term;

/// Give the oid of the branch or an appropriate error.
#[inline]
-
pub fn branch_oid(branch: &git::raw::Branch) -> anyhow::Result<git::Oid> {
+
pub fn branch_oid(branch: &git::raw::Branch) -> anyhow::Result<Oid> {
    let oid = branch
        .get()
        .target()
@@ -18,7 +18,7 @@ pub fn branch_oid(branch: &git::raw::Branch) -> anyhow::Result<git::Oid> {
}

#[inline]
-
fn get_branch(git_ref: git::Qualified) -> git::RefString {
+
fn get_branch(git_ref: git::fmt::Qualified) -> git::fmt::RefString {
    let (_, _, head, tail) = git_ref.non_empty_components();
    std::iter::once(head).chain(tail).collect()
}
@@ -28,16 +28,18 @@ fn get_branch(git_ref: git::Qualified) -> git::RefString {
pub fn get_merge_target(
    storage: &Repository,
    head_branch: &git::raw::Branch,
-
) -> anyhow::Result<(git::RefString, git::Oid)> {
+
) -> anyhow::Result<(git::fmt::RefString, git::Oid)> {
    let (qualified_ref, target_oid) = storage.canonical_head()?;
    let head_oid = branch_oid(head_branch)?;
-
    let merge_base = storage.raw().merge_base(*head_oid, *target_oid)?;
+
    let merge_base = storage
+
        .raw()
+
        .merge_base(head_oid.into(), target_oid.into())?;

-
    if head_oid == merge_base.into() {
+
    if head_oid == merge_base {
        anyhow::bail!("commits are already included in the target branch; nothing to do");
    }

-
    Ok((get_branch(qualified_ref), (*target_oid).into()))
+
    Ok((get_branch(qualified_ref), (target_oid)))
}

/// Get the diff stats between two commits.
@@ -47,8 +49,8 @@ pub fn diff_stats(
    old: &Oid,
    new: &Oid,
) -> Result<git::raw::DiffStats, git::raw::Error> {
-
    let old = repo.find_commit(*old)?;
-
    let new = repo.find_commit(*new)?;
+
    let old = repo.find_commit(old.into())?;
+
    let new = repo.find_commit(new.into())?;
    let old_tree = old.tree()?;
    let new_tree = new.tree()?;
    let mut diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?;
@@ -64,7 +66,7 @@ pub fn ahead_behind(
    revision_oid: Oid,
    head_oid: Oid,
) -> anyhow::Result<term::Line> {
-
    let (a, b) = repo.graph_ahead_behind(revision_oid, head_oid)?;
+
    let (a, b) = repo.graph_ahead_behind(revision_oid.into(), head_oid.into())?;
    if a == 0 && b == 0 {
        return Ok(term::Line::new(term::format::dim("up to date")));
    }
@@ -88,7 +90,7 @@ pub fn branches(target: &Oid, repo: &git::raw::Repository) -> anyhow::Result<Vec
            continue;
        }
        if let (Some(oid), Some(name)) = (&r.target(), &r.shorthand()) {
-
            if oid == target {
+
            if target == oid {
                branches.push(name.to_string());
            };
        };
@@ -97,7 +99,7 @@ pub fn branches(target: &Oid, repo: &git::raw::Repository) -> anyhow::Result<Vec
}

#[inline]
-
pub fn try_branch(reference: git::raw::Reference<'_>) -> anyhow::Result<git::raw::Branch> {
+
pub fn try_branch(reference: git::raw::Reference<'_>) -> anyhow::Result<git::raw::Branch<'_>> {
    let branch = if reference.is_branch() {
        git::raw::Branch::wrap(reference)
    } else {
modified crates/radicle-cli/src/terminal/patch/timeline.rs
@@ -9,158 +9,63 @@ use radicle::profile::Profile;
use crate::terminal as term;
use crate::terminal::format::Author;

-
pub fn timeline<'a>(
-
    profile: &'a Profile,
-
    patch: &'a Patch,
-
) -> impl Iterator<Item = term::Line> + 'a {
-
    Timeline::build(profile, patch).into_lines(profile)
-
}
-

/// The timeline of a [`Patch`].
///
-
/// A `Patch` will always have opened with a root revision and may
+
/// A [`Patch`] will always have opened with a root revision and may
/// have a series of revisions that update the patch.
///
-
/// The function, [`timeline`], builds a `Timeline` and converts it
-
/// into a series of [`term::Line`]s.
-
struct Timeline<'a> {
-
    opened: Opened<'a>,
-
    revisions: Vec<RevisionEntry<'a>>,
-
}
+
/// This function converts it into a series of [`term::Line`]s for
+
/// display.
+
pub fn timeline<'a>(
+
    profile: &'a Profile,
+
    patch: &'a Patch,
+
    verbose: bool,
+
) -> impl Iterator<Item = term::Line> + 'a {
+
    let mut revisions = patch
+
        .revisions()
+
        .map(|(id, revision)| {
+
            (
+
                revision.timestamp(),
+
                RevisionEntry::from_revision(patch, id, revision, profile, verbose),
+
            )
+
        })
+
        .collect::<Vec<_>>();

-
impl<'a> Timeline<'a> {
-
    fn build(profile: &Profile, patch: &'a Patch) -> Self {
-
        let opened = Opened::from_patch(patch, profile);
-
        let mut revisions = patch
-
            .revisions()
-
            .skip(1) // skip the root revision since it's handled in `Opened::from_patch`
-
            .map(|(id, revision)| {
-
                (
-
                    revision.timestamp(),
-
                    RevisionEntry::from_revision(patch, id, revision, profile),
-
                )
-
            })
-
            .collect::<Vec<_>>();
-
        revisions.sort_by_key(|(t, _)| *t);
-
        Timeline {
-
            opened,
-
            revisions: revisions.into_iter().map(|(_, e)| e).collect(),
-
        }
-
    }
+
    revisions.sort_by_key(|(t, _)| *t);

-
    fn into_lines(self, profile: &'a Profile) -> impl Iterator<Item = term::Line> + 'a {
-
        self.opened.into_lines(profile).chain(
-
            self.revisions
-
                .into_iter()
-
                .flat_map(|r| r.into_lines(profile)),
-
        )
-
    }
+
    revisions
+
        .into_iter()
+
        .map(|(_, e)| e)
+
        .flat_map(move |r| r.into_lines(profile, verbose))
}

-
/// The root `Revision` of the `Patch`.
-
struct Opened<'a> {
-
    /// The `Author` of the patch.
+
/// A revision entry in the timeline.
+
///
+
/// We do not distinguish between revisions created by the original author and
+
/// others, and also not between the initial revision and others. This tends to
+
/// confuse more than it helps.
+
struct RevisionEntry<'a> {
+
    /// Whether this entry is about the initial [`Revision`] of the patch.
+
    is_initial: bool,
+
    /// The [`Author`] that created the [`Revision`].
    author: Author<'a>,
-
    /// When the patch was created.
+
    /// When the [`Revision`] was created.
    timestamp: cob::Timestamp,
-
    /// The commit head of the `Revision`.
+
    /// The id of the [`Revision`].
+
    id: RevisionId,
+
    /// The commit head of the [`Revision`].
    head: git::Oid,
-
    /// Any updates performed on the root `Revision`.
+
    /// All [`Update`]s that occurred on the [`Revision`].
    updates: Vec<Update<'a>>,
}

-
impl<'a> Opened<'a> {
-
    fn from_patch(patch: &'a Patch, profile: &Profile) -> Self {
-
        let (root, revision) = patch.root();
-
        let mut updates = Vec::new();
-
        updates.extend(revision.reviews().map(|(_, review)| {
-
            (
-
                review.timestamp(),
-
                Update::Reviewed {
-
                    review: review.clone(),
-
                },
-
            )
-
        }));
-
        updates.extend(patch.merges().filter_map(|(nid, merge)| {
-
            if merge.revision == root {
-
                Some((
-
                    merge.timestamp,
-
                    Update::Merged {
-
                        author: Author::new(nid, profile, false),
-
                        merge: merge.clone(),
-
                    },
-
                ))
-
            } else {
-
                None
-
            }
-
        }));
-
        updates.sort_by_key(|(t, _)| *t);
-
        Opened {
-
            author: Author::new(&patch.author().id, profile, false),
-
            timestamp: patch.timestamp(),
-
            head: revision.head(),
-
            updates: updates.into_iter().map(|(_, up)| up).collect(),
-
        }
-
    }
-

-
    fn into_lines(self, profile: &'a Profile) -> impl Iterator<Item = term::Line> + 'a {
-
        iter::once(
-
            term::Line::spaced([
-
                term::format::positive("●").into(),
-
                term::format::default("opened by").into(),
-
            ])
-
            .space()
-
            .extend(self.author.line())
-
            .space()
-
            .extend(term::Line::spaced([
-
                term::format::parens(term::format::secondary(term::format::oid(self.head))).into(),
-
                term::format::dim(term::format::timestamp(self.timestamp)).into(),
-
            ])),
-
        )
-
        .chain(self.updates.into_iter().map(|up| {
-
            term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
-
                .extend(up.into_line(profile))
-
        }))
-
    }
-
}
-

-
/// A revision entry in the [`Timeline`].
-
enum RevisionEntry<'a> {
-
    /// An `Updated` entry means that the original author of the
-
    /// `Patch` created a new revision.
-
    Updated {
-
        /// When the `Revision` was created.
-
        timestamp: cob::Timestamp,
-
        /// The id of the `Revision`.
-
        id: RevisionId,
-
        /// The commit head of the `Revision`.
-
        head: git::Oid,
-
        /// All [`Update`]s that occurred on the `Revision`.
-
        updates: Vec<Update<'a>>,
-
    },
-
    /// A `Revised` entry means that an author other than the original
-
    /// author of the `Patch` created a new revision.
-
    Revised {
-
        /// The `Author` that created the `Revision` (that is not the
-
        /// `Patch` author).
-
        author: Author<'a>,
-
        /// When the `Revision` was created.
-
        timestamp: cob::Timestamp,
-
        /// The id of the `Revision`.
-
        id: RevisionId,
-
        /// The commit head of the `Revision`.
-
        head: git::Oid,
-
        /// All [`Update`]s that occurred on the `Revision`.
-
        updates: Vec<Update<'a>>,
-
    },
-
}
-

impl<'a> RevisionEntry<'a> {
    fn from_revision(
        patch: &'a Patch,
        id: RevisionId,
        revision: &'a Revision,
        profile: &Profile,
+
        verbose: bool,
    ) -> Self {
        let mut updates = Vec::new();
        updates.extend(revision.reviews().map(|(_, review)| {
@@ -176,8 +81,12 @@ impl<'a> RevisionEntry<'a> {
                Some((
                    merge.timestamp,
                    Update::Merged {
-
                        author: Author::new(nid, profile, false),
-
                        merge: merge.clone(),
+
                        author: Author::new(nid, profile, verbose),
+
                        merge: if merge.commit != revision.head() {
+
                            Some(merge.clone())
+
                        } else {
+
                            None
+
                        },
                    },
                ))
            } else {
@@ -186,84 +95,58 @@ impl<'a> RevisionEntry<'a> {
        }));
        updates.sort_by_key(|(t, _)| *t);

-
        if revision.author() == patch.author() {
-
            RevisionEntry::Updated {
-
                timestamp: revision.timestamp(),
-
                id,
-
                head: revision.head(),
-
                updates: updates.into_iter().map(|(_, up)| up).collect(),
-
            }
-
        } else {
-
            RevisionEntry::Revised {
-
                author: Author::new(&revision.author().id, profile, false),
-
                timestamp: revision.timestamp(),
-
                id,
-
                head: revision.head(),
-
                updates: updates.into_iter().map(|(_, up)| up).collect(),
-
            }
-
        }
-
    }
-

-
    fn into_lines(self, profile: &'a Profile) -> Vec<term::Line> {
-
        match self {
-
            RevisionEntry::Updated {
-
                timestamp,
-
                id,
-
                head,
-
                updates,
-
            } => Self::updated(profile, timestamp, id, head, updates).collect(),
-
            RevisionEntry::Revised {
-
                author,
-
                timestamp,
-
                id,
-
                head,
-
                updates,
-
            } => Self::revised(profile, author, timestamp, id, head, updates).collect(),
+
        RevisionEntry {
+
            is_initial: patch.root().0 == id,
+
            author: Author::new(&revision.author().id, profile, verbose),
+
            timestamp: revision.timestamp(),
+
            id,
+
            head: revision.head(),
+
            updates: updates.into_iter().map(|(_, up)| up).collect(),
        }
    }

-
    fn updated(
+
    fn into_lines(
+
        self,
        profile: &'a Profile,
-
        timestamp: cob::Timestamp,
-
        id: RevisionId,
-
        head: git::Oid,
-
        updates: Vec<Update<'a>>,
+
        verbose: bool,
    ) -> impl Iterator<Item = term::Line> + 'a {
-
        iter::once(term::Line::spaced([
-
            term::format::tertiary("↑").into(),
-
            term::format::default("updated to").into(),
-
            term::format::dim(id).into(),
-
            term::format::parens(term::format::secondary(term::format::oid(head))).into(),
-
            term::format::dim(term::format::timestamp(timestamp)).into(),
-
        ]))
-
        .chain(updates.into_iter().map(|up| {
-
            term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
-
                .extend(up.into_line(profile))
-
        }))
-
    }
+
        use term::{format::*, *};

-
    fn revised(
-
        profile: &'a Profile,
-
        author: Author<'a>,
-
        timestamp: cob::Timestamp,
-
        id: RevisionId,
-
        head: git::Oid,
-
        updates: Vec<Update<'a>>,
-
    ) -> impl Iterator<Item = term::Line> + 'a {
-
        let (alias, nid) = author.labels();
-
        iter::once(term::Line::spaced([
-
            term::format::tertiary("*").into(),
-
            term::format::default("revised by").into(),
-
            alias,
-
            nid,
-
            term::format::default("in").into(),
-
            term::format::dim(term::format::oid(id)).into(),
-
            term::format::parens(term::format::secondary(term::format::oid(head))).into(),
-
            term::format::dim(term::format::timestamp(timestamp)).into(),
-
        ]))
-
        .chain(updates.into_iter().map(|up| {
-
            term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
-
                .extend(up.into_line(profile))
+
        let id: Label = if verbose {
+
            self.id.to_string().into()
+
        } else {
+
            oid(self.id).into()
+
        };
+

+
        let icon = if self.is_initial {
+
            positive("●")
+
        } else {
+
            tertiary("↑")
+
        };
+

+
        let line = Line::spaced([icon.into(), dim("Revision").into(), id]).space();
+

+
        let line = line
+
            .item(dim(if verbose { "with head" } else { "@" }))
+
            .space();
+

+
        let line = line.item(secondary(if verbose {
+
            Paint::new(self.head.to_string())
+
        } else {
+
            oid(self.head)
+
        }));
+

+
        iter::once(
+
            line.space()
+
                .extend([dim("by").into()])
+
                .space()
+
                .extend(self.author.line())
+
                .space()
+
                .item(dim(timestamp(self.timestamp))),
+
        )
+
        .chain(self.updates.into_iter().map(move |up| {
+
            Line::spaced([Label::space(), Label::from("└─ ")])
+
                .extend(up.into_line(profile, verbose))
        }))
    }
}
@@ -273,56 +156,81 @@ enum Update<'a> {
    /// A revision of the patch was reviewed.
    Reviewed { review: Review },
    /// A revision of the patch was merged.
-
    Merged { author: Author<'a>, merge: Merge },
+
    Merged {
+
        author: Author<'a>,
+
        /// If the merge is none, this means that it was a fast-forward merge.
+
        merge: Option<Merge>,
+
    },
}

impl Update<'_> {
-
    fn timestamp(&self) -> cob::Timestamp {
-
        match self {
-
            Update::Reviewed { review } => review.timestamp(),
-
            Update::Merged { merge, .. } => merge.timestamp,
-
        }
-
    }
+
    fn into_line(self, profile: &Profile, verbose: bool) -> term::Line {
+
        use term::{format::*, *};

-
    fn into_line(self, profile: &Profile) -> term::Line {
-
        let timestamp = self.timestamp();
-
        let mut line = match self {
+
        match self {
            Update::Reviewed { review } => {
-
                let verdict = review.verdict();
-
                let verdict_symbol = match verdict {
-
                    Some(Verdict::Accept) => term::PREFIX_SUCCESS,
-
                    Some(Verdict::Reject) => term::PREFIX_ERROR,
-
                    None => term::format::dim("⋄"),
-
                };
-
                let verdict_verb = match verdict {
-
                    Some(Verdict::Accept) => term::format::default("accepted"),
-
                    Some(Verdict::Reject) => term::format::default("rejected"),
-
                    None => term::format::default("reviewed"),
+
                let by = " ".repeat(if verbose { 0 } else { 13 }) + "by";
+

+
                let (symbol, verb) = match review.verdict() {
+
                    Some(Verdict::Accept) => (PREFIX_SUCCESS, positive("accepted")),
+
                    Some(Verdict::Reject) => (PREFIX_ERROR, negative("rejected")),
+
                    None => (dim("⋄"), default("reviewed")),
                };
-
                term::Line::spaced([
-
                    verdict_symbol.into(),
-
                    verdict_verb.into(),
-
                    term::format::default("by").into(),
-
                ])
-
                .space()
-
                .extend(Author::new(&review.author().id.into(), profile, false).line())
+

+
                Line::spaced([symbol.into(), verb.into(), dim(by).into()])
+
                    .space()
+
                    .extend(Author::new(&review.author().id.into(), profile, verbose).line())
+
                    .space()
+
                    .item(dim(timestamp(review.timestamp())))
            }
            Update::Merged { author, merge } => {
+
                // The additional whitespace after makes it align, see:
+
                // - "merged  "
+
                // - "accepted"
+
                // - "rejected"
+
                // This is less noisy to look at in the terminal.
+
                const MERGED: &str = "merged  ";
+

+
                let at_commit = if !verbose { " @ " } else { " at commit " };
+

                let (alias, nid) = author.labels();
-
                term::Line::spaced([
-
                    term::PREFIX_SUCCESS.bold().into(),
-
                    term::format::default("merged by").into(),
-
                    alias,
-
                    nid,
-
                    term::format::default("at revision").into(),
-
                    term::format::dim(term::format::oid(merge.revision)).into(),
-
                    term::format::parens(term::format::secondary(term::format::oid(merge.commit)))
-
                        .into(),
-
                ])
+

+
                let (commit, timestamp) = match merge {
+
                    Some(merge) => (
+
                        Line::spaced([dim(at_commit).into(), secondary(oid(merge.commit)).into()])
+
                            .space(),
+
                        timestamp(merge.timestamp),
+
                    ),
+
                    None => {
+
                        let mut line = Line::blank();
+
                        if !verbose {
+
                            const LENGTH_OF_SHORT_COMMIT_HASH: usize = 7;
+
                            const LENGTH_OF_SPACES: usize = 2;
+
                            line.pad(
+
                                2 // alignment
+
                                    + 2 // parens
+
                                    + LENGTH_OF_SHORT_COMMIT_HASH
+
                                    + LENGTH_OF_SPACES,
+
                            );
+
                        }
+
                        (line, "".into())
+
                    }
+
                };
+

+
                Line::blank()
+
                    .item(PREFIX_SUCCESS.bold())
+
                    .space()
+
                    .item(Label::from(positive(MERGED)))
+
                    .space()
+
                    .extend(commit)
+
                    .item(dim("by"))
+
                    .space()
+
                    .item(alias)
+
                    .space()
+
                    .item(nid)
+
                    .space()
+
                    .item(timestamp)
            }
-
        };
-
        line.push(term::Label::space());
-
        line.push(term::format::dim(term::format::timestamp(timestamp)));
-
        line
+
        }
    }
}
modified crates/radicle-cli/src/terminal/upload_pack.rs
@@ -22,6 +22,7 @@ impl Default for UploadPack {

impl UploadPack {
    /// Construct an empty set of spinners.
+
    #[must_use]
    pub fn new() -> Self {
        Self {
            remotes: BTreeSet::new(),
modified crates/radicle-cli/src/warning.rs
@@ -22,25 +22,69 @@ fn nodes_renamed_for_option(
    option: &'static str,
    iter: impl IntoIterator<Item = ConnectAddress>,
) -> Vec<String> {
-
    let mut warnings: Vec<String> = vec![];
-

-
    for (i, value) in iter.into_iter().enumerate() {
+
    iter.into_iter().enumerate().fold(Vec::new(), |mut warnings, (i, value)| {
        let old: Address = value.into();
        if let Some(new) = NODES_RENAMED.get(&old) {
            warnings.push(format!(
-
                "Value of configuration option `{option}` at index {i} mentions node with address '{old}', which has been renamed to '{new}'. Please update your configuration."
+
                "Value of configuration option `{option}` at index {i} mentions node with address '{old}', which has been renamed to '{new}'. Please edit your configuration file to use the new address."
            ));
        }
-
    }
-

-
    warnings
+
        warnings
+
    })
}

-
pub(crate) fn nodes_renamed(config: &Config) -> Vec<String> {
+
fn nodes_renamed(config: &Config) -> Vec<String> {
    let mut warnings = nodes_renamed_for_option("node.connect", config.node.connect.clone());
    warnings.extend(nodes_renamed_for_option(
-
        "preferred_seeds",
+
        "preferredSeeds",
        config.preferred_seeds.clone(),
    ));
+

    warnings
}
+

+
fn implicit_seeding_policy_allow_scope(config: &Config) -> Vec<String> {
+
    use radicle::node::config::DefaultSeedingPolicy;
+
    use radicle::node::policy::Scope::*;
+

+
    let DefaultSeedingPolicy::Allow { scope } = config.node.seeding_policy else {
+
        return vec![];
+
    };
+

+
    if !scope.is_implicit() {
+
        return vec![];
+
    }
+

+
    vec![format!(
+
        "Configuration option 'node.seedingPolicy.scope' is not set, and thus takes the value '{}' by default. The default value will change to '{}' in a future release. Please edit your configuration file, and set it to one of ['{}', '{}'] explicitly.",
+
        scope.into_inner(),
+
        Followed,
+
        All,
+
        Followed,
+
    )]
+
}
+

+
pub(crate) fn config_warnings(config: &Config) -> Vec<String> {
+
    let mut warnings = nodes_renamed(config);
+
    warnings.extend(implicit_seeding_policy_allow_scope(config));
+

+
    warnings
+
}
+

+
/// Prints a deprecation warning to standard error.
+
pub(crate) fn deprecated(old: impl std::fmt::Display, new: impl std::fmt::Display) {
+
    eprintln!(
+
        "{} {} The command/option `{old}` is deprecated and will be removed. Please use `{new}` instead.",
+
        radicle_term::PREFIX_WARNING,
+
        radicle_term::Paint::yellow("Deprecated:").bold(),
+
    );
+
}
+

+
/// Prints an obsoletion warning to standard error.
+
pub(crate) fn obsolete(command: impl std::fmt::Display) {
+
    eprintln!(
+
        "{} {} The command `{command}` is obsolete and will be removed. Please stop using it.",
+
        radicle_term::PREFIX_WARNING,
+
        radicle_term::Paint::yellow("Obsolete:").bold(),
+
    );
+
}
modified crates/radicle-cli/tests/commands.rs
@@ -1,6 +1,7 @@
+
use core::panic;
use std::path::Path;
use std::str::FromStr;
-
use std::{net, thread, time};
+
use std::{fs, net, thread, time};

use radicle::cob;
use radicle::git;
@@ -19,6 +20,7 @@ use radicle::profile::Home;
use radicle::storage::{ReadStorage, RefUpdate, RemoteRepository};
use radicle::test::fixtures;

+
use radicle_localtime::LocalTime;
#[allow(unused_imports)]
use radicle_node::test::logger;
use radicle_node::test::node::Node;
@@ -36,26 +38,80 @@ pub(crate) fn test<'a>(
    envs: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Result<(), Box<dyn std::error::Error>> {
    let tmp = tempfile::tempdir().unwrap();
-
    let home = if let Some(home) = home {
-
        home.path().to_path_buf()
+

+
    let (unix_home, rad_home) = if let Some(home) = home {
+
        let unix_home = home.path().to_path_buf();
+
        let unix_home = unix_home.parent().unwrap().to_path_buf();
+
        (unix_home, home.path().to_path_buf())
    } else {
-
        tmp.path().to_path_buf()
+
        let mut rad_home = tmp.path().to_path_buf();
+
        rad_home.push(".radicle");
+
        (tmp.path().to_path_buf(), rad_home)
    };

    formula(cwd.as_ref(), test)?
-
        .env("RAD_HOME", home.to_string_lossy())
+
        .env("RAD_HOME", rad_home.to_string_lossy())
+
        .env(
+
            "JJ_CONFIG",
+
            unix_home.join(".jjconfig.toml").to_string_lossy(),
+
        )
        .envs(envs)
        .run()?;

    Ok(())
}

+
/// A utility to check that some program can be executed with a `--version`
+
/// argument and exits successfully.
+
///
+
/// # Panics
+
///
+
/// If there is an error executing the program other than the program not being
+
/// found, or the program does not exit successfully.
+
fn program_reports_version(program: &str) -> bool {
+
    use std::io::ErrorKind;
+
    use std::process::{Command, Stdio};
+

+
    match Command::new(program)
+
        .arg("--version")
+
        .stdout(Stdio::null())
+
        .status()
+
    {
+
        Err(e) if e.kind() == ErrorKind::NotFound => {
+
            log::warn!(target: "test", "`{program}` not found.");
+
            false
+
        }
+
        Err(e) => panic!("failure to execute `{program}`: {e}"),
+
        Ok(status) if status.success() => true,
+
        Ok(status) => panic!("executing `{program}` resulted in status {status}"),
+
    }
+
}
+

+
#[test]
+
fn rad_help() {
+
    Environment::alice(["rad-help"]);
+
}
+

#[test]
fn rad_auth() {
    test("examples/rad-auth.md", Path::new("."), None, []).unwrap();
}

#[test]
+
fn rad_key_mismatch() {
+
    let mut environment = Environment::new();
+
    let alice = environment.profile("alice");
+
    environment.repository(&alice);
+

+
    environment.test("rad-init", &alice).unwrap();
+

+
    // Replace the public key with one that does not match the secret key anymore.
+
    fs::write(alice.home.path().join("keys").join("radicle.pub"), "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE6Ul/D+P0I/Hl1JVOWGS8Z589us9FqKQXWv8OMOpKCh snakeoil\n").unwrap();
+

+
    environment.test("rad-key-mismatch", &alice).unwrap();
+
}
+

+
#[test]
fn rad_auth_errors() {
    test("examples/rad-auth-errors.md", Path::new("."), None, []).unwrap();
}
@@ -66,6 +122,11 @@ fn rad_issue() {
}

#[test]
+
fn rad_issue_list() {
+
    Environment::alice(["rad-init", "rad-issue", "rad-issue-list"]);
+
}
+

+
#[test]
fn rad_cob_update() {
    Environment::alice(["rad-init", "rad-cob-log"]);
}
@@ -97,21 +158,11 @@ fn rad_cob_update_identity() {

#[test]
fn rad_cob_multiset() {
-
    {
-
        // `rad-cob-multiset` is a `jq` script, which requires `jq` to be installed.
-
        // We test whether `jq` is installed, and have this test succeed if it is not.
-
        // Programmatic skipping of tests is not supported as of 2024-08.
-
        use std::io::ErrorKind;
-
        use std::process::{Command, Stdio};
-

-
        match Command::new("jq").arg("-V").stdout(Stdio::null()).status() {
-
            Err(e) if e.kind() == ErrorKind::NotFound => {
-
                log::warn!(target: "test", "`jq` not found. Succeeding prematurely.");
-
                return;
-
            }
-
            Err(e) => panic!("while checking for jq: {e}"),
-
            Ok(_) => {}
-
        }
+
    // `rad-cob-multiset` is a `jq` script, which requires `jq` to be installed.
+
    // We test whether `jq` is installed, and have this test succeed if it is not.
+
    // Programmatic skipping of tests is not supported as of 2024-08.
+
    if !program_reports_version("jq") {
+
        return;
    }

    let mut environment = Environment::new();
@@ -177,6 +228,15 @@ fn rad_init() {
}

#[test]
+
fn rad_init_bare() {
+
    let mut env = Environment::new();
+
    let alice = env.profile("alice");
+
    radicle::test::fixtures::bare_repository(env.work(&alice).as_path());
+
    env.tests(["git/git-is-bare-repository", "rad-init"], &alice)
+
        .unwrap();
+
}
+

+
#[test]
fn rad_init_existing() {
    let mut environment = Environment::new();
    let mut profile = environment.node("alice");
@@ -199,6 +259,28 @@ fn rad_init_existing() {
}

#[test]
+
fn rad_init_existing_bare() {
+
    let mut environment = Environment::new();
+
    let mut profile = environment.node("alice");
+
    let working = tempfile::tempdir().unwrap();
+
    let rid = profile.project("heartwood", "Radicle Heartwood Protocol & Stack");
+

+
    test(
+
        "examples/rad-init-existing-bare.md",
+
        working.path(),
+
        Some(&profile.home),
+
        [(
+
            "URL",
+
            git::url::File::new(profile.storage.path())
+
                .rid(rid)
+
                .to_string()
+
                .as_str(),
+
        )],
+
    )
+
    .unwrap();
+
}
+

+
#[test]
fn rad_init_no_seed() {
    Environment::alice(["rad-init-no-seed"]);
}
@@ -256,7 +338,7 @@ fn rad_config() {
    let mut environment = Environment::new();
    let alias = Alias::new("alice");
    let profile = environment.profile_with(profile::Config {
-
        preferred_seeds: vec![RADICLE_NODE_BOOTSTRAP_IRIS.clone()[0].clone()],
+
        preferred_seeds: vec![RADICLE_NODE_BOOTSTRAP_IRIS.clone().first().unwrap().clone()],
        ..profile::Config::new(alias)
    });
    let working = tempfile::tempdir().unwrap();
@@ -490,6 +572,7 @@ fn rad_id_multi_delegate() {

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

@@ -521,6 +604,56 @@ fn rad_id_multi_delegate() {
}

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

+
    environment.repository(&alice);
+

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

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

+
    // Alice sets up the seed
+
    alice.handle.seed(acme, Scope::Followed).unwrap();
+

+
    bob.connect(&alice).converge([&alice]);
+
    bob.rad(
+
        "clone",
+
        &[acme.to_string().as_str()],
+
        environment.work(&bob),
+
    )
+
    .unwrap();
+

+
    formula(
+
        &environment.tempdir(),
+
        "examples/rad-id-unauthorized-delegate.md",
+
    )
+
    .unwrap()
+
    .home(
+
        "alice",
+
        environment.work(&alice),
+
        [("RAD_HOME", alice.home.path().display())],
+
    )
+
    .home(
+
        "bob",
+
        environment.work(&bob),
+
        [("RAD_HOME", bob.home.path().display())],
+
    )
+
    .run()
+
    .unwrap();
+
}
+

+
#[test]
#[ignore = "slow"]
fn rad_id_collaboration() {
    let mut environment = Environment::new();
@@ -725,7 +858,7 @@ fn rad_node_connect_without_address() {
            &Alias::new("bob"),
            0,
            &UserAgent::default(),
-
            localtime::LocalTime::now().into(),
+
            LocalTime::now().into(),
            [node::KnownAddress::new(
                node::Address::from(bob.addr),
                node::address::Source::Imported,
@@ -784,7 +917,49 @@ fn rad_node() {

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

+
#[test]
+
fn rad_jj_bare() {
+
    // We test whether `jj` is installed, and have this test succeed if it is not.
+
    // Programmatic skipping of tests is not supported as of 2024-08.
+
    if !program_reports_version("jj") {
+
        return;
+
    }
+

+
    let mut environment = Environment::new();
+
    let mut profile = environment.node("alice");
+
    let rid = profile.project("heartwood", "Radicle Heartwood Protocol & Stack");
+

+
    test(
+
        "examples/rad-init-existing-bare.md",
+
        environment.work(&profile),
+
        Some(&profile.home),
+
        [(
+
            "URL",
+
            git::url::File::new(profile.storage.path())
+
                .rid(rid)
+
                .to_string()
+
                .as_str(),
+
        )],
+
    )
+
    .unwrap();
+

+
    environment
+
        .tests(["jj-config", "jj-init-bare"], &profile)
+
        .unwrap();
+
}
+

+
#[test]
+
fn rad_jj_colocated_patch() {
+
    // We test whether `jj` is installed, and have this test succeed if it is not.
+
    // Programmatic skipping of tests is not supported as of 2024-08.
+
    if !program_reports_version("jj") {
+
        return;
+
    }
+

+
    Environment::alice(["rad-init", "jj-config", "jj-init-colocate", "rad-patch-jj"])
}

#[test]
@@ -1125,6 +1300,26 @@ fn rad_clone() {
}

#[test]
+
fn rad_clone_bare() {
+
    let mut environment = Environment::new();
+
    let mut alice = environment.node("alice");
+
    let bob = environment.node("bob");
+
    let working = environment.tempdir().join("working");
+

+
    // Setup a test project.
+
    let acme = alice.project("heartwood", "Radicle Heartwood Protocol & Stack");
+

+
    let mut alice = alice.spawn();
+
    let mut bob = bob.spawn();
+
    // Prevent Alice from fetching Bob's fork, as we're not testing that and it may cause errors.
+
    alice.handle.seed(acme, Scope::Followed).unwrap();
+

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

+
    test("examples/rad-clone-bare.md", working, Some(&bob.home), []).unwrap();
+
}
+

+
#[test]
fn rad_clone_directory() {
    let mut environment = Environment::new();
    let mut alice = environment.node("alice");
@@ -1208,7 +1403,7 @@ fn rad_clone_partial_fail() {
            &Alias::new("carol"),
            0,
            &UserAgent::default(),
-
            localtime::LocalTime::now().into(),
+
            LocalTime::now().into(),
            [node::KnownAddress::new(
                // Eve will fail to connect to this address.
                node::Address::from(net::SocketAddr::from(([0, 0, 0, 0], 19873))),
@@ -1218,7 +1413,7 @@ fn rad_clone_partial_fail() {
        .unwrap();
    eve.db
        .routing_mut()
-
        .add_inventory([&acme], carol, localtime::LocalTime::now().into())
+
        .add_inventory([&acme], carol, LocalTime::now().into())
        .unwrap();
    eve.config.peers = node::config::PeerConfig::Static;

@@ -1232,7 +1427,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",
@@ -1252,7 +1447,7 @@ fn rad_clone_connect() {
    let mut eve = environment.node("eve");
    let acme = RepoId::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();
    let ua = UserAgent::default();
-
    let now = localtime::LocalTime::now().into();
+
    let now = LocalTime::now().into();

    fixtures::repository(working.join("acme"));

@@ -1544,8 +1739,16 @@ fn rad_fork() {
    environment.tests(["rad-fetch", "rad-fork"], &bob).unwrap();
}

+
#[cfg(unix)]
#[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 +1764,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 +1832,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
@@ -1858,17 +2061,19 @@ fn rad_remote() {
        .handle
        .follow(bob.id, Some(Alias::new("bob")))
        .unwrap();
+
    alice
+
        .handle
+
        .follow(eve.id, Some(Alias::new("eve")))
+
        .unwrap();

    bob.connect(&alice);
    bob.routes_to(&[(rid, alice.id)]);
    bob.fork(rid, bob.home.path()).unwrap();
-
    bob.announce(rid, 2, bob.home.path()).unwrap();
    alice.has_remote_of(&rid, &bob.id);

-
    eve.connect(&bob);
+
    eve.connect(&alice);
    eve.routes_to(&[(rid, alice.id)]);
    eve.fork(rid, eve.home.path()).unwrap();
-
    eve.announce(rid, 2, eve.home.path()).unwrap();
    alice.has_remote_of(&rid, &eve.id);

    test(
@@ -2276,6 +2481,11 @@ fn git_push_amend() {
}

#[test]
+
fn git_push_force_with_lease() {
+
    Environment::alice(["rad-init", "git/git-push-force-with-lease"]);
+
}
+

+
#[test]
fn git_push_rollback() {
    let mut environment = Environment::new();
    let alice = environment.node("alice");
@@ -2662,3 +2872,24 @@ fn rad_workflow() {
    )
    .unwrap();
}
+

+
#[test]
+
fn rad_seed_policy_allow_no_scope() {
+
    let mut environment = Environment::new();
+
    let alice = environment.node_with(Config {
+
        seeding_policy: DefaultSeedingPolicy::Allow {
+
            scope: node::config::Scope::implicit(),
+
        },
+
        ..Config::test(Alias::new("alice"))
+
    });
+

+
    let alice = alice.spawn();
+

+
    test(
+
        "examples/rad-seed-policy-allow-no-scope.md",
+
        environment.work(&alice),
+
        Some(&alice.home),
+
        [],
+
    )
+
    .unwrap();
+
}
modified crates/radicle-cli/tests/util/environment.rs
@@ -1,10 +1,10 @@
use std::path::PathBuf;
use std::str::FromStr;

-
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};
@@ -12,6 +12,7 @@ use radicle::profile::Home;
use radicle::profile::{self};
use radicle::storage::git::transport;
use radicle::{Profile, Storage};
+
use radicle_localtime::LocalTime;

use radicle_node::test::node::{Node, NodeHandle};

@@ -82,37 +83,37 @@ impl Default for Environment {

impl Environment {
    /// Create a new test environment.
-
    fn named(name: &'static str) -> Self {
+
    pub fn new() -> Self {
        Self {
-
            tempdir: tempfile::TempDir::with_prefix("radicle-".to_owned() + name).unwrap(),
+
            tempdir: tempfile::TempDir::new().unwrap(),
            users: 0,
        }
    }

-
    /// Create a new test environment.
-
    pub fn new() -> Self {
-
        Self::named("")
-
    }
-

-
    /// Return the temp directory path.
+
    /// Return the path of the temporary directory at which
+
    /// this testing environment is rooted.
    pub fn tempdir(&self) -> PathBuf {
        self.tempdir.path().into()
    }

-
    /// Path to the working directory designated for given alias.
-
    pub fn work(&self, has_alias: &impl HasAlias) -> PathBuf {
-
        self.tempdir().join("work").join(has_alias.alias().as_ref())
+
    /// Return the home directory of the user with the given alias.
+
    /// This is in analogy to a Unix home directory.
+
    pub fn unix_home(&self, has_alias: &impl HasAlias) -> PathBuf {
+
        self.tempdir().join(has_alias.alias().to_string())
    }

-
    /// We don't have `RAD_HOME` or `HOME` to rely on to compute a home as usual.
-
    pub fn home(&self, alias: &Alias) -> Home {
-
        Home::new(
-
            self.tempdir()
-
                .join("home")
-
                .join(alias.to_string())
-
                .join(".radicle"),
-
        )
-
        .unwrap()
+
    /// Return the Radicle path of the user with the given alias.
+
    /// This is in analogy to `$RAD_HOME` and always a subdirectory of
+
    /// the user's home directory (see [`Environment::unix_home`]).
+
    pub fn rad_home(&self, has_alias: &impl HasAlias) -> Home {
+
        Home::new(self.unix_home(has_alias).join(".radicle")).unwrap()
+
    }
+

+
    /// Path to the working directory of the user with the given alias.
+
    /// Tests that need to act on multiple repositories should crate
+
    /// subdirecories within this directory.
+
    pub fn work(&self, has_alias: &impl HasAlias) -> PathBuf {
+
        self.unix_home(has_alias).join("work")
    }

    /// Create a new default configuration.
@@ -135,7 +136,7 @@ impl Environment {
    /// is provided.
    pub fn profile_with(&mut self, config: profile::Config) -> Profile {
        let alias = config.alias().clone();
-
        let home = self.home(&alias);
+
        let home = self.rad_home(&alias);
        let keypair = KeyPair::from_seed(Seed::from([!(self.users as u8); 32]));
        let policies_db = home.node().join(POLICIES_DB_FILE);
        let cobs_db = home.cobs().join(COBS_DB_FILE);
@@ -224,37 +225,47 @@ impl Environment {
        self.node_with(config::seed(alias))
    }

-
    /// Convenience method for placing repository fixture.
+
    /// Convenience method for placing repository fixture into the
+
    /// directory returned by [`Environment::work`] for the user.
+
    /// Use this only in tests that act on *a single repository* only.
+
    /// For tests that need to act on multiple repositories,
+
    /// create the repositories as subdirectories of the working
+
    /// directory returned by [`Environment::work`].
    pub fn repository(
        &self,
        has_alias: &impl HasAlias,
-
    ) -> (radicle_cli::git::Repository, radicle_cli::git::Oid) {
+
    ) -> (radicle_cli::git::Repository, git::raw::Oid) {
        radicle::test::fixtures::repository(self.work(has_alias).as_path())
    }

-
    // Convenience method for exectuing a test formula with standard configuration.
+
    // Convenience method for executing a test formula with standard configuration.
    pub fn test(
        &self,
        test_file: &'static str,
-
        subject: &(impl HasAlias + HasHome),
+
        subject: &impl HasAlias,
    ) -> Result<(), Box<dyn std::error::Error>> {
        formula(
            self.work(subject).as_ref(),
            PathBuf::from("examples").join(test_file.to_owned() + ".md"),
        )?
+
        .env("USER", subject.alias().as_ref())
+
        .env("RAD_HOME", self.rad_home(subject).path().to_string_lossy())
        .env(
-
            "RAD_HOME",
-
            subject.home().path().to_path_buf().to_string_lossy(),
+
            "JJ_CONFIG",
+
            self.unix_home(subject)
+
                .join(".jjconfig.toml")
+
                .to_string_lossy(),
        )
        .run()?;

        Ok(())
    }

+
    /// Convenience method for executing multiple test formulas with standard configuration.
    pub fn tests(
        &self,
        test_files: impl IntoIterator<Item = &'static str>,
-
        subject: &(impl HasAlias + HasHome),
+
        subject: &impl HasAlias,
    ) -> Result<(), Box<dyn std::error::Error>> {
        for test_file in test_files {
            self.test(test_file, subject)?;
@@ -277,6 +288,12 @@ pub trait HasAlias {
    fn alias(&self) -> &Alias;
}

+
impl HasAlias for Alias {
+
    fn alias(&self) -> &Alias {
+
        self
+
    }
+
}
+

impl HasAlias for Node<MemorySigner> {
    fn alias(&self) -> &Alias {
        &self.config.alias
@@ -294,25 +311,3 @@ impl<G> HasAlias for NodeHandle<G> {
        &self.alias
    }
}
-

-
pub trait HasHome {
-
    fn home(&self) -> &Home;
-
}
-

-
impl HasHome for Profile {
-
    fn home(&self) -> &Home {
-
        &self.home
-
    }
-
}
-

-
impl HasHome for Node<MemorySigner> {
-
    fn home(&self) -> &Home {
-
        &self.home
-
    }
-
}
-

-
impl HasHome for NodeHandle<MemorySigner> {
-
    fn home(&self) -> &Home {
-
        &self.home
-
    }
-
}
modified crates/radicle-cli/tests/util/formula.rs
@@ -20,10 +20,14 @@ pub(crate) fn formula(
        .env("GIT_COMMITTER_DATE", "1671125284")
        .env("GIT_COMMITTER_EMAIL", "radicle@localhost")
        .env("GIT_COMMITTER_NAME", "radicle")
+
        .env("JJ_USER", "Test User")
+
        .env("JJ_EMAIL", "test.user@example.com")
+
        .env("JJ_OP_HOSTNAME", "host.example.com")
+
        .env("JJ_OP_USERNAME", "test-username")
+
        .env("JJ_TZ_OFFSET_MINS", "660")
        .env("EDITOR", "true")
        .env("TZ", "UTC")
        .env("LANG", "C")
-
        .env("USER", "alice")
        .env(env::RAD_PASSPHRASE, "radicle")
        .env(env::RAD_KEYGEN_SEED, RAD_SEED)
        .env(env::RAD_RNG_SEED, "0")
added crates/radicle-cob/CHANGELOG.md
@@ -0,0 +1,28 @@
+
# Changelog
+

+
All notable changes to this project will be documented in this file.
+

+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+

+
## [Unreleased]
+

+
### Added
+

+
### Changed
+

+
### Removed
+

+
### Security
+

+
## 0.18.0
+

+
### Removed
+

+
- The `radicle_cob::git::stable` module was removed, including the `STEP` and
+
  `STABLE_TIME` constants, and the `read_timestamp` and
+
  `with_advanced_timestamp` functions.
+

+
### Security
+

+
*No security updates.*
modified crates/radicle-cob/Cargo.toml
@@ -3,7 +3,7 @@ name = "radicle-cob"
description = "Radicle Collaborative Objects library"
homepage.workspace = true
repository.workspace = true
-
version = "0.16.0"
+
version = "0.18.0"
authors = [
  "Alex Good <alex@memoryandthought.me>",
  "Fintan Halpenny <fintan.halpenny@gmail.com>",
@@ -17,23 +17,28 @@ rust-version.workspace = true
default = []
# Only used for testing. Ensures that commit ids are stable.
stable-commit-ids = []
+
test = []

[dependencies]
fastrand = { workspace = true }
-
git2 = { workspace = true, features = ["vendored-libgit2"] }
+
git-ref-format-core = { workspace = true }
+
git2 = { workspace = true, optional = true }
log = { workspace = true }
nonempty = { workspace = true, features = ["serialize"] }
radicle-crypto = { workspace = true, features = ["ssh"] }
radicle-dag = { workspace = true }
-
radicle-git-ext = { workspace = true, features = ["serde"] }
+
radicle-git-metadata = { workspace = true }
+
radicle-oid = { workspace = true, features = ["git2", "serde", "sha1", "std"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
signature = { workspace = true }
-
thiserror = { workspace = true }
+
thiserror = { workspace = true, default-features = true }

[dev-dependencies]
fastrand = { workspace = true }
qcheck = { workspace = true }
qcheck-macros = { workspace = true }
-
radicle-crypto = { workspace = true, features = ["test", "radicle-git-ext"] }
-
tempfile = { workspace = true }

\ No newline at end of file
+
radicle-crypto = { workspace = true, features = ["test", "git-ref-format-core"] }
+
radicle-git-ref-format = { workspace = true, features = ["macro"] }
+
radicle-oid = { workspace = true, features = ["qcheck"] }
+
tempfile = { workspace = true }
modified crates/radicle-cob/src/backend.rs
@@ -1,3 +1,7 @@
// Copyright © 2022 The Radicle Link Contributors

+
#[cfg(feature = "git2")]
pub mod git;
+

+
#[cfg(feature = "stable-commit-ids")]
+
pub mod stable;
modified crates/radicle-cob/src/backend/git.rs
@@ -1,9 +1,8 @@
// Copyright © 2022 The Radicle Team

-
pub mod change;
+
mod commit;

-
#[cfg(feature = "stable-commit-ids")]
-
pub mod stable;
+
pub mod change;

/// Environment variable to set to overwrite the commit date for both the author and the committer.
///
modified crates/radicle-cob/src/backend/git/change.rs
@@ -5,11 +5,11 @@ use std::convert::TryFrom;
use std::path::PathBuf;
use std::sync::LazyLock;

-
use git_ext::author::Author;
-
use git_ext::commit::{headers::Headers, Commit};
-
use git_ext::Oid;
+
use metadata::author::Author;
+
use metadata::commit::headers::Headers;
+
use metadata::commit::trailers::OwnedTrailer;
use nonempty::NonEmpty;
-
use radicle_git_ext::commit::trailers::OwnedTrailer;
+
use oid::Oid;

use crate::change::store::Version;
use crate::signatures;
@@ -21,6 +21,8 @@ use crate::{
    trailers, Embed,
};

+
use super::commit::Commit;
+

/// Name of the COB manifest file.
pub const MANIFEST_BLOB_NAME: &str = "manifest";
/// Path under which COB embeds are kept.
@@ -30,8 +32,7 @@ pub mod error {
    use std::str::Utf8Error;
    use std::string::FromUtf8Error;

-
    use git_ext::commit;
-
    use git_ext::Oid;
+
    use oid::Oid;
    use thiserror::Error;

    use crate::signatures::error::Signatures;
@@ -39,7 +40,7 @@ pub mod error {
    #[derive(Debug, Error)]
    pub enum Create {
        #[error(transparent)]
-
        WriteCommit(#[from] commit::error::Write),
+
        WriteCommit(#[from] super::super::commit::error::Write),
        #[error(transparent)]
        FromUtf8(#[from] FromUtf8Error),
        #[error(transparent)]
@@ -55,7 +56,7 @@ pub mod error {
    #[derive(Debug, Error)]
    pub enum Load {
        #[error(transparent)]
-
        Read(#[from] commit::error::Read),
+
        Read(#[from] super::super::commit::error::Read),
        #[error(transparent)]
        Signatures(#[from] Signatures),
        #[error(transparent)]
@@ -123,7 +124,7 @@ impl change::Storage for git2::Repository {

        let (id, timestamp) = write_commit(
            self,
-
            resource.map(|o| *o),
+
            resource.map(|o| o.into()),
            // Commit to tips, extra parents and resource.
            tips.iter()
                .cloned()
@@ -134,7 +135,7 @@ impl change::Storage for git2::Repository {
            signature.clone(),
            related
                .iter()
-
                .map(|p| trailers::CommitTrailer::Related(**p).into()),
+
                .map(|p| trailers::CommitTrailer::Related(*p).into()),
            tree,
        )?;

@@ -153,40 +154,34 @@ impl change::Storage for git2::Repository {

    fn parents_of(&self, id: &Oid) -> Result<Vec<Oid>, Self::LoadError> {
        Ok(self
-
            .find_commit(**id)?
+
            .find_commit(id.into())?
            .parent_ids()
            .map(Oid::from)
            .collect::<Vec<_>>())
    }

    fn manifest_of(&self, id: &Oid) -> Result<crate::Manifest, Self::LoadError> {
-
        let commit = self.find_commit(**id)?;
+
        let commit = self.find_commit(id.into())?;
        let tree = commit.tree()?;
        load_manifest(self, &tree)
    }

    fn load(&self, id: Self::ObjectId) -> Result<Entry, Self::LoadError> {
-
        let commit = Commit::read(self, id.into())?;
-
        let timestamp = git2::Time::from(commit.committer().time).seconds() as u64;
+
        let commit = super::commit::Commit::read(self, id.into())?;
+
        let timestamp = commit.committer().time.seconds() as u64;
        let trailers = parse_trailers(commit.trailers())?;
        let (resources, related): (Vec<_>, Vec<_>) = trailers.iter().partition(|t| match t {
            CommitTrailer::Resource(_) => true,
            CommitTrailer::Related(_) => false,
        });
-
        let mut resources = resources
-
            .into_iter()
-
            .map(|r| r.oid().into())
-
            .collect::<Vec<_>>();
-
        let related = related
-
            .into_iter()
-
            .map(|r| r.oid().into())
-
            .collect::<Vec<_>>();
+
        let mut resources = resources.into_iter().map(|r| r.oid()).collect::<Vec<_>>();
+
        let related = related.into_iter().map(|r| r.oid()).collect::<Vec<_>>();
        let parents = commit
            .parents()
            .map(Oid::from)
            .filter(|p| !resources.contains(p) && !related.contains(p))
            .collect();
-
        let mut signatures = Signatures::try_from(&commit)?
+
        let mut signatures = Signatures::try_from(&*commit)?
            .into_iter()
            .collect::<Vec<_>>();
        let Some((key, sig)) = signatures.pop() else {
@@ -285,7 +280,7 @@ fn write_commit(
) -> Result<(Oid, Timestamp), error::Create> {
    let trailers: Vec<OwnedTrailer> = trailers
        .into_iter()
-
        .chain(resource.map(|r| trailers::CommitTrailer::Resource(r).into()))
+
        .chain(resource.map(|r| trailers::CommitTrailer::Resource(r.into()).into()))
        .collect();
    let author = repo.signature()?;
    #[allow(unused_variables)]
@@ -299,29 +294,37 @@ fn write_commit(
            .map_err(signatures::error::Signatures::from)?
            .as_str(),
    );
-
    let author = Author::try_from(&author)?;
+

+
    let author = Author {
+
        name: String::from_utf8(author.name_bytes().to_vec())?,
+
        email: String::from_utf8(author.email_bytes().to_vec())?,
+
        time: {
+
            let when = author.when();
+
            metadata::author::Time::new(when.seconds(), when.offset_minutes())
+
        },
+
    };

    #[cfg(feature = "stable-commit-ids")]
    // Ensures the commit id doesn't change on every run.
    let (author, timestamp) = {
-
        let stable = crate::git::stable::read_timestamp();
+
        let stable = crate::stable::read_timestamp();
        (
            Author {
-
                time: git_ext::author::Time::new(stable, 0),
+
                time: metadata::author::Time::new(stable, 0),
                ..author
            },
            stable,
        )
    };
-
    let (author, timestamp) = if let Ok(s) = std::env::var(crate::git::GIT_COMMITTER_DATE) {
+
    let (author, timestamp) = if let Ok(s) = std::env::var(super::GIT_COMMITTER_DATE) {
        let Ok(timestamp) = s.trim().parse::<i64>() else {
            panic!(
                "Invalid timestamp value {s:?} for `{}`",
-
                crate::git::GIT_COMMITTER_DATE
+
                super::GIT_COMMITTER_DATE
            );
        };
        let author = Author {
-
            time: git_ext::author::Time::new(timestamp, 0),
+
            time: metadata::author::Time::new(timestamp, 0),
            ..author
        };
        (author, timestamp)
@@ -380,7 +383,7 @@ fn write_manifest(
            let oid = embed.content;
            let path = PathBuf::from(embed.name);

-
            embeds_tree.insert(path, *oid, git2::FileMode::Blob.into())?;
+
            embeds_tree.insert(path, oid.into(), git2::FileMode::Blob.into())?;
        }
        let oid = embeds_tree.write()?;

added crates/radicle-cob/src/backend/git/commit.rs
@@ -0,0 +1,180 @@
+
mod trailers;
+

+
use std::fmt;
+
use std::str::{self, FromStr};
+

+
use git2::{ObjectType, Oid};
+

+
use metadata::author::Author;
+
use metadata::commit::headers::Headers;
+
use metadata::commit::trailers::OwnedTrailer;
+
use metadata::commit::CommitData;
+

+
use trailers::Trailers;
+

+
#[repr(transparent)]
+
pub(super) struct Commit(metadata::commit::CommitData<Oid, Oid>);
+

+
impl Commit {
+
    pub fn new<P, I, T>(
+
        tree: git2::Oid,
+
        parents: P,
+
        author: Author,
+
        committer: Author,
+
        headers: Headers,
+
        message: String,
+
        trailers: I,
+
    ) -> Self
+
    where
+
        P: IntoIterator<Item = Oid>,
+
        I: IntoIterator<Item = T>,
+
        OwnedTrailer: From<T>,
+
    {
+
        Self(CommitData::new(
+
            tree, parents, author, committer, headers, message, trailers,
+
        ))
+
    }
+
}
+

+
impl Commit {
+
    /// Read the [`Commit`] from the `repo` that is expected to be found at
+
    /// `oid`.
+
    pub fn read(repo: &git2::Repository, oid: Oid) -> Result<Self, error::Read> {
+
        let odb = repo.odb()?;
+
        let object = odb.read(oid)?;
+
        Ok(Commit::try_from(object.data())?)
+
    }
+

+
    /// Write the given [`Commit`] to the `repo`. The resulting `Oid`
+
    /// is the identifier for this commit.
+
    pub fn write(&self, repo: &git2::Repository) -> Result<Oid, error::Write> {
+
        let odb = repo.odb().map_err(error::Write::Odb)?;
+
        self.verify_for_write(&odb)?;
+
        Ok(odb.write(ObjectType::Commit, self.to_string().as_bytes())?)
+
    }
+

+
    fn verify_for_write(&self, odb: &git2::Odb) -> Result<(), error::Write> {
+
        for parent in self.0.parents() {
+
            verify_object(odb, &parent, ObjectType::Commit)?;
+
        }
+
        verify_object(odb, self.0.tree(), ObjectType::Tree)?;
+

+
        Ok(())
+
    }
+
}
+

+
fn verify_object(odb: &git2::Odb, oid: &Oid, expected: ObjectType) -> Result<(), error::Write> {
+
    use git2::{Error, ErrorClass, ErrorCode};
+

+
    let (_, kind) = odb
+
        .read_header(*oid)
+
        .map_err(|err| error::Write::OdbRead { oid: *oid, err })?;
+
    if kind != expected {
+
        Err(error::Write::NotCommit {
+
            oid: *oid,
+
            err: Error::new(
+
                ErrorCode::NotFound,
+
                ErrorClass::Object,
+
                format!("Object '{oid}' is not expected object type {expected}"),
+
            ),
+
        })
+
    } else {
+
        Ok(())
+
    }
+
}
+

+
pub mod error {
+
    use std::str;
+

+
    use thiserror::Error;
+

+
    #[derive(Debug, Error)]
+
    pub enum Write {
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error("the parent '{oid}' provided is not a commit object")]
+
        NotCommit {
+
            oid: git2::Oid,
+
            #[source]
+
            err: git2::Error,
+
        },
+
        #[error("failed to access git odb")]
+
        Odb(#[source] git2::Error),
+
        #[error("failed to read '{oid}' from git odb")]
+
        OdbRead {
+
            oid: git2::Oid,
+
            #[source]
+
            err: git2::Error,
+
        },
+
    }
+

+
    #[derive(Debug, Error)]
+
    pub enum Read {
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error(transparent)]
+
        Parse(#[from] Parse),
+
    }
+

+
    #[derive(Debug, Error)]
+
    pub enum Parse {
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error(transparent)]
+
        Header(#[from] metadata::commit::headers::ParseError),
+
        #[error(transparent)]
+
        Utf8(#[from] str::Utf8Error),
+
    }
+
}
+

+
impl TryFrom<&[u8]> for Commit {
+
    type Error = error::Parse;
+

+
    fn try_from(data: &[u8]) -> Result<Self, Self::Error> {
+
        Commit::from_str(str::from_utf8(data)?)
+
    }
+
}
+

+
impl FromStr for Commit {
+
    type Err = error::Parse;
+

+
    fn from_str(buffer: &str) -> Result<Self, Self::Err> {
+
        let (header, message) = buffer
+
            .split_once("\n\n")
+
            .ok_or(metadata::commit::headers::ParseError::InvalidFormat)?;
+

+
        let (tree, parents, author, committer, headers) =
+
            metadata::commit::headers::parse_commit_header(header)?;
+

+
        let trailers = Trailers::parse(message)?;
+

+
        let message = message
+
            .strip_suffix(&trailers.to_string(": "))
+
            .unwrap_or(message)
+
            .to_string();
+

+
        Ok(Self(CommitData::new(
+
            tree,
+
            parents,
+
            author,
+
            committer,
+
            headers,
+
            message,
+
            trailers.iter(),
+
        )))
+
    }
+
}
+

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

+
impl std::ops::Deref for Commit {
+
    type Target = CommitData<git2::Oid, git2::Oid>;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.0
+
    }
+
}
added crates/radicle-cob/src/backend/git/commit/trailers.rs
@@ -0,0 +1,110 @@
+
use std::{borrow::Cow, fmt, fmt::Write, str::FromStr};
+

+
use git2::{MessageTrailersStrs, MessageTrailersStrsIterator};
+

+
use metadata::commit::trailers::Separator;
+

+
/// A Git commit's set of trailers that are left in the commit's
+
/// message.
+
///
+
/// Trailers are key/value pairs in the last paragraph of a message,
+
/// not including any patches or conflicts that may be present.
+
///
+
/// # Usage
+
///
+
/// To construct `Trailers`, you can use [`Trailers::parse`] or its
+
/// `FromStr` implementation.
+
///
+
/// To iterate over the trailers, you can use [`Trailers::iter`].
+
///
+
/// To render the trailers to a `String`, you can use
+
/// [`Trailers::to_string`] or its `Display` implementation (note that
+
/// it will default to using `": "` as the separator.
+
///
+
/// # Examples
+
///
+
/// ```text
+
/// Add new functionality
+
///
+
/// Making code better with new functionality.
+
///
+
/// X-Signed-Off-By: Alex Sellier
+
/// X-Co-Authored-By: Fintan Halpenny
+
/// ```
+
///
+
/// The trailers in the above example are:
+
///
+
/// ```text
+
/// X-Signed-Off-By: Alex Sellier
+
/// X-Co-Authored-By: Fintan Halpenny
+
/// ```
+
pub struct Trailers {
+
    inner: MessageTrailersStrs,
+
}
+

+
impl Trailers {
+
    pub fn parse(message: &str) -> Result<Self, git2::Error> {
+
        Ok(Self {
+
            inner: git2::message_trailers_strs(message)?,
+
        })
+
    }
+

+
    pub fn iter(&self) -> Iter<'_> {
+
        Iter {
+
            inner: self.inner.iter(),
+
        }
+
    }
+

+
    pub fn to_string<'a, S>(&self, sep: S) -> String
+
    where
+
        S: Separator<'a>,
+
    {
+
        let mut buf = String::new();
+
        for (i, trailer) in self.iter().enumerate() {
+
            if i > 0 {
+
                writeln!(buf).ok();
+
            }
+

+
            write!(buf, "{}", trailer.display(sep.sep_for(&trailer.token))).ok();
+
        }
+
        writeln!(buf).ok();
+
        buf
+
    }
+
}
+

+
impl fmt::Display for Trailers {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        f.write_str(&self.to_string(": "))
+
    }
+
}
+

+
impl FromStr for Trailers {
+
    type Err = git2::Error;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        Self::parse(s)
+
    }
+
}
+

+
pub struct Iter<'a> {
+
    inner: MessageTrailersStrsIterator<'a>,
+
}
+

+
impl<'a> Iterator for Iter<'a> {
+
    type Item = metadata::commit::trailers::Trailer<'a>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        let (token, value) = self.inner.next()?;
+
        Some(metadata::commit::trailers::Trailer {
+
            token: {
+
                // This code used to live in the same module with `Token`,
+
                // but was separated because it depends on `git2`.
+
                // We have no way of directly constructing a `Token`, anymore
+
                // but `git2` still guarantees that the trailer is well-formed.
+
                metadata::commit::trailers::Token::try_from(token)
+
                    .expect("token from `git2` must be valid")
+
            },
+
            value: Cow::Borrowed(value),
+
        })
+
    }
+
}
deleted crates/radicle-cob/src/backend/git/stable.rs
@@ -1,79 +0,0 @@
-
use std::{cell::Cell, ops::Add};
-

-
thread_local! {
-
    /// The constant time used by the stable-commit-ids feature.
-
    pub static STABLE_TIME: Cell<i64> = const { Cell::new(1514817556) };
-
    /// An incrementing counter to advance the `STABLE_TIME` value with in
-
    /// [`with_advanced_timestamp`].
-
    pub static STEP: Cell<Step> = Cell::new(Step::default());
-
}
-

-
#[derive(Clone, Copy)]
-
struct Step(i64);
-

-
impl Default for Step {
-
    fn default() -> Self {
-
        Self(1)
-
    }
-
}
-

-
impl Add<Step> for i64 {
-
    type Output = i64;
-

-
    fn add(self, rhs: Step) -> Self::Output {
-
        self + rhs.0
-
    }
-
}
-

-
impl Add<i64> for Step {
-
    type Output = Step;
-

-
    fn add(self, rhs: i64) -> Self::Output {
-
        Step(self.0 + rhs)
-
    }
-
}
-

-
/// Read the current value of `STABLE_TIME`.
-
///
-
/// # Panics
-
///
-
/// The `STABLE_TIME` is declared in `thread_local`, and so the panic
-
/// information is repeated here.
-
///
-
/// Panics if the key currently has its destructor running, and it may panic if
-
/// the destructor has previously been run for this thread.
-
#[allow(clippy::unwrap_used)]
-
pub fn read_timestamp() -> i64 {
-
    STABLE_TIME.get()
-
}
-

-
/// Perform an action `f` that would rely on the `STABLE_TIME` value. This will
-
/// advance the `STABLE_TIME` by an increment of `1` for each time it is called,
-
/// within the same thread.
-
///
-
/// # Usage
-
///
-
/// ```rust, ignore
-
/// let oid1 = with_advanced_timestamp(|| cob.update("New revision OID"));
-
/// let oid2 = with_advanced_timestamp(|| cob.update("Another revision OID"));
-
/// ```
-
///
-
/// # Panics
-
///
-
/// The `STABLE_TIME` is declared in `thread_local`, and so the panic
-
/// information is repeated here.
-
///
-
/// Panics if the key currently has its destructor running, and it may panic if
-
/// the destructor has previously been run for this thread.
-
#[allow(clippy::unwrap_used)]
-
pub fn with_advanced_timestamp<F, T>(f: F) -> T
-
where
-
    F: FnOnce() -> T,
-
{
-
    let step = STEP.get();
-
    let original = read_timestamp();
-
    STABLE_TIME.replace(original + step);
-
    let result = f();
-
    STEP.replace(step + 1);
-
    result
-
}
added crates/radicle-cob/src/backend/stable.rs
@@ -0,0 +1,79 @@
+
use std::{cell::Cell, ops::Add};
+

+
thread_local! {
+
    /// The constant time used by the stable-commit-ids feature.
+
    pub static STABLE_TIME: Cell<i64> = const { Cell::new(1514817556) };
+
    /// An incrementing counter to advance the `STABLE_TIME` value with in
+
    /// [`with_advanced_timestamp`].
+
    pub static STEP: Cell<Step> = Cell::new(Step::default());
+
}
+

+
#[derive(Clone, Copy)]
+
struct Step(i64);
+

+
impl Default for Step {
+
    fn default() -> Self {
+
        Self(1)
+
    }
+
}
+

+
impl Add<Step> for i64 {
+
    type Output = i64;
+

+
    fn add(self, rhs: Step) -> Self::Output {
+
        self + rhs.0
+
    }
+
}
+

+
impl Add<i64> for Step {
+
    type Output = Step;
+

+
    fn add(self, rhs: i64) -> Self::Output {
+
        Step(self.0 + rhs)
+
    }
+
}
+

+
/// Read the current value of `STABLE_TIME`.
+
///
+
/// # Panics
+
///
+
/// The `STABLE_TIME` is declared in `thread_local`, and so the panic
+
/// information is repeated here.
+
///
+
/// Panics if the key currently has its destructor running, and it may panic if
+
/// the destructor has previously been run for this thread.
+
#[allow(clippy::unwrap_used)]
+
pub fn read_timestamp() -> i64 {
+
    STABLE_TIME.get()
+
}
+

+
/// Perform an action `f` that would rely on the `STABLE_TIME` value. This will
+
/// advance the `STABLE_TIME` by an increment of `1` for each time it is called,
+
/// within the same thread.
+
///
+
/// # Usage
+
///
+
/// ```rust, ignore
+
/// let oid1 = with_advanced_timestamp(|| cob.update("New revision OID"));
+
/// let oid2 = with_advanced_timestamp(|| cob.update("Another revision OID"));
+
/// ```
+
///
+
/// # Panics
+
///
+
/// The `STABLE_TIME` is declared in `thread_local`, and so the panic
+
/// information is repeated here.
+
///
+
/// Panics if the key currently has its destructor running, and it may panic if
+
/// the destructor has previously been run for this thread.
+
#[allow(clippy::unwrap_used)]
+
pub fn with_advanced_timestamp<F, T>(f: F) -> T
+
where
+
    F: FnOnce() -> T,
+
{
+
    let step = STEP.get();
+
    let original = read_timestamp();
+
    STABLE_TIME.replace(original + step);
+
    let result = f();
+
    STEP.replace(step + 1);
+
    result
+
}
modified crates/radicle-cob/src/change.rs
@@ -1,6 +1,6 @@
// Copyright © 2021 The Radicle Link Contributors

-
use git_ext::Oid;
+
use oid::Oid;

pub mod store;
pub use store::{Contents, EntryId, Storage, Template, Timestamp};
modified crates/radicle-cob/src/change/store.rs
@@ -3,14 +3,15 @@
use std::{error::Error, fmt, num::NonZeroUsize};

use nonempty::NonEmpty;
-
use radicle_git_ext::Oid;
+
use oid::Oid;
use serde::{Deserialize, Serialize};

+
use crate::object::collaboration::error::{Create, Update};
use crate::{signatures, TypeName};

/// Change entry storage.
pub trait Storage {
-
    type StoreError: Error + Send + Sync + 'static;
+
    type StoreError: Error + Send + Sync + 'static + Into<Create> + Into<Update>;
    type LoadError: Error + Send + Sync + 'static;

    type ObjectId;
@@ -194,6 +195,7 @@ pub struct Embed<T = Vec<u8>> {
    pub content: T,
}

+
#[cfg(feature = "git2")]
impl<T: From<Oid>> Embed<T> {
    /// Create a new embed.
    pub fn store(
@@ -210,6 +212,7 @@ impl<T: From<Oid>> Embed<T> {
    }
}

+
#[cfg(feature = "git2")]
impl Embed<Vec<u8>> {
    /// Get the object id of the embedded content.
    pub fn oid(&self) -> Oid {
modified crates/radicle-cob/src/change_graph.rs
@@ -3,8 +3,8 @@
use std::ops::ControlFlow;
use std::{cmp::Ordering, collections::BTreeSet};

-
use git_ext::Oid;
-
use radicle_dag::Dag;
+
use dag::Dag;
+
use oid::Oid;

use crate::{
    change, object, object::collaboration::Evaluate, signatures::ExtendedSignature,
modified crates/radicle-cob/src/history.rs
@@ -2,8 +2,8 @@
#![allow(clippy::too_many_arguments)]
use std::{cmp::Ordering, collections::BTreeSet, ops::ControlFlow};

-
use git_ext::Oid;
-
use radicle_dag::Dag;
+
use dag::Dag;
+
use oid::Oid;

pub use crate::change::{Contents, Entry, EntryId, Timestamp};

@@ -45,7 +45,7 @@ impl History {
    }

    /// A topological (parents before children) traversal of the dependency
-
    /// graph of this history. This is analagous to
+
    /// graph of this history. This is analogous to
    /// [`std::iter::Iterator::fold`] in that it folds every change into an
    /// accumulator value of type `A`. However, unlike `fold` the function `f`
    /// may prune branches from the dependency graph by returning
@@ -77,7 +77,7 @@ impl History {
        self.graph.node(id, change);

        for tip in tips {
-
            self.graph.dependency(id, (*tip).into());
+
            self.graph.dependency(id, tip);
        }
    }

modified crates/radicle-cob/src/lib.rs
@@ -59,12 +59,20 @@ extern crate qcheck;
#[macro_use(quickcheck)]
extern crate qcheck_macros;

+
extern crate git_ref_format_core as fmt;
extern crate radicle_crypto as crypto;
-
extern crate radicle_git_ext as git_ext;
+
extern crate radicle_dag as dag;
+
extern crate radicle_git_metadata as metadata;
+
extern crate radicle_oid as oid;

mod backend;
+

+
#[cfg(all(any(test, feature = "test"), feature = "git2"))]
pub use backend::git;

+
#[cfg(feature = "stable-commit-ids")]
+
pub use backend::stable;
+

mod change_graph;
mod trailers;

@@ -105,19 +113,9 @@ mod tests;
///
///   * [`object::Storage`]
///
-
/// **Note**: [`change::Storage`] is already implemented for
-
/// [`git2::Repository`]. It is expected that the underlying storage
-
/// for `object::Storage` will also be `git2::Repository`, but if not
-
/// please open an issue to change the definition of `Store` :)
pub trait Store
where
    Self: object::Storage
-
        + change::Storage<
-
            StoreError = git::change::error::Create,
-
            LoadError = git::change::error::Load,
-
            ObjectId = git_ext::Oid,
-
            Parent = git_ext::Oid,
-
            Signatures = ExtendedSignature,
-
        >,
+
        + change::Storage<ObjectId = oid::Oid, Parent = oid::Oid, Signatures = ExtendedSignature>,
{
}
modified crates/radicle-cob/src/object.rs
@@ -1,9 +1,9 @@
// Copyright © 2022 The Radicle Link Contributors

-
use std::{convert::TryFrom as _, fmt, ops::Deref, str::FromStr};
+
use std::{convert::TryFrom as _, ops::Deref, str::FromStr};

-
use git_ext::ref_format::{Component, RefString};
-
use git_ext::Oid;
+
use fmt::{Component, RefString};
+
use oid::Oid;
use serde::{Deserialize, Serialize};
use thiserror::Error;

@@ -19,7 +19,7 @@ pub use storage::{Commit, Objects, Reference, Storage};
#[derive(Debug, Error)]
pub enum ParseObjectId {
    #[error(transparent)]
-
    Git(#[from] git2::Error),
+
    Git(#[from] oid::str::ParseOidError),
}

/// The id of an object
@@ -48,12 +48,14 @@ impl From<&Oid> for ObjectId {
    }
}

+
#[cfg(feature = "git2")]
impl From<git2::Oid> for ObjectId {
    fn from(oid: git2::Oid) -> Self {
        Oid::from(oid).into()
    }
}

+
#[cfg(feature = "git2")]
impl From<&git2::Oid> for ObjectId {
    fn from(oid: &git2::Oid) -> Self {
        ObjectId(Oid::from(*oid))
@@ -68,8 +70,8 @@ impl Deref for ObjectId {
    }
}

-
impl fmt::Display for ObjectId {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
impl std::fmt::Display for ObjectId {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}
modified crates/radicle-cob/src/object/collaboration.rs
@@ -2,8 +2,8 @@
use std::convert::Infallible;
use std::fmt::Debug;

-
use git_ext::Oid;
use nonempty::NonEmpty;
+
use oid::Oid;

use crate::change::store::{Manifest, Version};
use crate::{change, Entry, History, ObjectId, TypeName};
@@ -101,11 +101,11 @@ impl<R> Evaluate<R> for NonEmpty<Entry> {
/// [`TypeName`] and [`ObjectId`] from it.
///
/// This assumes that the `refname` is in a
-
/// [`git_ext::ref_format::Qualified`] format. If it has any
+
/// [`fmt::Qualified`] format. If it has any
/// `refs/namespaces`, they will be stripped to access the underlying
-
/// [`git_ext::ref_format::Qualified`] format.
+
/// [`fmt::Qualified`] format.
///
-
/// In the [`git_ext::ref_format::Qualified`] format it assumes that the
+
/// In the [`fmt::Qualified`] format it assumes that the
/// reference name is of the form:
///
///   `refs/<category>/<typename>/<object_id>[/<rest>*]`
@@ -115,14 +115,14 @@ impl<R> Evaluate<R> for NonEmpty<Entry> {
///
/// Also note that this will return `None` if:
///
-
///   * The `refname` is not [`git_ext::ref_format::Qualified`]
+
///   * The `refname` is not [`fmt::Qualified`]
///   * The parsing of the [`ObjectId`] fails
///   * The parsing of the [`TypeName`] fails
pub fn parse_refstr<R>(name: &R) -> Option<(TypeName, ObjectId)>
where
-
    R: AsRef<git_ext::ref_format::RefStr>,
+
    R: AsRef<fmt::RefStr>,
{
-
    use git_ext::ref_format::Qualified;
+
    use fmt::Qualified;
    let name = name.as_ref();
    let refs_cobs = match name.to_namespaced() {
        None => Qualified::from_refstr(name)?,
modified crates/radicle-cob/src/object/collaboration/create.rs
@@ -4,7 +4,6 @@ use nonempty::NonEmpty;

use crate::Embed;
use crate::Evaluate;
-
use crate::Store;

use super::*;

@@ -23,7 +22,7 @@ pub struct Create {
}

impl Create {
-
    fn template(self) -> change::Template<git_ext::Oid> {
+
    fn template(self) -> change::Template<oid::Oid> {
        change::Template {
            type_name: self.type_name,
            tips: Vec::new(),
@@ -38,7 +37,7 @@ impl Create {
///
/// The `storage` is the backing storage for storing
/// [`crate::Entry`]s at content-addressable locations. Please see
-
/// [`Store`] for further information.
+
/// [`crate::Store`] for further information.
///
/// The `signer` is expected to be a cryptographic signing key. This
/// ensures that the objects origin is cryptographically verifiable.
@@ -47,7 +46,7 @@ impl Create {
/// software project. Its content-address is stored in the object's
/// history.
///
-
/// The `identifier` is a unqiue id that is passed through to the
+
/// The `identifier` is a unique id that is passed through to the
/// [`crate::object::Storage`].
///
/// The `args` are the metadata for this [`CollaborativeObject`]. See
@@ -57,19 +56,24 @@ pub fn create<T, S, G>(
    signer: &G,
    resource: Option<Oid>,
    related: Vec<Oid>,
-
    identifier: &S::Namespace,
+
    identifier: &<S as crate::object::Storage>::Namespace,
    args: Create,
) -> Result<CollaborativeObject<T>, error::Create>
where
    T: Evaluate<S>,
-
    S: Store,
+
    S: crate::object::Storage
+
        + crate::change::Storage<
+
            ObjectId = crate::object::Oid,
+
            Parent = crate::object::Oid,
+
            Signatures = crate::ExtendedSignature,
+
        >,
    G: signature::Signer<crate::ExtendedSignature>,
{
    let type_name = args.type_name.clone();
    let version = args.version;
    let init_change = storage
        .store(resource, related, signer, args.template())
-
        .map_err(error::Create::from)?;
+
        .map_err(Into::<error::Create>::into)?;
    let object_id = init_change.id().into();
    let object = T::init(&init_change, storage).map_err(error::Create::evaluate)?;

modified crates/radicle-cob/src/object/collaboration/error.rs
@@ -2,14 +2,13 @@

use thiserror::Error;

-
use crate::git;
-

#[derive(Debug, Error)]
pub enum Create {
    #[error(transparent)]
    Evaluate(Box<dyn std::error::Error + Send + Sync + 'static>),
    #[error(transparent)]
-
    CreateChange(#[from] git::change::error::Create),
+
    #[cfg(feature = "git2")]
+
    CreateChange(#[from] crate::backend::git::change::error::Create),
    #[error("failed to updated references for during object creation: {err}")]
    Refs {
        #[source]
@@ -39,6 +38,7 @@ pub enum Retrieve {
    #[error(transparent)]
    Evaluate(Box<dyn std::error::Error + Send + Sync + 'static>),
    #[error(transparent)]
+
    #[cfg(feature = "git2")]
    Git(#[from] git2::Error),
    #[error("failed to get references during object retrieval: {err}")]
    Refs {
@@ -62,13 +62,15 @@ pub enum Update {
    #[error("no object found")]
    NoSuchObject,
    #[error(transparent)]
-
    CreateChange(#[from] git::change::error::Create),
+
    #[cfg(feature = "git2")]
+
    CreateChange(#[from] crate::backend::git::change::error::Create),
    #[error("failed to get references during object update: {err}")]
    Refs {
        #[source]
        err: Box<dyn std::error::Error + Send + Sync + 'static>,
    },
    #[error(transparent)]
+
    #[cfg(feature = "git2")]
    Git(#[from] git2::Error),
    #[error(transparent)]
    Io(#[from] std::io::Error),
modified crates/radicle-cob/src/object/collaboration/get.rs
@@ -1,6 +1,8 @@
// Copyright © 2022 The Radicle Link Contributors

-
use crate::{change_graph::ChangeGraph, CollaborativeObject, Evaluate, ObjectId, Store, TypeName};
+
use crypto::ssh::ExtendedSignature;
+

+
use crate::{change_graph::ChangeGraph, CollaborativeObject, Evaluate, ObjectId, TypeName};

use super::error;

@@ -8,7 +10,7 @@ use super::error;
///
/// The `storage` is the backing storage for storing
/// [`crate::Entry`]s at content-addressable locations. Please see
-
/// [`Store`] for further information.
+
/// [`crate::Store`] for further information.
///
/// The `typename` is the type of object to be found, while the
/// `object_id` is the identifier for the particular object under that
@@ -20,7 +22,12 @@ pub fn get<T, S>(
) -> Result<Option<CollaborativeObject<T>>, error::Retrieve>
where
    T: Evaluate<S>,
-
    S: Store,
+
    S: crate::object::Storage,
+
    S: crate::change::Storage<
+
        ObjectId = crate::object::Oid,
+
        Parent = crate::object::Oid,
+
        Signatures = ExtendedSignature,
+
    >,
{
    let tip_refs = storage
        .objects(typename, oid)
modified crates/radicle-cob/src/object/collaboration/info.rs
@@ -6,9 +6,10 @@

use std::collections::BTreeSet;

-
use git_ext::Oid;
+
use crypto::ssh::ExtendedSignature;
+
use oid::Oid;

-
use crate::{change_graph::ChangeGraph, ObjectId, Store, TypeName};
+
use crate::{change_graph::ChangeGraph, ObjectId, TypeName};

use super::error;

@@ -28,7 +29,7 @@ pub struct ChangeGraphInfo {
///
/// The `storage` is the backing storage for storing
/// [`crate::Entry`]s at content-addressable locations. Please see
-
/// [`Store`] for further information.
+
/// [`crate::Store`] for further information.
///
/// The `typename` is the type of object to be found, while the `oid`
/// is the identifier for the particular object under that type.
@@ -38,7 +39,12 @@ pub fn changegraph<S>(
    oid: &ObjectId,
) -> Result<Option<ChangeGraphInfo>, error::Retrieve>
where
-
    S: Store,
+
    S: crate::object::Storage,
+
    S: crate::change::Storage<
+
        ObjectId = crate::object::Oid,
+
        Parent = crate::object::Oid,
+
        Signatures = ExtendedSignature,
+
    >,
{
    let tip_refs = storage
        .objects(typename, oid)
modified crates/radicle-cob/src/object/collaboration/list.rs
@@ -1,6 +1,6 @@
// Copyright © 2022 The Radicle Link Contributors

-
use crate::{change_graph::ChangeGraph, CollaborativeObject, Evaluate, Store, TypeName};
+
use crate::{change_graph::ChangeGraph, CollaborativeObject, Evaluate, TypeName};

use super::error;

@@ -8,7 +8,7 @@ use super::error;
///
/// The `storage` is the backing storage for storing
/// [`crate::Entry`]s at content-addressable locations. Please see
-
/// [`Store`] for further information.
+
/// [`crate::Store`] for further information.
///
/// The `typename` is the type of objects to be listed.
pub fn list<T, S>(
@@ -17,7 +17,12 @@ pub fn list<T, S>(
) -> Result<Vec<CollaborativeObject<T>>, error::Retrieve>
where
    T: Evaluate<S>,
-
    S: Store,
+
    S: crate::object::Storage,
+
    S: crate::change::Storage<
+
        ObjectId = crate::object::Oid,
+
        Parent = crate::object::Oid,
+
        Signatures = crate::ExtendedSignature,
+
    >,
{
    let references = storage
        .types(typename)
modified crates/radicle-cob/src/object/collaboration/remove.rs
@@ -1,6 +1,6 @@
// Copyright © 2022 The Radicle Link Contributors

-
use crate::{ObjectId, Store, TypeName};
+
use crate::{ObjectId, TypeName};

use super::error;

@@ -8,7 +8,7 @@ use super::error;
///
/// The `storage` is the backing storage for storing
/// [`crate::Entry`]s at content-addressable locations. Please see
-
/// [`Store`] for further information.
+
/// [`crate::Store`] for further information.
///
/// The `typename` is the type of object to be found, while the
/// `object_id` is the identifier for the particular object under that
@@ -20,7 +20,7 @@ pub fn remove<S>(
    oid: &ObjectId,
) -> Result<(), error::Remove>
where
-
    S: Store,
+
    S: crate::object::Storage,
{
    storage
        .remove(identifier, typename, oid)
modified crates/radicle-cob/src/object/collaboration/update.rs
@@ -1,12 +1,12 @@
// Copyright © 2022 The Radicle Link Contributors
use std::iter;

-
use git_ext::Oid;
use nonempty::NonEmpty;
+
use oid::Oid;

use crate::{
    change, change_graph::ChangeGraph, history::EntryId, CollaborativeObject, Embed, Evaluate,
-
    ExtendedSignature, ObjectId, Store, TypeName,
+
    ExtendedSignature, ObjectId, TypeName,
};

use super::error;
@@ -40,7 +40,7 @@ pub struct Update {
///
/// The `storage` is the backing storage for storing
/// [`crate::Entry`]s at content-addressable locations. Please see
-
/// [`Store`] for further information.
+
/// [`crate::Store`] for further information.
///
/// The `signer` is expected to be a cryptographic signing key. This
/// ensures that the objects origin is cryptographically verifiable.
@@ -50,11 +50,11 @@ pub struct Update {
/// The `parents` are other the parents of this object, for example a
/// code commit.
///
-
/// The `identifier` is a unqiue id that is passed through to the
+
/// The `identifier` is a unique id that is passed through to the
/// [`crate::object::Storage`].
///
/// The `args` are the metadata for this [`CollaborativeObject`]
-
/// udpate. See [`Update`] for further information.
+
/// update. See [`Update`] for further information.
pub fn update<T, S, G>(
    storage: &S,
    signer: &G,
@@ -65,7 +65,8 @@ pub fn update<T, S, G>(
) -> Result<Updated<T>, error::Update>
where
    T: Evaluate<S>,
-
    S: Store,
+
    S: crate::object::Storage,
+
    S: change::Storage<ObjectId = Oid, Parent = Oid, Signatures = ExtendedSignature>,
    G: signature::Signer<ExtendedSignature>,
{
    let Update {
@@ -86,18 +87,20 @@ where
        graph.evaluate(storage).map_err(error::Update::evaluate)?;

    // Create a commit for this change, but don't update any references yet.
-
    let entry = storage.store(
-
        resource,
-
        related,
-
        signer,
-
        change::Template {
-
            tips: object.history.tips().into_iter().collect(),
-
            embeds,
-
            contents: changes,
-
            type_name: typename.clone(),
-
            message,
-
        },
-
    )?;
+
    let entry = storage
+
        .store(
+
            resource,
+
            related,
+
            signer,
+
            change::Template {
+
                tips: object.history.tips().into_iter().collect(),
+
                embeds,
+
                contents: changes,
+
                type_name: typename.clone(),
+
                message,
+
            },
+
        )
+
        .map_err(Into::<error::Update>::into)?;
    let head = entry.id;
    let parents = entry.parents.to_vec();

modified crates/radicle-cob/src/object/storage.rs
@@ -2,8 +2,8 @@

use std::{collections::BTreeMap, error::Error};

-
use git_ext::ref_format::RefString;
-
use git_ext::Oid;
+
use fmt::RefString;
+
use oid::Oid;

use crate::change::EntryId;
use crate::{ObjectId, TypeName};
@@ -93,41 +93,43 @@ pub trait Storage {
pub mod convert {
    use std::str;

-
    use git_ext::ref_format::RefString;
+
    use fmt::RefString;
    use thiserror::Error;

-
    use super::{Commit, Reference};
-

    #[derive(Debug, Error)]
    pub enum Error {
        #[error("the reference '{name}' does not point to a commit object")]
        NotCommit {
            name: RefString,
+
            #[cfg(feature = "git2")]
            #[source]
            err: git2::Error,
        },
        #[error(transparent)]
-
        Ref(#[from] git_ext::ref_format::Error),
+
        Ref(#[from] fmt::Error),
        #[error(transparent)]
        Utf8(#[from] str::Utf8Error),
    }

-
    impl<'a> TryFrom<git2::Reference<'a>> for Reference {
+
    #[cfg(feature = "git2")]
+
    impl<'a> TryFrom<git2::Reference<'a>> for super::Reference {
        type Error = Error;

        fn try_from(value: git2::Reference<'a>) -> Result<Self, Self::Error> {
            let name = RefString::try_from(str::from_utf8(value.name_bytes())?)?;
-
            let target = Commit::from(value.peel_to_commit().map_err(|err| Error::NotCommit {
-
                name: name.clone(),
-
                err,
-
            })?);
+
            let target =
+
                super::Commit::from(value.peel_to_commit().map_err(|err| Error::NotCommit {
+
                    name: name.clone(),
+
                    err,
+
                })?);
            Ok(Self { name, target })
        }
    }

-
    impl<'a> From<git2::Commit<'a>> for Commit {
+
    #[cfg(feature = "git2")]
+
    impl<'a> From<git2::Commit<'a>> for super::Commit {
        fn from(commit: git2::Commit<'a>) -> Self {
-
            Commit {
+
            Self {
                id: commit.id().into(),
            }
        }
modified crates/radicle-cob/src/signatures.rs
@@ -8,9 +8,9 @@ use std::{
};

use crypto::{ssh, PublicKey};
-
use git_ext::commit::{
+
use metadata::commit::{
    headers::Signature::{Pgp, Ssh},
-
    Commit,
+
    CommitData,
};

pub use ssh::ExtendedSignature;
@@ -55,10 +55,10 @@ impl From<Signatures> for BTreeMap<PublicKey, crypto::Signature> {
    }
}

-
impl TryFrom<&Commit> for Signatures {
+
impl<Tree, Parent> TryFrom<&CommitData<Tree, Parent>> for Signatures {
    type Error = error::Signatures;

-
    fn try_from(value: &Commit) -> Result<Self, Self::Error> {
+
    fn try_from(value: &CommitData<Tree, Parent>) -> Result<Self, Self::Error> {
        value
            .signatures()
            .filter_map(|signature| {
modified crates/radicle-cob/src/signatures/error.rs
@@ -1,6 +1,6 @@
// Copyright © 2019-2020 The Radicle Foundation <hello@radicle.foundation>

-
use radicle_crypto::ssh::ExtendedSignatureError;
+
use crypto::ssh::ExtendedSignatureError;
use thiserror::Error;

#[derive(Debug, Error)]
modified crates/radicle-cob/src/test.rs
@@ -1,7 +1,11 @@
+
#[cfg(feature = "git2")]
pub mod identity;
+
#[cfg(feature = "git2")]
pub use identity::{Person, Project, RemoteProject};

+
#[cfg(feature = "git2")]
pub mod storage;
+
#[cfg(feature = "git2")]
pub use storage::Storage;

pub mod arbitrary;
modified crates/radicle-cob/src/test/arbitrary.rs
@@ -26,11 +26,7 @@ impl Arbitrary for TypeName {

impl Arbitrary for ObjectId {
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
-
        let mut rng = fastrand::Rng::with_seed(u64::arbitrary(g));
-
        let bytes = iter::repeat_with(|| rng.u8(..))
-
            .take(20)
-
            .collect::<Vec<_>>();
-
        Self::from(git_ext::Oid::try_from(bytes.as_slice()).unwrap())
+
        Self::from(oid::Oid::arbitrary(g))
    }
}

modified crates/radicle-cob/src/test/identity.rs
@@ -1,7 +1,9 @@
pub mod project;
pub use project::{Project, RemoteProject};

+
#[cfg(feature = "git2")]
pub mod person;
+
#[cfg(feature = "git2")]
pub use person::Person;

#[derive(Clone, Debug, PartialEq, Eq)]
modified crates/radicle-cob/src/test/identity/person.rs
@@ -1,4 +1,4 @@
-
use git_ext::Oid;
+
use oid::Oid;
use serde::{Deserialize, Serialize};

use crate::test::storage::{self, Storage};
modified crates/radicle-cob/src/test/identity/project.rs
@@ -1,6 +1,6 @@
use std::collections::BTreeSet;

-
use git_ext::Oid;
+
use oid::Oid;
use serde::{Deserialize, Serialize};

use crate::test;
modified crates/radicle-cob/src/test/storage.rs
@@ -1,6 +1,8 @@
use std::{collections::BTreeMap, convert::TryFrom as _};

-
use radicle_git_ext::ref_format::{refname, Component};
+
use radicle_git_ref_format::refname;
+

+
use fmt::Component;
use tempfile::TempDir;

use crate::{
@@ -29,7 +31,7 @@ pub mod error {
        #[error(transparent)]
        Git(#[from] git2::Error),
        #[error(transparent)]
-
        Format(#[from] git_ext::ref_format::Error),
+
        Format(#[from] fmt::Error),
    }
}

@@ -93,16 +95,16 @@ impl change::Storage for Storage {
        self.as_raw().load(id)
    }

-
    fn parents_of(&self, id: &git_ext::Oid) -> Result<Vec<git_ext::Oid>, Self::LoadError> {
+
    fn parents_of(&self, id: &oid::Oid) -> Result<Vec<radicle_oid::Oid>, Self::LoadError> {
        Ok(self
            .as_raw()
-
            .find_commit(**id)?
+
            .find_commit(id.into())?
            .parent_ids()
-
            .map(git_ext::Oid::from)
+
            .map(oid::Oid::from)
            .collect::<Vec<_>>())
    }

-
    fn manifest_of(&self, id: &git_ext::Oid) -> Result<crate::Manifest, Self::LoadError> {
+
    fn manifest_of(&self, id: &oid::Oid) -> Result<crate::Manifest, Self::LoadError> {
        self.as_raw().manifest_of(id)
    }
}
modified crates/radicle-cob/src/tests.rs
@@ -1,238 +1,274 @@
-
use std::ops::ControlFlow;
-

-
use crypto::test::signer::MockSigner;
-
use crypto::{PublicKey, Signer};
-
use git_ext::ref_format::{refname, Component, RefString};
-
use nonempty::{nonempty, NonEmpty};
-
use qcheck::Arbitrary;
-

-
use crate::{
-
    create, get, list, object, test::arbitrary::Invalid, update, Create, Entry, ObjectId, TypeName,
-
    Update, Updated, Version,
-
};
-

-
use super::test;
-

-
#[test]
-
fn roundtrip() {
-
    let storage = test::Storage::new();
-
    let signer = gen::<MockSigner>(1);
-
    let terry = test::Person::new(&storage, "terry", *signer.public_key()).unwrap();
-
    let proj = test::Project::new(&storage, "discworld", *signer.public_key()).unwrap();
-
    let proj = test::RemoteProject {
-
        project: proj,
-
        person: terry,
-
    };
-
    let typename = "xyz.rad.issue".parse::<TypeName>().unwrap();
-
    let cob = create::<NonEmpty<Entry>, _, _>(
-
        &storage,
-
        &signer,
-
        Some(proj.project.content_id),
-
        vec![],
-
        signer.public_key(),
-
        Create {
-
            contents: nonempty!(Vec::new()),
-
            type_name: typename.clone(),
-
            message: "creating xyz.rad.issue".to_string(),
-
            embeds: vec![],
-
            version: Version::default(),
-
        },
-
    )
-
    .unwrap();
-

-
    let expected = get(&storage, &typename, cob.id())
-
        .unwrap()
-
        .expect("BUG: cob was missing");
-

-
    assert_eq!(cob, expected);
-
}
+
use fmt::{Component, RefString};

-
#[test]
-
fn list_cobs() {
-
    let storage = test::Storage::new();
-
    let signer = gen::<MockSigner>(1);
-
    let terry = test::Person::new(&storage, "terry", *signer.public_key()).unwrap();
-
    let proj = test::Project::new(&storage, "discworld", *signer.public_key()).unwrap();
-
    let proj = test::RemoteProject {
-
        project: proj,
-
        person: terry,
-
    };
-
    let typename = "xyz.rad.issue".parse::<TypeName>().unwrap();
-
    let issue_1 = create::<NonEmpty<Entry>, _, _>(
-
        &storage,
-
        &signer,
-
        Some(proj.project.content_id),
-
        vec![],
-
        signer.public_key(),
-
        Create {
-
            contents: nonempty!(b"issue 1".to_vec()),
-
            type_name: typename.clone(),
-
            message: "creating xyz.rad.issue".to_string(),
-
            embeds: vec![],
-
            version: Version::default(),
-
        },
-
    )
-
    .unwrap();
-

-
    let issue_2 = create(
-
        &storage,
-
        &signer,
-
        Some(proj.project.content_id),
-
        vec![],
-
        signer.public_key(),
-
        Create {
-
            contents: nonempty!(b"issue 2".to_vec()),
-
            type_name: typename.clone(),
-
            message: "commenting xyz.rad.issue".to_string(),
-
            embeds: vec![],
-
            version: Version::default(),
-
        },
-
    )
-
    .unwrap();
-

-
    let mut expected = list(&storage, &typename).unwrap();
-
    expected.sort_by(|x, y| x.id().cmp(y.id()));
-

-
    let mut actual = vec![issue_1, issue_2];
-
    actual.sort_by(|x, y| x.id().cmp(y.id()));
-

-
    assert_eq!(actual, expected);
-
}
+
use radicle_git_ref_format::refname;

-
#[test]
-
fn update_cob() {
-
    let storage = test::Storage::new();
-
    let signer = gen::<MockSigner>(1);
-
    let terry = test::Person::new(&storage, "terry", *signer.public_key()).unwrap();
-
    let proj = test::Project::new(&storage, "discworld", *signer.public_key()).unwrap();
-
    let proj = test::RemoteProject {
-
        project: proj,
-
        person: terry,
-
    };
-
    let typename = "xyz.rad.issue".parse::<TypeName>().unwrap();
-
    let cob = create::<NonEmpty<Entry>, _, _>(
-
        &storage,
-
        &signer,
-
        Some(proj.project.content_id),
-
        vec![],
-
        signer.public_key(),
-
        Create {
-
            contents: nonempty!(Vec::new()),
-
            type_name: typename.clone(),
-
            message: "creating xyz.rad.issue".to_string(),
-
            embeds: vec![],
-
            version: Version::default(),
-
        },
-
    )
-
    .unwrap();
-

-
    let not_expected = get::<NonEmpty<Entry>, _>(&storage, &typename, cob.id())
-
        .unwrap()
-
        .expect("BUG: cob was missing");
-

-
    let Updated { object, .. } = update(
-
        &storage,
-
        &signer,
-
        Some(proj.project.content_id),
-
        vec![],
-
        signer.public_key(),
-
        Update {
-
            changes: nonempty!(b"issue 1".to_vec()),
-
            object_id: *cob.id(),
-
            type_name: typename.clone(),
-
            embeds: vec![],
-
            message: "commenting xyz.rad.issue".to_string(),
-
        },
-
    )
-
    .unwrap();
-

-
    let expected = get(&storage, &typename, object.id())
-
        .unwrap()
-
        .expect("BUG: cob was missing");
-

-
    assert_ne!(object, not_expected);
-
    assert_eq!(object, expected, "{object:#?} {expected:#?}");
-
}
+
use crate::{object, test::arbitrary::Invalid, ObjectId, TypeName};

-
#[test]
-
fn traverse_cobs() {
-
    let storage = test::Storage::new();
-
    let neil_signer = gen::<MockSigner>(2);
-
    let neil = test::Person::new(&storage, "gaiman", *neil_signer.public_key()).unwrap();
-
    let terry_signer = gen::<MockSigner>(1);
-
    let terry = test::Person::new(&storage, "pratchett", *terry_signer.public_key()).unwrap();
-
    let proj = test::Project::new(&storage, "discworld", *terry_signer.public_key()).unwrap();
-
    let terry_proj = test::RemoteProject {
-
        project: proj.clone(),
-
        person: terry,
-
    };
-
    let neil_proj = test::RemoteProject {
-
        project: proj,
-
        person: neil,
-
    };
-
    let typename = "xyz.rad.issue".parse::<TypeName>().unwrap();
-
    let cob = create::<NonEmpty<Entry>, _, _>(
-
        &storage,
-
        &terry_signer,
-
        Some(terry_proj.project.content_id),
-
        vec![],
-
        terry_signer.public_key(),
-
        Create {
-
            contents: nonempty!(b"issue 1".to_vec()),
-
            type_name: typename.clone(),
-
            message: "creating xyz.rad.issue".to_string(),
-
            embeds: vec![],
-
            version: Version::default(),
-
        },
-
    )
-
    .unwrap();
-
    copy_to(
-
        storage.as_raw(),
-
        terry_signer.public_key(),
-
        &neil_proj,
-
        &typename,
-
        *cob.id(),
-
    )
-
    .unwrap();
-

-
    let Updated { object, .. } = update::<NonEmpty<Entry>, _, _>(
-
        &storage,
-
        &neil_signer,
-
        Some(neil_proj.project.content_id),
-
        vec![],
-
        neil_signer.public_key(),
-
        Update {
-
            changes: nonempty!(b"issue 2".to_vec()),
-
            object_id: *cob.id(),
-
            type_name: typename,
-
            embeds: vec![],
-
            message: "commenting on xyz.rad.issue".to_string(),
-
        },
-
    )
-
    .unwrap();
-

-
    let root = object.history.root().id;
-
    // traverse over the history and filter by changes that were only authorized by terry
-
    let contents = object
-
        .history()
-
        .traverse(Vec::new(), &[root], |mut acc, _, entry| {
-
            if entry.author() == terry_signer.public_key() {
-
                acc.push(entry.contents().head.clone());
-
            }
-
            ControlFlow::Continue(acc)
-
        });
+
#[cfg(feature = "git2")]
+
mod git {
+
    use std::ops::ControlFlow;

-
    assert_eq!(contents, vec![b"issue 1".to_vec()]);
+
    use crypto::test::signer::MockSigner;
+
    use crypto::{PublicKey, Signer};
+
    use nonempty::{nonempty, NonEmpty};
+
    use qcheck::Arbitrary;

-
    // traverse over the history and filter by changes that were only authorized by neil
-
    let contents = object
-
        .history()
-
        .traverse(Vec::new(), &[root], |mut acc, _, entry| {
-
            acc.push(entry.contents().head.clone());
-
            ControlFlow::Continue(acc)
-
        });
+
    use crate::{
+
        create, get, list, update, Create, Entry, ObjectId, TypeName, Update, Updated, Version,
+
    };

-
    assert_eq!(contents, vec![b"issue 1".to_vec(), b"issue 2".to_vec()]);
+
    use crate::test;
+

+
    #[test]
+
    fn roundtrip() {
+
        let storage = test::Storage::new();
+
        let signer = gen::<MockSigner>(1);
+
        let terry = test::Person::new(&storage, "terry", *signer.public_key()).unwrap();
+
        let proj = test::Project::new(&storage, "discworld", *signer.public_key()).unwrap();
+
        let proj = test::RemoteProject {
+
            project: proj,
+
            person: terry,
+
        };
+
        let typename = "xyz.rad.issue".parse::<TypeName>().unwrap();
+
        let cob = create::<NonEmpty<Entry>, _, _>(
+
            &storage,
+
            &signer,
+
            Some(proj.project.content_id),
+
            vec![],
+
            signer.public_key(),
+
            Create {
+
                contents: nonempty!(Vec::new()),
+
                type_name: typename.clone(),
+
                message: "creating xyz.rad.issue".to_string(),
+
                embeds: vec![],
+
                version: Version::default(),
+
            },
+
        )
+
        .unwrap();
+

+
        let expected = get(&storage, &typename, cob.id())
+
            .unwrap()
+
            .expect("BUG: cob was missing");
+

+
        assert_eq!(cob, expected);
+
    }
+

+
    #[test]
+
    fn list_cobs() {
+
        let storage = test::Storage::new();
+
        let signer = gen::<MockSigner>(1);
+
        let terry = test::Person::new(&storage, "terry", *signer.public_key()).unwrap();
+
        let proj = test::Project::new(&storage, "discworld", *signer.public_key()).unwrap();
+
        let proj = test::RemoteProject {
+
            project: proj,
+
            person: terry,
+
        };
+
        let typename = "xyz.rad.issue".parse::<TypeName>().unwrap();
+
        let issue_1 = create::<NonEmpty<Entry>, _, _>(
+
            &storage,
+
            &signer,
+
            Some(proj.project.content_id),
+
            vec![],
+
            signer.public_key(),
+
            Create {
+
                contents: nonempty!(b"issue 1".to_vec()),
+
                type_name: typename.clone(),
+
                message: "creating xyz.rad.issue".to_string(),
+
                embeds: vec![],
+
                version: Version::default(),
+
            },
+
        )
+
        .unwrap();
+

+
        let issue_2 = create(
+
            &storage,
+
            &signer,
+
            Some(proj.project.content_id),
+
            vec![],
+
            signer.public_key(),
+
            Create {
+
                contents: nonempty!(b"issue 2".to_vec()),
+
                type_name: typename.clone(),
+
                message: "commenting xyz.rad.issue".to_string(),
+
                embeds: vec![],
+
                version: Version::default(),
+
            },
+
        )
+
        .unwrap();
+

+
        let mut expected = list(&storage, &typename).unwrap();
+
        expected.sort_by(|x, y| x.id().cmp(y.id()));
+

+
        let mut actual = vec![issue_1, issue_2];
+
        actual.sort_by(|x, y| x.id().cmp(y.id()));
+

+
        assert_eq!(actual, expected);
+
    }
+

+
    #[test]
+
    fn update_cob() {
+
        let storage = test::Storage::new();
+
        let signer = gen::<MockSigner>(1);
+
        let terry = test::Person::new(&storage, "terry", *signer.public_key()).unwrap();
+
        let proj = test::Project::new(&storage, "discworld", *signer.public_key()).unwrap();
+
        let proj = test::RemoteProject {
+
            project: proj,
+
            person: terry,
+
        };
+
        let typename = "xyz.rad.issue".parse::<TypeName>().unwrap();
+
        let cob = create::<NonEmpty<Entry>, _, _>(
+
            &storage,
+
            &signer,
+
            Some(proj.project.content_id),
+
            vec![],
+
            signer.public_key(),
+
            Create {
+
                contents: nonempty!(Vec::new()),
+
                type_name: typename.clone(),
+
                message: "creating xyz.rad.issue".to_string(),
+
                embeds: vec![],
+
                version: Version::default(),
+
            },
+
        )
+
        .unwrap();
+

+
        let not_expected = get::<NonEmpty<Entry>, _>(&storage, &typename, cob.id())
+
            .unwrap()
+
            .expect("BUG: cob was missing");
+

+
        let Updated { object, .. } = update(
+
            &storage,
+
            &signer,
+
            Some(proj.project.content_id),
+
            vec![],
+
            signer.public_key(),
+
            Update {
+
                changes: nonempty!(b"issue 1".to_vec()),
+
                object_id: *cob.id(),
+
                type_name: typename.clone(),
+
                embeds: vec![],
+
                message: "commenting xyz.rad.issue".to_string(),
+
            },
+
        )
+
        .unwrap();
+

+
        let expected = get(&storage, &typename, object.id())
+
            .unwrap()
+
            .expect("BUG: cob was missing");
+

+
        assert_ne!(object, not_expected);
+
        assert_eq!(object, expected, "{object:#?} {expected:#?}");
+
    }
+

+
    #[test]
+
    fn traverse_cobs() {
+
        let storage = test::Storage::new();
+
        let neil_signer = gen::<MockSigner>(2);
+
        let neil = test::Person::new(&storage, "gaiman", *neil_signer.public_key()).unwrap();
+
        let terry_signer = gen::<MockSigner>(1);
+
        let terry = test::Person::new(&storage, "pratchett", *terry_signer.public_key()).unwrap();
+
        let proj = test::Project::new(&storage, "discworld", *terry_signer.public_key()).unwrap();
+
        let terry_proj = test::RemoteProject {
+
            project: proj.clone(),
+
            person: terry,
+
        };
+
        let neil_proj = test::RemoteProject {
+
            project: proj,
+
            person: neil,
+
        };
+
        let typename = "xyz.rad.issue".parse::<TypeName>().unwrap();
+
        let cob = create::<NonEmpty<Entry>, _, _>(
+
            &storage,
+
            &terry_signer,
+
            Some(terry_proj.project.content_id),
+
            vec![],
+
            terry_signer.public_key(),
+
            Create {
+
                contents: nonempty!(b"issue 1".to_vec()),
+
                type_name: typename.clone(),
+
                message: "creating xyz.rad.issue".to_string(),
+
                embeds: vec![],
+
                version: Version::default(),
+
            },
+
        )
+
        .unwrap();
+
        copy_to(
+
            storage.as_raw(),
+
            terry_signer.public_key(),
+
            &neil_proj,
+
            &typename,
+
            *cob.id(),
+
        )
+
        .unwrap();
+

+
        let Updated { object, .. } = update::<NonEmpty<Entry>, _, _>(
+
            &storage,
+
            &neil_signer,
+
            Some(neil_proj.project.content_id),
+
            vec![],
+
            neil_signer.public_key(),
+
            Update {
+
                changes: nonempty!(b"issue 2".to_vec()),
+
                object_id: *cob.id(),
+
                type_name: typename,
+
                embeds: vec![],
+
                message: "commenting on xyz.rad.issue".to_string(),
+
            },
+
        )
+
        .unwrap();
+

+
        let root = object.history.root().id;
+
        // traverse over the history and filter by changes that were only authorized by terry
+
        let contents = object
+
            .history()
+
            .traverse(Vec::new(), &[root], |mut acc, _, entry| {
+
                if entry.author() == terry_signer.public_key() {
+
                    acc.push(entry.contents().head.clone());
+
                }
+
                ControlFlow::Continue(acc)
+
            });
+

+
        assert_eq!(contents, vec![b"issue 1".to_vec()]);
+

+
        // traverse over the history and filter by changes that were only authorized by neil
+
        let contents = object
+
            .history()
+
            .traverse(Vec::new(), &[root], |mut acc, _, entry| {
+
                acc.push(entry.contents().head.clone());
+
                ControlFlow::Continue(acc)
+
            });
+

+
        assert_eq!(contents, vec![b"issue 1".to_vec(), b"issue 2".to_vec()]);
+
    }
+

+
    fn copy_to(
+
        repo: &git2::Repository,
+
        from: &PublicKey,
+
        to: &test::RemoteProject,
+
        typename: &TypeName,
+
        object: ObjectId,
+
    ) -> Result<(), git2::Error> {
+
        let original = {
+
            let name = format!("refs/rad/{from}/cobs/{typename}/{object}");
+
            let r = repo.find_reference(&name)?;
+
            r.target().unwrap()
+
        };
+

+
        let name = format!(
+
            "refs/rad/{}/cobs/{}/{}",
+
            to.identifier().to_path(),
+
            typename,
+
            object
+
        );
+
        repo.reference(&name, original, false, "copying object reference")?;
+
        Ok(())
+
    }
+

+
    fn gen<T: Arbitrary>(size: usize) -> T {
+
        let mut gen = qcheck::Gen::new(size);
+

+
        T::arbitrary(&mut gen)
+
    }
}

#[quickcheck]
@@ -297,32 +333,3 @@ fn invalid_parse_refstr(oid: Invalid<ObjectId>, typename: TypeName) {
        None
    );
}
-

-
fn gen<T: Arbitrary>(size: usize) -> T {
-
    let mut gen = qcheck::Gen::new(size);
-

-
    T::arbitrary(&mut gen)
-
}
-

-
fn copy_to(
-
    repo: &git2::Repository,
-
    from: &PublicKey,
-
    to: &test::RemoteProject,
-
    typename: &TypeName,
-
    object: ObjectId,
-
) -> Result<(), git2::Error> {
-
    let original = {
-
        let name = format!("refs/rad/{from}/cobs/{typename}/{object}");
-
        let r = repo.find_reference(&name)?;
-
        r.target().unwrap()
-
    };
-

-
    let name = format!(
-
        "refs/rad/{}/cobs/{}/{}",
-
        to.identifier().to_path(),
-
        typename,
-
        object
-
    );
-
    repo.reference(&name, original, false, "copying object reference")?;
-
    Ok(())
-
}
modified crates/radicle-cob/src/trailers.rs
@@ -1,6 +1,6 @@
// Copyright © 2019-2020 The Radicle Foundation <hello@radicle.foundation>

-
use git_ext::commit::trailers::{OwnedTrailer, Token, Trailer};
+
use metadata::commit::trailers::{OwnedTrailer, Token, Trailer};
use std::ops::Deref as _;

pub mod error {
@@ -12,21 +12,22 @@ pub mod error {
        WrongToken,
        #[error("no value for Rad-Resource")]
        NoValue,
-
        #[error("invalid git OID")]
-
        InvalidOid,
+
        /// Invalid object ID.
+
        #[error("invalid oid: {0}")]
+
        InvalidOid(#[from] radicle_oid::str::ParseOidError),
    }
}

/// Commit trailer for COB commits.
pub enum CommitTrailer {
    /// Points to the owning resource.
-
    Resource(git2::Oid),
+
    Resource(oid::Oid),
    /// Points to a related change.
-
    Related(git2::Oid),
+
    Related(oid::Oid),
}

impl CommitTrailer {
-
    pub fn oid(&self) -> git2::Oid {
+
    pub fn oid(&self) -> oid::Oid {
        match self {
            Self::Resource(oid) => *oid,
            Self::Related(oid) => *oid,
@@ -38,12 +39,11 @@ impl TryFrom<&Trailer<'_>> for CommitTrailer {
    type Error = error::InvalidResourceTrailer;

    fn try_from(Trailer { value, token }: &Trailer<'_>) -> Result<Self, Self::Error> {
-
        let ext_oid =
-
            git_ext::Oid::try_from(value.as_ref()).map_err(|_| Self::Error::InvalidOid)?;
+
        let oid = value.as_ref().parse::<oid::Oid>()?;
        if token.deref() == "Rad-Resource" {
-
            Ok(CommitTrailer::Resource(ext_oid.into()))
+
            Ok(CommitTrailer::Resource(oid))
        } else if token.deref() == "Rad-Related" {
-
            Ok(CommitTrailer::Related(ext_oid.into()))
+
            Ok(CommitTrailer::Related(oid))
        } else {
            Err(Self::Error::WrongToken)
        }
modified crates/radicle-cob/src/type_name.rs
@@ -1,14 +1,17 @@
// Copyright © 2022 The Radicle Link Contributors

-
use std::{fmt, str::FromStr};
+
use std::str::FromStr;

-
use git_ext::ref_format::{Component, RefString};
+
use fmt::{Component, RefString};
use serde::{Deserialize, Serialize};
use thiserror::Error;

/// The typename of an object. Valid typenames MUST be sequences of
-
/// alphanumeric characters separated by a period. The name must start
-
/// and end with an alphanumeric character
+
/// alphanumeric characters or hyphens separated by a period. Each
+
/// component must start and end with an alphanumeric character.
+
///
+
/// The total length of a typename MUST NOT exceed 255, and each component
+
/// length MUST NOT exceed 63.
///
/// # Examples
///
@@ -19,13 +22,16 @@ use thiserror::Error;
pub struct TypeName(String);

impl TypeName {
+
    const MAX_LENGTH: usize = 255;
+
    const MAX_COMPONENT: usize = 63;
+

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

-
impl fmt::Display for TypeName {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
impl std::fmt::Display for TypeName {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.0.as_str())
    }
}
@@ -40,14 +46,35 @@ impl FromStr for TypeName {
    type Err = TypeNameParse;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        if s.len() > Self::MAX_LENGTH {
+
            return Err(TypeNameParse {
+
                invalid: s.to_string(),
+
            });
+
        }
        let split = s.split('.');
        for component in split {
+
            if component.len() > Self::MAX_COMPONENT {
+
                return Err(TypeNameParse {
+
                    invalid: s.to_string(),
+
                });
+
            }
            if component.is_empty() {
                return Err(TypeNameParse {
                    invalid: s.to_string(),
                });
            }
-
            if !component.chars().all(char::is_alphanumeric) {
+
            if !component
+
                .chars()
+
                .all(|c| c.is_ascii_alphanumeric() || c == '-')
+
            {
+
                return Err(TypeNameParse {
+
                    invalid: s.to_string(),
+
                });
+
            }
+

+
            let first = component.chars().next().expect("component is not empty");
+
            let last = component.chars().last().expect("component is not empty");
+
            if !first.is_ascii_alphanumeric() || !last.is_ascii_alphanumeric() {
                return Err(TypeNameParse {
                    invalid: s.to_string(),
                });
@@ -77,7 +104,27 @@ mod test {
        assert!(TypeName::from_str("abc.def.ghi").is_ok());
        assert!(TypeName::from_str("abc.123.ghi").is_ok());
        assert!(TypeName::from_str("1bc.123.ghi").is_ok());
+
        assert!(TypeName::from_str("1bc-123.ghi").is_ok());
+
    }
+

+
    #[test]
+
    fn invalid_typenames() {
+
        assert!(TypeName::from_str("").is_err());
+
        assert!(TypeName::from_str(".").is_err());
        assert!(TypeName::from_str(".abc.123.ghi").is_err());
        assert!(TypeName::from_str("abc.123.ghi.").is_err());
+
        assert!(TypeName::from_str("abc..ghi").is_err());
+
        assert!(TypeName::from_str("abc.-123.ghi").is_err());
+
        assert!(TypeName::from_str("abc.123-.ghi").is_err());
+
        assert!(TypeName::from_str(&format!(
+
            "a.very.long.name.that.exceeds.the.two-hundred-and-fifty-five.length.limit.{}",
+
            "a".repeat(255)
+
        ))
+
        .is_err());
+
        assert!(TypeName::from_str(&format!(
+
            "component.exceeds.sixty-three.limit.{}",
+
            "a".repeat(64)
+
        ))
+
        .is_err());
    }
}
added crates/radicle-core/Cargo.toml
@@ -0,0 +1,38 @@
+
[package]
+
name = "radicle-core"
+
description = "Radicle core data type definitions"
+
homepage.workspace = true
+
repository.workspace = true
+
version = "0.1.0"
+
edition.workspace = true
+
license.workspace = true
+
keywords = ["radicle", "git", "data-types"]
+
rust-version.workspace = true
+

+
# For documentation of features refer to the module documentation in `./lib.rs`
+
[features]
+
default = ["std"]
+
git2 = ["dep:git2", "radicle-oid/git2"]
+
gix = ["dep:gix-hash", "radicle-oid/gix"]
+
std = ["radicle-oid/std", "thiserror/std", "schemars/std", "serde/std"]
+

+
[dependencies]
+
git2 = { workspace = true, optional = true }
+
gix-hash = { workspace = true, optional = true }
+
multibase = { workspace = true }
+
proptest = { workspace = true, optional = true }
+
qcheck = { workspace = true, optional = true }
+
radicle-crypto = { workspace = true }
+
radicle-git-ref-format = { workspace = true, optional = true }
+
radicle-oid = { workspace = true, default-features = false, features = ["sha1"] }
+
schemars = { workspace = true, optional = true, default-features = false, features = ["derive"] }
+
serde = { workspace = true, optional = true, default-features = false }
+
sqlite = { workspace = true, optional = true }
+
thiserror = { workspace = true, default-features = false }
+

+
[dev-dependencies]
+
proptest = { workspace = true }
+
serde_json = { workspace = true }
+

+
[lints]
+
workspace = true
added crates/radicle-core/src/lib.rs
@@ -0,0 +1,78 @@
+
#![no_std]
+

+
//! This a crate for defining core data type for the Radicle protocol, such as
+
//! [`RepoId`].
+
//!
+
//! # Feature Flags
+
//!
+
//! The only default feature is `std`.
+
//!
+
//! ## `std`
+
//!
+
//! [`OsString`]: ::doc_std::ffi::OsString
+
//!
+
//! Provides implementation of [`TryFrom<OsString>`].
+
//!
+
//! Enabled by default, since it is expected that most dependents will use the
+
//! standard library.
+
//!
+
//! ## `git2`
+
//!
+
//! [`git2::Oid`]: ::git2::Oid
+
//!
+
//! Provides conversion from a [`git2::Oid`] to a [`RepoId`].
+
//!
+
//! ## `gix`
+
//!
+
//! [`ObjectId`]: ::gix_hash::ObjectId
+
//!
+
//! Provides conversion from a [`ObjectId`] to a [`RepoId`].
+
//!
+
//! ## `radicle-git-ref-format`
+
//!
+
//! Provides conversions from data types defined in `radicle-core` into valid
+
//! reference components and/or strings.
+
//!
+
//! ## `serde`
+
//!
+
//! [`Serialize`]: ::serde::ser::Serialize
+
//! [`Deserialize`]: ::serde::de::Deserialize
+
//!
+
//! Provides implementations of [`Serialize`] and [`Deserialize`].
+
//!
+
//! ## `schemars`
+
//!
+
//! [`JsonSchema`]: ::schemars::JsonSchema
+
//!
+
//! Provides implementations of [`JsonSchema`].
+
//!
+
//! ## `proptest`
+
//!
+
//! [`proptest::Strategy`]: ::proptest::strategy::Strategy
+
//!
+
//! Provides functions for generating different types of [`proptest::Strategy`].
+
//!
+
//! ## `qcheck`
+
//!
+
//! [`qcheck::Arbitrary`]: ::qcheck::Arbitrary
+
//!
+
//! Provides implementations of [`qcheck::Arbitrary`].
+
//!
+
//! ## `sqlite`
+
//!
+
//! [`sqlite::BindableWithIndex`]: ::sqlite::BindableWithIndex
+
//! [`sqlite::Value`]: ::sqlite::Value
+
//!
+
//! Provides implementations of [`sqlite::BindableWithIndex`] and `TryFrom`
+
//! implementations from the [`sqlite::Value`] type to the domain type.
+

+
#[cfg(doc)]
+
extern crate std as doc_std;
+

+
extern crate alloc;
+

+
pub mod node;
+
pub use node::NodeId;
+

+
pub mod repo;
+
pub use repo::RepoId;
added crates/radicle-core/src/node.rs
@@ -0,0 +1,24 @@
+
//! A Radicle node on the network is identified by its [`NodeId`], which in turn
+
//! is a Ed25519 public key.
+
//!
+
//! The human-readable format is a multibase-encoded format of the underlying Ed25519 public key, i.e.
+
//! ```text
+
//! MULTIBASE(base58-btc, MULTICODEC(public-key-type, raw-public-key-bytes))
+
//! ```
+
//! which results in strings that look like:
+
//! ```text
+
//! z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
//! ```
+

+
use radicle_crypto::PublicKey;
+

+
/// Public identifier of a node device in the network.
+
///
+
/// # Legacy
+
///
+
/// This is a type alias, providing little protection around evolving a [`NodeId`]
+
/// and having it very tightly coupled with a [`PublicKey`].
+
///
+
/// Future iterations will change this to provide a better API for working with
+
/// [`NodeId`]'s and their usage in the protocol.
+
pub type NodeId = PublicKey;
added crates/radicle-core/src/repo.rs
@@ -0,0 +1,395 @@
+
use alloc::fmt;
+
use alloc::string::String;
+
use alloc::string::ToString as _;
+
use alloc::vec::Vec;
+

+
use radicle_oid::Oid;
+
use thiserror::Error;
+

+
/// Radicle identifier prefix.
+
pub const RAD_PREFIX: &str = "rad:";
+

+
#[non_exhaustive]
+
#[derive(Error, Debug)]
+
pub enum IdError {
+
    #[error(transparent)]
+
    Multibase(#[from] multibase::Error),
+
    #[error("invalid length: expected {expected} bytes, got {actual} bytes")]
+
    Length { expected: usize, actual: usize },
+
    #[error(fmt = fmt_mismatched_base_encoding)]
+
    MismatchedBaseEncoding {
+
        input: String,
+
        expected: Vec<multibase::Base>,
+
        found: multibase::Base,
+
    },
+
}
+

+
fn fmt_mismatched_base_encoding(
+
    input: &String,
+
    expected: &[multibase::Base],
+
    found: &multibase::Base,
+
    formatter: &mut fmt::Formatter,
+
) -> fmt::Result {
+
    write!(
+
        formatter,
+
        "invalid multibase encoding '{}' for '{}', expected one of {:?}",
+
        found.code(),
+
        input,
+
        expected.iter().map(|base| base.code()).collect::<Vec<_>>()
+
    )
+
}
+

+
/// A repository identifier.
+
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
pub struct RepoId(
+
    #[cfg_attr(feature = "schemars", schemars(
+
        with = "String",
+
        description = "A repository identifier. Starts with \"rad:\", followed by a multibase Base58 encoded Git object identifier.",
+
        regex(pattern = r"rad:z[1-9a-km-zA-HJ-NP-Z]+"),
+
        length(min = 5),
+
        example = &"rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
+
    ))]
+
    Oid,
+
);
+

+
impl core::fmt::Display for RepoId {
+
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+
        f.write_str(self.urn().as_str())
+
    }
+
}
+

+
impl core::fmt::Debug for RepoId {
+
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+
        write!(f, "RepoId({self})")
+
    }
+
}
+

+
impl RepoId {
+
    const ALLOWED_BASES: [multibase::Base; 1] = [multibase::Base::Base58Btc];
+

+
    /// Format the identifier as a human-readable URN.
+
    ///
+
    /// Eg. `rad:z3XncAdkZjeK9mQS5Sdc4qhw98BUX`.
+
    ///
+
    #[must_use]
+
    pub fn urn(&self) -> String {
+
        RAD_PREFIX.to_string() + &self.canonical()
+
    }
+

+
    /// Parse an identifier from the human-readable URN format.
+
    /// Accepts strings without the radicle prefix as well,
+
    /// for convenience.
+
    pub fn from_urn(s: &str) -> Result<Self, IdError> {
+
        let s = s.strip_prefix(RAD_PREFIX).unwrap_or(s);
+
        let id = Self::from_canonical(s)?;
+

+
        Ok(id)
+
    }
+

+
    /// Format the identifier as a multibase string.
+
    ///
+
    /// Eg. `z3XncAdkZjeK9mQS5Sdc4qhw98BUX`.
+
    ///
+
    #[must_use]
+
    pub fn canonical(&self) -> String {
+
        multibase::encode(multibase::Base::Base58Btc, AsRef::<[u8]>::as_ref(&self.0))
+
    }
+

+
    /// Decode the input string into a [`RepoId`].
+
    ///
+
    /// # Errors
+
    ///
+
    /// - The [multibase] decoding fails
+
    /// - The decoded [multibase] code does not match any expected multibase code
+
    /// - The input exceeds the expected number of bytes, post multibase decoding
+
    ///
+
    /// [multibase]: https://github.com/multiformats/multibase?tab=readme-ov-file#multibase-table
+
    pub fn from_canonical(input: &str) -> Result<Self, IdError> {
+
        const EXPECTED_LEN: usize = 20;
+
        let (base, bytes) = multibase::decode(input)?;
+
        Self::guard_base_encoding(input, base)?;
+
        let bytes: [u8; EXPECTED_LEN] =
+
            bytes.try_into().map_err(|bytes: Vec<u8>| IdError::Length {
+
                expected: EXPECTED_LEN,
+
                actual: bytes.len(),
+
            })?;
+
        Ok(Self(Oid::from_sha1(bytes)))
+
    }
+

+
    fn guard_base_encoding(input: &str, base: multibase::Base) -> Result<(), IdError> {
+
        if !Self::ALLOWED_BASES.contains(&base) {
+
            Err(IdError::MismatchedBaseEncoding {
+
                input: input.to_string(),
+
                expected: Self::ALLOWED_BASES.to_vec(),
+
                found: base,
+
            })
+
        } else {
+
            Ok(())
+
        }
+
    }
+
}
+

+
impl core::str::FromStr for RepoId {
+
    type Err = IdError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        Self::from_urn(s)
+
    }
+
}
+

+
#[cfg(feature = "std")]
+
mod std_impls {
+
    extern crate std;
+

+
    use super::{IdError, RepoId};
+

+
    use std::ffi::OsString;
+

+
    impl TryFrom<OsString> for RepoId {
+
        type Error = IdError;
+

+
        fn try_from(value: OsString) -> Result<Self, Self::Error> {
+
            let string = value.to_string_lossy();
+
            Self::from_canonical(&string)
+
        }
+
    }
+

+
    impl std::hash::Hash for RepoId {
+
        fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+
            self.0.hash(state)
+
        }
+
    }
+
}
+

+
impl From<Oid> for RepoId {
+
    fn from(oid: Oid) -> Self {
+
        Self(oid)
+
    }
+
}
+

+
impl core::ops::Deref for RepoId {
+
    type Target = Oid;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.0
+
    }
+
}
+

+
#[cfg(feature = "git2")]
+
mod git2_impls {
+
    use super::RepoId;
+

+
    impl From<git2::Oid> for RepoId {
+
        fn from(oid: git2::Oid) -> Self {
+
            Self(oid.into())
+
        }
+
    }
+
}
+

+
#[cfg(feature = "gix")]
+
mod gix_impls {
+
    use super::RepoId;
+

+
    impl From<gix_hash::ObjectId> for RepoId {
+
        fn from(oid: gix_hash::ObjectId) -> Self {
+
            Self(oid.into())
+
        }
+
    }
+
}
+

+
#[cfg(feature = "radicle-git-ref-format")]
+
mod radicle_git_ref_format_impls {
+
    use alloc::string::ToString;
+

+
    use radicle_git_ref_format::{Component, RefString};
+

+
    use super::RepoId;
+

+
    impl From<&RepoId> for Component<'_> {
+
        fn from(id: &RepoId) -> Self {
+
            let refstr = RefString::try_from(id.0.to_string())
+
                .expect("repository id's are valid ref strings");
+
            Component::from_refstr(refstr).expect("repository id's are valid refname components")
+
        }
+
    }
+
}
+

+
#[cfg(feature = "serde")]
+
mod serde_impls {
+
    use alloc::string::String;
+

+
    use serde::{de, Deserialize, Deserializer, Serialize};
+

+
    use super::RepoId;
+

+
    impl Serialize for RepoId {
+
        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
        where
+
            S: serde::Serializer,
+
        {
+
            serializer.collect_str(&self.urn())
+
        }
+
    }
+

+
    impl<'de> Deserialize<'de> for RepoId {
+
        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+
        where
+
            D: Deserializer<'de>,
+
        {
+
            String::deserialize(deserializer)?
+
                .parse()
+
                .map_err(de::Error::custom)
+
        }
+
    }
+

+
    #[cfg(test)]
+
    mod test {
+
        use proptest::proptest;
+

+
        use super::super::*;
+

+
        fn prop_roundtrip_serde_json(rid: RepoId) {
+
            let encoded = serde_json::to_string(&rid).unwrap();
+
            let decoded = serde_json::from_str(&encoded).unwrap();
+

+
            assert_eq!(rid, decoded);
+
        }
+

+
        proptest! {
+
            #[test]
+
            fn assert_prop_roundtrip_serde_json(rid in arbitrary::rid()) {
+
                prop_roundtrip_serde_json(rid)
+
            }
+
        }
+
    }
+
}
+

+
#[cfg(feature = "sqlite")]
+
mod sqlite_impls {
+
    use alloc::format;
+
    use alloc::string::ToString;
+

+
    use super::RepoId;
+

+
    use sqlite::{BindableWithIndex, Error, ParameterIndex, Statement, Value};
+

+
    impl TryFrom<&Value> for RepoId {
+
        type Error = Error;
+

+
        fn try_from(value: &Value) -> Result<Self, Self::Error> {
+
            match value {
+
                Value::String(id) => RepoId::from_urn(id).map_err(|e| Error {
+
                    code: None,
+
                    message: Some(e.to_string()),
+
                }),
+
                Value::Binary(_) | Value::Float(_) | Value::Integer(_) | Value::Null => {
+
                    Err(Error {
+
                        code: None,
+
                        message: Some(format!("sql: invalid type `{:?}` for id", value.kind())),
+
                    })
+
                }
+
            }
+
        }
+
    }
+

+
    impl BindableWithIndex for &RepoId {
+
        fn bind<I: ParameterIndex>(self, stmt: &mut Statement<'_>, i: I) -> sqlite::Result<()> {
+
            self.urn().as_str().bind(stmt, i)
+
        }
+
    }
+
}
+

+
#[cfg(any(test, feature = "proptest"))]
+
pub mod arbitrary {
+
    use proptest::prelude::Strategy;
+

+
    use super::RepoId;
+

+
    pub fn rid() -> impl Strategy<Value = RepoId> {
+
        proptest::array::uniform20(proptest::num::u8::ANY)
+
            .prop_map(|bytes| RepoId::from(radicle_oid::Oid::from_sha1(bytes)))
+
    }
+
}
+

+
#[cfg(feature = "qcheck")]
+
impl qcheck::Arbitrary for RepoId {
+
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
+
        let bytes = <[u8; 20]>::arbitrary(g);
+
        let oid = radicle_oid::Oid::from_sha1(bytes);
+

+
        RepoId::from(oid)
+
    }
+
}
+

+
#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
+
mod test {
+
    use super::*;
+
    use proptest::proptest;
+

+
    fn prop_roundtrip_parse(rid: RepoId) {
+
        use core::str::FromStr as _;
+
        let encoded = rid.to_string();
+
        let decoded = RepoId::from_str(&encoded).unwrap();
+

+
        assert_eq!(rid, decoded);
+
    }
+

+
    proptest! {
+
        #[test]
+
        fn assert_prop_roundtrip_parse(rid in arbitrary::rid()) {
+
            prop_roundtrip_parse(rid)
+
        }
+
    }
+

+
    #[test]
+
    fn invalid() {
+
        assert!("".parse::<RepoId>().is_err());
+
        assert!("not-a-valid-rid".parse::<RepoId>().is_err());
+
        assert!("xyz:z3gqcJUoA1n9HaHKufZs5FCSGazv5"
+
            .parse::<RepoId>()
+
            .is_err());
+
        assert!("RAD:z3gqcJUoA1n9HaHKufZs5FCSGazv5"
+
            .parse::<RepoId>()
+
            .is_err());
+
        assert!("rad:".parse::<RepoId>().is_err());
+
        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSG0zv5"
+
            .parse::<RepoId>()
+
            .is_err());
+
        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSGOzv5"
+
            .parse::<RepoId>()
+
            .is_err());
+
        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSGIzv5"
+
            .parse::<RepoId>()
+
            .is_err());
+
        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSGlzv5"
+
            .parse::<RepoId>()
+
            .is_err());
+
        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSGázv5"
+
            .parse::<RepoId>()
+
            .is_err());
+
        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSG@zv5"
+
            .parse::<RepoId>()
+
            .is_err());
+
        assert!("rad:Z3gqcJUoA1n9HaHKufZs5FCSGazv5"
+
            .parse::<RepoId>()
+
            .is_err());
+
        assert!("rad:z3gqcJUoA1n9HaHKuf".parse::<RepoId>().is_err());
+
        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5abcdef"
+
            .parse::<RepoId>()
+
            .is_err());
+
        assert!("rad: z3gqcJUoA1n9HaHKufZs5FCSGazv5"
+
            .parse::<RepoId>()
+
            .is_err());
+
    }
+

+
    #[test]
+
    fn valid() {
+
        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5"
+
            .parse::<RepoId>()
+
            .is_ok());
+
        assert!("z3gqcJUoA1n9HaHKufZs5FCSGazv5".parse::<RepoId>().is_ok());
+
        assert!("z3XncAdkZjeK9mQS5Sdc4qhw98BUX".parse::<RepoId>().is_ok());
+
    }
+
}
added crates/radicle-crypto/CHANGELOG.md
@@ -0,0 +1,36 @@
+
# Changelog
+

+
All notable changes to this project will be documented in this file.
+

+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+

+
## [Unreleased]
+

+
### Added
+

+
### Changed
+

+
### Removed
+

+
### Security
+

+
## 0.15.0
+

+
### Changed
+

+
- The following enums are now marked as `non_exhaustive`:
+
  `radicle_crypto::SignatureError`, `radicle_crypto::PublicKeyError`,
+
  `radicle_crypto::ssh::PublicKeyError`, `radicle_crypto::ssh::SignatureError`,
+
  `radicle_crypto::ssh::SecretKeyError`,
+
  `radicle_crypto::ssh::ExtendedSignatureError`,
+
  `radicle_crypto::ssh::keystore::Error`, and
+
  `radicle_crypto::ssh::keystore::MemorySignerError`.
+

+
### Removed
+

+
- The `radicle-git-ext` Cargo feature was removed.
+

+
### Security
+

+
*No security updates.*
modified crates/radicle-crypto/Cargo.toml
@@ -4,7 +4,7 @@ description = "Radicle cryptographic primitives"
homepage.workspace = true
repository.workspace = true
license.workspace = true
-
version = "0.13.0"
+
version = "0.15.0"
authors = [
  "cloudhead <cloudhead@radicle.xyz>",
  "Fintan Halpenny <fintan.halpenny@gmail.com>",
@@ -23,13 +23,14 @@ ec25519 = "0.1.0"
fastrand = { workspace = true, optional = true }
multibase = { workspace = true }
qcheck = { workspace = true, optional = true }
-
radicle-git-ext = { workspace = true, optional = true }
+
git-ref-format-core = { workspace = true, optional = true }
radicle-ssh = { workspace = true, optional = true }
-
serde = { workspace = true, features = ["derive"] }
+
schemars = { workspace = true, optional = true, features = ["derive", "std"] }
+
serde = { workspace = true, features = ["derive", "std"] }
signature = { workspace = true }
sqlite = { workspace = true, features = ["bundled"], optional = true }
ssh-key = { version = "0.6.3", default-features = false, features = ["std", "encryption", "getrandom"], optional = true }
-
thiserror = { workspace = true }
+
thiserror = { workspace = true, default-features = true }
zeroize = { workspace = true }

[dev-dependencies]
modified crates/radicle-crypto/src/lib.rs
@@ -28,6 +28,7 @@ pub type SharedSecret = [u8; 32];
/// Error returned if signing fails, eg. due to an HSM or KMS.
#[derive(Debug, Clone, Error)]
#[error(transparent)]
+
#[non_exhaustive]
pub struct SignerError {
    #[from]
    source: Arc<dyn std::error::Error + Send + Sync>,
@@ -93,6 +94,7 @@ impl fmt::Debug for Signature {
}

#[derive(Error, Debug)]
+
#[non_exhaustive]
pub enum SignatureError {
    #[error("invalid multibase string: {0}")]
    Multibase(#[from] multibase::Error),
@@ -156,6 +158,20 @@ impl TryFrom<String> for Signature {
/// The public/verification key.
#[derive(Hash, Serialize, Deserialize, PartialEq, Eq, Copy, Clone)]
#[serde(into = "String", try_from = "String")]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
#[cfg_attr(
+
    feature = "schemars",
+
    schemars(
+
        title = "Ed25519",
+
        description = "An Ed25519 public key in multibase encoding.",
+
        extend("examples" = [
+
            "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7",
+
            "z6MkvUJtYD9dHDJfpevWRT98mzDDpdAtmUjwyDSkyqksUr7C",
+
            "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
            "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
+
        ]),
+
    ),
+
)]
pub struct PublicKey(pub ed25519::PublicKey);

#[cfg(feature = "cyphernet")]
@@ -273,6 +289,7 @@ impl Deref for SecretKey {
}

#[derive(Error, Debug)]
+
#[non_exhaustive]
pub enum PublicKeyError {
    #[error("invalid length {0}")]
    InvalidLength(usize),
@@ -350,20 +367,20 @@ impl PublicKey {
        multibase::encode(multibase::Base::Base58Btc, buf)
    }

-
    #[cfg(feature = "radicle-git-ext")]
-
    pub fn to_namespace(&self) -> radicle_git_ext::ref_format::RefString {
-
        use radicle_git_ext::ref_format::{refname, Component};
-
        refname!("refs/namespaces").join(Component::from(self))
+
    #[cfg(feature = "git-ref-format-core")]
+
    pub fn to_namespace(&self) -> git_ref_format_core::RefString {
+
        use git_ref_format_core::name::{Component, NAMESPACES, REFS};
+
        REFS.to_owned().and(NAMESPACES).and(Component::from(self))
    }

-
    #[cfg(feature = "radicle-git-ext")]
-
    pub fn to_component(&self) -> radicle_git_ext::ref_format::Component {
-
        radicle_git_ext::ref_format::Component::from(self)
+
    #[cfg(feature = "git-ref-format-core")]
+
    pub fn to_component(&self) -> git_ref_format_core::Component<'_> {
+
        git_ref_format_core::Component::from(self)
    }

-
    #[cfg(feature = "radicle-git-ext")]
+
    #[cfg(feature = "git-ref-format-core")]
    pub fn from_namespaced(
-
        refstr: &radicle_git_ext::ref_format::Namespaced,
+
        refstr: &git_ref_format_core::Namespaced,
    ) -> Result<Self, PublicKeyError> {
        let name = refstr.namespace().into_inner();

@@ -403,10 +420,10 @@ impl Deref for PublicKey {
    }
}

-
#[cfg(feature = "radicle-git-ext")]
-
impl From<&PublicKey> for radicle_git_ext::ref_format::Component<'_> {
+
#[cfg(feature = "git-ref-format-core")]
+
impl From<&PublicKey> for git_ref_format_core::Component<'_> {
    fn from(id: &PublicKey) -> Self {
-
        use radicle_git_ext::ref_format::{Component, RefString};
+
        use git_ref_format_core::{Component, RefString};
        let refstr =
            RefString::try_from(id.to_string()).expect("encoded public keys are valid ref strings");
        Component::from_refstr(refstr).expect("encoded public keys are valid refname components")
modified crates/radicle-crypto/src/ssh.rs
@@ -16,6 +16,7 @@ use crate::PublicKey;
pub use keystore::{Keystore, Passphrase};

#[derive(Debug, Error)]
+
#[non_exhaustive]
pub enum ExtendedSignatureError {
    #[error(transparent)]
    Ssh(#[from] ssh_key::Error),
@@ -128,6 +129,7 @@ pub mod fmt {
}

#[derive(Debug, Error)]
+
#[non_exhaustive]
pub enum SignatureError {
    #[error(transparent)]
    Invalid(#[from] crypto::Error),
@@ -164,6 +166,7 @@ impl Encodable for crypto::Signature {
}

#[derive(Debug, Error)]
+
#[non_exhaustive]
pub enum PublicKeyError {
    #[error(transparent)]
    Invalid(#[from] crypto::Error),
@@ -199,6 +202,7 @@ impl Encodable for PublicKey {
}

#[derive(Debug, Error)]
+
#[non_exhaustive]
pub enum SecretKeyError {
    #[error(transparent)]
    Encoding(#[from] encoding::Error),
modified crates/radicle-crypto/src/ssh/keystore.rs
@@ -15,6 +15,7 @@ use super::ExtendedSignature;
pub type Passphrase = Zeroizing<String>;

#[derive(Debug, Error)]
+
#[non_exhaustive]
pub enum Error {
    #[error(transparent)]
    Io(#[from] io::Error),
@@ -22,8 +23,8 @@ pub enum Error {
    Ssh(#[from] ssh_key::Error),
    #[error("invalid key type, expected ed25519 key")]
    InvalidKeyType,
-
    #[error("keystore already initialized")]
-
    AlreadyInitialized,
+
    #[error("keystore already initialized, file '{exists}' exists")]
+
    AlreadyInitialized { exists: PathBuf },
    #[error("keystore is encrypted; a passphrase is required")]
    PassphraseMissing,
}
@@ -38,28 +39,52 @@ impl Error {
/// Stores keys on disk, in OpenSSH format.
#[derive(Debug, Clone)]
pub struct Keystore {
-
    path: PathBuf,
+
    path_secret: PathBuf,
+
    path_public: Option<PathBuf>,
}

impl Keystore {
-
    /// Create a new keystore pointing to the given path. Use [`Keystore::init`] to initialize.
+
    /// Create a new keystore pointing to the given path.
+
    ///
+
    /// Use [`Keystore::init`] to initialize.
    pub fn new<P: AsRef<Path>>(path: &P) -> Self {
+
        const DEFAULT_SECRET_KEY_FILE_NAME: &str = "radicle";
+
        const DEFAULT_PUBLIC_KEY_FILE_NAME: &str = "radicle.pub";
+

+
        let keys = path.as_ref().to_path_buf();
+

+
        Self {
+
            path_secret: keys.join(DEFAULT_SECRET_KEY_FILE_NAME),
+
            path_public: Some(keys.join(DEFAULT_PUBLIC_KEY_FILE_NAME)),
+
        }
+
    }
+

+
    /// Create a new keystore pointing to the given paths.
+
    ///
+
    /// Use [`Keystore::init`] to initialize.
+
    pub fn from_secret_path<P: AsRef<Path>>(secret: &P) -> Self {
        Self {
-
            path: path.as_ref().to_path_buf(),
+
            path_secret: secret.as_ref().to_path_buf(),
+
            path_public: None,
        }
    }

-
    /// Get the path to the keystore.
-
    pub fn path(&self) -> &Path {
-
        self.path.as_path()
+
    /// Get the path to the secret key backing the keystore.
+
    pub fn secret_key_path(&self) -> &Path {
+
        self.path_secret.as_path()
+
    }
+

+
    /// Get the path to the public key backing the keystore, if present.
+
    pub fn public_key_path(&self) -> Option<&Path> {
+
        self.path_public.as_deref()
    }

-
    /// Initialize a keystore by generating a key pair and storing the secret and public key
-
    /// at the given path.
+
    /// Initialize a keystore by generating a key pair and storing the secret
+
    /// and public key at the given path.
    ///
-
    /// The `comment` is associated with the private key.
-
    /// The `passphrase` is used to encrypt the private key.
-
    /// The `seed` is used to derive the private key and should almost always be generated.
+
    /// The `comment` is associated with the private key. The `passphrase` is
+
    /// used to encrypt the private key. The `seed` is used to derive the
+
    /// private key and should almost always be generated.
    ///
    /// If `passphrase` is `None`, the key is not encrypted.
    pub fn init(
@@ -71,7 +96,7 @@ impl Keystore {
        self.store(KeyPair::from_seed(seed), comment, passphrase)
    }

-
    /// Store a keypair on disk. Returns an error if the key already exists.
+
    /// Store a keypair on disk. Returns an error if any of the two key files already exist.
    pub fn store(
        &self,
        keypair: KeyPair,
@@ -87,13 +112,25 @@ impl Keystore {
            secret
        };
        let public = secret.public_key();
-
        let path = self.path.join("radicle");

-
        if path.exists() {
-
            return Err(Error::AlreadyInitialized);
+
        if self.path_secret.exists() {
+
            return Err(Error::AlreadyInitialized {
+
                exists: self.path_secret.to_path_buf(),
+
            });
        }

-
        {
+
        if let Some(path_public) = &self.path_public {
+
            if path_public.exists() {
+
                return Err(Error::AlreadyInitialized {
+
                    exists: path_public.to_path_buf(),
+
                });
+
            }
+
        }
+

+
        // NOTE: If [`PathBuf::parent`] returns `None`,
+
        // then the path is at root or empty, so don't
+
        // attempt to create any parents.
+
        self.path_secret.parent().map_or(Ok(()), |parent| {
            let mut builder = fs::DirBuilder::new();
            builder.recursive(true);

@@ -103,27 +140,43 @@ impl Keystore {
                builder.mode(0o700);
            }

-
            builder.create(&self.path)?;
-
        }
+
            builder.create(parent)
+
        })?;
+
        secret.write_openssh_file(&self.path_secret, ssh_key::LineEnding::default())?;

-
        secret.write_openssh_file(&path, ssh_key::LineEnding::default())?;
-
        public.write_openssh_file(&path.with_extension("pub"))?;
+
        if let Some(path_public) = &self.path_public {
+
            path_public.parent().map_or(Ok(()), |parent| {
+
                let mut builder = fs::DirBuilder::new();
+
                builder.recursive(true);
+

+
                #[cfg(unix)]
+
                {
+
                    use std::os::unix::fs::DirBuilderExt as _;
+
                    builder.mode(0o700);
+
                }
+

+
                builder.create(parent)
+
            })?;
+
            public.write_openssh_file(path_public)?;
+
        }

        Ok(keypair.pk.into())
    }

    /// Load the public key from the store. Returns `None` if it wasn't found.
    pub fn public_key(&self) -> Result<Option<PublicKey>, Error> {
-
        let path = self.path.join("radicle.pub");
-
        if !path.exists() {
+
        let Some(path_public) = &self.path_public else {
            return Ok(None);
-
        }
+
        };

-
        let public = ssh_key::PublicKey::read_openssh_file(&path)?;
-
        match public.try_into() {
-
            Ok(public) => Ok(Some(public)),
-
            _ => Err(Error::InvalidKeyType),
+
        if !path_public.exists() {
+
            return Ok(None);
        }
+

+
        let public = ssh_key::PublicKey::read_openssh_file(path_public)?;
+
        PublicKey::try_from(public)
+
            .map(Some)
+
            .map_err(|_| Error::InvalidKeyType)
    }

    /// Load the secret key from the store, decrypting it with the given passphrase.
@@ -132,12 +185,13 @@ impl Keystore {
        &self,
        passphrase: Option<Passphrase>,
    ) -> Result<Option<Zeroizing<SecretKey>>, Error> {
-
        let path = self.path.join("radicle");
+
        let path = &self.path_secret;
        if !path.exists() {
            return Ok(None);
        }

-
        let secret = ssh_key::PrivateKey::read_openssh_file(&path)?;
+
        let secret = ssh_key::PrivateKey::read_openssh_file(path)?;
+

        let secret = if let Some(p) = passphrase {
            secret.decrypt(p)?
        } else if secret.is_encrypted() {
@@ -155,12 +209,11 @@ impl Keystore {

    /// Check that the passphrase is valid.
    pub fn is_valid_passphrase(&self, passphrase: &Passphrase) -> Result<bool, Error> {
-
        let path = self.path.join("radicle");
-
        if !path.exists() {
+
        if !self.path_secret.exists() {
            return Err(Error::Io(io::ErrorKind::NotFound.into()));
        }

-
        let secret = ssh_key::PrivateKey::read_openssh_file(&path)?;
+
        let secret = ssh_key::PrivateKey::read_openssh_file(&self.path_secret)?;
        let valid = secret.decrypt(passphrase).is_ok();

        Ok(valid)
@@ -168,14 +221,14 @@ impl Keystore {

    /// Check whether the secret key is encrypted.
    pub fn is_encrypted(&self) -> Result<bool, Error> {
-
        let path = self.path.join("radicle");
-
        let secret = ssh_key::PrivateKey::read_openssh_file(&path)?;
+
        let secret = ssh_key::PrivateKey::read_openssh_file(&self.path_secret)?;

        Ok(secret.is_encrypted())
    }
}

#[derive(Debug, Error)]
+
#[non_exhaustive]
pub enum MemorySignerError {
    #[error(transparent)]
    Keystore(#[from] Error),
@@ -183,6 +236,8 @@ pub enum MemorySignerError {
    NotFound(PathBuf),
    #[error("invalid passphrase")]
    InvalidPassphrase,
+
    #[error("secret key '{secret}' and public key '{public}' do not match")]
+
    KeyMismatch { secret: PathBuf, public: PathBuf },
}

/// An in-memory signer that keeps its secret key internally
@@ -258,9 +313,6 @@ impl MemorySigner {
        keystore: &Keystore,
        passphrase: Option<Passphrase>,
    ) -> Result<Self, MemorySignerError> {
-
        let public = keystore
-
            .public_key()?
-
            .ok_or_else(|| MemorySignerError::NotFound(keystore.path().to_path_buf()))?;
        let secret = keystore
            .secret_key(passphrase)
            .map_err(|e| {
@@ -270,11 +322,37 @@ impl MemorySigner {
                    e.into()
                }
            })?
-
            .ok_or_else(|| MemorySignerError::NotFound(keystore.path().to_path_buf()))?;
+
            .ok_or_else(|| MemorySignerError::NotFound(keystore.secret_key_path().to_path_buf()))?;
+

+
        let Some(public_path) = keystore.public_key_path() else {
+
            // There is no public key in the key store, so there's nothing
+
            // to validate. Derive it from the secret key.
+
            return Ok(Self::from_secret(secret));
+
        };
+

+
        let public = keystore
+
            .public_key()?
+
            .ok_or_else(|| MemorySignerError::NotFound(public_path.to_path_buf()))?;
+

+
        secret
+
            .validate_public_key(&public)
+
            .map_err(|_| MemorySignerError::KeyMismatch {
+
                secret: keystore.secret_key_path().to_path_buf(),
+
                public: public_path.to_path_buf(),
+
            })?;

        Ok(Self { public, secret })
    }

+
    /// Create a new memory signer from the given secret key, deriving
+
    /// the public key from the secret key.
+
    pub fn from_secret(secret: Zeroizing<SecretKey>) -> Self {
+
        Self {
+
            public: PublicKey(secret.public_key()),
+
            secret,
+
        }
+
    }
+

    /// Box this signer into a trait object.
    pub fn boxed(self) -> Box<dyn Signer> {
        Box::new(self)
@@ -312,7 +390,7 @@ mod tests {
    #[test]
    fn test_init_passphrase() {
        let tmp = tempfile::tempdir().unwrap();
-
        let store = Keystore::new(&tmp.path());
+
        let store = Keystore::new(&tmp);

        let public = store
            .init(
@@ -338,7 +416,7 @@ mod tests {
    #[test]
    fn test_init_no_passphrase() {
        let tmp = tempfile::tempdir().unwrap();
-
        let store = Keystore::new(&tmp.path());
+
        let store = Keystore::new(&tmp);

        let public = store.init("test", None, ec25519::Seed::default()).unwrap();
        assert_eq!(public, store.public_key().unwrap().unwrap());
@@ -351,7 +429,7 @@ mod tests {
    #[test]
    fn test_signer() {
        let tmp = tempfile::tempdir().unwrap();
-
        let store = Keystore::new(&tmp.path());
+
        let store = Keystore::new(&tmp);

        let public = store
            .init(
modified crates/radicle-fetch/Cargo.toml
@@ -3,7 +3,7 @@ name = "radicle-fetch"
description = "Radicle fetch protocol"
homepage.workspace = true
license.workspace = true
-
version = "0.15.0"
+
version = "0.17.0"
authors = ["Fintan Halpenny <fintan.halpenny@gmail.com>"]
edition.workspace = true
rust-version.workspace = true
@@ -11,14 +11,16 @@ rust-version.workspace = true
[dependencies]
bstr = { workspace = true }
either = "1.9.0"
-
gix-features = { version = "0.39.1", features = ["progress"] }
-
gix-hash = "0.15.1"
-
gix-odb = "0.66.0"
-
gix-pack = "0.56.0"
-
gix-protocol = { version = "0.47.0", features = ["blocking-client"] }
-
gix-transport = { version = "0.44.0", features = ["blocking-client"] }
+
gix-features = { version = "0.46", features = ["progress"] }
+
gix-hash = { workspace = true }
+
gix-odb = "0.75.0"
+
gix-pack = "0.65.0"
+
gix-protocol = { version = "0.57.0", features = ["blocking-client"] }
+
gix-refspec = "0.37"
+
gix-transport = { version = "0.54.0", features = ["blocking-client"] }
log = { workspace = true, features = ["std"] }
nonempty = { workspace = true }
radicle = { workspace = true }
-
radicle-git-ext = { workspace = true, features = ["bstr"] }
-
thiserror = { workspace = true }

\ No newline at end of file
+
radicle-oid = { workspace = true, features = ["gix"] }
+
radicle-git-ref-format = { workspace = true, features = ["bstr"] }
+
thiserror = { workspace = true, default-features = true }

\ No newline at end of file
modified crates/radicle-fetch/src/git.rs
@@ -3,21 +3,3 @@ pub(crate) mod packfile;
pub(crate) mod repository;

pub mod refs;
-

-
pub(crate) mod oid {
-
    //! Helper functions for converting to/from [`radicle::git::Oid`] and
-
    //! [`ObjectId`].
-

-
    use gix_hash::ObjectId;
-
    use radicle::git::Oid;
-

-
    /// Convert from an [`ObjectId`] to an [`Oid`].
-
    pub fn to_oid(oid: ObjectId) -> Oid {
-
        Oid::try_from(oid.as_bytes()).expect("invalid gix Oid")
-
    }
-

-
    /// Convert from an [`Oid`] to an [`ObjectId`].
-
    pub fn to_object_id(oid: Oid) -> ObjectId {
-
        ObjectId::from(gix_hash::oid::from_bytes_unchecked(oid.as_ref()))
-
    }
-
}
modified crates/radicle-fetch/src/git/mem.rs
@@ -1,6 +1,7 @@
use std::collections::HashMap;

-
use radicle::git::{Component, Oid, Qualified, RefString};
+
use radicle::git::fmt::{Component, Qualified, RefString};
+
use radicle::git::Oid;
use radicle::prelude::PublicKey;

use super::refs::{Applied, RefUpdate, Update};
@@ -25,10 +26,10 @@ impl Refdb {
        &'a self,
        remote: &'a PublicKey,
    ) -> impl Iterator<Item = (RefString, Oid)> + 'a {
+
        let remote = Component::from(remote);
        self.0.iter().filter_map(move |(refname, oid)| {
            let ns = refname.to_namespaced()?;
-
            (ns.namespace() == Component::from(remote))
-
                .then(|| (ns.strip_namespace().to_ref_string(), *oid))
+
            (ns.namespace() == remote).then(|| (ns.strip_namespace().to_ref_string(), *oid))
        })
    }

modified crates/radicle-fetch/src/git/packfile.rs
@@ -26,7 +26,7 @@ impl Keepfile {
impl Drop for Keepfile {
    fn drop(&mut self) {
        if let Err(e) = fs::remove_file(&self.path) {
-
            log::warn!(target: "fetch", "Failed to remove {:?}: {e}", self.path);
+
            log::warn!("Failed to remove {:?}: {e}", self.path);
        }
    }
}
modified crates/radicle-fetch/src/git/refs/update.rs
@@ -18,7 +18,8 @@
use std::collections::BTreeMap;

use either::Either;
-
use radicle::git::{Namespaced, Oid, Qualified};
+
use radicle::git::fmt::{Namespaced, Qualified};
+
use radicle::git::Oid;
use radicle::prelude::PublicKey;

pub use radicle::storage::RefUpdate;
modified crates/radicle-fetch/src/git/repository.rs
@@ -1,7 +1,12 @@
pub mod error;

use either::Either;
-
use radicle::git::{self, Namespaced, Oid, Qualified};
+
use radicle::git::raw::ErrorExt as _;
+
use radicle::git::{
+
    self,
+
    fmt::{Namespaced, Qualified},
+
    Oid,
+
};
use radicle::storage::git::Repository;

use super::refs::{Applied, Policy, RefUpdate, Update};
@@ -47,29 +52,44 @@ pub fn contains(repo: &Repository, oid: Oid) -> Result<bool, error::Contains> {
/// - The object does not peel to a commit
/// - Attempting to find the object fails
fn find_and_peel(repo: &Repository, oid: Oid) -> Result<Oid, error::Ancestry> {
-
    match repo.backend.find_object(*oid, None) {
+
    match repo.backend.find_object(oid.into(), None) {
        Ok(object) => Ok(object
            .peel(git::raw::ObjectType::Commit)
            .map_err(|err| error::Ancestry::Peel { oid, err })?
            .id()
            .into()),
-
        Err(e) if git::is_not_found_err(&e) => Err(error::Ancestry::Missing { oid }),
+
        Err(e) if e.is_not_found() => Err(error::Ancestry::Missing { oid }),
        Err(err) => Err(error::Ancestry::Object { oid, err }),
    }
}

+
/// Peels the two objects to commits (see [`find_and_peel`]) and determines
+
/// their ancestry relationship (see [`ahead_behind`]).
pub fn ancestry(repo: &Repository, old: Oid, new: Oid) -> Result<Ancestry, error::Ancestry> {
    let old = find_and_peel(repo, old)?;
    let new = find_and_peel(repo, new)?;

-
    if old == new {
+
    ahead_behind(repo, old, new)
+
}
+

+
/// Determine the ancestry relationship between two commits.
+
pub fn ahead_behind(
+
    repo: &Repository,
+
    old_commit: Oid,
+
    new_commit: Oid,
+
) -> Result<Ancestry, error::Ancestry> {
+
    if old_commit == new_commit {
        return Ok(Ancestry::Equal);
    }

    let (ahead, behind) = repo
        .backend
-
        .graph_ahead_behind(*new, *old)
-
        .map_err(|err| error::Ancestry::Check { old, new, err })?;
+
        .graph_ahead_behind(new_commit.into(), old_commit.into())
+
        .map_err(|err| error::Ancestry::Check {
+
            old: old_commit,
+
            new: new_commit,
+
            err,
+
        })?;

    if ahead > 0 && behind == 0 {
        Ok(Ancestry::Ahead)
@@ -84,7 +104,7 @@ pub fn refname_to_id<'a, N>(repo: &Repository, refname: N) -> Result<Option<Oid>
where
    N: Into<Qualified<'a>>,
{
-
    use radicle::git::raw::ErrorCode::NotFound;
+
    use git::raw::ErrorCode::NotFound;

    let refname = refname.into();
    match repo.backend.refname_to_id(refname.as_ref()) {
@@ -128,77 +148,130 @@ fn direct<'a>(
    target: Oid,
    no_ff: Policy,
) -> Result<Updated<'a>, error::Update> {
-
    let tip = refname_to_id(repo, name.clone())?;
-
    match tip {
-
        Some(prev) => {
-
            let ancestry = ancestry(repo, prev, target)?;
-

-
            match ancestry {
-
                Ancestry::Equal => Ok(RefUpdate::Skipped {
-
                    name: name.to_ref_string(),
-
                    oid: target,
-
                }
-
                .into()),
-
                Ancestry::Ahead => {
-
                    // N.b. the update is a fast-forward so we can safely
-
                    // pass `force: true`.
-
                    repo.backend
-
                        .reference(name.as_ref(), target.into(), true, "radicle: update")
-
                        .map_err(|err| error::Update::Create {
-
                            name: name.to_owned(),
-
                            target,
-
                            err,
-
                        })?;
-
                    Ok(RefUpdate::from(name.to_ref_string(), prev, target).into())
-
                }
-
                Ancestry::Behind | Ancestry::Diverged if matches!(no_ff, Policy::Allow) => {
-
                    // N.b. the update is a non-fast-forward but
-
                    // we allow it, so we pass `force: true`.
-
                    repo.backend
-
                        .reference(name.as_ref(), target.into(), true, "radicle: update")
-
                        .map_err(|err| error::Update::Create {
-
                            name: name.to_owned(),
-
                            target,
-
                            err,
-
                        })?;
-
                    Ok(RefUpdate::from(name.to_ref_string(), prev, target).into())
-
                }
-
                // N.b. if the target is behind, we simply reject the update
-
                Ancestry::Behind => Ok(Update::Direct {
-
                    name,
-
                    target,
-
                    no_ff,
-
                }
-
                .into()),
-
                Ancestry::Diverged if matches!(no_ff, Policy::Reject) => Ok(Update::Direct {
-
                    name,
-
                    target,
-
                    no_ff,
-
                }
-
                .into()),
-
                Ancestry::Diverged => Err(error::Update::NonFF {
-
                    name: name.to_owned(),
-
                    new: target,
-
                    cur: prev,
-
                }),
+
    let Some(reference) = find(repo, &name)? else {
+
        repo.backend
+
            .reference(name.as_ref(), target.into(), false, "radicle: create")
+
            .map_err(|err| error::Update::Create {
+
                name: name.to_owned(),
+
                target,
+
                err,
+
            })?;
+

+
        return Ok(RefUpdate::Created {
+
            name: name.to_ref_string(),
+
            oid: target,
+
        }
+
        .into());
+
    };
+

+
    let Some(prev) = reference.target() else {
+
        // This should never happen, as there are no facilities to create
+
        // symbolic references in Radicle namespaces. If it does, e.g. because
+
        // some external program or the user themselves created it, we better
+
        // do not touch it.
+
        return Err(error::Update::Symbolic {
+
            name: name.to_owned(),
+
        });
+
    };
+

+
    if target == prev {
+
        // If the two objects are identical, their ancestry does not matter,
+
        // we can always skip the update.
+
        return Ok(RefUpdate::Skipped {
+
            name: name.to_ref_string(),
+
            oid: target,
+
        }
+
        .into());
+
    }
+

+
    let ancestry = {
+
        use git::raw::ObjectType::{self, *};
+
        const ANY_KIND: Option<ObjectType> = Some(Any);
+

+
        let prev = repo.backend.find_object(prev, ANY_KIND).map_err(|err| {
+
            error::Update::Ancestry(error::Ancestry::Object {
+
                oid: prev.into(),
+
                err,
+
            })
+
        })?;
+

+
        let target = repo
+
            .backend
+
            .find_object(target.into(), ANY_KIND)
+
            .map_err(|err| error::Update::Ancestry(error::Ancestry::Object { oid: target, err }))?;
+

+
        match (prev.kind(), target.kind()) {
+
            (Some(Commit), Some(Commit)) => {
+
                // This is the common case, we have two commits to compare.
+
                let prev = prev.id().into();
+
                let target = target.id().into();
+
                Some(ahead_behind(repo, prev, target)?)
+
            }
+
            (Some(Tag), Some(Tag)) => {
+
                // Even though these tags might point to the same commit,
+
                // refuse to peel, because that tag itself has changed
+
                // (e.g. its name or signature).
+
                None
+
            }
+
            (Some(Commit | Tag), Some(Commit | Tag)) => {
+
                // The reference changes from a commit to a tag or vice versa.
+
                None
+
            }
+
            _ => {
+
                // One of the objects is not a commit or a tag, we're clueless.
+
                None
            }
        }
-
        None => {
-
            // N.b. the reference didn't exist so we pass `force:
-
            // false`.
+
    };
+

+
    match ancestry {
+
        Some(Ancestry::Equal) => Ok(RefUpdate::Skipped {
+
            name: name.to_ref_string(),
+
            oid: target,
+
        }
+
        .into()),
+
        Some(Ancestry::Ahead) => {
+
            // N.b. the update is a fast-forward so we can safely
+
            // pass `force: true`.
            repo.backend
-
                .reference(name.as_ref(), target.into(), false, "radicle: create")
+
                .reference(name.as_ref(), target.into(), true, "radicle: update")
                .map_err(|err| error::Update::Create {
                    name: name.to_owned(),
                    target,
                    err,
                })?;
-
            Ok(RefUpdate::Created {
-
                name: name.to_ref_string(),
-
                oid: target,
-
            }
-
            .into())
+
            Ok(RefUpdate::from(name.to_ref_string(), prev, target).into())
        }
+
        Some(Ancestry::Behind | Ancestry::Diverged) | None if matches!(no_ff, Policy::Allow) => {
+
            // N.b. the update is a non-fast-forward but
+
            // we allow it, so we pass `force: true`.
+
            repo.backend
+
                .reference(name.as_ref(), target.into(), true, "radicle: update")
+
                .map_err(|err| error::Update::Create {
+
                    name: name.to_owned(),
+
                    target,
+
                    err,
+
                })?;
+
            Ok(RefUpdate::from(name.to_ref_string(), prev, target).into())
+
        }
+
        // N.b. if the target is behind, we simply reject the update
+
        Some(Ancestry::Behind) => Ok(Update::Direct {
+
            name,
+
            target,
+
            no_ff,
+
        }
+
        .into()),
+
        Some(Ancestry::Diverged) | None if matches!(no_ff, Policy::Reject) => Ok(Update::Direct {
+
            name,
+
            target,
+
            no_ff,
+
        }
+
        .into()),
+
        Some(Ancestry::Diverged) | None => Err(error::Update::NonFF {
+
            name: name.to_owned(),
+
            new: target,
+
            cur: prev.into(),
+
        }),
    }
}

@@ -207,7 +280,7 @@ fn prune<'a>(
    name: Namespaced<'a>,
    prev: Either<Oid, Qualified<'a>>,
) -> Result<Updated<'a>, error::Update> {
-
    use radicle::git::raw::ObjectType;
+
    use git::raw::ObjectType;

    match find(repo, &name)? {
        Some(mut r) => {
modified crates/radicle-fetch/src/git/repository/error.rs
@@ -1,9 +1,13 @@
-
use radicle::git::{ext, raw, Namespaced, Oid, Qualified};
+
use radicle::git::{
+
    self,
+
    fmt::{Namespaced, Qualified},
+
    Oid,
+
};
use thiserror::Error;

#[derive(Debug, Error)]
#[error("could not open Git ODB")]
-
pub struct Contains(#[source] pub raw::Error);
+
pub struct Contains(#[source] pub git::raw::Error);

#[derive(Debug, Error)]
pub enum Ancestry {
@@ -14,19 +18,19 @@ pub enum Ancestry {
        old: Oid,
        new: Oid,
        #[source]
-
        err: raw::Error,
+
        err: git::raw::Error,
    },
    #[error("failed to peel object to commit {oid}: {err}")]
    Peel {
        oid: Oid,
        #[source]
-
        err: raw::Error,
+
        err: git::raw::Error,
    },
    #[error("failed to find object {oid}: {err}")]
    Object {
        oid: Oid,
        #[source]
-
        err: raw::Error,
+
        err: git::raw::Error,
    },
}

@@ -35,15 +39,7 @@ pub enum Ancestry {
pub struct Resolve {
    pub name: Qualified<'static>,
    #[source]
-
    pub err: raw::Error,
-
}
-

-
#[derive(Debug, Error)]
-
#[error("failed to scan for refs matching {pattern}")]
-
pub struct Scan {
-
    pub pattern: radicle::git::PatternString,
-
    #[source]
-
    pub err: ext::Error,
+
    pub err: git::raw::Error,
}

#[derive(Debug, Error)]
@@ -55,19 +51,19 @@ pub enum Update {
        name: Namespaced<'static>,
        target: Oid,
        #[source]
-
        err: raw::Error,
+
        err: git::raw::Error,
    },
    #[error("failed to delete reference {name}")]
    Delete {
        name: Namespaced<'static>,
        #[source]
-
        err: raw::Error,
+
        err: git::raw::Error,
    },
    #[error("failed to find ref {name}")]
    Find {
        name: Namespaced<'static>,
        #[source]
-
        err: raw::Error,
+
        err: git::raw::Error,
    },
    #[error("non-fast-forward update of {name} (current: {cur}, new: {new})")]
    NonFF {
@@ -76,7 +72,10 @@ pub enum Update {
        cur: Oid,
    },
    #[error("failed to peel ref to object")]
-
    Peel(#[source] raw::Error),
+
    Peel(#[source] git::raw::Error),
    #[error(transparent)]
    Resolve(#[from] Resolve),
+

+
    #[error("refusing to update symbolic ref {name}")]
+
    Symbolic { name: Namespaced<'static> },
}
modified crates/radicle-fetch/src/handle.rs
@@ -12,9 +12,9 @@ use crate::policy::{Allowed, BlockList};
use crate::transport::{ConnectionStream, Transport};

/// The handle used for pulling or cloning changes from a remote peer.
-
pub struct Handle<S> {
+
pub struct Handle<R, S> {
    pub(crate) local: PublicKey,
-
    pub(crate) repo: Repository,
+
    repo: R,
    pub(crate) allowed: Allowed,
    pub(crate) transport: Transport<S>,
    /// The set of keys we will ignore when fetching from a
@@ -29,10 +29,36 @@ pub struct Handle<S> {
    pub(crate) interrupt: Arc<AtomicBool>,
}

-
impl<S> Handle<S> {
+
impl<R, S> Handle<R, S> {
+
    pub fn is_blocked(&self, key: &PublicKey) -> bool {
+
        self.blocked.is_blocked(key)
+
    }
+

+
    #[inline]
+
    pub fn local(&self) -> &PublicKey {
+
        &self.local
+
    }
+

+
    pub fn interrupt_pack_writer(&mut self) {
+
        self.interrupt.store(true, atomic::Ordering::Relaxed);
+
    }
+

+
    pub fn allowed(&self) -> Allowed {
+
        self.allowed.clone()
+
    }
+

+
    pub fn into_inner(self) -> R {
+
        self.repo
+
    }
+
}
+

+
impl<R, S> Handle<R, S>
+
where
+
    R: AsRef<Repository>,
+
{
    pub fn new(
        local: PublicKey,
-
        repo: Repository,
+
        repo: R,
        follow: Allowed,
        blocked: BlockList,
        connection: S,
@@ -40,8 +66,12 @@ impl<S> Handle<S> {
    where
        S: ConnectionStream,
    {
-
        let git_dir = repo.backend.path().to_path_buf();
-
        let transport = Transport::new(git_dir, BString::from(repo.id.canonical()), connection);
+
        let git_dir = repo.as_ref().backend.path().to_path_buf();
+
        let transport = Transport::new(
+
            git_dir,
+
            BString::from(repo.as_ref().id.canonical()),
+
            connection,
+
        );

        Ok(Self {
            local,
@@ -53,32 +83,13 @@ impl<S> Handle<S> {
        })
    }

-
    pub fn is_blocked(&self, key: &PublicKey) -> bool {
-
        self.blocked.is_blocked(key)
-
    }
-

+
    #[inline]
    pub fn repository(&self) -> &Repository {
-
        &self.repo
-
    }
-

-
    pub fn repository_mut(&mut self) -> &mut Repository {
-
        &mut self.repo
-
    }
-

-
    pub fn local(&self) -> &PublicKey {
-
        &self.local
-
    }
-

-
    pub fn interrupt_pack_writer(&mut self) {
-
        self.interrupt.store(true, atomic::Ordering::Relaxed);
+
        self.repo.as_ref()
    }

    pub fn verified(&self, head: Oid) -> Result<Doc, DocError> {
-
        Ok(self.repo.identity_doc_at(head)?.doc)
-
    }
-

-
    pub fn allowed(&self) -> Allowed {
-
        self.allowed.clone()
+
        Ok(self.repository().identity_doc_at(head)?.doc)
    }
}

modified crates/radicle-fetch/src/lib.rs
@@ -9,13 +9,15 @@ mod refs;
mod stage;
mod state;

+
use std::io;
use std::time::Instant;

-
use gix_protocol::handshake;
+
use gix_protocol::{handshake, Handshake};

pub use gix_protocol::{transport::bstr::ByteSlice, RemoteProgress};
pub use handle::Handle;
pub use policy::{Allowed, BlockList, Scope};
+
use radicle::storage::git::Repository;
pub use state::{FetchLimit, FetchResult};
pub use transport::Transport;

@@ -27,8 +29,8 @@ use thiserror::Error;

#[derive(Debug, Error)]
pub enum Error {
-
    #[error("failed to perform fetch handshake: {0}")]
-
    Handshake(#[from] Box<handshake::Error>),
+
    #[error(transparent)]
+
    Handshake(Box<HandshakeError>),
    #[error("failed to load `rad/id`")]
    Identity {
        #[source]
@@ -42,18 +44,33 @@ pub enum Error {
    ReplicateSelf,
}

+
impl From<HandshakeError> for Error {
+
    fn from(err: HandshakeError) -> Self {
+
        Self::Handshake(Box::new(err))
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
pub enum HandshakeError {
+
    #[error("failed to perform fetch handshake: {0}")]
+
    Gix(handshake::Error),
+
    #[error("an I/O error occurred during the fetch handshake ({0})")]
+
    Io(io::Error),
+
}
+

/// Pull changes from the `remote`.
///
/// It is expected that the local peer has a copy of the repository
/// and is pulling new changes. If the repository does not exist, then
/// [`clone`] should be used.
-
pub fn pull<S>(
-
    handle: &mut Handle<S>,
+
pub fn pull<R, S>(
+
    handle: &mut Handle<R, S>,
    limit: FetchLimit,
    remote: PublicKey,
    refs_at: Option<Vec<RefsAt>>,
) -> Result<FetchResult, Error>
where
+
    R: AsRef<Repository>,
    S: transport::ConnectionStream,
{
    let start = Instant::now();
@@ -71,9 +88,8 @@ where
        .map_err(Error::Protocol);

    log::debug!(
-
        target: "fetch",
        "Finished pull of {} ({}ms)",
-
        handle.repo.id(),
+
        handle.repository().id(),
        start.elapsed().as_millis()
    );
    result
@@ -83,12 +99,13 @@ where
///
/// It is expected that the local peer has an empty repository which
/// they want to populate with the `remote`'s view of the project.
-
pub fn clone<S>(
-
    handle: &mut Handle<S>,
+
pub fn clone<R, S>(
+
    handle: &mut Handle<R, S>,
    limit: FetchLimit,
    remote: PublicKey,
) -> Result<FetchResult, Error>
where
+
    R: AsRef<Repository>,
    S: transport::ConnectionStream,
{
    let start = Instant::now();
@@ -101,34 +118,36 @@ where
        .run(handle, &handshake, limit, remote, None)
        .map_err(Error::Protocol);
    let elapsed = start.elapsed().as_millis();
-
    let rid = handle.repo.id();
+
    let rid = handle.repository().id();

    match &result {
        Ok(_) => {
-
            log::debug!(
-
                target: "fetch",
-
                "Finished clone of {rid} from {remote} ({elapsed}ms)",
-
            );
+
            log::debug!("Finished clone of {rid} from {remote} ({elapsed}ms)",);
        }
        Err(e) => {
-
            log::debug!(
-
                target: "fetch",
-
                "Clone of {rid} from {remote} failed with '{e}' ({elapsed}ms)",
-
            );
+
            log::debug!("Clone of {rid} from {remote} failed with '{e}' ({elapsed}ms)",);
        }
    }
    result
}

-
fn perform_handshake<S>(handle: &mut Handle<S>) -> Result<handshake::Outcome, Error>
+
fn perform_handshake<R, S>(handle: &mut Handle<R, S>) -> Result<Handshake, Error>
where
    S: transport::ConnectionStream,
{
-
    let result = handle.transport.handshake();
+
    handle
+
        .transport
+
        .handshake()
+
        .map_err(handle_handshake_err)
+
        .map_err(Error::from)
+
}

-
    if let Err(err) = &result {
-
        log::warn!(target: "fetch", "Failed to perform handshake: {err}");
+
fn handle_handshake_err(err: handshake::Error) -> HandshakeError {
+
    match err {
+
        handshake::Error::Transport(error) => match error {
+
            gix_transport::client::Error::Io(error) => HandshakeError::Io(error),
+
            err => HandshakeError::Gix(handshake::Error::Transport(err)),
+
        },
+
        err => HandshakeError::Gix(err),
    }
-

-
    Ok(result?)
}
modified crates/radicle-fetch/src/policy.rs
@@ -20,7 +20,7 @@ impl Allowed {
            .map_err(|err| error::Policy::FailedPolicy { rid, err })?;
        match entry.policy {
            SeedingPolicy::Block => {
-
                log::error!(target: "fetch", "Attempted to fetch non-seeded repo {rid}");
+
                log::info!("Attempted to fetch non-seeded repo {rid}");
                Err(error::Policy::BlockedPolicy { rid })
            }
            SeedingPolicy::Allow { scope: Scope::All } => Ok(Self::All),
@@ -37,7 +37,7 @@ impl Allowed {
                    let node = match node {
                        Ok(policy) => policy,
                        Err(err) => {
-
                            log::error!(target: "fetch", "Failed to read follow policy for {rid}: {err}");
+
                            log::debug!("Failed to read follow policy for {rid}: {err}");
                            continue;
                        }
                    };
@@ -81,7 +81,7 @@ impl BlockList {
            let entry = match entry {
                Ok(entry) => entry,
                Err(err) => {
-
                    log::error!(target: "fetch", "Failed to read follow policy: {err}");
+
                    log::debug!("Failed to read follow policy: {err}");
                    continue;
                }
            };
modified crates/radicle-fetch/src/refs.rs
@@ -1,7 +1,11 @@
use bstr::{BString, ByteSlice};
use either::Either;
use radicle::crypto::PublicKey;
-
use radicle::git::{self, Component, Namespaced, Oid, Qualified};
+
use radicle::git::{
+
    self,
+
    fmt::{Component, Namespaced, Qualified},
+
    Oid,
+
};
use thiserror::Error;

pub use radicle::git::refs::storage::Special;
@@ -26,7 +30,6 @@ pub enum Error {
pub(crate) fn unpack_ref<'a>(
    r: gix_protocol::handshake::Ref,
) -> Result<(ReceivedRefname<'a>, Oid), Error> {
-
    use crate::git::oid;
    use gix_protocol::handshake::Ref;

    match r {
@@ -43,7 +46,7 @@ pub(crate) fn unpack_ref<'a>(
            full_ref_name,
            object,
            ..
-
        } => ReceivedRefname::try_from(full_ref_name).map(|name| (name, oid::to_oid(object))),
+
        } => ReceivedRefname::try_from(full_ref_name).map(|name| (name, object.into())),
        Ref::Unborn { full_ref_name, .. } => {
            unreachable!("BUG: unborn ref {}", full_ref_name)
        }
modified crates/radicle-fetch/src/sigrefs.rs
@@ -1,6 +1,7 @@
use std::collections::{BTreeMap, BTreeSet};
use std::ops::{Deref, Not as _};

+
use radicle::storage::git::Repository;
pub use radicle::storage::refs::SignedRefsAt;
pub use radicle::storage::{git::Validation, Validations};
use radicle::{crypto::PublicKey, storage::ValidateRepository};
@@ -53,10 +54,13 @@ impl<T> DelegateStatus<T> {

    /// Construct a `DelegateStatus` with [`SignedRefsAt`] signed reference
    /// data, if it can be found in `repo`.
-
    pub fn load<S>(
+
    pub fn load<R, S>(
        self,
-
        cached: &Cached<S>,
-
    ) -> Result<DelegateStatus<Option<SignedRefsAt>>, radicle::storage::refs::Error> {
+
        cached: &Cached<R, S>,
+
    ) -> Result<DelegateStatus<Option<SignedRefsAt>>, radicle::storage::refs::Error>
+
    where
+
        R: AsRef<Repository>,
+
    {
        let remote = *self.remote();
        self.traverse(|_| cached.load(&remote))
    }
@@ -102,10 +106,13 @@ impl RemoteRefs {
    ///
    /// If the sigrefs are missing for a given remote, regardless of delegate
    /// status, then that remote is filtered out.
-
    pub(crate) fn load<'a, S>(
-
        cached: &Cached<S>,
+
    pub(crate) fn load<'a, R, S>(
+
        cached: &Cached<R, S>,
        remotes: impl Iterator<Item = &'a PublicKey>,
-
    ) -> Result<Self, error::RemoteRefs> {
+
    ) -> Result<Self, error::RemoteRefs>
+
    where
+
        R: AsRef<Repository>,
+
    {
        remotes
            .filter_map(|id| match cached.load(id) {
                Ok(None) => None,
modified crates/radicle-fetch/src/stage.rs
@@ -32,12 +32,12 @@

use std::collections::{BTreeMap, BTreeSet, HashSet};

-
use bstr::BString;
+
use bstr::{BStr, BString};
use either::Either;
use gix_protocol::handshake::Ref;
use nonempty::NonEmpty;
use radicle::crypto::PublicKey;
-
use radicle::git::{refname, Component, Namespaced, Qualified};
+
use radicle::git::fmt::{refname, Component, Namespaced, Qualified};
use radicle::storage::git::Repository;
use radicle::storage::refs::{RefsAt, Special};
use radicle::storage::ReadRepository;
@@ -52,7 +52,7 @@ use crate::{policy, refs};

pub mod error {
    use radicle::crypto::PublicKey;
-
    use radicle::git::RefString;
+
    use radicle::git::fmt::RefString;
    use thiserror::Error;

    use crate::transport::WantsHavesError;
@@ -89,6 +89,72 @@ pub mod error {
    }
}

+
/// A `ref-prefix` used in the `ls-refs` step of the fetch protocol.
+
///
+
/// Since the Radicle protocol only wants to filter by very specific references,
+
/// this type captures the possible reference prefixes.
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
+
pub(crate) enum RefPrefix {
+
    /// Represents `refs/rad/id`.
+
    RadId,
+
    /// Represents `"refs/namespaces/<namespace>/refs/rad/id"`.
+
    NamespacedRadId { namespace: PublicKey },
+
    /// Represents `"refs/namespaces/<namespace>/refs/rad/sigrefs"`.
+
    NamespacedRadSigrefs { namespace: PublicKey },
+
    /// Represents `"refs/namespaces"`
+
    AllNamespaces,
+
}
+

+
impl RefPrefix {
+
    /// Convert the [`RefPrefix`] into its equivalent [`BString`].
+
    ///
+
    /// See the [`RefPrefix`] variants for their [`BString`] values.
+
    pub fn into_bstring(self) -> BString {
+
        match self {
+
            RefPrefix::RadId => refs::REFS_RAD_ID.as_bstr().into(),
+
            RefPrefix::NamespacedRadId { namespace } => {
+
                radicle::git::refs::storage::id(&namespace).as_bstr().into()
+
            }
+
            RefPrefix::NamespacedRadSigrefs { namespace } => {
+
                radicle::git::refs::storage::sigrefs(&namespace)
+
                    .as_bstr()
+
                    .into()
+
            }
+
            RefPrefix::AllNamespaces => "refs/namespaces".into(),
+
        }
+
    }
+

+
    /// Convert the [`RefPrefix`] into its equivalent [`RefSpec`].
+
    ///
+
    /// See the [`RefPrefix`] variants for their [`BString`] values.
+
    ///
+
    /// # Panics
+
    ///
+
    /// This will panic if the reference as a [`BString`] value no longer parses
+
    /// in the upstream [`gix_refspec`] crate.
+
    ///
+
    /// [`RefSpec`]: gix_refspec::RefSpec
+
    pub fn as_refspec(&self) -> gix_refspec::RefSpec {
+
        use gix_refspec::parse::Operation;
+
        let parse = |spec: &BStr| -> gix_refspec::RefSpec {
+
            gix_refspec::parse(spec, Operation::Fetch)
+
                .expect("RefPrefix should be valid refspec")
+
                .to_owned()
+
        };
+

+
        match self {
+
            RefPrefix::RadId => parse(refs::REFS_RAD_ID.as_bstr()),
+
            RefPrefix::NamespacedRadId { namespace } => {
+
                parse(radicle::git::refs::storage::id(namespace).as_bstr())
+
            }
+
            RefPrefix::NamespacedRadSigrefs { namespace } => {
+
                parse(radicle::git::refs::storage::sigrefs(namespace).as_bstr())
+
            }
+
            RefPrefix::AllNamespaces => parse(BStr::new("refs/namespaces")),
+
        }
+
    }
+
}
+

/// A [`ProtocolStage`] describes a single roundtrip with the Radicle
/// node that is serving the data.
///
@@ -107,7 +173,7 @@ pub mod error {
///      refdb (in-memory and production).
pub(crate) trait ProtocolStage {
    /// If and how to perform `ls-refs`.
-
    fn ls_refs(&self) -> Option<NonEmpty<BString>>;
+
    fn ls_refs(&self) -> Option<NonEmpty<RefPrefix>>;

    /// Filter a remote-advertised [`Ref`].
    ///
@@ -163,8 +229,8 @@ pub struct CanonicalId {
}

impl ProtocolStage for CanonicalId {
-
    fn ls_refs(&self) -> Option<NonEmpty<BString>> {
-
        Some(NonEmpty::new(refs::REFS_RAD_ID.as_bstr().into()))
+
    fn ls_refs(&self) -> Option<NonEmpty<RefPrefix>> {
+
        Some(NonEmpty::new(RefPrefix::RadId))
    }

    fn ref_filter(&self, r: Ref) -> Option<ReceivedRef> {
@@ -250,17 +316,17 @@ pub struct SpecialRefs {
}

impl ProtocolStage for SpecialRefs {
-
    fn ls_refs(&self) -> Option<NonEmpty<BString>> {
+
    fn ls_refs(&self) -> Option<NonEmpty<RefPrefix>> {
        match &self.followed {
-
            policy::Allowed::All => Some(NonEmpty::new("refs/namespaces".into())),
+
            policy::Allowed::All => Some(NonEmpty::new(RefPrefix::AllNamespaces)),
            policy::Allowed::Followed { remotes } => NonEmpty::collect(
                remotes
                    .iter()
                    .chain(self.delegates.iter())
                    .flat_map(|remote| {
                        [
-
                            BString::from(radicle::git::refs::storage::id(remote).to_string()),
-
                            BString::from(radicle::git::refs::storage::sigrefs(remote).to_string()),
+
                            RefPrefix::NamespacedRadSigrefs { namespace: *remote },
+
                            RefPrefix::NamespacedRadId { namespace: *remote },
                        ]
                    }),
            ),
@@ -331,12 +397,16 @@ pub struct SigrefsAt {
}

impl ProtocolStage for SigrefsAt {
-
    fn ls_refs(&self) -> Option<NonEmpty<BString>> {
+
    fn ls_refs(&self) -> Option<NonEmpty<RefPrefix>> {
        // N.b. the `Oid`s are known but the `rad/sigrefs` are still
        // asked for to mark them for updating the fetch state.
-
        NonEmpty::collect(self.refs_at.iter().map(|refs_at| {
-
            BString::from(radicle::git::refs::storage::sigrefs(&refs_at.remote).to_string())
-
        }))
+
        NonEmpty::collect(
+
            self.refs_at
+
                .iter()
+
                .map(|refs_at| RefPrefix::NamespacedRadSigrefs {
+
                    namespace: refs_at.remote,
+
                }),
+
        )
    }

    // We only asked for `rad/sigrefs` so we should only get
@@ -421,7 +491,7 @@ pub struct DataRefs {
impl ProtocolStage for DataRefs {
    // We don't need to ask for refs since we have all reference names
    // and `Oid`s in `rad/sigrefs`.
-
    fn ls_refs(&self) -> Option<NonEmpty<BString>> {
+
    fn ls_refs(&self) -> Option<NonEmpty<RefPrefix>> {
        None
    }

@@ -617,3 +687,26 @@ where

    Ok(())
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::RefPrefix;
+

+
    /// Ensure that the call to [`RefPrefix::as_refspec`] does not panic
+
    #[test]
+
    fn valid_refspecs() {
+
        let namespace = "z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
+
            .parse()
+
            .unwrap();
+
        let prefixes = [
+
            RefPrefix::AllNamespaces,
+
            RefPrefix::RadId,
+
            RefPrefix::NamespacedRadId { namespace },
+
            RefPrefix::NamespacedRadSigrefs { namespace },
+
        ];
+

+
        for prefix in prefixes {
+
            prefix.as_refspec();
+
        }
+
    }
+
}
modified crates/radicle-fetch/src/state.rs
@@ -1,13 +1,14 @@
use std::collections::{BTreeMap, BTreeSet};
use std::time::Instant;

-
use gix_protocol::handshake;
+
use gix_protocol::Handshake;
use radicle::crypto::PublicKey;
-
use radicle::git::{Oid, Qualified};
+
use radicle::git::{fmt::Qualified, Oid};
use radicle::identity::{Did, Doc, DocError};

use radicle::prelude::Verified;
use radicle::storage;
+
use radicle::storage::git::Repository;
use radicle::storage::refs::RefsAt;
use radicle::storage::{
    git::Validation, Remote, RemoteId, RemoteRepository, Remotes, ValidateRepository, Validations,
@@ -86,6 +87,8 @@ pub mod error {
        Resolve(#[from] git::repository::error::Resolve),
        #[error(transparent)]
        Verified(#[from] radicle::identity::DocError),
+
        #[error("failed to verify `refs/rad/id`: {0}")]
+
        Graph(#[source] radicle::git::raw::Error),
    }
}

@@ -196,7 +199,10 @@ impl FetchState {
        ap
    }

-
    pub(crate) fn as_cached<'a, S>(&'a mut self, handle: &'a mut Handle<S>) -> Cached<'a, S> {
+
    pub(crate) fn as_cached<'a, R, S>(
+
        &'a mut self,
+
        handle: &'a mut Handle<R, S>,
+
    ) -> Cached<'a, R, S> {
        Cached {
            handle,
            state: self,
@@ -207,29 +213,30 @@ impl FetchState {
impl FetchState {
    /// Perform the ls-refs and fetch for the given `step`. The result
    /// of these processes is kept track of in the internal state.
-
    pub(super) fn run_stage<S, F>(
+
    pub(super) fn run_stage<R, S, F>(
        &mut self,
-
        handle: &mut Handle<S>,
-
        handshake: &handshake::Outcome,
+
        handle: &mut Handle<R, S>,
+
        handshake: &Handshake,
        step: &F,
    ) -> Result<BTreeSet<PublicKey>, error::Step>
    where
+
        R: AsRef<Repository>,
        S: transport::ConnectionStream,
        F: ProtocolStage,
    {
        let refs = match step.ls_refs() {
            Some(refs) => handle
                .transport
-
                .ls_refs(refs.into(), handshake)?
+
                .ls_refs(refs, handshake)?
                .into_iter()
                .filter_map(|r| step.ref_filter(r))
                .collect::<Vec<_>>(),
            None => vec![],
        };
-
        log::trace!(target: "fetch", "Received refs {refs:?}");
+
        log::trace!("Received refs {refs:#?}");
        step.pre_validate(&refs)?;

-
        let wants_haves = step.wants_haves(&handle.repo, &refs)?;
+
        let wants_haves = step.wants_haves(handle.repository(), &refs)?;
        if !wants_haves.wants.is_empty() {
            let keepfile =
                handle
@@ -237,7 +244,7 @@ impl FetchState {
                    .fetch(wants_haves, handle.interrupt.clone(), handshake)?;
            self.keepfiles.extend(keepfile);
        } else {
-
            log::trace!(target: "fetch", "Nothing to fetch")
+
            log::trace!("Nothing to fetch")
        };

        let mut fetched = BTreeSet::new();
@@ -261,7 +268,7 @@ impl FetchState {
            }
        }

-
        let up = step.prepare_updates(self, &handle.repo, &refs)?;
+
        let up = step.prepare_updates(self, handle.repository(), &refs)?;
        self.update_all(up.tips);

        Ok(fetched)
@@ -280,10 +287,10 @@ impl FetchState {
    /// The resulting [`sigrefs::RemoteRefs`] will be the set of
    /// `rad/sigrefs` of the fetched remotes.
    #[allow(clippy::too_many_arguments)]
-
    fn run_special_refs<S>(
+
    fn run_special_refs<R, S>(
        &mut self,
-
        handle: &mut Handle<S>,
-
        handshake: &handshake::Outcome,
+
        handle: &mut Handle<R, S>,
+
        handshake: &Handshake,
        delegates: BTreeSet<PublicKey>,
        threshold: usize,
        limit: &FetchLimit,
@@ -291,6 +298,7 @@ impl FetchState {
        refs_at: Option<Vec<RefsAt>>,
    ) -> Result<sigrefs::RemoteRefs, error::Protocol>
    where
+
        R: AsRef<Repository>,
        S: transport::ConnectionStream,
    {
        match refs_at {
@@ -302,7 +310,7 @@ impl FetchState {
                    blocked: handle.blocked.clone(),
                    limit: limit.special,
                };
-
                log::trace!(target: "fetch", "{sigrefs_at:?}");
+
                log::trace!("{sigrefs_at:?}");
                self.run_stage(handle, handshake, &sigrefs_at)?;
                let remotes = refs_at.iter().map(|r| &r.remote);

@@ -311,7 +319,7 @@ impl FetchState {
            }
            None => {
                let followed = handle.allowed();
-
                log::trace!(target: "fetch", "Followed nodes {followed:?}");
+
                log::trace!("Followed nodes {followed:?}");
                let special_refs = stage::SpecialRefs {
                    blocked: handle.blocked.clone(),
                    remote,
@@ -320,7 +328,7 @@ impl FetchState {
                    threshold,
                    limit: limit.special,
                };
-
                log::trace!(target: "fetch", "{special_refs:?}");
+
                log::trace!("{special_refs:?}");
                let fetched = self.run_stage(handle, handshake, &special_refs)?;

                let signed_refs = sigrefs::RemoteRefs::load(
@@ -348,15 +356,16 @@ impl FetchState {
    ///      of updating tips.
    ///   7. Apply the valid tips, iff no delegates failed validation.
    ///   8. Signal to the other side that the process has completed.
-
    pub(super) fn run<S>(
+
    pub(super) fn run<R, S>(
        mut self,
-
        handle: &mut Handle<S>,
-
        handshake: &handshake::Outcome,
+
        handle: &mut Handle<R, S>,
+
        handshake: &Handshake,
        limit: FetchLimit,
        remote: PublicKey,
        refs_at: Option<Vec<RefsAt>>,
    ) -> Result<FetchResult, error::Protocol>
    where
+
        R: AsRef<Repository>,
        S: transport::ConnectionStream,
    {
        let start = Instant::now();
@@ -371,7 +380,7 @@ impl FetchState {
                limit: limit.special,
            },
        )?;
-
        log::debug!(target: "fetch", "Fetched rad/id ({}ms)", start.elapsed().as_millis());
+
        log::debug!("Fetched rad/id ({}ms)", start.elapsed().as_millis());

        // N.b. The error case here should not happen. In the case of
        // a `clone` we have asked for refs/rad/id and ensured it was
@@ -392,7 +401,7 @@ impl FetchState {
            .map(|did| PublicKey::from(*did))
            .collect::<BTreeSet<_>>();

-
        log::trace!(target: "fetch", "Identity delegates {delegates:?}");
+
        log::trace!("Identity delegates {delegates:?}");

        // The local peer does not need to count towards the threshold
        // since they must be valid already.
@@ -411,7 +420,6 @@ impl FetchState {
            refs_at,
        )?;
        log::debug!(
-
            target: "fetch",
            "Fetched data for {} remote(s) ({}ms)",
            signed_refs.len(),
            start.elapsed().as_millis()
@@ -424,7 +432,6 @@ impl FetchState {
        };
        self.run_stage(handle, handshake, &data_refs)?;
        log::debug!(
-
            target: "fetch",
            "Fetched data refs for {} remotes ({}ms)",
            data_refs.remotes.len(),
            start.elapsed().as_millis()
@@ -434,9 +441,9 @@ impl FetchState {
        // We're finished fetching on this side, and all that's left
        // is validation.
        match handle.transport.done() {
-
            Ok(()) => log::debug!(target: "fetch", "Sent done signal to remote {remote}"),
+
            Ok(()) => log::debug!("Sent done signal to remote {remote}"),
            Err(err) => {
-
                log::warn!(target: "fetch", "Attempted to send done to remote {remote}: {err}")
+
                log::debug!("Failed to signal EOF to {remote}: {err}")
            }
        }

@@ -465,7 +472,7 @@ impl FetchState {
        // private function.
        for remote in signed_refs.keys() {
            if handle.is_blocked(remote) {
-
                log::trace!(target: "fetch", "Skipping blocked remote {remote}");
+
                log::trace!("Skipping blocked remote {remote}");
                continue;
            }

@@ -473,12 +480,12 @@ impl FetchState {
                .load(&self.as_cached(handle))?;
            match remote {
                sigrefs::DelegateStatus::NonDelegate { remote, data: None } => {
-
                    log::debug!(target: "fetch", "Pruning non-delegate {remote} tips, missing 'rad/sigrefs'");
+
                    log::debug!("Pruning non-delegate {remote} tips, missing 'rad/sigrefs'");
                    failures.push(sigrefs::Validation::MissingRadSigRefs(remote));
                    self.prune(&remote);
                }
                sigrefs::DelegateStatus::Delegate { remote, data: None } => {
-
                    log::warn!(target: "fetch", "Pruning delegate {remote} tips, missing 'rad/sigrefs'");
+
                    log::debug!("Pruning delegate {remote} tips, missing 'rad/sigrefs'");
                    failures.push(sigrefs::Validation::MissingRadSigRefs(remote));
                    self.prune(&remote);
                    // This delegate has removed their `rad/sigrefs`.
@@ -493,13 +500,14 @@ impl FetchState {
                    remote,
                    data: Some(sigrefs),
                } => {
-
                    if let Some(SignedRefsAt { at, .. }) = SignedRefsAt::load(remote, &handle.repo)?
+
                    if let Some(SignedRefsAt { at, .. }) =
+
                        SignedRefsAt::load(remote, handle.repository())?
                    {
                        // Prune non-delegates if they're behind or
                        // diverged. A diverged case is non-fatal for
                        // delegates.
                        if matches!(
-
                            repository::ancestry(&handle.repo, at, sigrefs.at)?,
+
                            repository::ancestry(handle.repository(), at, sigrefs.at)?,
                            repository::Ancestry::Behind | repository::Ancestry::Diverged
                        ) {
                            self.prune(&remote);
@@ -510,7 +518,6 @@ impl FetchState {
                    let cache = self.as_cached(handle);
                    if let Some(warns) = sigrefs::validate(&cache, sigrefs)?.as_mut() {
                        log::debug!(
-
                            target: "fetch",
                            "Pruning non-delegate {remote} tips, due to validation failures"
                        );
                        self.prune(&remote);
@@ -523,11 +530,15 @@ impl FetchState {
                    remote,
                    data: Some(sigrefs),
                } => {
-
                    if let Some(SignedRefsAt { at, .. }) = SignedRefsAt::load(remote, &handle.repo)?
+
                    if let Some(SignedRefsAt { at, .. }) =
+
                        SignedRefsAt::load(remote, handle.repository())?
                    {
-
                        let ancestry = repository::ancestry(&handle.repo, at, sigrefs.at)?;
+
                        let ancestry = repository::ancestry(handle.repository(), at, sigrefs.at)?;
                        if matches!(ancestry, repository::Ancestry::Behind) {
-
                            log::trace!(target: "fetch", "Advertised `rad/sigrefs` {} is behind {at} for {remote}", sigrefs.at);
+
                            log::trace!(
+
                                "Advertised `rad/sigrefs` {} is behind {at} for {remote}",
+
                                sigrefs.at
+
                            );
                            self.prune(&remote);
                            continue;
                        } else if matches!(ancestry, repository::Ancestry::Diverged) {
@@ -543,7 +554,7 @@ impl FetchState {
                    let mut fails =
                        sigrefs::validate(&cache, sigrefs)?.unwrap_or(Validations::default());
                    if !fails.is_empty() {
-
                        log::warn!(target: "fetch", "Pruning delegate {remote} tips, due to validation failures");
+
                        log::debug!("Pruning delegate {remote} tips, due to validation failures");
                        self.prune(&remote);
                        valid_delegates.remove(&remote);
                        failed_delegates.insert(remote);
@@ -556,7 +567,6 @@ impl FetchState {
            }
        }
        log::debug!(
-
            target: "fetch",
            "Validated {} remote(s) ({}ms)",
            remotes.len(),
            start.elapsed().as_millis()
@@ -566,13 +576,13 @@ impl FetchState {
        // delegates that pass the threshold.
        if valid_delegates.len() >= threshold {
            let applied = repository::update(
-
                &handle.repo,
+
                handle.repository(),
                self.tips
                    .clone()
                    .into_values()
                    .flat_map(|ups| ups.into_iter()),
            )?;
-
            log::debug!(target: "fetch", "Applied updates ({}ms)", start.elapsed().as_millis());
+
            log::debug!("Applied updates ({}ms)", start.elapsed().as_millis());
            Ok(FetchResult::Success {
                applied,
                remotes,
@@ -580,7 +590,6 @@ impl FetchState {
            })
        } else {
            log::debug!(
-
                target: "fetch",
                "Fetch failed: {} failure(s) ({}ms)",
                failures.len(),
                start.elapsed().as_millis()
@@ -596,12 +605,15 @@ impl FetchState {

/// A cached version of [`Handle`] by using the underlying
/// [`FetchState`]'s data for performing lookups.
-
pub(crate) struct Cached<'a, S> {
-
    handle: &'a mut Handle<S>,
+
pub(crate) struct Cached<'a, R, S> {
+
    handle: &'a mut Handle<R, S>,
    state: &'a mut FetchState,
}

-
impl<S> Cached<'_, S> {
+
impl<R, S> Cached<'_, R, S>
+
where
+
    R: AsRef<Repository>,
+
{
    /// Resolves `refname` to its [`ObjectId`] by first looking at the
    /// [`FetchState`] and falling back to the [`Handle::refdb`].
    pub fn refname_to_id<'b, N>(
@@ -613,7 +625,7 @@ impl<S> Cached<'_, S> {
    {
        let refname = refname.into();
        match self.state.refs.refname_to_id(refname.clone()) {
-
            None => repository::refname_to_id(&self.handle.repo, refname),
+
            None => repository::refname_to_id(self.handle.repository(), refname),
            Some(oid) => Ok(Some(oid)),
        }
    }
@@ -627,19 +639,54 @@ impl<S> Cached<'_, S> {
        self.handle.verified(head)
    }

+
    /// Resolve the verified [`Doc`], by choosing a `refs/rad/id` head to
+
    /// resolve from.
+
    ///
+
    /// There are two candidate namespaces:
+
    ///
+
    ///   1. Of the fetching node.
+
    ///   2. Of the node being fetched from.
+
    ///
+
    /// Both might be unset, in this case [`None`] is returned.
+
    ///
+
    /// If exactly one of the two is set, it is used.
+
    ///
+
    /// Otherwise, the ahead/behind relationship between the two candidates
+
    /// is checked, and (2.) is used if it is ahead of (1.).
    pub fn canonical(&self) -> Result<Option<Doc>, error::Canonical> {
        let tip = self.refname_to_id(refs::REFS_RAD_ID.clone())?;
        let cached_tip = self.canonical_rad_id();

-
        tip.or(cached_tip)
-
            .map(|tip| self.verified(tip).map_err(error::Canonical::from))
-
            .transpose()
+
        let oid = match (tip, cached_tip) {
+
            (None, None) => {
+
                return Ok(None);
+
            }
+
            (Some(oid), None) | (None, Some(oid)) => oid,
+
            (Some(repository), Some(cached)) => {
+
                let repo = self.handle.repository();
+
                match repo
+
                    .backend
+
                    .graph_ahead_behind(repository.into(), cached.into())
+
                {
+
                    Ok((ahead, behind)) => match (ahead, behind) {
+
                        (0, _) => cached,
+
                        _ => repository,
+
                    },
+
                    Err(err) if err.code() == radicle::git::raw::ErrorCode::NotFound => repository,
+
                    Err(err) => {
+
                        return Err(error::Canonical::Graph(err));
+
                    }
+
                }
+
            }
+
        };
+

+
        self.verified(oid).map(Some).map_err(error::Canonical::from)
    }

    pub fn load(&self, remote: &PublicKey) -> Result<Option<SignedRefsAt>, sigrefs::error::Load> {
        match self.state.sigrefs.get(remote) {
-
            None => SignedRefsAt::load(*remote, &self.handle.repo),
-
            Some(tip) => SignedRefsAt::load_at(*tip, *remote, &self.handle.repo).map(Some),
+
            None => SignedRefsAt::load(*remote, self.handle.repository()),
+
            Some(tip) => SignedRefsAt::load_at(*tip, *remote, self.handle.repository()).map(Some),
        }
    }

@@ -649,11 +696,14 @@ impl<S> Cached<'_, S> {
    }
}

-
impl<S> RemoteRepository for Cached<'_, S> {
+
impl<R, S> RemoteRepository for Cached<'_, R, S>
+
where
+
    R: AsRef<Repository>,
+
{
    fn remote(&self, remote: &RemoteId) -> Result<Remote, storage::refs::Error> {
        // N.b. this is unused so we just delegate to the underlying
        // repository for a correct implementation.
-
        self.handle.repo.remote(remote)
+
        self.handle.repository().remote(remote)
    }

    fn remotes(&self) -> Result<Remotes<Verified>, storage::refs::Error> {
@@ -665,11 +715,14 @@ impl<S> RemoteRepository for Cached<'_, S> {
    }

    fn remote_refs_at(&self) -> Result<Vec<RefsAt>, storage::refs::Error> {
-
        self.handle.repo.remote_refs_at()
+
        self.handle.repository().remote_refs_at()
    }
}

-
impl<S> ValidateRepository for Cached<'_, S> {
+
impl<R, S> ValidateRepository for Cached<'_, R, S>
+
where
+
    R: AsRef<Repository>,
+
{
    // N.b. we don't verify the `rad/id` of each remote since they may
    // not have a reference to the COB if they have not interacted
    // with it.
modified crates/radicle-fetch/src/transport.rs
@@ -10,17 +10,18 @@ use std::sync::Arc;
use bstr::BString;
use gix_features::progress::prodash::progress;
use gix_protocol::handshake;
+
use gix_protocol::Handshake;
use gix_transport::client;
use gix_transport::Protocol;
use gix_transport::Service;
+
use radicle::git::fmt::Qualified;
use radicle::git::Oid;
-
use radicle::git::Qualified;
use radicle::storage::git::Repository;
use thiserror::Error;

-
use crate::git::oid;
use crate::git::packfile::Keepfile;
use crate::git::repository;
+
use crate::stage::RefPrefix;

/// Open a reader and writer stream to pass to the ls-refs and fetch
/// processes for communicating during their respective protocols.
@@ -90,26 +91,26 @@ where
    }

    /// Perform the handshake with the server side.
-
    pub(crate) fn handshake(&mut self) -> Result<handshake::Outcome, Box<handshake::Error>> {
-
        log::trace!(target: "fetch", "Performing handshake for {}", self.repo);
+
    #[allow(clippy::result_large_err)]
+
    pub(crate) fn handshake(&mut self) -> Result<Handshake, handshake::Error> {
+
        log::trace!("Performing handshake for {}", self.repo);
        let (read, write) = self.stream.open();
-
        gix_protocol::fetch::handshake(
+
        gix_protocol::handshake(
            &mut Connection::new(read, write, self.repo.clone()),
+
            Service::UploadPack,
            |_| Ok(None),
            vec![],
            &mut progress::Discard,
        )
-
        .map_err(Box::new)
    }

    /// Perform ls-refs with the server side.
    pub(crate) fn ls_refs(
        &mut self,
-
        mut prefixes: Vec<BString>,
-
        handshake: &handshake::Outcome,
+
        prefixes: impl IntoIterator<Item = RefPrefix>,
+
        handshake: &Handshake,
    ) -> Result<Vec<handshake::Ref>, Error> {
-
        prefixes.sort();
-
        prefixes.dedup();
+
        let prefixes = prefixes.into_iter().collect::<BTreeSet<_>>();
        let (read, write) = self.stream.open();
        Ok(ls_refs::run(
            ls_refs::Config {
@@ -127,10 +128,9 @@ where
        &mut self,
        wants_haves: WantsHaves,
        interrupt: Arc<AtomicBool>,
-
        handshake: &handshake::Outcome,
+
        handshake: &Handshake,
    ) -> Result<Option<Keepfile>, Error> {
        log::trace!(
-
            target: "fetch",
            "Running fetch wants={:?}, haves={:?}",
            wants_haves.wants,
            wants_haves.haves
@@ -163,7 +163,7 @@ where

            let idx = File::at(pack_path, gix_hash::Kind::Sha1)?;
            for oid in wants_haves.wants {
-
                if idx.lookup(oid::to_object_id(oid)).is_none() {
+
                if idx.lookup(oid).is_none() {
                    return Err(Error::NotFound(oid));
                }
            }
@@ -181,7 +181,7 @@ where
}

pub(crate) struct Connection<R, W> {
-
    inner: client::git::Connection<R, W>,
+
    inner: client::git::blocking_io::Connection<R, W>,
}

impl<R, W> Connection<R, W>
@@ -191,7 +191,7 @@ where
{
    pub fn new(read: R, write: W, repo: BString) -> Self {
        Self {
-
            inner: client::git::Connection::new(
+
            inner: client::git::blocking_io::Connection::new(
                read,
                write,
                Protocol::V2,
@@ -204,16 +204,25 @@ where
    }
}

-
impl<R, W> client::Transport for Connection<R, W>
+
impl<R, W> client::blocking_io::Transport for Connection<R, W>
where
    R: std::io::Read,
    W: std::io::Write,
{
+
    fn request(
+
        &mut self,
+
        write_mode: client::WriteMode,
+
        on_into_read: client::MessageKind,
+
        trace: bool,
+
    ) -> Result<client::blocking_io::RequestWriter<'_>, client::Error> {
+
        self.inner.request(write_mode, on_into_read, trace)
+
    }
+

    fn handshake<'b>(
        &mut self,
        service: Service,
        extra_parameters: &'b [(&'b str, Option<&'b str>)],
-
    ) -> Result<client::SetServiceResponse<'_>, client::Error> {
+
    ) -> Result<client::blocking_io::SetServiceResponse<'_>, client::Error> {
        self.inner.handshake(service, extra_parameters)
    }
}
@@ -223,15 +232,6 @@ where
    R: std::io::Read,
    W: std::io::Write,
{
-
    fn request(
-
        &mut self,
-
        write_mode: client::WriteMode,
-
        on_into_read: client::MessageKind,
-
        trace: bool,
-
    ) -> Result<client::RequestWriter<'_>, client::Error> {
-
        self.inner.request(write_mode, on_into_read, trace)
-
    }
-

    fn to_url(&self) -> std::borrow::Cow<'_, bstr::BStr> {
        self.inner.to_url()
    }
@@ -329,8 +329,8 @@ fn agent_name() -> String {
        Ok(version) => version,
        Err(err) => {
            use radicle::git::VERSION_REQUIRED;
-
            log::warn!(target: "fetch", "The git version could not be determined: {err}");
-
            log::warn!(target: "fetch", "Pretending that we are on git version {VERSION_REQUIRED}.");
+
            log::debug!("The git version could not be determined: {err}");
+
            log::debug!("Pretending that we are on git version {VERSION_REQUIRED}.");
            VERSION_REQUIRED
        }
    };
modified crates/radicle-fetch/src/transport/fetch.rs
@@ -4,12 +4,11 @@ use std::sync::{atomic::AtomicBool, Arc};

use gix_features::progress::{DynNestedProgress, NestedProgress};
use gix_pack as pack;
-
use gix_protocol::fetch;
use gix_protocol::fetch::negotiate::one_round::State;
-
use gix_protocol::handshake;
use gix_protocol::handshake::Ref;
+
use gix_protocol::{fetch, Handshake};

-
use crate::git::{oid, packfile};
+
use crate::git::packfile;

use super::{agent_name, Connection, WantsHaves};

@@ -109,7 +108,7 @@ impl fetch::Negotiate for Negotiate {
    ) -> bool {
        let mut has_want = false;
        for oid in &self.wants_haves.wants {
-
            arguments.want(oid::to_object_id(*oid));
+
            arguments.want(oid);
            has_want = true;
        }
        has_want
@@ -126,7 +125,7 @@ impl fetch::Negotiate for Negotiate {
        _previous_response: Option<&fetch::Response>,
    ) -> Result<(fetch::negotiate::Round, bool), fetch::negotiate::Error> {
        for oid in &self.wants_haves.haves {
-
            arguments.have(oid::to_object_id(*oid));
+
            arguments.have(oid);
        }

        let round = fetch::negotiate::Round {
@@ -149,7 +148,7 @@ impl fetch::Negotiate for Negotiate {
pub(crate) fn run<P, R, W>(
    wants_haves: WantsHaves,
    pack_writer: PackWriter,
-
    handshake: &handshake::Outcome,
+
    handshake: &Handshake,
    mut conn: Connection<R, W>,
    progress: &mut P,
) -> Result<FetchOut, Error>
@@ -159,7 +158,7 @@ where
    R: io::Read,
    W: io::Write,
{
-
    log::trace!(target: "fetch", "Performing fetch");
+
    log::trace!("Performing fetch");

    if wants_haves.wants.is_empty() {
        return Err(Error::ReadRemainingBytes(io::Error::new(
@@ -214,6 +213,6 @@ where
        .and_then(packfile::Keepfile::new);
    out.pack = Some(pack_out);

-
    log::trace!(target: "fetch", "fetched refs: {:?}", out.refs);
+
    log::trace!("fetched refs: {:?}", out.refs);
    Ok(out)
}
modified crates/radicle-fetch/src/transport/ls_refs.rs
@@ -1,11 +1,14 @@
use std::borrow::Cow;
+
use std::collections::BTreeSet;
use std::io;

use gix_features::progress::Progress;
-
use gix_protocol::handshake::{self, Ref};
-
use gix_protocol::ls_refs;
+
use gix_protocol::handshake::Ref;
use gix_protocol::transport::Protocol;
-
use gix_transport::bstr::{BString, ByteVec};
+
use gix_protocol::{ls_refs, Handshake};
+
use gix_transport::bstr::BString;
+

+
use crate::stage::RefPrefix;

use super::{agent_name, Connection};

@@ -17,7 +20,7 @@ pub struct Config {
    #[allow(dead_code)]
    pub repo: BString,
    /// Ref prefixes for filtering the output of the ls-refs process.
-
    pub prefixes: Vec<BString>,
+
    pub prefixes: BTreeSet<RefPrefix>,
}

/// Run the ls-refs process using the provided `config`.
@@ -30,7 +33,7 @@ pub struct Config {
/// the `config`.
pub(crate) fn run<R, W>(
    config: Config,
-
    handshake: &handshake::Outcome,
+
    handshake: &Handshake,
    mut conn: Connection<R, W>,
    progress: &mut impl Progress,
) -> Result<Vec<Ref>, ls_refs::Error>
@@ -38,8 +41,8 @@ where
    R: io::Read,
    W: io::Write,
{
-
    log::trace!(target: "fetch", "Performing ls-refs: {:?}", config.prefixes);
-
    let handshake::Outcome {
+
    log::trace!("Performing ls-refs: {:?}", config.prefixes);
+
    let Handshake {
        server_protocol_version: protocol,
        capabilities,
        ..
@@ -51,21 +54,39 @@ where
        )));
    }

-
    let refs = gix_protocol::ls_refs(
-
        &mut conn,
+
    let (refspecs, prefixes) = {
+
        let n = config.prefixes.len();
+
        config.prefixes.into_iter().fold(
+
            (Vec::with_capacity(n), Vec::with_capacity(n)),
+
            |(mut specs, mut prefixes), prefix| {
+
                specs.push(prefix.as_refspec());
+
                prefixes.push(prefix.into_bstring());
+
                (specs, prefixes)
+
            },
+
        )
+
    };
+

+
    log::trace!("ls-refs prefixes: {:#?}", refspecs);
+

+
    let ls_refs = gix_protocol::LsRefsCommand::new(
+
        Some(&refspecs),
        capabilities,
-
        |_caps, args, features| {
-
            for prefix in &config.prefixes {
-
                let mut arg = BString::from("ref-prefix ");
-
                arg.push_str(prefix);
-
                args.push(arg)
-
            }
-
            features.push(("agent", Some(Cow::Owned(agent_name()))));
-
            Ok(gix_protocol::ls_refs::Action::Continue)
-
        },
-
        progress,
-
        false, /* trace packetlines */
-
    )?;
+
        ("agent", Some(Cow::Owned(agent_name()))),
+
    );
+

+
    // According to [1], in the section on `ls-refs`, we must still filter on
+
    // this side, since `ref-prefix` is simply an optimization.
+
    //
+
    // [1]: https://mirrors.edge.kernel.org/pub/software/scm/git/docs/gitprotocol-v2.html
+
    let refs = ls_refs
+
        .invoke_blocking(&mut conn, progress, false)?
+
        .into_iter()
+
        .filter(|r| {
+
            let (refname, _, _) = r.unpack();
+
            prefixes.iter().any(|prefix| refname.starts_with(prefix))
+
        })
+
        .collect();

+
    log::trace!("ls-refs received: {refs:#?}");
    Ok(refs)
}
added crates/radicle-git-metadata/Cargo.toml
@@ -0,0 +1,13 @@
+
[package]
+
name = "radicle-git-metadata"
+
description = "Radicle structs that carry Git commit metadata"
+
homepage.workspace = true
+
repository.workspace = true
+
version = "0.1.0"
+
edition.workspace = true
+
license.workspace = true
+
keywords = ["radicle", "git", "metadata"]
+
rust-version.workspace = true
+

+
[dependencies]
+
thiserror = { workspace = true, default-features = true }

\ No newline at end of file
added crates/radicle-git-metadata/src/author.rs
@@ -0,0 +1,125 @@
+
use std::{
+
    fmt,
+
    num::ParseIntError,
+
    str::{self, FromStr},
+
};
+

+
use thiserror::Error;
+

+
/// The data for indicating authorship of an action within
+
/// [`crate::commit::CommitData`].
+
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+
pub struct Author {
+
    /// Name corresponding to `user.name` in the git config.
+
    ///
+
    /// Note: this must not contain `<` or `>`.
+
    pub name: String,
+
    /// Email corresponding to `user.email` in the git config.
+
    ///
+
    /// Note: this must not contain `<` or `>`.
+
    pub email: String,
+
    /// The time of this author's action.
+
    pub time: Time,
+
}
+

+
/// The time of a [`Author`]'s action.
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+
pub struct Time {
+
    seconds: i64,
+
    offset: i32,
+
}
+

+
impl Time {
+
    pub fn new(seconds: i64, offset: i32) -> Self {
+
        Self { seconds, offset }
+
    }
+

+
    /// Return the time, in seconds, since the epoch.
+
    pub fn seconds(&self) -> i64 {
+
        self.seconds
+
    }
+

+
    /// Return the timezone offset, in minutes.
+
    pub fn offset(&self) -> i32 {
+
        self.offset
+
    }
+

+
    fn from_components<'a>(cs: &mut impl Iterator<Item = &'a str>) -> Result<Self, ParseError> {
+
        let offset = match cs.next() {
+
            None => Err(ParseError::Missing("offset")),
+
            Some(offset) => Self::parse_offset(offset).map_err(ParseError::Offset),
+
        }?;
+
        let time = match cs.next() {
+
            None => return Err(ParseError::Missing("time")),
+
            Some(time) => time.parse::<i64>().map_err(ParseError::Time)?,
+
        };
+
        Ok(Self::new(time, offset))
+
    }
+

+
    fn parse_offset(offset: &str) -> Result<i32, ParseIntError> {
+
        // The offset is in the form of timezone offset,
+
        // e.g. +0200, -0100.  This needs to be converted into
+
        // minutes. The first two digits in the offset are the
+
        // number of hours in the offset, while the latter two
+
        // digits are the number of minutes in the offset.
+
        let tz_offset = offset.parse::<i32>()?;
+
        let hours = tz_offset / 100;
+
        let minutes = tz_offset % 100;
+
        Ok(hours * 60 + minutes)
+
    }
+
}
+

+
impl FromStr for Time {
+
    type Err = ParseError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        Self::from_components(&mut s.split(' ').rev())
+
    }
+
}
+

+
impl fmt::Display for Time {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        let sign = if self.offset.is_negative() { '-' } else { '+' };
+
        let hours = self.offset.abs() / 60;
+
        let minutes = self.offset.abs() % 60;
+
        write!(f, "{} {}{:0>2}{:0>2}", self.seconds, sign, hours, minutes)
+
    }
+
}
+

+
impl fmt::Display for Author {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        write!(f, "{} <{}> {}", self.name, self.email, self.time,)
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
pub enum ParseError {
+
    #[error("missing '{0}' while parsing person signature")]
+
    Missing(&'static str),
+
    #[error("offset was incorrect format while parsing person signature")]
+
    Offset(#[source] ParseIntError),
+
    #[error("time was incorrect format while parsing person signature")]
+
    Time(#[source] ParseIntError),
+
}
+

+
impl FromStr for Author {
+
    type Err = ParseError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        // Splitting the string in 4 subcomponents is expected to give back the
+
        // following iterator entries: timezone offset, time, email, and name
+
        let mut components = s.rsplitn(4, ' ');
+
        let time = Time::from_components(&mut components)?;
+
        let email = components
+
            .next()
+
            .ok_or(ParseError::Missing("email"))?
+
            .trim_matches(|c| c == '<' || c == '>')
+
            .to_owned();
+
        let name = components.next().ok_or(ParseError::Missing("name"))?;
+
        Ok(Self {
+
            name: name.to_owned(),
+
            email: email.to_owned(),
+
            time,
+
        })
+
    }
+
}
added crates/radicle-git-metadata/src/commit.rs
@@ -0,0 +1,179 @@
+
pub mod headers;
+
pub mod trailers;
+

+
use core::fmt;
+
use std::str;
+

+
use headers::{Headers, Signature};
+
use trailers::{OwnedTrailer, Trailer};
+

+
use crate::author::Author;
+

+
/// A git commit in its object description form, i.e. the output of
+
/// `git cat-file` for a commit object.
+
#[derive(Debug)]
+
pub struct CommitData<Tree, Parent> {
+
    tree: Tree,
+
    parents: Vec<Parent>,
+
    author: Author,
+
    committer: Author,
+
    headers: Headers,
+
    message: String,
+
    trailers: Vec<OwnedTrailer>,
+
}
+

+
impl<Tree, Parent> CommitData<Tree, Parent> {
+
    pub fn new<P, I, T>(
+
        tree: Tree,
+
        parents: P,
+
        author: Author,
+
        committer: Author,
+
        headers: Headers,
+
        message: String,
+
        trailers: I,
+
    ) -> Self
+
    where
+
        P: IntoIterator<Item = Parent>,
+
        I: IntoIterator<Item = T>,
+
        OwnedTrailer: From<T>,
+
    {
+
        let trailers = trailers.into_iter().map(OwnedTrailer::from).collect();
+
        let parents = parents.into_iter().collect();
+
        Self {
+
            tree,
+
            parents,
+
            author,
+
            committer,
+
            headers,
+
            message,
+
            trailers,
+
        }
+
    }
+

+
    /// The tree this commit points to.
+
    pub fn tree(&self) -> &Tree {
+
        &self.tree
+
    }
+

+
    /// The parents of this commit.
+
    pub fn parents(&self) -> impl Iterator<Item = Parent> + '_
+
    where
+
        Parent: Clone,
+
    {
+
        self.parents.iter().cloned()
+
    }
+

+
    /// The author of this commit, i.e. the header corresponding to `author`.
+
    pub fn author(&self) -> &Author {
+
        &self.author
+
    }
+

+
    /// The committer of this commit, i.e. the header corresponding to
+
    /// `committer`.
+
    pub fn committer(&self) -> &Author {
+
        &self.committer
+
    }
+

+
    /// The message body of this commit.
+
    pub fn message(&self) -> &str {
+
        &self.message
+
    }
+

+
    /// The [`Signature`]s found in this commit, i.e. the headers corresponding
+
    /// to `gpgsig`.
+
    pub fn signatures(&self) -> impl Iterator<Item = Signature<'_>> + '_ {
+
        self.headers.signatures()
+
    }
+

+
    /// The [`Headers`] found in this commit.
+
    ///
+
    /// Note: these do not include `tree`, `parent`, `author`, and `committer`.
+
    pub fn headers(&self) -> impl Iterator<Item = (&str, &str)> {
+
        self.headers.iter()
+
    }
+

+
    /// Iterate over the [`Headers`] values that match the provided `name`.
+
    pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> + 'a {
+
        self.headers.values(name)
+
    }
+

+
    /// Push a header to the end of the headers section.
+
    pub fn push_header(&mut self, name: &str, value: &str) {
+
        self.headers.push(name, value.trim());
+
    }
+

+
    pub fn trailers(&self) -> impl Iterator<Item = &OwnedTrailer> {
+
        self.trailers.iter()
+
    }
+

+
    /// Convert the `CommitData::tree` into a value of type `U`. The
+
    /// conversion function `f` can be fallible.
+
    ///
+
    /// For example, `map_tree` can be used to turn raw tree data into
+
    /// an `Oid` by writing it to a repository.
+
    pub fn map_tree<U, E, F>(self, f: F) -> Result<CommitData<U, Parent>, E>
+
    where
+
        F: FnOnce(Tree) -> Result<U, E>,
+
    {
+
        Ok(CommitData {
+
            tree: f(self.tree)?,
+
            parents: self.parents,
+
            author: self.author,
+
            committer: self.committer,
+
            headers: self.headers,
+
            message: self.message,
+
            trailers: self.trailers,
+
        })
+
    }
+

+
    /// Convert the [`CommitData::parents`] into a vector containing
+
    /// values of type `U`. The conversion function `f` can be
+
    /// fallible.
+
    ///
+
    /// For example, this can be used to resolve the object identifiers
+
    /// to their respective full commits.
+
    pub fn map_parents<U, E, F>(self, f: F) -> Result<CommitData<Tree, U>, E>
+
    where
+
        F: FnMut(Parent) -> Result<U, E>,
+
    {
+
        Ok(CommitData {
+
            tree: self.tree,
+
            parents: self
+
                .parents
+
                .into_iter()
+
                .map(f)
+
                .collect::<Result<Vec<_>, _>>()?,
+
            author: self.author,
+
            committer: self.committer,
+
            headers: self.headers,
+
            message: self.message,
+
            trailers: self.trailers,
+
        })
+
    }
+
}
+

+
impl<Tree: fmt::Display, Parent: fmt::Display> fmt::Display for CommitData<Tree, Parent> {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        writeln!(f, "tree {}", self.tree)?;
+
        for parent in self.parents.iter() {
+
            writeln!(f, "parent {parent}")?;
+
        }
+
        writeln!(f, "author {}", self.author)?;
+
        writeln!(f, "committer {}", self.committer)?;
+

+
        for (name, value) in self.headers.iter() {
+
            writeln!(f, "{name} {}", value.replace('\n', "\n "))?;
+
        }
+
        writeln!(f)?;
+
        write!(f, "{}", self.message.trim())?;
+
        writeln!(f)?;
+

+
        if !self.trailers.is_empty() {
+
            writeln!(f)?;
+
        }
+
        for trailer in self.trailers.iter() {
+
            writeln!(f, "{}", Trailer::from(trailer).display(": "))?;
+
        }
+
        Ok(())
+
    }
+
}
added crates/radicle-git-metadata/src/commit/headers.rs
@@ -0,0 +1,168 @@
+
use core::fmt;
+
use std::borrow::Cow;
+

+
const BEGIN_SSH: &str = "-----BEGIN SSH SIGNATURE-----\n";
+
const BEGIN_PGP: &str = "-----BEGIN PGP SIGNATURE-----\n";
+

+
/// A collection of headers stored in [`super::CommitData`].
+
///
+
/// Note: these do not include `tree`, `parent`, `author`, and `committer`.
+
#[derive(Clone, Debug, Default)]
+
pub struct Headers(pub(super) Vec<(String, String)>);
+

+
/// A `gpgsig` signature stored in [`super::CommitData`].
+
#[derive(Debug)]
+
pub enum Signature<'a> {
+
    /// A PGP signature, i.e. starts with `-----BEGIN PGP SIGNATURE-----`.
+
    Pgp(Cow<'a, str>),
+
    /// A SSH signature, i.e. starts with `-----BEGIN SSH SIGNATURE-----`.
+
    Ssh(Cow<'a, str>),
+
}
+

+
impl<'a> Signature<'a> {
+
    fn from_str(s: &'a str) -> Result<Self, UnknownScheme> {
+
        if s.starts_with(BEGIN_SSH) {
+
            Ok(Signature::Ssh(Cow::Borrowed(s)))
+
        } else if s.starts_with(BEGIN_PGP) {
+
            Ok(Signature::Pgp(Cow::Borrowed(s)))
+
        } else {
+
            Err(UnknownScheme)
+
        }
+
    }
+
}
+

+
impl fmt::Display for Signature<'_> {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            Signature::Pgp(pgp) => f.write_str(pgp.as_ref()),
+
            Signature::Ssh(ssh) => f.write_str(ssh.as_ref()),
+
        }
+
    }
+
}
+

+
pub struct UnknownScheme;
+

+
impl Headers {
+
    pub fn new() -> Self {
+
        Headers(Vec::new())
+
    }
+

+
    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
+
        self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
+
    }
+

+
    pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> + 'a {
+
        self.iter()
+
            .filter_map(move |(k, v)| (k == name).then_some(v))
+
    }
+

+
    pub fn signatures(&self) -> impl Iterator<Item = Signature<'_>> + '_ {
+
        self.0.iter().filter_map(|(k, v)| {
+
            if k == "gpgsig" {
+
                Signature::from_str(v).ok()
+
            } else {
+
                None
+
            }
+
        })
+
    }
+

+
    /// Push a header to the end of the headers section.
+
    pub fn push(&mut self, name: &str, value: &str) {
+
        self.0.push((name.to_owned(), value.trim().to_owned()));
+
    }
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
pub enum ParseError {
+
    #[error("missing tree")]
+
    MissingTree,
+
    #[error("invalid tree")]
+
    InvalidTree,
+
    #[error("invalid format")]
+
    InvalidFormat,
+
    #[error("invalid parent")]
+
    InvalidParent,
+
    #[error("invalid header")]
+
    InvalidHeader,
+
    #[error("invalid author")]
+
    InvalidAuthor,
+
    #[error("missing author")]
+
    MissingAuthor,
+
    #[error("invalid committer")]
+
    InvalidCommitter,
+
    #[error("missing committer")]
+
    MissingCommitter,
+
}
+

+
pub fn parse_commit_header<
+
    Tree: std::str::FromStr,
+
    Parent: std::str::FromStr,
+
    Signature: std::str::FromStr,
+
>(
+
    header: &str,
+
) -> Result<(Tree, Vec<Parent>, Signature, Signature, Headers), ParseError> {
+
    let mut lines = header.lines();
+

+
    let tree = match lines.next() {
+
        Some(tree) => tree
+
            .strip_prefix("tree ")
+
            .map(Tree::from_str)
+
            .transpose()
+
            .map_err(|_| ParseError::InvalidTree)?
+
            .ok_or(ParseError::MissingTree)?,
+
        None => return Err(ParseError::MissingTree),
+
    };
+

+
    let mut parents = Vec::new();
+
    let mut author: Option<Signature> = None;
+
    let mut committer: Option<Signature> = None;
+
    let mut headers = Headers::new();
+

+
    for line in lines {
+
        // Check if a signature is still being parsed
+
        if let Some(rest) = line.strip_prefix(' ') {
+
            let value: &mut String = headers
+
                .0
+
                .last_mut()
+
                .map(|(_, v)| v)
+
                .ok_or(ParseError::InvalidFormat)?;
+
            value.push('\n');
+
            value.push_str(rest);
+
            continue;
+
        }
+

+
        if let Some((name, value)) = line.split_once(' ') {
+
            match name {
+
                "parent" => parents.push(
+
                    value
+
                        .parse::<Parent>()
+
                        .map_err(|_| ParseError::InvalidParent)?,
+
                ),
+
                "author" => {
+
                    author = Some(
+
                        value
+
                            .parse::<Signature>()
+
                            .map_err(|_| ParseError::InvalidAuthor)?,
+
                    )
+
                }
+
                "committer" => {
+
                    committer = Some(
+
                        value
+
                            .parse::<Signature>()
+
                            .map_err(|_| ParseError::InvalidCommitter)?,
+
                    )
+
                }
+
                _ => headers.push(name, value),
+
            }
+
            continue;
+
        }
+
    }
+

+
    Ok((
+
        tree,
+
        parents,
+
        author.ok_or(ParseError::MissingAuthor)?,
+
        committer.ok_or(ParseError::MissingCommitter)?,
+
        headers,
+
    ))
+
}
added crates/radicle-git-metadata/src/commit/trailers.rs
@@ -0,0 +1,127 @@
+
use std::{borrow::Cow, fmt, ops::Deref};
+

+
pub trait Separator<'a> {
+
    fn sep_for(&self, token: &Token) -> &'a str;
+
}
+

+
impl<'a> Separator<'a> for &'a str {
+
    fn sep_for(&self, _: &Token) -> &'a str {
+
        self
+
    }
+
}
+

+
impl<'a, F> Separator<'a> for F
+
where
+
    F: Fn(&Token) -> &'a str,
+
{
+
    fn sep_for(&self, token: &Token) -> &'a str {
+
        self(token)
+
    }
+
}
+

+
#[derive(Debug, Clone, Eq, PartialEq)]
+
pub struct Token<'a>(&'a str);
+

+
impl Deref for Token<'_> {
+
    type Target = str;
+

+
    fn deref(&self) -> &Self::Target {
+
        self.0
+
    }
+
}
+

+
impl<'a> TryFrom<&'a str> for Token<'a> {
+
    type Error = &'static str;
+

+
    fn try_from(s: &'a str) -> Result<Self, Self::Error> {
+
        let is_token = s.chars().all(|c| c.is_alphanumeric() || c == '-');
+
        if is_token {
+
            Ok(Token(s))
+
        } else {
+
            Err("token contains invalid characters")
+
        }
+
    }
+
}
+

+
pub struct Display<'a> {
+
    trailer: &'a Trailer<'a>,
+
    separator: &'a str,
+
}
+

+
impl fmt::Display for Display<'_> {
+
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+
        write!(
+
            f,
+
            "{}{}{}",
+
            self.trailer.token.deref(),
+
            self.separator,
+
            self.trailer.value,
+
        )
+
    }
+
}
+

+
/// A trailer is a key/value pair found in the last paragraph of a Git
+
/// commit message, not including any patches or conflicts that may be
+
/// present.
+
#[derive(Debug, Clone, Eq, PartialEq)]
+
pub struct Trailer<'a> {
+
    pub token: Token<'a>,
+
    pub value: Cow<'a, str>,
+
}
+

+
impl<'a> Trailer<'a> {
+
    pub fn display(&'a self, separator: &'a str) -> Display<'a> {
+
        Display {
+
            trailer: self,
+
            separator,
+
        }
+
    }
+

+
    pub fn to_owned(&self) -> OwnedTrailer {
+
        OwnedTrailer::from(self)
+
    }
+
}
+

+
/// A version of the [`Trailer`] which owns its token and
+
/// value. Useful for when you need to carry trailers around in a long
+
/// lived data structure.
+
#[derive(Debug)]
+
pub struct OwnedTrailer {
+
    pub token: OwnedToken,
+
    pub value: String,
+
}
+

+
#[derive(Debug)]
+
pub struct OwnedToken(String);
+

+
impl Deref for OwnedToken {
+
    type Target = str;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.0
+
    }
+
}
+

+
impl<'a> From<&Trailer<'a>> for OwnedTrailer {
+
    fn from(t: &Trailer<'a>) -> Self {
+
        OwnedTrailer {
+
            token: OwnedToken(t.token.0.to_string()),
+
            value: t.value.to_string(),
+
        }
+
    }
+
}
+

+
impl<'a> From<Trailer<'a>> for OwnedTrailer {
+
    fn from(t: Trailer<'a>) -> Self {
+
        (&t).into()
+
    }
+
}
+

+
impl<'a> From<&'a OwnedTrailer> for Trailer<'a> {
+
    fn from(t: &'a OwnedTrailer) -> Self {
+
        Trailer {
+
            token: Token(t.token.0.as_str()),
+
            value: Cow::from(&t.value),
+
        }
+
    }
+
}
added crates/radicle-git-metadata/src/lib.rs
@@ -0,0 +1,2 @@
+
pub mod author;
+
pub mod commit;
added crates/radicle-git-ref-format/Cargo.toml
@@ -0,0 +1,18 @@
+
[package]
+
name = "radicle-git-ref-format"
+
description = "Radicle re-exports and macros for `git-ref-format-core`"
+
homepage.workspace = true
+
repository.workspace = true
+
version = "0.1.0"
+
edition.workspace = true
+
license.workspace = true
+
keywords = ["radicle", "git", "refname", "ref", "references"]
+
rust-version.workspace = true
+

+
[features]
+
macro = []
+
bstr = ["git-ref-format-core/bstr"]
+
serde = ["git-ref-format-core/serde"]
+

+
[dependencies]
+
git-ref-format-core.workspace = true
added crates/radicle-git-ref-format/src/lib.rs
@@ -0,0 +1,298 @@
+
#![no_std]
+

+
//! [`git_ref_format`]: https://crates.io/crates/git-ref-format
+
//! [`radicle-git-ext`]: https://crates.io/crates/radicle-git-ext
+
//!
+
//! This crate depends on and re-exports from [`git_ref_format_core`].
+
//!
+
//! ## Macros
+
//!
+
//! Instead of providing procedural macros, like [`git_ref_format`]
+
//! it just provides much simpler declarative macros, guarded by the feature
+
//! flag `macro`.
+
//!
+
//! ### Benefits
+
//!
+
//! - Does not depend on [`radicle-git-ext`].
+
//! - Does not pull in procedural macro dependencies.
+
//! - Has much smaller compile-time overhead than [`git_ref_format`].
+
//!
+
//! ### Drawback
+
//!
+
//! The main drawback is that the macros in this crate cannot provide compile
+
//! time validation of the argument. Thus, these macros must be used in
+
//! conjunction with testing: If all generated objects are used in tests, and
+
//! these tests are run, then the guarantees are equally strong. Consumers that
+
//! do not or cannot test their code should not use the macros then.
+

+
pub use git_ref_format_core::*;
+

+
/// Create a [`git_ref_format_core::RefString`] from a string literal.
+
///
+
/// Similar to [`core::debug_assert`], an optimized build will not validate
+
/// (but rather perform an unsafe conversion) unless `-C debug-assertions` is
+
/// passed to the compiler.
+
#[cfg(any(feature = "macro", test))]
+
#[macro_export]
+
macro_rules! refname {
+
    ($arg:literal) => {{
+
        use $crate::RefString;
+

+
        #[cfg(debug_assertions)]
+
        {
+
            RefString::try_from($arg).expect(core::concat!(
+
                "literal `",
+
                $arg,
+
                "` must be a valid reference name"
+
            ))
+
        }
+

+
        #[cfg(not(debug_assertions))]
+
        {
+
            extern crate alloc;
+

+
            use alloc::string::String;
+

+
            let s: String = $arg.to_owned();
+
            unsafe { core::mem::transmute::<_, RefString>(s) }
+
        }
+
    }};
+
}
+

+
/// Create a [`git_ref_format_core::Qualified`] from a string literal.
+
///
+
/// Similar to [`core::debug_assert`], an optimized build will not validate
+
/// (but rather perform an unsafe conversion) unless `-C debug-assertions` is
+
/// passed to the compiler.
+
#[cfg(any(feature = "macro", test))]
+
#[macro_export]
+
macro_rules! qualified {
+
    ($arg:literal) => {{
+
        use $crate::Qualified;
+

+
        #[cfg(debug_assertions)]
+
        {
+
            Qualified::from_refstr($crate::refname!($arg)).expect(core::concat!(
+
                "literal `",
+
                $arg,
+
                "` must be of the form 'refs/<category>/<name>'"
+
            ))
+
        }
+

+
        #[cfg(not(debug_assertions))]
+
        {
+
            extern crate alloc;
+

+
            use core::mem::transmute;
+

+
            use alloc::borrow::Cow;
+
            use alloc::string::String;
+

+
            use $crate::{RefStr, RefString};
+

+
            let s: String = $arg.to_owned();
+
            let refstring: RefString = unsafe { transmute(s) };
+
            let cow: Cow<'_, RefStr> = Cow::Owned(refstring);
+
            let qualified: Qualified = unsafe { transmute(cow) };
+

+
            qualified
+
        }
+
    }};
+
}
+

+
/// Create a [`git_ref_format_core::Component`] from a string literal.
+
///
+
/// Similar to [`core::debug_assert`], an optimized build will not validate
+
/// (but rather perform an unsafe conversion) unless `-C debug-assertions` is
+
/// passed to the compiler.
+
#[cfg(any(feature = "macro", test))]
+
#[macro_export]
+
macro_rules! component {
+
    ($arg:literal) => {{
+
        use $crate::Component;
+

+
        #[cfg(debug_assertions)]
+
        {
+
            Component::from_refstr($crate::refname!($arg)).expect(core::concat!(
+
                "literal `",
+
                $arg,
+
                "` must be a valid component (cannot contain '/')"
+
            ))
+
        }
+

+
        #[cfg(not(debug_assertions))]
+
        {
+
            extern crate alloc;
+

+
            use core::mem::transmute;
+

+
            use alloc::borrow::Cow;
+
            use alloc::string::String;
+

+
            use $crate::{RefStr, RefString};
+

+
            let s: String = $arg.to_owned();
+
            let refstring: RefString = unsafe { transmute(s) };
+
            let cow: Cow<'_, RefStr> = Cow::Owned(refstring);
+
            let component: Component = unsafe { transmute(cow) };
+

+
            component
+
        }
+
    }};
+
}
+

+
/// Create a [`git_ref_format_core::refspec::PatternString`] from a string literal.
+
///
+
/// Similar to [`core::debug_assert`], an optimized build will not validate
+
/// (but rather perform an unsafe conversion) unless `-C debug-assertions` is
+
/// passed to the compiler.
+
#[cfg(any(feature = "macro", test))]
+
#[macro_export]
+
macro_rules! pattern {
+
    ($arg:literal) => {{
+
        use $crate::refspec::PatternString;
+

+
        #[cfg(debug_assertions)]
+
        {
+
            PatternString::try_from($arg).expect(core::concat!(
+
                "literal `",
+
                $arg,
+
                "` must be a valid refspec pattern"
+
            ))
+
        }
+

+
        #[cfg(not(debug_assertions))]
+
        {
+
            extern crate alloc;
+

+
            use alloc::string::String;
+

+
            let s: String = $arg.to_owned();
+
            unsafe { core::mem::transmute::<_, PatternString>(s) }
+
        }
+
    }};
+
}
+

+
/// Create a [`git_ref_format_core::refspec::QualifiedPattern`] from a string literal.
+
///
+
/// Similar to [`core::debug_assert`], an optimized build will not validate
+
/// (but rather perform an unsafe conversion) unless `-C debug-assertions` is
+
/// passed to the compiler.
+
#[cfg(any(feature = "macro", test))]
+
#[macro_export]
+
macro_rules! qualified_pattern {
+
    ($arg:literal) => {{
+
        use $crate::refspec::QualifiedPattern;
+

+
        #[cfg(debug_assertions)]
+
        {
+
            use core::concat;
+

+
            use $crate::refspec::PatternStr;
+

+
            let pattern = PatternStr::try_from_str($arg).expect(concat!(
+
                "literal `",
+
                $arg,
+
                "` must be a valid refspec pattern"
+
            ));
+

+
            QualifiedPattern::from_patternstr(pattern).expect(concat!(
+
                "literal `",
+
                $arg,
+
                "` must be a valid qualified refspec pattern"
+
            ))
+
        }
+

+
        #[cfg(not(debug_assertions))]
+
        {
+
            extern crate alloc;
+

+
            use core::mem::transmute;
+

+
            use alloc::borrow::Cow;
+
            use alloc::string::String;
+

+
            use $crate::refspec::{PatternStr, PatternString};
+

+
            let s: String = $arg.to_owned();
+
            let pattern: PatternString = unsafe { transmute(s) };
+
            let cow: Cow<'_, PatternStr> = Cow::Owned(pattern);
+
            let qualified: QualifiedPattern = unsafe { transmute(cow) };
+

+
            qualified
+
        }
+
    }};
+
}
+

+
#[cfg(test)]
+
mod test {
+
    #[test]
+
    fn refname() {
+
        let _ = crate::refname!("refs/heads/main");
+
        let _ = crate::refname!("refs/tags/v1.0.0");
+
        let _ = crate::refname!("refs/remotes/origin/main");
+
        let _ = crate::refname!("a");
+
    }
+

+
    #[test]
+
    #[should_panic]
+
    fn refname_invalid() {
+
        let _ = crate::refname!("a~b");
+
    }
+

+
    #[test]
+
    fn qualified() {
+
        let _ = crate::qualified!("refs/heads/main");
+
        let _ = crate::qualified!("refs/tags/v1.0.0");
+
        let _ = crate::qualified!("refs/remotes/origin/main");
+
    }
+

+
    #[test]
+
    #[should_panic]
+
    fn qualified_invalid() {
+
        let _ = crate::qualified!("a");
+
    }
+

+
    #[test]
+
    fn component() {
+
        let _ = crate::component!("a");
+
    }
+

+
    #[test]
+
    #[should_panic]
+
    fn component_invalid() {
+
        let _ = crate::component!("a/b");
+
    }
+

+
    #[test]
+
    fn pattern() {
+
        let _ = crate::pattern!("refs/heads/main");
+
        let _ = crate::pattern!("refs/tags/v1.0.0");
+
        let _ = crate::pattern!("refs/remotes/origin/main");
+

+
        let _ = crate::pattern!("a");
+
        let _ = crate::pattern!("a/*");
+
        let _ = crate::pattern!("*");
+
        let _ = crate::pattern!("a/b*");
+
        let _ = crate::pattern!("a/b*/c");
+
        let _ = crate::pattern!("a/*/c");
+
    }
+

+
    #[test]
+
    fn qualified_pattern() {
+
        let _ = crate::qualified_pattern!("refs/heads/main");
+
        let _ = crate::qualified_pattern!("refs/tags/v1.0.0");
+
        let _ = crate::qualified_pattern!("refs/remotes/origin/main");
+

+
        let _ = crate::qualified_pattern!("refs/heads/main/*");
+
        let _ = crate::qualified_pattern!("refs/tags/v*");
+
        let _ = crate::qualified_pattern!("refs/remotes/origin/main");
+
        let _ = crate::qualified_pattern!("refs/remotes/origin/department/*/person");
+
    }
+

+
    #[test]
+
    #[should_panic]
+
    fn qualified_pattern_invalid() {
+
        let _ = crate::qualified_pattern!("a/*/b");
+
    }
+
}
added crates/radicle-localtime/Cargo.toml
@@ -0,0 +1,19 @@
+
[package]
+
name = "radicle-localtime"
+
version = "0.1.0"
+
description = "Minimal, zero-dependency, monotonic, unix time library."
+
edition.workspace = true
+
homepage.workspace = true
+
license.workspace = true
+
repository.workspace = true
+
rust-version.workspace = true
+

+
[lints]
+
workspace = true
+

+
[dependencies]
+
serde = { workspace = true, optional = true, features = ["derive"] }
+
schemars = { workspace = true, optional = true, features = ["derive", "std"] }
+

+
[dev-dependencies]
+
serde_json = { workspace = true }

\ No newline at end of file
added crates/radicle-localtime/src/lib.rs
@@ -0,0 +1,335 @@
+
//! Minimal, zero-dependency, monotonic, unix time library for rust.
+
//!
+
//! Taken from <https://github.com/cloudhead/localtime>
+

+
use std::sync::atomic;
+
use std::time::{SystemTime, UNIX_EPOCH};
+

+
/// Local time.
+
///
+
/// This clock is monotonic.
+
#[derive(Debug, PartialEq, Eq, Clone, Copy, Ord, PartialOrd, Default)]
+
#[cfg_attr(
+
    feature = "schemars",
+
    derive(schemars::JsonSchema),
+
    schemars(description = "A timestamp measured locally in seconds.")
+
)]
+
pub struct LocalTime {
+
    /// Milliseconds since Epoch.
+
    millis: u128,
+
}
+

+
impl std::fmt::Display for LocalTime {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        write!(f, "{}", self.as_secs())
+
    }
+
}
+

+
impl LocalTime {
+
    /// Construct a local time from the current system time.
+
    pub fn now() -> Self {
+
        static LAST: atomic::AtomicU64 = atomic::AtomicU64::new(0);
+

+
        let now = SystemTime::now()
+
            .duration_since(UNIX_EPOCH)
+
            .map(|duration| Self {
+
                millis: duration.as_millis(),
+
            })
+
            .expect("should run after 1970-01-01");
+

+
        let last_in_secs = LAST.load(atomic::Ordering::SeqCst);
+
        let now_in_secs = now.as_secs();
+

+
        // If the current time is in the past, return the last recorded time instead.
+
        if now_in_secs < last_in_secs {
+
            Self::from_secs(last_in_secs)
+
        } else {
+
            LAST.store(now_in_secs, atomic::Ordering::SeqCst);
+
            now
+
        }
+
    }
+

+
    /// Construct a local time from whole seconds since Epoch.
+
    #[must_use]
+
    pub const fn from_secs(secs: u64) -> Self {
+
        Self {
+
            millis: secs as u128 * 1000,
+
        }
+
    }
+

+
    /// Construct a local time from milliseconds since Epoch.
+
    #[must_use]
+
    pub const fn from_millis(millis: u128) -> Self {
+
        Self { millis }
+
    }
+

+
    /// Return whole seconds since Epoch.
+
    #[must_use]
+
    pub fn as_secs(&self) -> u64 {
+
        (self.millis / 1000).try_into().unwrap()
+
    }
+

+
    /// Return milliseconds since Epoch.
+
    #[must_use]
+
    pub fn as_millis(&self) -> u64 {
+
        self.millis.try_into().unwrap()
+
    }
+

+
    /// Get the duration since the given time.
+
    ///
+
    /// # Panics
+
    ///
+
    /// This function will panic if `earlier` is later than `self`.
+
    #[must_use]
+
    pub fn duration_since(&self, earlier: LocalTime) -> LocalDuration {
+
        LocalDuration::from_millis(
+
            self.millis
+
                .checked_sub(earlier.millis)
+
                .expect("supplied time is later than self"),
+
        )
+
    }
+

+
    /// Get the difference between two times.
+
    #[must_use]
+
    pub fn diff(&self, other: LocalTime) -> LocalDuration {
+
        if self > &other {
+
            self.duration_since(other)
+
        } else {
+
            other.duration_since(*self)
+
        }
+
    }
+

+
    /// Elapse time.
+
    ///
+
    /// Adds the given duration to the time.
+
    pub fn elapse(&mut self, duration: LocalDuration) {
+
        self.millis += duration.as_millis()
+
    }
+
}
+

+
/// Subtract two local times. Yields a duration.
+
impl std::ops::Sub<LocalTime> for LocalTime {
+
    type Output = LocalDuration;
+

+
    fn sub(self, other: LocalTime) -> LocalDuration {
+
        LocalDuration(self.millis.saturating_sub(other.millis))
+
    }
+
}
+

+
/// Subtract a duration from a local time. Yields a local time.
+
impl std::ops::Sub<LocalDuration> for LocalTime {
+
    type Output = LocalTime;
+

+
    fn sub(self, other: LocalDuration) -> LocalTime {
+
        LocalTime {
+
            millis: self.millis - other.0,
+
        }
+
    }
+
}
+

+
/// Add a duration to a local time. Yields a local time.
+
impl std::ops::Add<LocalDuration> for LocalTime {
+
    type Output = LocalTime;
+

+
    fn add(self, other: LocalDuration) -> LocalTime {
+
        LocalTime {
+
            millis: self.millis + other.0,
+
        }
+
    }
+
}
+

+
/// Time duration as measured locally.
+
#[derive(Debug, Copy, Clone, PartialOrd, Ord, PartialEq, Eq)]
+
#[cfg_attr(
+
    feature = "schemars",
+
    derive(schemars::JsonSchema),
+
    schemars(description = "A time duration measured locally in seconds.")
+
)]
+
pub struct LocalDuration(u128);
+

+
impl LocalDuration {
+
    /// The time interval between blocks. The "block time".
+
    pub const BLOCK_INTERVAL: LocalDuration = Self::from_mins(10);
+

+
    /// Maximum duration.
+
    pub const MAX: LocalDuration = LocalDuration(u128::MAX);
+

+
    /// Create a new duration from whole seconds.
+
    #[must_use]
+
    pub const fn from_secs(secs: u64) -> Self {
+
        Self(secs as u128 * 1000)
+
    }
+

+
    /// Create a new duration from whole minutes.
+
    #[must_use]
+
    pub const fn from_mins(mins: u64) -> Self {
+
        Self::from_secs(mins * 60)
+
    }
+

+
    /// Construct a new duration from milliseconds.
+
    #[must_use]
+
    pub const fn from_millis(millis: u128) -> Self {
+
        Self(millis)
+
    }
+

+
    /// Return the number of minutes in this duration.
+
    #[must_use]
+
    pub const fn as_mins(&self) -> u64 {
+
        self.as_secs() / 60
+
    }
+

+
    /// Return the number of seconds in this duration.
+
    #[must_use]
+
    pub const fn as_secs(&self) -> u64 {
+
        (self.0 / 1000) as u64
+
    }
+

+
    /// Return the number of milliseconds in this duration.
+
    #[must_use]
+
    pub const fn as_millis(&self) -> u128 {
+
        self.0
+
    }
+
}
+

+
impl std::fmt::Display for LocalDuration {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        if self.as_millis() < 1000 {
+
            write!(f, "{} millisecond(s)", self.as_millis())
+
        } else if self.as_secs() < 60 {
+
            let fraction = self.as_millis() % 1000;
+
            if fraction > 0 {
+
                write!(f, "{}.{} second(s)", self.as_secs(), fraction)
+
            } else {
+
                write!(f, "{} second(s)", self.as_secs())
+
            }
+
        } else if self.as_mins() < 60 {
+
            let fraction = self.as_secs() % 60;
+
            if fraction > 0 {
+
                write!(
+
                    f,
+
                    "{:.2} minute(s)",
+
                    self.as_mins() as f64 + (fraction as f64 / 60.)
+
                )
+
            } else {
+
                write!(f, "{} minute(s)", self.as_mins())
+
            }
+
        } else {
+
            let fraction = self.as_mins() % 60;
+
            if fraction > 0 {
+
                write!(f, "{:.2} hour(s)", self.as_mins() as f64 / 60.)
+
            } else {
+
                write!(f, "{} hour(s)", self.as_mins() / 60)
+
            }
+
        }
+
    }
+
}
+

+
impl<'a> std::iter::Sum<&'a LocalDuration> for LocalDuration {
+
    fn sum<I: Iterator<Item = &'a LocalDuration>>(iter: I) -> LocalDuration {
+
        let mut total: u128 = 0;
+

+
        for entry in iter {
+
            total = total
+
                .checked_add(entry.0)
+
                .expect("iter::sum should not overflow");
+
        }
+
        Self(total)
+
    }
+
}
+

+
impl std::ops::Add<LocalDuration> for LocalDuration {
+
    type Output = LocalDuration;
+

+
    fn add(self, other: LocalDuration) -> LocalDuration {
+
        LocalDuration(self.0 + other.0)
+
    }
+
}
+

+
impl std::ops::Div<u32> for LocalDuration {
+
    type Output = LocalDuration;
+

+
    fn div(self, other: u32) -> LocalDuration {
+
        LocalDuration(self.0 / other as u128)
+
    }
+
}
+

+
impl std::ops::Mul<u64> for LocalDuration {
+
    type Output = LocalDuration;
+

+
    fn mul(self, other: u64) -> LocalDuration {
+
        LocalDuration(self.0 * other as u128)
+
    }
+
}
+

+
impl From<LocalDuration> for std::time::Duration {
+
    fn from(other: LocalDuration) -> Self {
+
        std::time::Duration::from_millis(other.0 as u64)
+
    }
+
}
+

+
#[cfg(feature = "serde")]
+
mod serde_impls {
+
    use super::{LocalDuration, LocalTime};
+

+
    impl serde::Serialize for LocalTime {
+
        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
        where
+
            S: serde::Serializer,
+
        {
+
            serializer.serialize_u64(self.as_secs())
+
        }
+
    }
+

+
    impl<'de> serde::Deserialize<'de> for LocalTime {
+
        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+
        where
+
            D: serde::Deserializer<'de>,
+
        {
+
            u64::deserialize(deserializer).map(LocalTime::from_secs)
+
        }
+
    }
+

+
    impl serde::Serialize for LocalDuration {
+
        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
        where
+
            S: serde::Serializer,
+
        {
+
            serializer.serialize_u64(self.as_secs())
+
        }
+
    }
+

+
    impl<'de> serde::Deserialize<'de> for LocalDuration {
+
        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+
        where
+
            D: serde::Deserializer<'de>,
+
        {
+
            u64::deserialize(deserializer).map(LocalDuration::from_secs)
+
        }
+
    }
+

+
    #[cfg(test)]
+
    mod test {
+
        use crate::LocalTime;
+

+
        #[test]
+
        fn test_localtime() {
+
            #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)]
+
            struct Test {
+
                time: LocalTime,
+
            }
+
            let value = Test {
+
                time: LocalTime::from_secs(1699636852107),
+
            };
+

+
            assert_eq!(
+
                serde_json::from_str::<Test>(r#"{"time":1699636852107}"#).unwrap(),
+
                value
+
            );
+
            assert_eq!(
+
                serde_json::from_str::<Test>(serde_json::to_string(&value).unwrap().as_str())
+
                    .unwrap(),
+
                value
+
            );
+
        }
+
    }
+
}
added crates/radicle-node/CHANGELOG.md
@@ -0,0 +1,41 @@
+
# Changelog
+

+
All notable changes to this project will be documented in this file.
+

+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+

+
## [Unreleased]
+

+
### Added
+

+
### Changed
+

+
### Removed
+

+
### Security
+

+
## 0.17.0
+

+
### Added
+

+
- The test `Handle` struct now has a `blocked` field.
+

+
### Removed
+

+
- The `radicle_node::wire` module was removed, including the `Wire` struct,
+
  the `Control` enum, the `dial` and `accept` functions, and the
+
  `NOISE_XK`, `DEFAULT_CONNECTION_TIMEOUT`, `DEFAULT_DIAL_TIMEOUT`, and
+
  `MAX_INBOX_SIZE` constants.
+
- The `radicle_node::control` module was removed, including the `Error` enum
+
  and the `listen` function.
+
- The `radicle_node::worker` module was removed, including the `Config`,
+
  `Pool`, `Task`, `TaskResult`, `FetchConfig`, `Channels`, and
+
  `ChannelsConfig` structs, the `ChannelEvent` enum, and the
+
  `worker::fetch::Handle` enum.
+
- The `radicle_node::worker::garbage` module was removed, including the
+
  `Expiry` enum, the `collect` function, and the `EXPIRY_DEFAULT` constant.
+

+
### Security
+

+
*No security updates.*
modified crates/radicle-node/Cargo.toml
@@ -3,39 +3,35 @@ name = "radicle-node"
description = "The Radicle Node"
homepage.workspace = true
license.workspace = true
-
version = "0.15.0"
+
version = "0.17.0"
authors = ["cloudhead <cloudhead@radicle.xyz>"]
edition.workspace = true
build = "build.rs"
rust-version.workspace = true

[features]
-
default = ["systemd"]
+
default = ["backtrace", "systemd", "structured-logger", "socket2"]
systemd = ["dep:radicle-systemd"]
test = ["radicle/test", "radicle-crypto/test", "radicle-crypto/cyphernet", "radicle-protocol/test", "qcheck", "snapbox"]

[dependencies]
-
amplify = { workspace = true }
+
backtrace = { version = "0.3.75", optional = true }
bloomy = "1.2"
bytes = { workspace = true }
chrono = { workspace = true, features = ["clock"] }
colored = { workspace = true }
crossbeam-channel = { workspace = true }
-
cyphernet = { workspace = true, features = ["tor", "dns", "ed25519", "p2p-ed25519"] }
+
cyphernet = { workspace = true, features = ["tor", "dns", "ed25519", "p2p-ed25519", "noise-framework", "noise_sha2"] }
fastrand = { workspace = true }
-
io-reactor = { version = "0.5.1", features = ["popol"] }
+
gix-packetline = { workspace = true, features = ["blocking-io"] }
lexopt = { workspace = true }
-
libc = { workspace = true }
-
log = { workspace = true, features = ["std"] }
-
localtime = { workspace = true }
-
netservices = { version = "0.8.0", features = ["io-reactor", "socket2"] }
+
log = { workspace = true, features = ["kv", "std"] }
+
mio = { version = "1", features = ["net", "os-poll"] }
nonempty = { workspace = true, features = ["serialize"] }
qcheck = { workspace = true, optional = true }
radicle = { workspace = true, features = ["logger"] }
radicle-fetch = { 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-localtime = { workspace = true }
radicle-protocol = { workspace = true }
radicle-signals = { workspace = true }
sqlite = { workspace = true, features = ["bundled"] }
@@ -43,21 +39,24 @@ scrypt = { version = "0.11.0", default-features = false }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["preserve_order"] }
snapbox = { workspace = true, optional = true }
-
socket2 = "0.5.7"
+
socket2 = { version = "0.5.7", features = ["all"], optional = true }
+
structured-logger = { version = "1.0.4", optional = true }
tempfile = { workspace = true }
-
thiserror = { workspace = true }
+
thiserror = { workspace = true, default-features = true }

[target.'cfg(target_os = "linux")'.dependencies]
radicle-systemd = { workspace = true, optional = true }

[target.'cfg(windows)'.dependencies]
-
winpipe = { workspace = true }
+
radicle-windows = { workspace = true }
+
uds_windows = { workspace = true }

[dev-dependencies]
+
mio = { version = "1", features = ["os-ext"] }
qcheck = { workspace = true }
qcheck-macros = { workspace = true }
radicle = { workspace = true, features = ["test"] }
radicle-protocol = { workspace = true, features = ["test"] }
radicle-crypto = { workspace = true, features = ["test", "cyphernet"] }
snapbox = { workspace = true }
-
test-log = "0.2.18"
+
test-log = "0.2.19"
modified crates/radicle-node/src/control.rs
@@ -6,9 +6,9 @@ use std::path::PathBuf;
use std::{io, net, time};

#[cfg(unix)]
-
use std::os::unix::net::{UnixListener as Listener, UnixStream as Stream};
+
use std::os::unix::net::{UnixListener, UnixStream};
#[cfg(windows)]
-
use winpipe::{WinListener as Listener, WinStream as Stream};
+
use uds_windows::{UnixListener, UnixStream};

use radicle::node::Handle;
use serde_json as json;
@@ -33,7 +33,7 @@ pub enum Error {
}

/// Listen for commands on the control socket, and process them.
-
pub fn listen<E, H>(listener: Listener, handle: H) -> Result<(), Error>
+
pub fn listen<E, H>(listener: UnixListener, handle: H) -> Result<(), Error>
where
    H: Handle<Error = runtime::HandleError> + 'static,
    H::Sessions: serde::Serialize,
@@ -45,12 +45,12 @@ where

    for incoming in listener.incoming() {
        match incoming {
-
            Ok(stream) => {
+
            Ok(mut stream) => {
                let handle = handle.clone();

                thread::spawn(&nid, "control", move || {
-
                    if let Err((e, mut stream)) = command(stream, handle) {
-
                        log::error!(target: "control", "Command returned error: {e}");
+
                    if let Err(e) = command(&stream, handle) {
+
                        log::debug!(target: "control", "Command returned error: {e}");

                        CommandResult::error(e).to_writer(&mut stream).ok();

@@ -59,7 +59,7 @@ where
                    }
                });
            }
-
            Err(e) => log::error!(target: "control", "Failed to accept incoming connection: {e}"),
+
            Err(e) => log::warn!(target: "control", "Failed to accept incoming connection: {e}"),
        }
    }
    log::debug!(target: "control", "Exiting control loop..");
@@ -77,63 +77,16 @@ enum CommandError {
    Io(#[from] io::Error),
}

-
#[cfg(unix)]
-
fn command<E, H>(stream: Stream, handle: H) -> Result<(), (CommandError, Stream)>
-
where
-
    H: Handle<Error = runtime::HandleError> + 'static,
-
    H::Sessions: serde::Serialize,
-
    CommandResult<E>: From<H::Event>,
-
    E: serde::Serialize,
-
{
-
    let reader = BufReader::new(&stream);
-
    let writer = LineWriter::new(&stream);
-

-
    command_internal(reader, writer, handle).map_err(|e| (e, stream))
-
}
-

-
/// Due to different mutability requirements between Unix and Windows,
-
/// we are forced to clone the stream on Windows.
-
///
-
/// # Errors
-
///
-
/// As of winpipe 0.1.1, [`WinStream::try_clone`] is actually infallible.
-
#[cfg(windows)]
-
fn command<E, H>(stream: Stream, handle: H) -> Result<(), (CommandError, Stream)>
+
fn command<E, H>(stream: &UnixStream, mut handle: H) -> Result<(), CommandError>
where
    H: Handle<Error = runtime::HandleError> + 'static,
    H::Sessions: serde::Serialize,
    CommandResult<E>: From<H::Event>,
    E: serde::Serialize,
{
-
    let mut reader = match stream.try_clone() {
-
        Ok(reader) => reader,
-
        Err(err) => return Err((err.into(), stream)),
-
    };
-
    let reader = BufReader::new(&mut reader);
-

-
    let mut writer = match stream.try_clone() {
-
        Ok(writer) => writer,
-
        Err(err) => return Err((err.into(), stream)),
-
    };
-
    let writer = LineWriter::new(&mut writer);
-

-
    command_internal(reader, writer, handle).map_err(|e| (e, stream))
-
}
+
    let mut reader = BufReader::new(stream);
+
    let mut writer = LineWriter::new(stream);

-
#[inline(always)]
-
fn command_internal<E, H, R, W>(
-
    mut reader: BufReader<R>,
-
    mut writer: LineWriter<W>,
-
    mut handle: H,
-
) -> Result<(), CommandError>
-
where
-
    H: Handle<Error = runtime::HandleError> + 'static,
-
    H::Sessions: serde::Serialize,
-
    CommandResult<E>: From<H::Event>,
-
    E: serde::Serialize,
-
    R: io::Read,
-
    W: io::Write,
-
{
    let mut line = String::new();

    reader.read_line(&mut line)?;
@@ -172,11 +125,17 @@ where

            CommandResult::Okay(addrs).to_writer(writer)?;
        }
+
        #[allow(deprecated)]
        Command::Seeds { rid } => {
            let seeds = handle.seeds(rid)?;

            CommandResult::Okay(seeds).to_writer(writer)?;
        }
+
        Command::SeedsFor { rid, namespaces } => {
+
            let seeds = handle.seeds_for(rid, namespaces)?;
+

+
            CommandResult::Okay(seeds).to_writer(writer)?;
+
        }
        Command::Sessions => {
            let sessions = handle.sessions()?;

@@ -211,6 +170,14 @@ where
                return Err(CommandError::Runtime(e));
            }
        },
+
        Command::Block { nid } => match handle.block(nid) {
+
            Ok(result) => {
+
                CommandResult::updated(result).to_writer(writer)?;
+
            }
+
            Err(e) => {
+
                return Err(CommandError::Runtime(e));
+
            }
+
        },
        Command::Unfollow { nid } => match handle.unfollow(nid) {
            Ok(result) => {
                CommandResult::updated(result).to_writer(writer)?;
@@ -219,11 +186,17 @@ where
                return Err(CommandError::Runtime(e));
            }
        },
+
        #[allow(deprecated)]
        Command::AnnounceRefs { rid } => {
            let refs = handle.announce_refs(rid)?;

            CommandResult::Okay(refs).to_writer(writer)?;
        }
+
        Command::AnnounceRefsFor { rid, namespaces } => {
+
            let refs = handle.announce_refs_for(rid, namespaces)?;
+

+
            CommandResult::Okay(refs).to_writer(writer)?;
+
        }
        Command::AnnounceInventory => {
            if let Err(e) = handle.announce_inventory() {
                return Err(CommandError::Runtime(e));
@@ -307,7 +280,8 @@ mod tests {
        let handle = test::handle::Handle::default();
        let socket = tmp.path().join("alice.sock");
        let rids = test::arbitrary::set::<RepoId>(1..3);
-
        let listener = Listener::bind(&socket).unwrap();
+
        let listener = UnixListener::bind(&socket).unwrap();
+
        let nid = handle.nid().unwrap();

        thread::spawn({
            let handle = handle.clone();
@@ -317,15 +291,16 @@ mod tests {

        for rid in &rids {
            let mut stream = loop {
-
                if let Ok(stream) = Stream::connect(&socket) {
+
                if let Ok(stream) = UnixStream::connect(&socket) {
                    break stream;
                }
            };
            writeln!(
                &mut stream,
                "{}",
-
                json::to_string(&Command::AnnounceRefs {
-
                    rid: rid.to_owned()
+
                json::to_string(&Command::AnnounceRefsFor {
+
                    rid: rid.to_owned(),
+
                    namespaces: [nid].into(),
                })
                .unwrap()
            )
@@ -345,7 +320,7 @@ mod tests {
        }

        for rid in &rids {
-
            assert!(handle.updates.lock().unwrap().contains(rid));
+
            assert!(handle.updates.lock().unwrap().contains(&(*rid, nid)));
        }
    }

@@ -355,7 +330,7 @@ mod tests {
        let socket = tmp.path().join("node.sock");
        let proj = test::arbitrary::gen::<RepoId>(1);
        let peer = test::arbitrary::gen::<NodeId>(1);
-
        let listener = Listener::bind(&socket).unwrap();
+
        let listener = UnixListener::bind(&socket).unwrap();
        let mut handle = Node::new(&socket);

        thread::spawn({
added crates/radicle-node/src/fingerprint.rs
@@ -0,0 +1,134 @@
+
//! Fingerprint the public key corresponding to the secret key used by
+
//! `radicle-node`.
+
//!
+
//! This allows users to configure the path to the secret key
+
//! freely, while ensuring that the key is not changed.
+
//!
+
//! In order to achieve this, the fingerprint of the public key
+
//! derived from the secret key is stored in the Radicle home
+
//! in a file (usually at `.radicle/node/fingerprint`).
+
//! When the node starts up and this file does not exist, it is assumed that
+
//! this is the first time the node is started, and the fingerprint is
+
//! initialized from the secret key in the keystore.
+
//! On subsequent startups, the fingerprint of the public key
+
//! derived from the secret key in the keystore is compared to the
+
//! fingerprint stored on disk, and if they do not match, the node
+
//! refuses to start (this last part is implemented in `main.rs`).
+
//!
+
//! If the user deletes the fingerprint file, the node will not be able
+
//! to detect a possible change of the secret key. The consequences of
+
//! doing this are unclear.
+

+
use thiserror::Error;
+

+
use radicle::crypto;
+
use radicle::profile::Home;
+

+
/// Fingerprint of a public key.
+
#[derive(Debug, PartialEq)]
+
pub struct Fingerprint(String);
+

+
impl std::fmt::Display for Fingerprint {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        write!(f, "{}", self.0)
+
    }
+
}
+

+
#[derive(Debug, PartialEq, Eq)]
+
pub enum FingerprintVerification {
+
    Match,
+
    Mismatch,
+
}
+

+
#[derive(Error, Debug)]
+
pub enum Error {
+
    #[error(transparent)]
+
    Io(#[from] std::io::Error),
+

+
    #[error("fingerprint file is not valid UTF-8: {0}")]
+
    Utf8(#[from] std::str::Utf8Error),
+
}
+

+
impl Fingerprint {
+
    /// Return fingerprint of the node, if it exists.
+
    pub fn read(home: &Home) -> Result<Option<Fingerprint>, Error> {
+
        match std::fs::read(path(home)) {
+
            Ok(contents) => Ok(Some(Fingerprint(
+
                String::from(std::str::from_utf8(contents.as_ref())?)
+
                    .trim_end()
+
                    .to_string(),
+
            ))),
+
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
+
            Err(err) => Err(Error::Io(err)),
+
        }
+
    }
+

+
    /// Initialize the fingerprint of the node with given public key.
+
    pub fn init(
+
        home: &Home,
+
        secret_key: &impl std::ops::Deref<Target = crypto::SecretKey>,
+
    ) -> Result<(), Error> {
+
        let public_key = crypto::PublicKey(secret_key.deref().public_key());
+
        let mut file = std::fs::OpenOptions::new()
+
            .create_new(true)
+
            .write(true)
+
            .open(path(home))?;
+
        {
+
            use std::io::Write as _;
+
            file.write_all(crypto::ssh::fmt::fingerprint(&public_key).as_ref())?;
+
        }
+

+
        Ok(())
+
    }
+

+
    /// Verify that the fingerprint of given public key matches self.
+
    pub fn verify(
+
        &self,
+
        secret_key: &impl std::ops::Deref<Target = crypto::SecretKey>,
+
    ) -> FingerprintVerification {
+
        let public_key = crypto::PublicKey(secret_key.deref().public_key());
+
        if crypto::ssh::fmt::fingerprint(&public_key) == self.0 {
+
            FingerprintVerification::Match
+
        } else {
+
            FingerprintVerification::Mismatch
+
        }
+
    }
+
}
+

+
/// Return the location of the node fingerprint.
+
fn path(home: &Home) -> std::path::PathBuf {
+
    home.node().join("fingerprint")
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    use super::*;
+

+
    use crypto::ssh::Keystore;
+

+
    #[test]
+
    fn matching() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let home = Home::new(tmp.path()).unwrap();
+

+
        let store = Keystore::new(&home.keys());
+
        store.init("test 1", None, crypto::Seed::default()).unwrap();
+
        let secret = store.secret_key(None).unwrap().unwrap();
+

+
        assert_eq!(Fingerprint::read(&home).unwrap(), None);
+
        Fingerprint::init(&home, &secret).unwrap();
+

+
        let fp = Fingerprint::read(&home).unwrap().unwrap();
+
        assert_eq!(fp.verify(&secret), FingerprintVerification::Match);
+

+
        // Generate a new keypair, which does not match the fingerprint.
+
        // This simulates the user modifying `~/.radicle/keys`.
+
        std::fs::remove_dir_all(home.keys()).unwrap();
+
        store.init("test 1", None, crypto::Seed::default()).unwrap();
+
        let other_secret = store.secret_key(None).unwrap().unwrap();
+

+
        assert_ne!(secret, other_secret);
+
        // Note that `fp` has not changed since it was initialized from `secret`.
+
        assert_eq!(fp.verify(&other_secret), FingerprintVerification::Mismatch);
+
    }
+
}
modified crates/radicle-node/src/lib.rs
@@ -3,18 +3,24 @@
// suggestions did not make sense.
#![allow(clippy::byte_char_slices)]

-
use std::str::FromStr;
-
use std::sync::LazyLock;
-

-
pub mod control;
+
pub mod fingerprint;
+
pub mod reactor;
pub mod runtime;
+

+
mod control;
pub(crate) use radicle_protocol::service;
+
mod wire;
+
mod worker;
+

#[cfg(any(test, feature = "test"))]
pub mod test;
#[cfg(test)]
pub mod tests;
-
pub mod wire;
-
pub mod worker;
+

+
extern crate radicle_localtime as localtime;
+

+
use std::str::FromStr;
+
use std::sync::LazyLock;

use radicle::version::Version;

modified crates/radicle-node/src/main.rs
@@ -2,6 +2,7 @@ use std::io;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::exit;
+
use std::str::FromStr;

use crossbeam_channel as chan;
use thiserror::Error;
@@ -10,21 +11,11 @@ use radicle::node::device::Device;
use radicle::profile;

use radicle_node::crypto::ssh::keystore::{Keystore, MemorySigner};
+
use radicle_node::fingerprint::{Fingerprint, FingerprintVerification};
use radicle_node::{Runtime, VERSION};
-
#[cfg(unix)]
use radicle_signals as signals;

-
/// The log level to use before reading any other value
-
/// from configuration.
-
///
-
/// Note that this is different from the default value
-
/// of the command line argument `--log`, as it is valid
-
/// *even before that argument is parsed*.
-
/// It ensures that we log the errors parsing the
-
/// command line arguments, such as `--log`.
-
const LOG_LEVEL_DEFAULT: &log::Level = &log::Level::Warn;
-

-
pub const HELP_MSG: &str = r#"
+
const HELP_MSG: &str = r#"
Usage

   radicle-node [<option>...]
@@ -34,18 +25,89 @@ Usage

Options

-
    --config             <path>         Config file to use (default ~/.radicle/config.json)
-
    --force                             Force start even if an existing control socket is found
-
    --listen             <address>      Address to listen on
-
    --log                <level>        Set log level (default: info)
-
    --version                           Print program version
-
    --help                              Print help
+
    --config      <path>                            Config file to use
+
                  (default: ~/.radicle/config.json)
+
    --secret      <path>                            Secret key to use
+
                  (default ~/.radicle/keys/radicle)
+
    --force                                         Force start even if an existing control socket
+
                                                      is found
+
    --listen      <address>                         Address to listen on
+
    --log-level   <level>                           Set log level
+
                  (default: info)
+
    --log-logger  (radicle | structured | systemd)  Set logger implementation
+
                  (default: radicle)
+
    --log-format  json                              Set log format for logger implementation
+
    --version                                       Print program version
+
    --help                                          Print help
"#;

+
#[derive(Debug, Clone)]
+
enum Logger {
+
    Radicle,
+
    #[cfg(feature = "structured-logger")]
+
    Structured,
+
    #[cfg(all(feature = "systemd", target_os = "linux"))]
+
    Systemd,
+
}
+

+
// Required for Mac and potentially Windows as clippy complains because of the OS specific
+
// guard below.
+
#[allow(clippy::derivable_impls)]
+
impl Default for Logger {
+
    fn default() -> Self {
+
        #[cfg(all(feature = "systemd", target_os = "linux"))]
+
        if radicle_systemd::journal::connected() {
+
            return Logger::Systemd;
+
        }
+

+
        Logger::Radicle
+
    }
+
}
+

+
impl FromStr for Logger {
+
    type Err = &'static str;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        match s {
+
            "radicle" => Ok(Logger::Radicle),
+
            #[cfg(feature = "structured-logger")]
+
            "structured" => Ok(Logger::Structured),
+
            #[cfg(all(feature = "systemd", target_os = "linux"))]
+
            "systemd" => Ok(Logger::Systemd),
+
            _ => Err("unknown logger"),
+
        }
+
    }
+
}
+

+
#[derive(Clone, Copy)]
+
enum LogFormat {
+
    #[cfg(feature = "structured-logger")]
+
    Json,
+
}
+

+
impl FromStr for LogFormat {
+
    type Err = &'static str;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        match s {
+
            #[cfg(feature = "structured-logger")]
+
            "json" => Ok(LogFormat::Json),
+
            _ => Err("unknown log format"),
+
        }
+
    }
+
}
+

+
struct LogOptions {
+
    level: Option<log::Level>,
+
    logger: Logger,
+
    format: Option<LogFormat>,
+
}
+

struct Options {
    config: Option<PathBuf>,
+
    secret: Option<PathBuf>,
    listen: Vec<SocketAddr>,
-
    log: Option<log::Level>,
+
    log: LogOptions,
    force: bool,
}

@@ -56,8 +118,11 @@ fn parse_options() -> Result<Options, lexopt::Error> {
    let mut parser = lexopt::Parser::from_env();
    let mut listen = Vec::new();
    let mut config = None;
+
    let mut secret = None;
    let mut force = false;
-
    let mut log = None;
+
    let mut log_level = None;
+
    let mut log_logger = Logger::default();
+
    let mut log_format = None;

    while let Some(arg) = parser.next()? {
        match arg {
@@ -67,12 +132,31 @@ fn parse_options() -> Result<Options, lexopt::Error> {
            Long("config") => {
                config = Some(parser.value()?.parse_with(PathBuf::from_str)?);
            }
+
            Long("secret") => {
+
                secret = Some(parser.value()?.parse()?);
+
            }
            Long("listen") => {
                let addr = parser.value()?.parse_with(SocketAddr::from_str)?;
                listen.push(addr);
            }
-
            Long("log") => {
-
                log = Some(parser.value()?.parse_with(log::Level::from_str)?);
+
            Long("log") | Long("log-level") => {
+
                if matches!(arg, Long("log")) {
+
                    eprintln!("Warning: The option `--log` is deprecated and will be removed. Please use `--log-level` instead.");
+
                }
+
                log_level = Some(parser.value()?.parse_with(log::Level::from_str)?);
+
            }
+
            Long("log-logger") => {
+
                let parsed = parser.value()?.parse_with(Logger::from_str)?;
+
                if matches!(parsed, Logger::Radicle) {
+
                    return Err(lexopt::Error::Custom(
+
                        "explicitly choosing this logger is forbidden, because it is deprecated"
+
                            .into(),
+
                    ));
+
                }
+
                log_logger = parsed;
+
            }
+
            Long("log-format") => {
+
                log_format = Some(parser.value()?.parse_with(LogFormat::from_str)?);
            }
            Long("help") | Short('h') => {
                println!("{HELP_MSG}");
@@ -90,9 +174,14 @@ fn parse_options() -> Result<Options, lexopt::Error> {

    Ok(Options {
        force,
+
        secret,
        listen,
-
        log,
        config,
+
        log: LogOptions {
+
            level: log_level,
+
            logger: log_logger,
+
            format: log_format,
+
        },
    })
}

@@ -103,9 +192,21 @@ enum ExecutionError {
    #[error(transparent)]
    ConfigurationLoading(#[from] profile::config::LoadError),
    #[error(transparent)]
-
    MemorySigner(#[from] radicle::crypto::ssh::keystore::MemorySignerError),
-
    #[error(transparent)]
    Runtime(#[from] radicle_node::runtime::Error),
+
    #[error(transparent)]
+
    Fingerprint(#[from] radicle_node::fingerprint::Error),
+
    #[error("failed to load secret key '{path}': not found")]
+
    SecretNotFound { path: PathBuf },
+
    #[error("failed to load secret '{path}': {source}")]
+
    SecretLoading {
+
        path: PathBuf,
+
        source: radicle::crypto::ssh::keystore::Error,
+
    },
+
    #[error("failed to load secret key '{secret}': fingerprint of corresponding public key is different from '{fingerprint}'")]
+
    FingerprintMismatch {
+
        secret: PathBuf,
+
        fingerprint: Fingerprint,
+
    },
}

fn execute(options: Options) -> Result<(), ExecutionError> {
@@ -115,14 +216,14 @@ fn execute(options: Options) -> Result<(), ExecutionError> {
    // The first thing we do after reading command line options is
    // to set the log level, as this influences logging during
    // configuration loading.
-
    if let Some(level) = options.log {
+
    if let Some(level) = options.log.level {
        log::set_max_level(level.to_level_filter());
    }

    let config = options.config.unwrap_or_else(|| home.config());
    let mut config = profile::Config::load(&config)?;

-
    if options.log.is_none() {
+
    if options.log.level.is_none() {
        log::set_max_level(log::Level::from(config.node.log).to_level_filter());
    } else {
        // It might seem counter-intuitive at first, as there
@@ -136,10 +237,73 @@ fn execute(options: Options) -> Result<(), ExecutionError> {
    log::info!(target: "node", "Version {} ({})", env!("RADICLE_VERSION"), env!("GIT_HEAD"));
    log::info!(target: "node", "Unlocking node keystore..");

-
    let passphrase = profile::env::passphrase();
-
    let keystore = Keystore::new(&home.keys());
-
    let signer = Device::from(MemorySigner::load(&keystore, passphrase)?);
+
    let passphrase = None;
+

+
    #[cfg(all(feature = "systemd", target_os = "linux"))]
+
    let passphrase = passphrase.or_else(|| {
+
        const ID: &str = "xyz.radicle.node.passphrase";
+
        match radicle_systemd::credential::path(ID) {
+
            Err(err) => {
+
                log::warn!(target: "node", "Failed to obtain path of the passphrase file via systemd credential with '{ID}': {err}");
+
                None
+
            },
+
            Ok(Some(ref path)) => match std::fs::read_to_string(path) {
+
                Ok(passphrase) => Some(passphrase.into()),
+
                Err(err) => {
+
                    log::warn!(target: "node", "Failed to read passphrase from '{}': {err}", path.display());
+
                    None
+
                }
+
            }
+
            Ok(None) => None,
+
        }
+
    });
+

+
    let passphrase = passphrase.or_else(profile::env::passphrase);
+

+
    let secret_path = options.secret;
+

+
    #[cfg(all(feature = "systemd", target_os = "linux"))]
+
    let secret_path = secret_path.or_else(|| {
+
        const ID: &str = "xyz.radicle.node.secret";
+
        match radicle_systemd::credential::path(ID) {
+
            Err(err) => {
+
                log::warn!(target: "node", "Failed to obtain path of the secret key via systemd credential with ID '{ID}': {err}");
+
                None
+
            },
+
            Ok(path) => path
+
        }
+
    });
+

+
    let secret_path = secret_path
+
        .or_else(|| config.node.secret.clone())
+
        .unwrap_or_else(|| home.keys().join("radicle"));
+

+
    let keystore = Keystore::from_secret_path(&secret_path);
+

+
    let secret_key = keystore
+
        .secret_key(passphrase.clone())
+
        .map_err(|err| ExecutionError::SecretLoading {
+
            path: secret_path.clone(),
+
            source: err,
+
        })?
+
        .ok_or_else(|| ExecutionError::SecretNotFound {
+
            path: secret_path.clone(),
+
        })?;
+

+
    if let Some(fp) = Fingerprint::read(&home)? {
+
        log::debug!(target: "node", "Verifying fingerprint..");
+
        if fp.verify(&secret_key) != FingerprintVerification::Match {
+
            return Err(ExecutionError::FingerprintMismatch {
+
                secret: keystore.secret_key_path().to_path_buf(),
+
                fingerprint: fp,
+
            });
+
        }
+
    } else {
+
        log::info!(target: "node", "Initializing fingerprint..");
+
        Fingerprint::init(&home, &secret_key)?;
+
    }

+
    let signer = Device::from(MemorySigner::from_secret(secret_key));
    log::info!(target: "node", "Node ID is {}", signer.public_key());

    // Add the preferred seeds as persistent peers so that we reconnect to them automatically.
@@ -155,20 +319,12 @@ fn execute(options: Options) -> Result<(), ExecutionError> {
        log::warn!(target: "node", "Unable to set process open file limit: {e}");
    }

-
    #[cfg(unix)]
    let signals = {
        let (notify, signals) = chan::bounded(1);
        signals::install(notify)?;
        signals
    };

-
    #[cfg(windows)]
-
    let signals = {
-
        let (_, signals) = chan::bounded(1);
-
        log::warn!(target: "node", "Signal handlers not installed.");
-
        signals
-
    };
-

    if options.force {
        log::debug!(target: "node", "Removing existing control socket..");
        std::fs::remove_file(home.socket()).ok();
@@ -178,72 +334,97 @@ fn execute(options: Options) -> Result<(), ExecutionError> {
    Ok(())
}

-
fn initialize_logging() {
-
    let level = *LOG_LEVEL_DEFAULT;
+
fn initialize_logging(options: &LogOptions) -> Result<(), Box<dyn std::error::Error>> {
+
    let level = options.level.unwrap_or(log::Level::Info);

-
    //  - We are compiling conditionally, so cannot depend
-
    //    on the concrete type of the logger(s).
-
    //  - We are dealing with `Option`, so we need `Box: Sized`.
-
    //  - We want to provide a `Box` to `log::set_boxed_logger`.
-
    //  - We also want to keep around any errors along the way.
-
    type Logger = Box<dyn log::Log>;
-
    type Error = Box<dyn std::error::Error>;
+
    let logger: Box<dyn log::Log> = {
+
        match options.logger {
+
            #[cfg(feature = "structured-logger")]
+
            Logger::Structured => {
+
                use structured_logger::{json, Builder};

-
    let journal: Result<Option<Logger>, Error> = {
-
        #[cfg(all(feature = "systemd", target_os = "linux"))]
-
        {
-
            use thiserror::Error;
-

-
            #[derive(Error, Debug)]
-
            #[error("Error connecting to systemd journal: {0}")]
-
            struct JournalError(io::Error);
+
                let writer = match options.format.unwrap_or(LogFormat::Json) {
+
                    LogFormat::Json => json::new_writer(io::stdout()),
+
                };

-
            radicle_systemd::journal::logger::<&str, &str, _>("radicle-node".to_string(), [])
-
                .map_err(|err| Box::new(JournalError(err)) as Error)
-
        }
-
        #[cfg(not(all(feature = "systemd", target_os = "linux")))]
-
        {
-
            // This is constant, and `rustc` will hopefully use it to
-
            // optimize away the `match` below.
-
            Ok(None)
+
                Box::new(Builder::new().with_default_writer(writer).build())
+
            }
+
            #[cfg(all(feature = "systemd", target_os = "linux"))]
+
            Logger::Systemd => {
+
                use radicle_systemd::journal::*;
+
                use thiserror::Error;
+

+
                #[derive(Error, Debug)]
+
                enum JournalError {
+
                    #[error("journald not connected")]
+
                    NotConnected,
+
                    #[error("journald i/o: {0}")]
+
                    Io(#[from] io::Error),
+
                }
+

+
                if !connected() {
+
                    return Err(Box::new(JournalError::NotConnected));
+
                }
+

+
                const SYSLOG_IDENTIFIER: &str = "radicle-node";
+
                logger::<&str, &str, _>(SYSLOG_IDENTIFIER.to_string(), []).map_err(Box::new)?
+
            }
+
            Logger::Radicle => Box::new(radicle::logger::Logger::new(level)),
        }
    };

-
    let (logger, err) = match journal {
-
        Ok(Some(logger)) => (logger, None),
-
        otherwise => (
-
            Box::new(radicle::logger::Logger::new(level)) as Logger,
-
            otherwise.err(),
-
        ),
-
    };
-

    log::set_boxed_logger(logger).expect("no other logger should have been set already");
    log::set_max_level(level.to_level_filter());

-
    if let Some(err) = err {
-
        log::warn!(target: "node", "Error initializing logger (fell back to default): {err}");
-
    }
+
    Ok(())
}

-
fn main() {
-
    // If `RUST_BACKTRACE` does not have a value, then we set it to capture
-
    // backtraces for better debugging, otherwise we keep the environments
-
    // value.
-
    const RUST_BACKTRACE: &str = "RUST_BACKTRACE";
-
    if std::env::var_os(RUST_BACKTRACE).is_none() {
-
        std::env::set_var(RUST_BACKTRACE, "1");
+
fn panic_hook(info: &std::panic::PanicHookInfo) {
+
    #[cfg(feature = "backtrace")]
+
    let backtrace = format!("{:?}", backtrace::Backtrace::new());
+

+
    #[cfg(not(feature = "backtrace"))]
+
    let backtrace = " (no backtrace available)";
+

+
    let thread = std::thread::current();
+
    let thread = thread.name().unwrap_or("<unnamed>");
+

+
    let msg = info
+
        .payload()
+
        .downcast_ref::<&'static str>()
+
        .copied()
+
        .or(info.payload().downcast_ref::<String>().map(|s| s.as_str()))
+
        .unwrap_or("Box<Any>");
+

+
    match info.location() {
+
        Some(location) => {
+
            log::error!(
+
                target: "panic", "thread '{thread}' panicked at '{msg}': {}:{}{backtrace}",
+
                location.file(),
+
                location.line(),
+
            );
+
        }
+
        None => log::error!(
+
            target: "panic", "thread '{thread}' panicked at '{msg}'{backtrace}",
+
        ),
    }

-
    initialize_logging();
+
    log::logger().flush();
+
}

-
    let options = match parse_options() {
-
        Ok(options) => options,
-
        Err(err) => {
-
            // The lexopt errors read nicely with a comma.
-
            log::error!(target: "node", "Failed to parse options, {err:#}");
-
            exit(2);
-
        }
-
    };
+
fn main() {
+
    let options = parse_options().unwrap_or_else(|err| {
+
        // The lexopt errors read nicely with a comma.
+
        eprintln!("Failed to parse options, {err:#}");
+
        exit(2);
+
    });
+

+
    initialize_logging(&options.log).unwrap_or_else(|err| {
+
        eprintln!("Failed to initialize logging: {err:#}");
+
        exit(3);
+
    });
+

+
    std::panic::set_hook(Box::new(panic_hook));

    if let Err(err) = execute(options) {
        log::error!(target: "node", "{err:#}");
added crates/radicle-node/src/reactor.rs
@@ -0,0 +1,616 @@
+
mod controller;
+
mod listener;
+
mod session;
+
mod timer;
+
mod token;
+
mod transport;
+

+
use std::collections::HashMap;
+
use std::fmt::{Debug, Display, Formatter};
+
use std::io::ErrorKind;
+
use std::sync::Arc;
+
use std::thread::JoinHandle;
+
use std::time::{Duration, Instant};
+
use std::{io, thread};
+

+
use crossbeam_channel::{unbounded, Receiver, TryRecvError};
+
use mio::event::{Event, Source};
+
use mio::{Events, Interest, Poll, Waker};
+
use thiserror::Error;
+

+
use timer::Timer;
+
use token::WAKER;
+

+
use crate::wire;
+

+
pub(crate) use self::controller::{ControlMessage, Controller};
+
pub(crate) use listener::Listener;
+
pub use session::{NoiseSession, ProtocolArtifact, Socks5Session};
+
pub(crate) use token::{Token, Tokens};
+
pub(crate) use transport::{SessionEvent, Transport};
+

+
const SECONDS_IN_AN_HOUR: u64 = 60 * 60;
+

+
/// Maximum amount of time to wait for I/O.
+
const WAIT_TIMEOUT: Duration = Duration::from_secs(SECONDS_IN_AN_HOUR);
+

+
/// Maximum duration to accept the service to spend handling events (and errors,
+
/// ticking, etc.) without warning. Set to log whenever the service becomes so
+
/// is so slow to respond that it would not be able to handle at least 10
+
/// "requests" per second, i.e. `1s / 10 = 100ms`.
+
const LAG_TIMEOUT: Duration = Duration::from_millis(100);
+

+
/// A resource which can be managed by the reactor.
+
pub trait EventHandler {
+
    /// The type of reactions which this resource may generate upon receiving
+
    /// I/O from the reactor via [`EventHandler::handle`]. These events are
+
    /// passed to the reactor [`crate::reactor::ReactionHandler`].
+
    type Reaction;
+

+
    /// Method informing the reactor which types of events this resource is subscribed for.
+
    fn interests(&self) -> Option<Interest>;
+

+
    /// Method called by the reactor when an I/O readiness event
+
    /// is received for this resource.
+
    fn handle(&mut self, event: &Event) -> Vec<Self::Reaction>;
+
}
+

+
/// The trait guarantees that the data are either written in full or, in case
+
/// of an error, none of the data is written. Types implementing the trait must
+
/// also guarantee that multiple attempts to write do not result in
+
/// data to be written out of the initial ordering.
+
pub trait WriteAtomic: std::io::Write {
+
    /// Atomic non-blocking I/O write operation, which must either write the whole buffer to a
+
    /// resource without blocking or fail.
+
    ///
+
    /// # Panics
+
    ///
+
    /// If [`WriteAtomic::write_or_buf`] returns an [`std::io::Error`] of kind
+
    /// [`ErrorKind::Interrupted`], [`ErrorKind::WouldBlock`], [`ErrorKind::WriteZero`].
+
    /// In this case, [`WriteAtomic::write_or_buf`] is expected to buffer.
+
    fn write_atomic(&mut self, buf: &[u8]) -> io::Result<()> {
+
        use ErrorKind::*;
+

+
        if !self.is_ready_to_write() {
+
            panic!("WriteAtomic::write_atomic was called when the resource is not ready to write");
+
        }
+

+
        let result = self.write_or_buf(buf);
+

+
        debug_assert!(
+
            !matches!(
+
                result.as_ref().err().map(|err| err.kind()),
+
                Some(Interrupted | WouldBlock | WriteZero)
+
            ),
+
            "WriteAtomic::write_or_buf must handle errors of kind {Interrupted:?}, {WouldBlock:?}, {WriteZero:?} by buffering",
+
        );
+

+
        result
+
    }
+

+
    /// Checks whether resource can be written to without blocking.
+
    fn is_ready_to_write(&self) -> bool;
+

+
    /// Writes to the resource in a non-blocking way, buffering the data if necessary,
+
    /// or failing with a system-level error.
+
    ///
+
    /// This method shouldn't be called directly; call [`WriteAtomic::write_atomic`] instead.
+
    ///
+
    /// The method must handle [`std::io::Error`] of kind
+
    /// [`ErrorKind::Interrupted`], [`ErrorKind::WouldBlock`], [`ErrorKind::WriteZero`].
+
    /// and buffer the data in such cases.
+
    fn write_or_buf(&mut self, buf: &[u8]) -> io::Result<()>;
+
}
+

+
/// Reactor errors
+
#[derive(Error)]
+
pub enum Error<L: EventHandler, T: EventHandler> {
+
    #[error("listener {0:?} got disconnected during poll operation")]
+
    ListenerDisconnect(Token, L),
+

+
    #[error("transport {0:?} got disconnected during poll operation")]
+
    TransportDisconnect(Token, T),
+

+
    #[error("registration of a resource has failed: {0}")]
+
    Poll(io::Error),
+

+
    #[error("registration of a resource has failed: {0}")]
+
    Registration(io::Error),
+
}
+

+
impl<L: EventHandler, T: EventHandler> Debug for Error<L, T> {
+
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+
        Display::fmt(self, f)
+
    }
+
}
+

+
/// Actions which can be provided to the [`Reactor`] by the [`ReactionHandler`].
+
///
+
/// Reactor reads actions on each event loop using [`ReactionHandler`] iterator interface.
+
pub enum Action<L, T> {
+
    /// Register a new listener resource for the reactor poll.
+
    ///
+
    /// Reactor can't instantiate the resource, like bind a network listener.
+
    /// Reactor only can register already active resource for polling in the event loop.
+
    RegisterListener(Token, L),
+

+
    /// Register a new transport resource for the reactor poll.
+
    ///
+
    /// Reactor can't instantiate the resource, like open a file or establish network connection.
+
    /// Reactor only can register already active resource for polling in the event loop.
+
    RegisterTransport(Token, T),
+

+
    /// Unregister listener resource from the reactor poll and handover it to the [`ReactionHandler`] via
+
    /// [`ReactionHandler::handover_listener`].
+
    ///
+
    /// When the resource is unregistered no action is performed, i.e. the file descriptor is not
+
    /// closed, listener is not unbound, connections are not closed etc. All these actions must be
+
    /// handled by the handler upon the handover event.
+
    #[allow(dead_code)] // For future use
+
    UnregisterListener(Token),
+

+
    /// Unregister transport resource from the reactor poll and handover it to the [`ReactionHandler`] via
+
    /// [`ReactionHandler::handover_transport`].
+
    ///
+
    /// When the resource is unregistered no action is performed, i.e. the file descriptor is not
+
    /// closed, listener is not unbound, connections are not closed etc. All these actions must be
+
    /// handled by the handler upon the handover event.
+
    UnregisterTransport(Token),
+

+
    /// Write the data to one of the transport resources using [`io::Write`].
+
    Send(Token, Vec<u8>),
+

+
    /// Set a new timer for a given duration from this moment.
+
    ///
+
    /// When the timer elapses, the reactor will timeout from poll and call
+
    /// [`ReactionHandler::timer_reacted`].
+
    SetTimer(Duration),
+
}
+

+
impl<L: EventHandler, T: EventHandler> Display for Action<L, T> {
+
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            Action::RegisterListener(token, _listener) => f
+
                .debug_struct("RegisterListener")
+
                .field("token", token)
+
                .field("listener", &"<omitted>")
+
                .finish(),
+
            Action::RegisterTransport(token, _transport) => f
+
                .debug_struct("RegisterTransport")
+
                .field("token", token)
+
                .field("transport", &"<omitted>")
+
                .finish(),
+
            Action::UnregisterListener(token) => f
+
                .debug_struct("UnregisterListener")
+
                .field("token", token)
+
                .finish(),
+
            Action::UnregisterTransport(token) => f
+
                .debug_struct("UnregisterTransport")
+
                .field("token", token)
+
                .finish(),
+
            Action::Send(token, _data) => f
+
                .debug_struct("Send")
+
                .field("token", token)
+
                .field("data", &"<omitted>")
+
                .finish(),
+
            Action::SetTimer(duration) => f
+
                .debug_struct("SetTimer")
+
                .field("duration", duration)
+
                .finish(),
+
        }
+
    }
+
}
+

+
/// A service which handles reactions to the events generated in the [`Reactor`].
+
pub trait ReactionHandler: Send + Iterator<Item = Action<Self::Listener, Self::Transport>> {
+
    /// Type for a listener resource.
+
    ///
+
    /// Listener resources are resources which may spawn more resources and can't be written to. A
+
    /// typical example of a listener resource is a [`std::net::TcpListener`], however this may also
+
    /// be a special form of a peripheral device or something else.
+
    type Listener: EventHandler + Source + Send + Debug;
+

+
    /// Type for a transport resource.
+
    ///
+
    /// Transport is a "full" resource which can be read from - and written to. Usual files, network
+
    /// connections, database connections etc are all fall into this category.
+
    type Transport: EventHandler + Source + Send + Debug + WriteAtomic;
+

+
    /// Method called by the reactor on the start of each event loop once the poll has returned.
+
    fn tick(&mut self);
+

+
    /// Method called by the reactor when a previously set timeout is fired.
+
    ///
+
    /// Related: [`Action::SetTimer`].
+
    fn timer_reacted(&mut self);
+

+
    /// Method called by the reactor upon a reaction to an I/O event on a listener resource.
+
    ///
+
    /// Since listener doesn't support writing, it can be only a read event (indicating that a new
+
    /// resource can be spawned from the listener).
+
    fn listener_reacted(
+
        &mut self,
+
        token: Token,
+
        reaction: <Self::Listener as EventHandler>::Reaction,
+
        instant: Instant,
+
    );
+

+
    /// Method called by the reactor upon a reaction to an I/O event on a transport resource.
+
    fn transport_reacted(
+
        &mut self,
+
        token: Token,
+
        reaction: <Self::Transport as EventHandler>::Reaction,
+
        instant: Instant,
+
    );
+

+
    /// Method called by the reactor when a given resource was successfully registered
+
    /// for given token.
+
    ///
+
    /// The token will be used later in [`ReactionHandler::listener_reacted`]
+
    /// and [`ReactionHandler::handover_listener`] calls to the handler.
+
    fn listener_registered(&mut self, token: Token, listener: &Self::Listener);
+

+
    /// Method called by the reactor when a given resource was successfully registered
+
    /// for given token.
+
    ///
+
    /// The token will be used later in [`ReactionHandler::transport_reacted`],
+
    /// [`ReactionHandler::handover_transport`] calls to the handler.
+
    fn transport_registered(&mut self, token: Token, transport: &Self::Transport);
+

+
    /// Method called by the reactor when a command is received for the
+
    /// [`ReactionHandler`].
+
    ///
+
    /// The commands are sent via `Controller` from outside of the reactor, including other
+
    /// threads.
+
    fn handle_command(&mut self, cmd: wire::Control);
+

+
    /// Method called by the reactor on any kind of error during the event loop, including errors of
+
    /// the poll syscall or I/O errors returned as a part of the poll result events.
+
    ///
+
    /// See [`enum@Error`] for the details on errors which may happen.
+
    fn handle_error(&mut self, err: Error<Self::Listener, Self::Transport>);
+

+
    /// Method called by the reactor upon receiving [`Action::UnregisterListener`].
+
    ///
+
    /// Passes the listener resource to the [`ReactionHandler`] when it is already not a part of the reactor
+
    /// poll. From this point of time it is safe to send the resource to other threads (like
+
    /// workers) or close the resource.
+
    fn handover_listener(&mut self, token: Token, listener: Self::Listener);
+

+
    /// Method called by the reactor upon receiving [`Action::UnregisterTransport`].
+
    ///
+
    /// Passes the transport resource to the [`ReactionHandler`] when it is already not a part of the
+
    /// reactor poll. From this point of time it is safe to send the resource to other threads
+
    /// (like workers) or close the resource.
+
    fn handover_transport(&mut self, token: Token, transport: Self::Transport);
+
}
+

+
/// High-level reactor API wrapping reactor [`Runtime`] into a thread and providing basic thread
+
/// management for it.
+
///
+
/// Apps running the [`Reactor`] can interface it and a [`ReactionHandler`] via use of the `Controller`
+
/// API.
+
pub struct Reactor {
+
    thread: JoinHandle<()>,
+
    controller: Controller,
+
}
+

+
impl Reactor {
+
    /// Creates new reactor and a service exposing the [`ReactionHandler`] to
+
    /// the reactor.
+
    ///
+
    /// The service is sent to the newly created reactor thread which runs the
+
    /// reactor [`Runtime`].
+
    pub fn new<H>(service: H, thread_name: String) -> Result<Self, io::Error>
+
    where
+
        H: 'static + ReactionHandler,
+
    {
+
        let builder = thread::Builder::new().name(thread_name);
+
        let (sender, receiver) = unbounded();
+
        let poll = Poll::new()?;
+
        let controller = Controller::new(sender, Arc::new(Waker::new(poll.registry(), WAKER)?));
+

+
        log::debug!(target: "reactor-controller", "Initializing reactor thread...");
+
        let thread = builder.spawn(move || {
+
            let runtime = Runtime {
+
                service,
+
                poll,
+
                receiver,
+
                listeners: HashMap::new(),
+
                transports: HashMap::new(),
+
                timeouts: Timer::new(),
+
            };
+

+
            log::info!(target: "reactor", "Entering reactor event loop");
+

+
            runtime.run();
+
        })?;
+

+
        // Waking up to consume actions which were provided by the service on launch
+
        controller.wake()?;
+

+
        Ok(Self { thread, controller })
+
    }
+

+
    /// Provides a `Controller` that can be used to send events to
+
    /// [`ReactionHandler`] via self.
+
    pub fn controller(&self) -> Controller {
+
        self.controller.clone()
+
    }
+

+
    /// Joins the reactor thread.
+
    pub fn join(self) -> thread::Result<()> {
+
        self.thread.join()
+
    }
+
}
+

+
/// Internal [`Reactor`] runtime which is run in a dedicated thread.
+
///
+
/// This runtime structure *does not* spawn a thread and is *blocking*.
+
/// It implements the actual reactor event loop.
+
pub struct Runtime<H: ReactionHandler> {
+
    service: H,
+
    poll: Poll,
+
    receiver: Receiver<ControlMessage>,
+
    listeners: HashMap<Token, H::Listener>,
+
    transports: HashMap<Token, H::Transport>,
+
    timeouts: Timer,
+
}
+

+
impl<H: ReactionHandler> Runtime<H> {
+
    fn register_interests(&mut self) -> io::Result<()> {
+
        let registry = self.poll.registry();
+
        for (id, res) in self.listeners.iter_mut() {
+
            match res.interests() {
+
                None => registry.deregister(res)?,
+
                Some(interests) => registry.reregister(res, *id, interests)?,
+
            };
+
        }
+
        for (id, res) in self.transports.iter_mut() {
+
            match res.interests() {
+
                None => registry.deregister(res)?,
+
                Some(interests) => registry.reregister(res, *id, interests)?,
+
            };
+
        }
+
        Ok(())
+
    }
+

+
    fn run(mut self) {
+
        loop {
+
            let timeout = self
+
                .timeouts
+
                .next_expiring_from(Instant::now())
+
                .unwrap_or(WAIT_TIMEOUT);
+

+
            self.register_interests()
+
                .expect("registering interests must work to ensure correct operation");
+

+
            log::trace!(target: "reactor", "Polling with timeout {timeout:?}");
+

+
            let mut events = Events::with_capacity(1024);
+

+
            // Block and wait for I/O events, wake by other threads, or timeout.
+
            let res = self.poll.poll(&mut events, Some(timeout));
+

+
            // This instant allows to measure the time spent by the service
+
            // to handle the result of polling.
+
            let tick = Instant::now();
+

+
            // Inform the service that time has advanced.
+
            self.service.tick();
+

+
            // Inform the service about errors during polling.
+
            if let Err(err) = res {
+
                log::warn!(target: "reactor", "Failure during polling: {err}");
+
                self.service.handle_error(Error::Poll(err));
+
            }
+

+
            // Inform the service that some timers have reacted.
+
            // The way this is currently used basically ignores which
+
            // timers have expired. As long as *something* timed out,
+
            // the service is informed.
+
            let timers_fired = self.timeouts.remove_expired_by(tick);
+
            if timers_fired > 0 {
+
                log::trace!(target: "reactor", "Timer has fired");
+
                self.service.timer_reacted();
+
            }
+

+
            if self.handle_events(tick, events) {
+
                // If a wake event was emitted, eagerly consume all control messages.
+
                loop {
+
                    use ControlMessage::*;
+
                    use TryRecvError::*;
+

+
                    match self.receiver.try_recv() {
+
                        Ok(Command(cmd)) => self.service.handle_command(*cmd),
+
                        Ok(Shutdown) => return self.handle_shutdown(),
+
                        Err(Empty) => break,
+
                        Err(Disconnected) => panic!("control channel disconnected unexpectedly"),
+
                    }
+
                }
+
            }
+

+
            let duration = Instant::now().duration_since(tick);
+
            if duration > LAG_TIMEOUT {
+
                log::warn!(target: "reactor", "Service was busy {:?} which exceeds the timeout of {:?}", duration, LAG_TIMEOUT);
+
            }
+

+
            self.handle_actions(tick);
+
        }
+
    }
+

+
    /// # Returns
+
    ///
+
    /// Whether one of the events was originated from the waker.
+
    fn handle_events(&mut self, instant: Instant, events: Events) -> bool {
+
        log::trace!(target: "reactor", "Handling events");
+
        let mut awoken = false;
+
        let mut deregistered = Vec::new();
+

+
        for event in events.into_iter() {
+
            let token = event.token();
+

+
            if token == WAKER {
+
                log::trace!(target: "reactor", "Awoken by the controller");
+
                awoken = true;
+
            } else if self.listeners.contains_key(&token) {
+
                log::trace!(target: "reactor", token=token.0; "Event from listener with token {}: {:?}", token.0, event);
+
                if !event.is_error() {
+
                    let listener = self
+
                        .listeners
+
                        .get_mut(&token)
+
                        .expect("resource disappeared");
+
                    listener
+
                        .handle(event)
+
                        .into_iter()
+
                        .for_each(|service_event| {
+
                            self.service.listener_reacted(token, service_event, instant);
+
                        });
+
                } else {
+
                    let listener = self.deregister_listener(token).unwrap_or_else(|| {
+
                        panic!("listener with token {} has disappeared", token.0)
+
                    });
+
                    self.service
+
                        .handle_error(Error::ListenerDisconnect(token, listener));
+
                    deregistered.push(token);
+
                }
+
            } else if self.transports.contains_key(&token) {
+
                log::trace!(target: "reactor", token=token.0; "Event from transport with token {}: {:?}", token.0, event);
+
                if !event.is_error() {
+
                    let transport = self
+
                        .transports
+
                        .get_mut(&token)
+
                        .expect("resource disappeared");
+
                    transport
+
                        .handle(event)
+
                        .into_iter()
+
                        .for_each(|service_event| {
+
                            self.service
+
                                .transport_reacted(token, service_event, instant);
+
                        });
+
                } else {
+
                    let transport = self.deregister_transport(token).unwrap_or_else(|| {
+
                        panic!("transport with token {} has disappeared", token.0)
+
                    });
+
                    self.service
+
                        .handle_error(Error::TransportDisconnect(token, transport));
+
                    deregistered.push(token);
+
                }
+
            } else if !deregistered.contains(&token) {
+
                log::debug!(target: "reactor", token=token.0; "Event from unknown token {}: {:?}", token.0, event);
+
            }
+
        }
+

+
        awoken
+
    }
+

+
    fn handle_actions(&mut self, instant: Instant) {
+
        while let Some(action) = self.service.next() {
+
            log::trace!(target: "reactor", "Handling action {action} from the service");
+

+
            // Deadlock may happen here if the service will generate events over and over
+
            // in the handle_* calls we may never get out of this loop
+
            if let Err(err) = self.handle_action(action, instant) {
+
                log::warn!(target: "reactor", "Failure: {err}");
+
                self.service.handle_error(err);
+
            }
+
        }
+
    }
+

+
    fn handle_action(
+
        &mut self,
+
        action: Action<H::Listener, H::Transport>,
+
        instant: Instant,
+
    ) -> Result<(), Error<H::Listener, H::Transport>> {
+
        match action {
+
            Action::RegisterListener(token, mut listener) => {
+
                log::trace!(target: "reactor", token=token.0; "Registering listener {:?} with token {}", listener, token.0);
+

+
                self.poll
+
                    .registry()
+
                    .register(&mut listener, token, Interest::READABLE)
+
                    .map_err(Error::Registration)?;
+
                self.listeners.insert(token, listener);
+
                self.service
+
                    .listener_registered(token, &self.listeners[&token]);
+
            }
+
            Action::RegisterTransport(token, mut transport) => {
+
                log::debug!(target: "reactor", token=token.0; "Registering transport");
+

+
                self.poll
+
                    .registry()
+
                    .register(&mut transport, token, Interest::READABLE)
+
                    .map_err(Error::Registration)?;
+
                self.transports.insert(token, transport);
+
                self.service
+
                    .transport_registered(token, &self.transports[&token]);
+
            }
+
            Action::UnregisterListener(token) => {
+
                let Some(listener) = self.deregister_listener(token) else {
+
                    return Ok(());
+
                };
+

+
                log::debug!(target: "reactor", token=token.0; "Handing over listener {listener:?} with token {}", token.0);
+
                self.service.handover_listener(token, listener);
+
            }
+
            Action::UnregisterTransport(token) => {
+
                let Some(transport) = self.deregister_transport(token) else {
+
                    return Ok(());
+
                };
+

+
                log::debug!(target: "reactor", token=token.0; "Handing over transport {transport:?} with token {}", token.0);
+
                self.service.handover_transport(token, transport);
+
            }
+
            Action::Send(token, data) => {
+
                log::trace!(target: "reactor", token=token.0; "Sending {} bytes to {token:?}", data.len());
+

+
                if let Some(transport) = self.transports.get_mut(&token) {
+
                    if let Err(e) = transport.write_atomic(&data) {
+
                        log::error!(target: "reactor", "Fatal error writing to transport {token:?}, disconnecting. Error details: {e:?}");
+
                        if let Some(transport) = self.deregister_transport(token) {
+
                            return Err(Error::TransportDisconnect(token, transport));
+
                        }
+
                    }
+
                } else {
+
                    log::debug!(target: "reactor", token=token.0; "No transport with token {token:?} is known!");
+
                }
+
            }
+
            Action::SetTimer(duration) => {
+
                log::trace!(target: "reactor", "Adding timer {duration:?} from now");
+

+
                self.timeouts.set_timeout(duration, instant);
+
            }
+
        }
+
        Ok(())
+
    }
+

+
    fn handle_shutdown(self) {
+
        log::info!(target: "reactor", "Shutdown");
+
    }
+

+
    fn deregister_listener(&mut self, token: Token) -> Option<H::Listener> {
+
        let Some(mut source) = self.listeners.remove(&token) else {
+
            log::debug!(target: "reactor", token=token.0; "Deregistering non-registered listener with token {}", token.0);
+
            return None;
+
        };
+

+
        if let Err(err) = self.poll.registry().deregister(&mut source) {
+
            log::debug!(target: "reactor", token=token.0; "Failed to deregister listener with token {} from mio: {err}", token.0);
+
        }
+

+
        Some(source)
+
    }
+

+
    fn deregister_transport(&mut self, token: Token) -> Option<H::Transport> {
+
        let Some(mut source) = self.transports.remove(&token) else {
+
            log::debug!(target: "reactor", token=token.0; "Deregistering non-registered transport with token {}", token.0);
+
            return None;
+
        };
+

+
        if let Err(err) = self.poll.registry().deregister(&mut source) {
+
            log::debug!(target: "reactor", token=token.0; "Failed to deregister transport with token {} from mio: {err}", token.0);
+
        }
+

+
        Some(source)
+
    }
+
}
added crates/radicle-node/src/reactor/controller.rs
@@ -0,0 +1,54 @@
+
use crossbeam_channel::Sender;
+
use mio::Waker;
+
use std::io;
+
use std::io::ErrorKind;
+
use std::sync::Arc;
+

+
use crate::wire;
+

+
/// A command which may be sent to the [`super::ReactionHandler`] from outside of the [`super::Reactor`],
+
/// including other threads.
+
///
+
/// The handler object is owned by the reactor runtime and executes always in the context of the
+
/// reactor runtime thread. Thus, if other (micro)services within the app needs to communicate
+
/// to the handler they have to use this data type, which usually is an enumeration for a set of
+
/// commands supported by the handler.
+
pub enum ControlMessage {
+
    Command(Box<wire::Control>),
+
    Shutdown,
+
}
+

+
/// Used by the [`crate::reactor::Reactor`] to inform the
+
/// [`crate::reactor::ReactionHandler`] about
+
/// incoming commands, sent via this [`Controller`].
+
#[derive(Clone)]
+
pub struct Controller {
+
    sender: Sender<ControlMessage>,
+
    waker: Arc<Waker>,
+
}
+

+
impl Controller {
+
    pub fn new(sender: Sender<ControlMessage>, waker: Arc<Waker>) -> Self {
+
        Self { sender, waker }
+
    }
+

+
    pub fn wake(&self) -> io::Result<()> {
+
        log::trace!(target: "reactor::controller", "Wakening the reactor");
+
        self.waker.wake()
+
    }
+

+
    pub fn cmd(&self, command: wire::Control) -> io::Result<()> {
+
        log::trace!(target: "reactor::controller", "Sending command {command:?} to the reactor");
+
        self.sender
+
            .send(ControlMessage::Command(Box::new(command)))
+
            .map_err(|_| ErrorKind::BrokenPipe)?;
+
        self.wake()
+
    }
+

+
    pub fn shutdown(self) -> Result<(), Self> {
+
        log::info!(target: "reactor::controller", "Initiating reactor shutdown...");
+
        let res1 = self.sender.send(ControlMessage::Shutdown);
+
        let res2 = self.wake();
+
        res1.or(res2).map_err(|_| self)
+
    }
+
}
added crates/radicle-node/src/reactor/listener.rs
@@ -0,0 +1,73 @@
+
use mio::event::{Event, Source};
+
use mio::net::{TcpListener, TcpStream};
+
use mio::{Interest, Registry, Token};
+
use std::io::Result;
+

+
use std::net::SocketAddr;
+
use std::time::Duration;
+

+
use crate::reactor::EventHandler;
+

+
/// A reactor-manageable TCP listener which can
+
/// be aware of additional encryption, authentication and other forms of
+
/// transport-layer protocols which will be automatically injected into accepted
+
/// connections.
+
#[derive(Debug)]
+
pub struct Listener(TcpListener);
+

+
impl Source for Listener {
+
    fn register(&mut self, registry: &Registry, token: Token, interests: Interest) -> Result<()> {
+
        self.0.register(registry, token, interests)
+
    }
+

+
    fn reregister(&mut self, registry: &Registry, token: Token, interests: Interest) -> Result<()> {
+
        self.0.reregister(registry, token, interests)
+
    }
+

+
    fn deregister(&mut self, registry: &Registry) -> Result<()> {
+
        self.0.deregister(registry)
+
    }
+
}
+

+
impl Listener {
+
    pub fn bind(addr: SocketAddr) -> Result<Self> {
+
        Ok(Self(TcpListener::bind(addr)?))
+
    }
+

+
    /// Returns the local [`std::net::SocketAddr`] on which self accepts
+
    /// connections.
+
    pub fn local_addr(&self) -> std::net::SocketAddr {
+
        self.0.local_addr().expect("TCP listener has local address")
+
    }
+

+
    fn accept(&mut self) -> Result<(TcpStream, SocketAddr)> {
+
        /// Maximum time to wait when reading from a socket.
+
        const READ_TIMEOUT: Duration = Duration::from_secs(6);
+

+
        /// Maximum time to wait when writing to a socket.
+
        const WRITE_TIMEOUT: Duration = Duration::from_secs(3);
+

+
        let (stream, peer) = self.0.accept()?;
+
        let stream = std::net::TcpStream::from(stream);
+
        stream.set_read_timeout(Some(READ_TIMEOUT))?;
+
        stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
+
        stream.set_nonblocking(true)?;
+
        Ok((TcpStream::from_std(stream), peer))
+
    }
+
}
+

+
impl EventHandler for Listener {
+
    type Reaction = Result<(TcpStream, SocketAddr)>;
+

+
    fn interests(&self) -> Option<Interest> {
+
        Some(Interest::READABLE)
+
    }
+

+
    fn handle(&mut self, event: &Event) -> Vec<Self::Reaction> {
+
        if !event.is_readable() {
+
            return vec![];
+
        }
+

+
        vec![self.accept()]
+
    }
+
}
added crates/radicle-node/src/reactor/session.rs
@@ -0,0 +1,320 @@
+
use std::error;
+
use std::fmt::{Debug, Display};
+
use std::io;
+
use std::io::{Read, Write};
+
use std::net::{Shutdown, SocketAddr};
+

+
use cyphernet::encrypt::noise::NoiseState;
+
use cyphernet::proxy::socks5;
+

+
use mio::event::Source;
+
use mio::net::TcpStream;
+
use mio::{Interest, Registry, Token};
+

+
pub type NoiseSession<E, D, S> = Protocol<NoiseState<E, D>, S>;
+
pub type Socks5Session<S> = Protocol<socks5::Socks5, S>;
+

+
pub trait Session: Send + Read + Write {
+
    type Inner: Session;
+
    type Artifact: Display;
+

+
    fn is_established(&self) -> bool {
+
        self.artifact().is_some()
+
    }
+

+
    fn run_handshake(&mut self) -> io::Result<()> {
+
        Ok(())
+
    }
+

+
    fn display(&self) -> String {
+
        self.artifact()
+
            .map(|artifact| artifact.to_string())
+
            .unwrap_or_else(|| "<no-id>".to_string())
+
    }
+

+
    fn artifact(&self) -> Option<Self::Artifact>;
+

+
    fn stream(&mut self) -> &mut TcpStream;
+

+
    fn disconnect(self) -> io::Result<()>;
+
}
+

+
pub trait StateMachine: Sized + Send {
+
    const NAME: &'static str;
+

+
    type Artifact;
+

+
    type Error: error::Error + Send + Sync + 'static;
+

+
    fn next_read_len(&self) -> usize;
+

+
    fn advance(&mut self, input: &[u8]) -> Result<Vec<u8>, Self::Error>;
+

+
    fn artifact(&self) -> Option<Self::Artifact>;
+

+
    // Blocking
+
    fn run_handshake<RW>(&mut self, stream: &mut RW) -> io::Result<()>
+
    where
+
        RW: Read + Write,
+
    {
+
        let mut input = vec![];
+
        while !self.is_complete() {
+
            let act = self.advance(&input).map_err(|err| {
+
                log::error!(target: Self::NAME, "Handshake failure: {err}");
+
                io::Error::other(err)
+
            })?;
+
            if !act.is_empty() {
+
                log::trace!(target: Self::NAME, "Sending handshake act {act:02x?}");
+

+
                stream.write_all(&act)?;
+
            }
+
            if !self.is_complete() {
+
                input = vec![0u8; self.next_read_len()];
+
                stream.read_exact(&mut input)?;
+

+
                log::trace!(target: Self::NAME, "Receiving handshake act {input:02x?}");
+
            }
+
        }
+

+
        log::debug!(target: Self::NAME, "Handshake protocol {} successfully completed", Self::NAME);
+
        Ok(())
+
    }
+

+
    fn is_complete(&self) -> bool {
+
        self.artifact().is_some()
+
    }
+
}
+

+
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
+
pub struct ProtocolArtifact<M: StateMachine, S: Session> {
+
    pub(crate) session: S::Artifact,
+
    pub(crate) state: M::Artifact,
+
}
+

+
impl<M: StateMachine, S: Session> Display for ProtocolArtifact<M, S> {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        f.debug_struct("ProtocolArtifact")
+
            .field("session", &"<omitted>")
+
            .field("state", &"<omitted>")
+
            .finish()
+
    }
+
}
+

+
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
+
pub struct Protocol<M: StateMachine, S: Session> {
+
    pub(crate) state: M,
+
    pub(crate) session: S,
+
}
+

+
impl<M: StateMachine, S: Session> Protocol<M, S> {
+
    pub fn new(session: S, state_machine: M) -> Self {
+
        Self {
+
            state: state_machine,
+
            session,
+
        }
+
    }
+
}
+

+
impl<M: StateMachine, S: Session> io::Read for Protocol<M, S> {
+
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+
        log::trace!(target: M::NAME, "Reading event");
+

+
        if self.state.is_complete() || !self.session.is_established() {
+
            log::trace!(target: M::NAME, "Passing reading to inner not yet established session");
+
            return self.session.read(buf);
+
        }
+

+
        let len = self.state.next_read_len();
+
        let mut input = vec![0u8; len];
+
        self.session.read_exact(&mut input)?;
+

+
        log::trace!(target: M::NAME, "Received handshake act: {input:02x?}");
+

+
        if !input.is_empty() {
+
            let output = self.state.advance(&input).map_err(|err| {
+
                log::error!(target: M::NAME, "Handshake failure: {err}");
+
                io::Error::other(err)
+
            })?;
+

+
            if !output.is_empty() {
+
                log::trace!(target: M::NAME, "Sending handshake act on read: {output:02x?}");
+
                self.session.write_all(&output)?;
+
            }
+
        }
+

+
        Ok(0)
+
    }
+
}
+

+
impl<M: StateMachine, S: Session> Write for Protocol<M, S> {
+
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
+
        log::trace!(target: M::NAME, "Writing event (state_complete={}, session_established={})", self.state.is_complete(), self.session.is_established());
+

+
        if self.state.is_complete() || !self.session.is_established() {
+
            log::trace!(target: M::NAME, "Passing writing to inner session");
+
            return self.session.write(buf);
+
        }
+

+
        if self.state.next_read_len() == 0 {
+
            log::trace!(target: M::NAME, "Starting handshake protocol");
+

+
            let act = self.state.advance(&[]).map_err(|err| {
+
                log::error!(target: M::NAME, "Handshake failure: {err}");
+
                io::Error::other(err)
+
            })?;
+

+
            if !act.is_empty() {
+
                log::trace!(target: M::NAME, "Sending handshake act on write: {act:02x?}");
+
                self.session.write_all(&act)?;
+
            } else {
+
                log::trace!(target: M::NAME, "Handshake complete, passing data to inner session");
+
                return self.session.write(buf);
+
            }
+
        }
+

+
        if buf.is_empty() {
+
            Ok(0)
+
        } else {
+
            Err(io::ErrorKind::Interrupted.into())
+
        }
+
    }
+

+
    fn flush(&mut self) -> io::Result<()> {
+
        self.session.flush()
+
    }
+
}
+

+
impl<M: StateMachine, S: Session> Session for Protocol<M, S> {
+
    type Inner = S;
+
    type Artifact = ProtocolArtifact<M, S>;
+

+
    fn run_handshake(&mut self) -> io::Result<()> {
+
        log::debug!(target: M::NAME, "Starting handshake protocol {}", M::NAME);
+

+
        if !self.session.is_established() {
+
            self.session.run_handshake()?;
+
        }
+

+
        self.state.run_handshake(self.session.stream())
+
    }
+

+
    fn artifact(&self) -> Option<Self::Artifact> {
+
        Some(ProtocolArtifact {
+
            session: self.session.artifact()?,
+
            state: self.state.artifact()?,
+
        })
+
    }
+

+
    fn stream(&mut self) -> &mut TcpStream {
+
        self.session.stream()
+
    }
+

+
    fn disconnect(self) -> io::Result<()> {
+
        self.session.disconnect()
+
    }
+
}
+

+
impl<M: StateMachine, S: Session + Source> Source for Protocol<M, S> {
+
    fn register(
+
        &mut self,
+
        registry: &Registry,
+
        token: Token,
+
        interests: Interest,
+
    ) -> io::Result<()> {
+
        self.session.register(registry, token, interests)
+
    }
+

+
    fn reregister(
+
        &mut self,
+
        registry: &Registry,
+
        token: Token,
+
        interests: Interest,
+
    ) -> io::Result<()> {
+
        self.session.reregister(registry, token, interests)
+
    }
+

+
    fn deregister(&mut self, registry: &Registry) -> io::Result<()> {
+
        self.session.deregister(registry)
+
    }
+
}
+

+
impl Session for TcpStream {
+
    type Inner = Self;
+
    type Artifact = SocketAddr;
+

+
    fn artifact(&self) -> Option<Self::Artifact> {
+
        self.peer_addr().ok()
+
    }
+

+
    fn stream(&mut self) -> &mut TcpStream {
+
        self
+
    }
+

+
    fn disconnect(self) -> io::Result<()> {
+
        self.shutdown(Shutdown::Both)
+
    }
+
}
+

+
mod impl_noise {
+
    use cyphernet::encrypt::noise::{error::NoiseError as Error, NoiseState as Noise};
+
    use cyphernet::{Digest, Ecdh};
+

+
    use super::*;
+

+
    #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
+
    pub struct NoiseArtifact<E: Ecdh, D: Digest> {
+
        pub handshake_hash: D::Output,
+
        pub remote_static_key: Option<E::Pk>,
+
    }
+

+
    impl<E: Ecdh, D: Digest> StateMachine for Noise<E, D> {
+
        const NAME: &'static str = "noise";
+
        type Artifact = NoiseArtifact<E, D>;
+
        type Error = Error;
+

+
        fn next_read_len(&self) -> usize {
+
            self.next_read_len()
+
        }
+

+
        fn advance(&mut self, input: &[u8]) -> Result<Vec<u8>, Self::Error> {
+
            self.advance(input)
+
        }
+

+
        fn artifact(&self) -> Option<Self::Artifact> {
+
            self.get_handshake_hash().map(|hh| NoiseArtifact {
+
                handshake_hash: hh,
+
                remote_static_key: self.get_remote_static_key(),
+
            })
+
        }
+
    }
+
}
+

+
mod impl_socks5 {
+
    use cyphernet::addr::{Host as _, HostName, NetAddr};
+
    use cyphernet::proxy::socks5::{Error, Socks5};
+

+
    use super::*;
+

+
    impl StateMachine for Socks5 {
+
        const NAME: &'static str = "socks5";
+

+
        type Artifact = NetAddr<HostName>;
+
        type Error = Error;
+

+
        fn next_read_len(&self) -> usize {
+
            self.next_read_len()
+
        }
+

+
        fn advance(&mut self, input: &[u8]) -> Result<Vec<u8>, Self::Error> {
+
            self.advance(input)
+
        }
+

+
        fn artifact(&self) -> Option<Self::Artifact> {
+
            match self {
+
                Socks5::Initial(addr, false) if !addr.requires_proxy() => Some(addr.clone()),
+
                Socks5::Active(addr) => Some(addr.clone()),
+
                _ => None,
+
            }
+
        }
+
    }
+
}
added crates/radicle-node/src/reactor/timer.rs
@@ -0,0 +1,126 @@
+
use std::time::Duration;
+
use std::{collections::BTreeSet, time::Instant};
+

+
/// Manages timers and triggers timeouts.
+
#[derive(Debug, Default)]
+
pub struct Timer {
+
    /// Timeouts are durations since the UNIX epoch.
+
    timeouts: BTreeSet<Instant>,
+
}
+

+
impl Timer {
+
    /// Create a new timer containing no timeouts.
+
    pub fn new() -> Self {
+
        Self {
+
            timeouts: BTreeSet::new(),
+
        }
+
    }
+

+
    /// Return the number of timeouts being tracked.
+
    #[cfg(test)]
+
    pub fn count(&self) -> usize {
+
        self.timeouts.len()
+
    }
+

+
    /// Check whether there are timeouts being tracked.
+
    #[cfg(test)]
+
    pub fn has_timeouts(&self) -> bool {
+
        !self.timeouts.is_empty()
+
    }
+

+
    /// Register a new timeout relative to a certain point in time.
+
    pub fn set_timeout(&mut self, timeout: Duration, after: Instant) {
+
        let time = after + timeout;
+
        self.timeouts.insert(time);
+
    }
+

+
    /// Get the first timeout expiring right at or after certain moment of time.
+
    /// Returns [`None`] if there are no timeouts.
+
    pub fn next_expiring_from(&self, time: impl Into<Instant>) -> Option<Duration> {
+
        let time = time.into();
+
        let last = *self.timeouts.first()?;
+
        Some(if last >= time {
+
            last - time
+
        } else {
+
            Duration::default()
+
        })
+
    }
+

+
    /// Removes timeouts which expire by a certain moment of time (inclusive),
+
    /// returning total number of timeouts which were removed.
+
    pub fn remove_expired_by(&mut self, instant: Instant) -> usize {
+
        // Since `split_off` returns everything *after* the given key, including the key,
+
        // if a timer is set for exactly the given time, it would remain in the "after"
+
        // set of unexpired keys. This isn't what we want, therefore we add `1` to the
+
        // given time value so that it is put in the "before" set that gets expired
+
        // and overwritten.
+
        let at = instant + Duration::from_millis(1);
+
        let unexpired = self.timeouts.split_off(&at);
+
        let fired = self.timeouts.len();
+
        self.timeouts = unexpired;
+
        fired
+
    }
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    use super::*;
+

+
    #[test]
+
    fn test_wake_exact() {
+
        let mut tm = Timer::new();
+

+
        let now = Instant::now();
+
        tm.set_timeout(Duration::from_secs(8), now);
+
        tm.set_timeout(Duration::from_secs(9), now);
+
        tm.set_timeout(Duration::from_secs(10), now);
+

+
        assert_eq!(tm.remove_expired_by(now + Duration::from_secs(9)), 2);
+
        assert_eq!(tm.count(), 1);
+
    }
+

+
    #[test]
+
    fn test_wake() {
+
        let mut tm = Timer::new();
+

+
        let now = Instant::now();
+
        tm.set_timeout(Duration::from_secs(8), now);
+
        tm.set_timeout(Duration::from_secs(16), now);
+
        tm.set_timeout(Duration::from_secs(64), now);
+
        tm.set_timeout(Duration::from_secs(72), now);
+

+
        assert_eq!(tm.remove_expired_by(now), 0);
+
        assert_eq!(tm.count(), 4);
+

+
        assert_eq!(tm.remove_expired_by(now + Duration::from_secs(9)), 1);
+
        assert_eq!(tm.count(), 3, "one timeout has expired");
+

+
        assert_eq!(tm.remove_expired_by(now + Duration::from_secs(66)), 2);
+
        assert_eq!(tm.count(), 1, "another two timeouts have expired");
+

+
        assert_eq!(tm.remove_expired_by(now + Duration::from_secs(96)), 1);
+
        assert!(!tm.has_timeouts(), "all timeouts have expired");
+
    }
+

+
    #[test]
+
    fn test_next() {
+
        let mut tm = Timer::new();
+

+
        let mut now = Instant::now();
+
        tm.set_timeout(Duration::from_secs(3), now);
+
        assert_eq!(tm.next_expiring_from(now), Some(Duration::from_secs(3)));
+

+
        now += Duration::from_secs(2);
+
        assert_eq!(tm.next_expiring_from(now), Some(Duration::from_secs(1)));
+

+
        now += Duration::from_secs(1);
+
        assert_eq!(tm.next_expiring_from(now), Some(Duration::from_secs(0)));
+

+
        now += Duration::from_secs(1);
+
        assert_eq!(tm.next_expiring_from(now), Some(Duration::from_secs(0)));
+

+
        assert_eq!(tm.remove_expired_by(now), 1);
+
        assert_eq!(tm.count(), 0);
+
        assert_eq!(tm.next_expiring_from(now), None);
+
    }
+
}
added crates/radicle-node/src/reactor/token.rs
@@ -0,0 +1,46 @@
+
pub use mio::Token;
+

+
pub const WAKER: Token = Token(0);
+

+
#[derive(Clone, Debug)]
+
pub struct Tokens {
+
    initial: usize,
+
    current: usize,
+
}
+

+
impl Tokens {
+
    pub fn new(initial: usize) -> Self {
+
        Tokens {
+
            initial,
+
            current: initial,
+
        }
+
    }
+

+
    #[inline]
+
    pub fn advance(&mut self) -> Token {
+
        let current = self.current;
+

+
        self.current = {
+
            let candidate = current.wrapping_add(1);
+

+
            if candidate == usize::MIN {
+
                // If we overflowed, reset to the initial value.
+
                // The range of `usize` is so large that likely
+
                // a few years have passed since the early tokens
+
                // were used.
+
                log::info!(target = "reactor"; "Tokens wrapped.");
+
                self.initial
+
            } else {
+
                candidate
+
            }
+
        };
+

+
        Token(current)
+
    }
+
}
+

+
impl Default for Tokens {
+
    fn default() -> Self {
+
        Tokens::new(1)
+
    }
+
}
added crates/radicle-node/src/reactor/transport.rs
@@ -0,0 +1,341 @@
+
use std::collections::VecDeque;
+
use std::fmt::{Debug, Display, Formatter};
+
use std::io::Write;
+
use std::{fmt, io};
+

+
use mio::event::{Event, Source};
+
use mio::{Interest, Registry, Token};
+
use radicle::node::Link;
+

+
use crate::reactor::session::Session;
+
use crate::reactor::{EventHandler, WriteAtomic};
+

+
const READ_BUFFER_SIZE: usize = u16::MAX as usize;
+

+
/// An event happening for a [`Transport`] network transport and delivered to
+
/// a [`ReactionHandler`].
+
///
+
/// [`ReactionHandler`]: crate::reactor::ReactionHandler
+
pub enum SessionEvent<S: Session> {
+
    Established(S::Artifact),
+
    Data(Vec<u8>),
+
    Terminated(io::Error),
+
}
+

+
/// A state of [`Transport`] network transport.
+
#[derive(Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
+
pub enum TransportState {
+
    /// The transport is initiated, but the connection has not been established yet.
+
    /// This happens only for outgoing connections due to the use of
+
    /// non-blocking calls to `connect`. The state changes once
+
    /// we receive the first notification on a `write` event on this resource
+
    /// from the reactor.
+
    Init,
+

+
    /// The connection is established, but the session handshake is still in
+
    /// progress. This happens while encryption handshake, authentication and
+
    /// other protocols injected into the session haven't completed yet.
+
    Handshake,
+

+
    /// The session is active. All handshakes have completed.
+
    Active,
+

+
    /// Session was terminated (for an unspecified reason, e.g. local shutdown,
+
    /// remote orderly shutdown, connectivity issue, dropped connections,
+
    /// encryption, or authentication problem etc.
+
    /// Reading and writing from the resource in
+
    /// this state will result in an error ([`io::Error`]).
+
    Terminated,
+
}
+

+
/// Transport is an adaptor around a specific [`Session`] (implementing
+
/// session management, including optional handshake, encoding, etc.) to be used
+
/// as a transport resource in a [`crate::reactor::Reactor`].
+
pub struct Transport<S: Session> {
+
    state: TransportState,
+
    session: S,
+
    link_direction: Link,
+
    write_intent: bool,
+
    read_buffer: Box<[u8; READ_BUFFER_SIZE]>,
+
    write_buffer: VecDeque<u8>,
+
}
+

+
impl<S: Session> std::fmt::Debug for Transport<S> {
+
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+
        f.debug_struct("Transport")
+
            .field("session", &self.session.display())
+
            .field("state", &self.state)
+
            .field("link_direction", &self.link_direction)
+
            .field("write_intent", &self.write_intent)
+
            .finish()
+
    }
+
}
+

+
impl<S: Session + Source> Source for Transport<S> {
+
    fn register(
+
        &mut self,
+
        registry: &Registry,
+
        token: Token,
+
        interests: Interest,
+
    ) -> io::Result<()> {
+
        self.session.register(registry, token, interests)
+
    }
+

+
    fn reregister(
+
        &mut self,
+
        registry: &Registry,
+
        token: Token,
+
        interests: Interest,
+
    ) -> io::Result<()> {
+
        self.session.reregister(registry, token, interests)
+
    }
+

+
    fn deregister(&mut self, registry: &Registry) -> io::Result<()> {
+
        self.session.deregister(registry)
+
    }
+
}
+

+
impl<S: Session> Display for Transport<S> {
+
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+
        match self.session.artifact() {
+
            None => f
+
                .debug_struct("Transport")
+
                .field("state", &self.state)
+
                .field("link_direction", &self.link_direction)
+
                .field("write_intent", &self.write_intent)
+
                .finish(),
+
            Some(id) => Display::fmt(&id, f),
+
        }
+
    }
+
}
+

+
impl<S: Session> Transport<S> {
+
    /// Constructs reactor-managed resource around an existing [`Session`].
+
    ///
+
    /// Must not be called for connections created in a non-blocking mode!
+
    ///
+
    /// # Errors
+
    ///
+
    /// If a session can be put into a non-blocking mode.
+
    pub fn with_session(session: S, link_direction: Link) -> io::Result<Self> {
+
        let state = if session.is_established() {
+
            // If we are disconnected, we will get instantly updated from the
+
            // reactor and the state will change automatically
+
            TransportState::Active
+
        } else {
+
            TransportState::Handshake
+
        };
+
        Ok(Self {
+
            state,
+
            session,
+
            link_direction,
+
            write_intent: true,
+
            read_buffer: Box::new([0u8; READ_BUFFER_SIZE]),
+
            write_buffer: VecDeque::new(),
+
        })
+
    }
+

+
    pub fn display(&self) -> impl Display {
+
        self.session.display()
+
    }
+

+
    fn terminate(&mut self, reason: io::Error) -> SessionEvent<S> {
+
        log::trace!(target: "transport", "Terminating session {self} due to {reason:?}");
+

+
        self.state = TransportState::Terminated;
+
        SessionEvent::Terminated(reason)
+
    }
+

+
    fn handle_io(&mut self, interest: Interest) -> Option<SessionEvent<S>> {
+
        if self.state == TransportState::Terminated {
+
            log::debug!(target: "transport", "Transport {self} is terminated, ignoring I/O event");
+
            return None;
+
        }
+

+
        let mut force_write_intent = false;
+
        if self.state == TransportState::Init {
+
            log::debug!(target: "transport", "Transport {self} is connected, initializing handshake");
+

+
            force_write_intent = true;
+
            self.state = TransportState::Handshake;
+
        } else if self.state == TransportState::Handshake {
+
            debug_assert!(!self.session.is_established());
+

+
            log::trace!(target: "transport", "Transport {self} got I/O while in handshake mode");
+
        }
+

+
        let resp = match interest {
+
            Interest::READABLE => self.handle_readable(),
+
            Interest::WRITABLE => self.handle_writable(),
+
            _ => unreachable!(),
+
        };
+

+
        if force_write_intent {
+
            self.write_intent = true;
+
        } else if self.state == TransportState::Handshake {
+
            // During handshake, after each read we need to write and then wait
+
            self.write_intent = interest == Interest::READABLE;
+
        }
+

+
        if matches!(&resp, Some(SessionEvent::Terminated(e)) if e.kind() == io::ErrorKind::ConnectionReset)
+
            && self.state != TransportState::Handshake
+
        {
+
            log::debug!(target: "transport", "Peer {self} has reset the connection");
+

+
            self.state = TransportState::Terminated;
+
            resp
+
        } else if self.session.is_established() && self.state == TransportState::Handshake {
+
            log::debug!(target: "transport", "Handshake with {self} is complete");
+

+
            // We just got connected; may need to send output
+
            self.write_intent = true;
+
            self.state = TransportState::Active;
+
            Some(SessionEvent::Established(
+
                self.session.artifact().expect("session is established"),
+
            ))
+
        } else {
+
            resp
+
        }
+
    }
+

+
    fn handle_writable(&mut self) -> Option<SessionEvent<S>> {
+
        if !self.session.is_established() {
+
            let _ = self.session.write(&[]);
+
            self.write_intent = true;
+
            return None;
+
        }
+
        match self.flush() {
+
            Ok(_) => None,
+
            // In this case, the write could not complete. Leave `write_intent` set
+
            // to be notified when the socket is ready to write again.
+
            Err(err)
+
                if matches!(
+
                    err.kind(),
+
                    io::ErrorKind::WouldBlock
+
                        | io::ErrorKind::WriteZero
+
                        | io::ErrorKind::OutOfMemory
+
                        | io::ErrorKind::Interrupted
+
                ) =>
+
            {
+
                log::debug!(target: "transport", "Resource {} was not able to consume any data even though it has announced its write readiness", self.display());
+
                self.write_intent = true;
+
                None
+
            }
+
            Err(err) => Some(self.terminate(err)),
+
        }
+
    }
+

+
    fn handle_readable(&mut self) -> Option<SessionEvent<S>> {
+
        // Since `poll`, which this reactor is based on, is *level-triggered*,
+
        // we will be notified again if there is still data to be read on the socket.
+
        // Hence, there is no use in putting this socket read in a loop, as the second
+
        // invocation would likely block.
+
        match self.session.read(self.read_buffer.as_mut()) {
+
            Ok(0) if !self.session.is_established() => None,
+
            Ok(0) => Some(SessionEvent::Terminated(
+
                io::ErrorKind::ConnectionReset.into(),
+
            )),
+
            Ok(len) => Some(SessionEvent::Data(self.read_buffer[..len].to_vec())),
+
            Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
+
                // This should not happen, since this function is only called
+
                // when there's data on the socket. We leave it here in case external
+
                // conditions change.
+

+
                log::trace!(target: "transport",
+
                    "WOULD_BLOCK on resource which had read intent - probably normal thing to happen"
+
                );
+
                None
+
            }
+
            Err(err) => Some(self.terminate(err)),
+
        }
+
    }
+

+
    fn flush_buffer(&mut self) -> io::Result<()> {
+
        let orig_len = self.write_buffer.len();
+

+
        log::trace!(target: "transport", "Resource {} is flushing its buffer of {orig_len} bytes", self.display());
+
        let len =
+
            self.session.write(self.write_buffer.make_contiguous()).or_else(|err| {
+
                match err.kind() {
+
                    io::ErrorKind::WouldBlock
+
                    | io::ErrorKind::OutOfMemory
+
                    | io::ErrorKind::WriteZero
+
                    | io::ErrorKind::Interrupted => {
+
                        log::trace!(target: "transport", "Resource {} kernel buffer is full (system message is '{err}')", self.display());
+
                        Ok(0)
+
                    },
+
                    _ => {
+
                        log::warn!(target: "transport", "Resource {} failed write operation with message '{err}'", self.display());
+
                        Err(err)
+
                    },
+
                }
+
            })?;
+
        if orig_len > len {
+
            log::debug!(target: "transport", "Resource {} was able to consume only a part of the buffered data ({len} of {orig_len} bytes)", self.display());
+
            self.write_intent = true;
+
        } else {
+
            log::trace!(target: "transport", "Resource {} was able to consume all of the buffered data ({len} of {orig_len} bytes)", self.display());
+
            self.write_intent = false;
+
        }
+
        self.write_buffer.drain(..len);
+
        Ok(())
+
    }
+
}
+

+
impl<S: Session + Source> EventHandler for Transport<S> {
+
    type Reaction = SessionEvent<S>;
+

+
    fn interests(&self) -> Option<Interest> {
+
        use mio::Interest;
+
        use TransportState::*;
+

+
        match self.state {
+
            Init => Some(Interest::WRITABLE),
+
            Active | Handshake if self.write_intent => {
+
                Some(Interest::READABLE | Interest::WRITABLE)
+
            }
+
            Active | Handshake => Some(Interest::READABLE),
+
            Terminated => None,
+
        }
+
    }
+

+
    fn handle(&mut self, event: &Event) -> Vec<Self::Reaction> {
+
        let mut events = Vec::with_capacity(2);
+
        if event.is_writable() {
+
            if let Some(event) = self.handle_io(Interest::WRITABLE) {
+
                events.push(event);
+
            }
+
        }
+
        if event.is_readable() {
+
            if let Some(event) = self.handle_io(Interest::READABLE) {
+
                events.push(event);
+
            }
+
        }
+
        events
+
    }
+
}
+

+
impl<S: Session> Write for Transport<S> {
+
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
+
        self.write_atomic(buf).map(|_| buf.len())
+
    }
+

+
    fn flush(&mut self) -> io::Result<()> {
+
        let res = self.flush_buffer();
+
        self.session.flush().and(res)
+
    }
+
}
+

+
impl<S: Session> WriteAtomic for Transport<S> {
+
    fn is_ready_to_write(&self) -> bool {
+
        self.state == TransportState::Active
+
    }
+

+
    fn write_or_buf(&mut self, buf: &[u8]) -> io::Result<()> {
+
        if buf.is_empty() {
+
            return Ok(());
+
        }
+
        self.write_buffer.extend(buf);
+
        self.flush_buffer()
+
    }
+
}
modified crates/radicle-node/src/runtime.rs
@@ -1,24 +1,22 @@
pub mod handle;
pub mod thread;

+
use std::fmt::Debug;
use std::path::PathBuf;
use std::{fs, io, net};

#[cfg(unix)]
-
use std::os::unix::net::UnixListener as Listener;
+
use std::os::unix::net::UnixListener;
#[cfg(windows)]
-
use winpipe::WinListener as Listener;
+
use uds_windows::UnixListener;

use crossbeam_channel as chan;
use cyphernet::Ecdh;
-
use netservices::resource::NetAccept;
use radicle::cob::migrate;
use radicle::crypto;
use radicle::node::device::Device;
use radicle_fetch::FetchLimit;
use radicle_signals::Signal;
-
use reactor::poller::popol;
-
use reactor::Reactor;
use thiserror::Error;

use radicle::node;
@@ -33,8 +31,9 @@ use radicle::{cob, git, storage, Storage};

use crate::control;
use crate::node::{routing, NodeId};
+
use crate::reactor;
+
use crate::reactor::Reactor;
use crate::service::gossip;
-
use crate::wire;
use crate::wire::Wire;
use crate::worker;
use crate::{service, LocalTime};
@@ -100,12 +99,12 @@ impl From<service::Error> for Error {
    }
}

-
/// Wraps a [`Listener`] but tracks its origin.
+
/// Wraps a [`UnixListener`] but tracks its origin.
pub enum ControlSocket {
    /// The listener was created by binding to it.
-
    Bound(Listener, PathBuf),
+
    Bound(UnixListener, PathBuf),
    /// The listener was received via socket activation.
-
    Received(Listener),
+
    Received(UnixListener),
}

/// Holds join handles to the client threads, as well as a client handle.
@@ -115,7 +114,7 @@ pub struct Runtime {
    pub control: ControlSocket,
    pub handle: Handle,
    pub storage: Storage,
-
    pub reactor: Reactor<wire::Control, popol::Poller>,
+
    pub reactor: Reactor,
    pub pool: worker::Pool,
    pub local_addrs: Vec<net::SocketAddr>,
    pub signals: chan::Receiver<Signal>,
@@ -133,7 +132,11 @@ impl Runtime {
        signer: Device<G>,
    ) -> Result<Runtime, Error>
    where
-
        G: crypto::signature::Signer<crypto::Signature> + Ecdh<Pk = NodeId> + Clone + 'static,
+
        G: crypto::signature::Signer<crypto::Signature>
+
            + Ecdh<Pk = NodeId>
+
            + Clone
+
            + Debug
+
            + 'static,
    {
        let id = *signer.public_key();
        let alias = config.alias.clone();
@@ -225,13 +228,13 @@ impl Runtime {
        let mut local_addrs = Vec::new();

        for addr in listen {
-
            let listener = NetAccept::bind(&addr)?;
+
            let listener = reactor::Listener::bind(addr)?;
            let local_addr = listener.local_addr();

            local_addrs.push(local_addr);
            wire.listen(listener);
        }
-
        let reactor = Reactor::named(wire, popol::Poller::new(), thread::name(&id, "service"))?;
+
        let reactor = Reactor::new(wire, thread::name(&id, "service"))?;
        let handle = Handle::new(home.clone(), reactor.controller(), emitter);

        let nid = *signer.public_key();
@@ -284,7 +287,6 @@ impl Runtime {
            || control::listen(listener, handle)
        });

-
        #[cfg(unix)]
        let _signals = thread::spawn(&self.id, "signals", move || loop {
            use radicle::node::Handle as _;

@@ -320,7 +322,7 @@ impl Runtime {
    }

    #[cfg(all(feature = "systemd", target_os = "linux"))]
-
    fn receive_listener() -> Option<Listener> {
+
    fn receive_listener() -> Option<UnixListener> {
        let fd = match radicle_systemd::listen::fd("control") {
            Ok(Some(fd)) => fd,
            Ok(None) => return None,
@@ -345,7 +347,7 @@ impl Runtime {
            return None;
        }

-
        Some(Listener::from(socket))
+
        Some(UnixListener::from(socket))
    }

    fn bind(path: PathBuf) -> Result<ControlSocket, Error> {
@@ -358,7 +360,7 @@ impl Runtime {
        }

        log::info!(target: "node", "Binding control socket {}..", &path.display());
-
        match Listener::bind(&path) {
+
        match UnixListener::bind(&path) {
            Ok(sock) => Ok(ControlSocket::Bound(sock, path)),
            Err(err) if err.kind() == io::ErrorKind::AddrInUse => Err(Error::AlreadyRunning(path)),
            Err(err) => Err(err.into()),
modified crates/radicle-node/src/runtime/handle.rs
@@ -1,28 +1,30 @@
+
use std::collections::HashSet;
use std::net;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::{fmt, io, time};

#[cfg(unix)]
-
use std::os::unix::net::UnixStream as Stream;
+
use std::os::unix::net::UnixStream;
#[cfg(windows)]
-
use winpipe::WinStream as Stream;
+
use uds_windows::UnixStream;

use crossbeam_channel as chan;
+
use radicle::crypto::PublicKey;
use radicle::node::events::{Event, Events};
use radicle::node::policy;
use radicle::node::{Config, NodeId};
use radicle::node::{ConnectOptions, ConnectResult, Seeds};
-
use reactor::poller::popol::PopolWaker;
use serde_json::json;
use thiserror::Error;

use crate::identity::RepoId;
use crate::node::{Alias, Command, FetchResult};
use crate::profile::Home;
+
use crate::reactor;
use crate::runtime::Emitter;
use crate::service;
-
use crate::service::{CommandError, QueryState};
+
use crate::service::QueryState;
use crate::storage::refs::RefsAt;
use crate::wire;
use crate::wire::StreamId;
@@ -36,11 +38,11 @@ pub enum Error {
    ChannelDisconnected,
    /// The command returned an error.
    #[error("command failed: {0}")]
-
    Command(#[from] CommandError),
+
    Command(#[from] service::command::Error),
    /// The operation timed out.
    #[error("the operation timed out")]
    Timeout,
-
    /// An I/O error occured.
+
    /// An I/O error occurred.
    #[error(transparent)]
    Io(#[from] std::io::Error),
}
@@ -68,7 +70,7 @@ impl<T> From<chan::SendError<T>> for Error {

pub struct Handle {
    pub(crate) home: Home,
-
    pub(crate) controller: reactor::Controller<wire::Control, PopolWaker>,
+
    pub(crate) controller: reactor::Controller,

    /// Whether a shutdown was initiated or not. Prevents attempting to shutdown twice.
    shutdown: Arc<AtomicBool>,
@@ -101,11 +103,7 @@ impl Clone for Handle {
}

impl Handle {
-
    pub fn new(
-
        home: Home,
-
        controller: reactor::Controller<wire::Control, PopolWaker>,
-
        emitter: Emitter<Event>,
-
    ) -> Self {
+
    pub fn new(home: Home, controller: reactor::Controller, emitter: Emitter<Event>) -> Self {
        Self {
            home,
            controller,
@@ -200,22 +198,30 @@ impl radicle::node::Handle for Handle {
            .map_err(Error::from)
    }

-
    fn seeds(&mut self, id: RepoId) -> Result<Seeds, Self::Error> {
-
        let (sender, receiver) = chan::bounded(1);
-
        self.command(service::Command::Seeds(id, sender))?;
-
        receiver.recv().map_err(Error::from)
+
    fn seeds_for(
+
        &mut self,
+
        id: RepoId,
+
        namespaces: impl IntoIterator<Item = PublicKey>,
+
    ) -> Result<Seeds, Self::Error> {
+
        let (responder, receiver) = service::command::Responder::oneshot();
+
        self.command(service::Command::Seeds(
+
            id,
+
            HashSet::from_iter(namespaces),
+
            responder,
+
        ))?;
+
        Ok(receiver.recv()??)
    }

    fn config(&self) -> Result<Config, Self::Error> {
-
        let (sender, receiver) = chan::bounded(1);
-
        self.command(service::Command::Config(sender))?;
-
        receiver.recv().map_err(Error::from)
+
        let (responder, receiver) = service::command::Responder::oneshot();
+
        self.command(service::Command::Config(responder))?;
+
        Ok(receiver.recv()??)
    }

    fn listen_addrs(&self) -> Result<Vec<net::SocketAddr>, Self::Error> {
-
        let (sender, receiver) = chan::bounded(1);
-
        self.command(service::Command::ListenAddrs(sender))?;
-
        receiver.recv().map_err(Error::from)
+
        let (responder, receiver) = service::command::Responder::oneshot();
+
        self.command(service::Command::ListenAddrs(responder))?;
+
        Ok(receiver.recv()??)
    }

    fn fetch(
@@ -224,39 +230,53 @@ impl radicle::node::Handle for Handle {
        from: NodeId,
        timeout: time::Duration,
    ) -> Result<FetchResult, Error> {
-
        let (sender, receiver) = chan::bounded(1);
-
        self.command(service::Command::Fetch(id, from, timeout, sender))?;
-
        receiver.recv().map_err(Error::from)
+
        let (responder, receiver) = service::command::Responder::oneshot();
+
        self.command(service::Command::Fetch(id, from, timeout, responder))?;
+
        Ok(receiver.recv()??)
    }

    fn follow(&mut self, id: NodeId, alias: Option<Alias>) -> Result<bool, Error> {
-
        let (sender, receiver) = chan::bounded(1);
-
        self.command(service::Command::Follow(id, alias, sender))?;
-
        receiver.recv().map_err(Error::from)
+
        let (responder, receiver) = service::command::Responder::oneshot();
+
        self.command(service::Command::Follow(id, alias, responder))?;
+
        Ok(receiver.recv()??)
    }

    fn unfollow(&mut self, id: NodeId) -> Result<bool, Error> {
+
        let (responder, receiver) = service::command::Responder::oneshot();
+
        self.command(service::Command::Unfollow(id, responder))?;
+
        Ok(receiver.recv()??)
+
    }
+

+
    fn block(&mut self, id: NodeId) -> Result<bool, Self::Error> {
        let (sender, receiver) = chan::bounded(1);
-
        self.command(service::Command::Unfollow(id, sender))?;
+
        self.command(service::Command::Block(id, sender))?;
        receiver.recv().map_err(Error::from)
    }

    fn seed(&mut self, id: RepoId, scope: policy::Scope) -> Result<bool, Error> {
-
        let (sender, receiver) = chan::bounded(1);
-
        self.command(service::Command::Seed(id, scope, sender))?;
-
        receiver.recv().map_err(Error::from)
+
        let (responder, receiver) = service::command::Responder::oneshot();
+
        self.command(service::Command::Seed(id, scope, responder))?;
+
        Ok(receiver.recv()??)
    }

    fn unseed(&mut self, id: RepoId) -> Result<bool, Error> {
-
        let (sender, receiver) = chan::bounded(1);
-
        self.command(service::Command::Unseed(id, sender))?;
-
        receiver.recv().map_err(Error::from)
+
        let (responder, receiver) = service::command::Responder::oneshot();
+
        self.command(service::Command::Unseed(id, responder))?;
+
        Ok(receiver.recv()??)
    }

-
    fn announce_refs(&mut self, id: RepoId) -> Result<RefsAt, Error> {
-
        let (sender, receiver) = chan::bounded(1);
-
        self.command(service::Command::AnnounceRefs(id, sender))?;
-
        receiver.recv().map_err(Error::from)
+
    fn announce_refs_for(
+
        &mut self,
+
        id: RepoId,
+
        namespaces: impl IntoIterator<Item = PublicKey>,
+
    ) -> Result<RefsAt, Error> {
+
        let (responder, receiver) = service::command::Responder::oneshot();
+
        self.command(service::Command::AnnounceRefs(
+
            id,
+
            HashSet::from_iter(namespaces),
+
            responder,
+
        ))?;
+
        Ok(receiver.recv()??)
    }

    fn announce_inventory(&mut self) -> Result<(), Error> {
@@ -265,9 +285,9 @@ impl radicle::node::Handle for Handle {
    }

    fn add_inventory(&mut self, rid: RepoId) -> Result<bool, Error> {
-
        let (sender, receiver) = chan::bounded(1);
-
        self.command(service::Command::AddInventory(rid, sender))?;
-
        receiver.recv().map_err(Error::from)
+
        let (responder, receiver) = service::command::Responder::oneshot();
+
        self.command(service::Command::AddInventory(rid, responder))?;
+
        Ok(receiver.recv()??)
    }

    fn subscribe(&self, _timeout: time::Duration) -> Result<Self::Events, Self::Error> {
@@ -324,7 +344,7 @@ impl radicle::node::Handle for Handle {
        // Send a shutdown request to our own control socket. This is the only way to kill the
        // control thread gracefully. Since the control thread may have called this function,
        // the control socket may already be disconnected. Ignore errors.
-
        Stream::connect(self.home.socket())
+
        UnixStream::connect(self.home.socket())
            .and_then(|sock| Command::Shutdown.to_writer(sock))
            .ok();

@@ -336,28 +356,10 @@ impl radicle::node::Handle for Handle {
    fn debug(&self) -> Result<serde_json::Value, Self::Error> {
        let (sender, receiver) = chan::bounded(1);
        let query: Arc<QueryState> = Arc::new(move |state| {
+
            let fetching = debug::Fetching::new(state.fetching());
            let debug = serde_json::json!({
                "outboxSize": state.outbox().len(),
-
                "fetching": state.fetching().iter().map(|(rid, state)| {
-
                    json!({
-
                        "rid": rid,
-
                        "from": state.from,
-
                        "refsAt": state.refs_at,
-
                        "subscribers": state.subscribers.len(),
-
                    })
-
                }).collect::<Vec<_>>(),
-
                "queue": state.sessions().values().map(|sess| {
-
                    json!({
-
                        "nid": sess.id,
-
                        "queue": sess.queue.iter().map(|fetch| {
-
                            json!({
-
                                "rid": fetch.rid,
-
                                "from": fetch.from,
-
                                "refsAt": fetch.refs_at,
-
                            })
-
                        }).collect::<Vec<_>>()
-
                    })
-
                }).collect::<Vec<_>>(),
+
                "fetching": fetching,
                "rateLimiter": state.limiter().buckets.iter().map(|(host, bucket)| {
                    json!({
                        "host": host.to_string(),
@@ -383,3 +385,76 @@ impl radicle::node::Handle for Handle {
        Ok(debug)
    }
}
+

+
mod debug {
+
    //! Serialization formats for the output of [`Handle::debug`] output.
+

+
    use radicle_protocol::fetcher;
+
    use radicle_protocol::fetcher::FetcherState;
+
    use serde::Serialize;
+

+
    use super::{NodeId, RefsAt, RepoId};
+

+
    #[derive(Serialize)]
+
    #[serde(rename_all = "camelCase")]
+
    pub struct Fetching {
+
        active: Vec<ActiveFetch>,
+
        queued: Vec<QueuedFetch>,
+
    }
+

+
    impl Fetching {
+
        pub fn new(state: &FetcherState) -> Self {
+
            let active = state
+
                .active_fetches()
+
                .iter()
+
                .map(|(rid, fetch)| ActiveFetch::new(*rid, fetch.clone()))
+
                .collect();
+
            let queued = state
+
                .queued_fetches()
+
                .iter()
+
                .flat_map(|(node, queue)| {
+
                    queue
+
                        .iter()
+
                        .map(|fetch| QueuedFetch::new(*node, fetch.clone()))
+
                })
+
                .collect();
+
            Self { active, queued }
+
        }
+
    }
+

+
    #[derive(Serialize)]
+
    #[serde(rename_all = "camelCase")]
+
    pub struct ActiveFetch {
+
        rid: RepoId,
+
        from: NodeId,
+
        refs_at: Vec<RefsAt>,
+
    }
+

+
    impl ActiveFetch {
+
        pub fn new(rid: RepoId, fetch: fetcher::ActiveFetch) -> Self {
+
            Self {
+
                rid,
+
                from: fetch.from,
+
                refs_at: fetch.refs.into(),
+
            }
+
        }
+
    }
+

+
    #[derive(Serialize)]
+
    #[serde(rename_all = "camelCase")]
+
    pub struct QueuedFetch {
+
        nid: NodeId,
+
        rid: RepoId,
+
        refs_at: Vec<RefsAt>,
+
    }
+

+
    impl QueuedFetch {
+
        pub fn new(node: NodeId, fetch: fetcher::QueuedFetch) -> Self {
+
            Self {
+
                nid: node,
+
                rid: fetch.rid,
+
                refs_at: fetch.refs.into(),
+
            }
+
        }
+
    }
+
}
deleted crates/radicle-node/src/test/environment.rs
@@ -1,654 +0,0 @@
-
use std::io::BufRead as _;
-
use std::mem::ManuallyDrop;
-
use std::path::{Path, PathBuf};
-
use std::{
-
    collections::{BTreeMap, BTreeSet},
-
    fs, io, iter, net, process, thread, time,
-
    time::Duration,
-
};
-

-
use crossbeam_channel as chan;
-

-
use localtime::LocalTime;
-
use radicle::cob::{issue, migrate};
-
use radicle::crypto;
-
use radicle::crypto::ssh::{keystore::MemorySigner, Keystore};
-
use radicle::crypto::test::signer::MockSigner;
-
use radicle::crypto::{KeyPair, Seed};
-
use radicle::git::refname;
-
use radicle::identity::{RepoId, Visibility};
-
use radicle::node::config::ConnectAddress;
-
use radicle::node::device::Device;
-
use radicle::node::events::Event;
-
use radicle::node::policy::store as policy;
-
use radicle::node::seed::Store as _;
-
pub use radicle::node::Config;
-
use radicle::node::{Alias, Database, UserAgent, POLICIES_DB_FILE};
-
use radicle::node::{ConnectOptions, Handle as _};
-
use radicle::profile;
-
use radicle::profile::{env, Home, Profile};
-
use radicle::rad;
-
use radicle::storage::{ReadStorage as _, RemoteRepository as _, SignRepository as _};
-
use radicle::test::fixtures;
-
use radicle::Storage;
-
use radicle::{cli, node};
-
use radicle::{cob, explorer};
-
use radicle::{git, web};
-

-
use crate::node::NodeId;
-
use crate::storage::git::transport;
-
use crate::{runtime, runtime::Handle, service, Runtime};
-

-
/// Test environment.
-
pub struct Environment {
-
    tempdir: tempfile::TempDir,
-
    users: usize,
-
}
-

-
impl Default for Environment {
-
    fn default() -> Self {
-
        Self {
-
            tempdir: tempfile::tempdir().unwrap(),
-
            users: 0,
-
        }
-
    }
-
}
-

-
impl Environment {
-
    /// Create a new test environment.
-
    pub fn new() -> Self {
-
        Self::default()
-
    }
-

-
    /// Return the temp directory path.
-
    pub fn tmp(&self) -> PathBuf {
-
        self.tempdir.path().join("misc")
-
    }
-

-
    /// Get the scale or "test size". This is used to scale tests with more data. Defaults to `1`.
-
    pub fn scale(&self) -> usize {
-
        env::var("RAD_TEST_SCALE")
-
            .map(|s| {
-
                s.parse()
-
                    .expect("repository: invalid value for `RAD_TEST_SCALE`")
-
            })
-
            .unwrap_or(1)
-
    }
-

-
    /// Create a new node in this environment. This should be used when a running node
-
    /// is required. Use [`Environment::profile`] otherwise.
-
    pub fn node(&mut self, node: Config) -> Node<MemorySigner> {
-
        let alias = node.alias.clone();
-
        let profile = self.profile(profile::Config {
-
            node,
-
            ..Environment::config(alias)
-
        });
-
        Node::new(profile)
-
    }
-

-
    /// Create a new default configuration.
-
    pub fn config(alias: Alias) -> profile::Config {
-
        profile::Config {
-
            node: node::Config::test(alias),
-
            cli: cli::Config { hints: false },
-
            public_explorer: explorer::Explorer::default(),
-
            preferred_seeds: vec![],
-
            web: web::Config::default(),
-
        }
-
    }
-

-
    /// Create a new profile in this environment.
-
    /// This should be used when a running node is not required.
-
    pub fn profile(&mut self, config: profile::Config) -> Profile {
-
        let alias = config.alias().clone();
-
        let home = Home::new(
-
            self.tmp()
-
                .join("home")
-
                .join(alias.to_string())
-
                .join(".radicle"),
-
        )
-
        .unwrap();
-
        let keystore = Keystore::new(&home.keys());
-
        let keypair = KeyPair::from_seed(Seed::from([!(self.users as u8); 32]));
-
        let policies_db = home.node().join(POLICIES_DB_FILE);
-
        let now = LocalTime::now();
-

-
        config.write(&home.config()).unwrap();
-

-
        let storage = Storage::open(
-
            home.storage(),
-
            git::UserInfo {
-
                alias: alias.clone(),
-
                key: keypair.pk.into(),
-
            },
-
        )
-
        .unwrap();
-
        let public_key = keypair.pk.into();
-

-
        let mut db = home.cobs_db_mut().unwrap();
-
        db.migrate(migrate::ignore).unwrap();
-

-
        policy::Store::open(policies_db).unwrap();
-
        home.database_mut()
-
            .unwrap()
-
            .init(
-
                &public_key,
-
                config.node.features(),
-
                &Alias::new(alias),
-
                &UserAgent::default(),
-
                now.into(),
-
                config.node.external_addresses.iter(),
-
            )
-
            .unwrap();
-

-
        transport::local::register(storage.clone());
-
        keystore.store(keypair.clone(), "radicle", None).unwrap();
-

-
        // Ensures that each user has a unique but deterministic public key.
-
        self.users += 1;
-

-
        Profile {
-
            home,
-
            storage,
-
            keystore,
-
            public_key,
-
            config,
-
        }
-
    }
-
}
-

-
/// A node that can be run.
-
pub struct Node<G> {
-
    pub id: NodeId,
-
    pub home: Home,
-
    pub signer: Device<G>,
-
    pub storage: Storage,
-
    pub config: Config,
-
    pub db: service::Stores<Database>,
-
    pub policies: policy::Store<policy::Write>,
-
}
-

-
impl Node<MemorySigner> {
-
    pub fn new(profile: Profile) -> Self {
-
        let signer = Device::from(MemorySigner::load(&profile.keystore, None).unwrap());
-
        let id = *profile.id();
-
        let policies_db = profile.home.node().join(POLICIES_DB_FILE);
-
        let policies = policy::Store::open(policies_db).unwrap();
-
        let db = profile.database_mut().unwrap();
-
        let db = service::Stores::from(db);
-

-
        Node {
-
            id,
-
            home: profile.home,
-
            config: profile.config.node,
-
            signer,
-
            db,
-
            policies,
-
            storage: profile.storage,
-
        }
-
    }
-
}
-

-
/// Handle to a running node.
-
pub struct NodeHandle<G: crypto::signature::Signer<crypto::Signature> + cyphernet::Ecdh + 'static> {
-
    pub id: NodeId,
-
    pub storage: Storage,
-
    pub signer: Device<G>,
-
    pub home: Home,
-
    pub addr: net::SocketAddr,
-
    pub thread: ManuallyDrop<thread::JoinHandle<Result<(), runtime::Error>>>,
-
    pub handle: ManuallyDrop<Handle>,
-
}
-

-
impl<G: crypto::signature::Signer<crypto::Signature> + cyphernet::Ecdh + 'static> Drop
-
    for NodeHandle<G>
-
{
-
    fn drop(&mut self) {
-
        log::debug!(target: "test", "Node {} shutting down..", self.id);
-

-
        unsafe { ManuallyDrop::take(&mut self.handle) }
-
            .shutdown()
-
            .unwrap();
-
        unsafe { ManuallyDrop::take(&mut self.thread) }
-
            .join()
-
            .unwrap()
-
            .unwrap();
-
    }
-
}
-

-
impl<G: crypto::signature::Signer<crypto::Signature> + cyphernet::Ecdh> NodeHandle<G> {
-
    /// Connect this node to another node, and wait for the connection to be established both ways.
-
    pub fn connect(&mut self, remote: &NodeHandle<G>) -> &mut Self {
-
        let local_events = self.handle.events();
-
        let remote_events = remote.handle.events();
-

-
        self.handle
-
            .connect(remote.id, remote.addr.into(), ConnectOptions::default())
-
            .ok();
-

-
        local_events
-
            .iter()
-
            .find(|e| {
-
                matches!(
-
                    e, Event::PeerConnected { nid } if nid == &remote.id
-
                )
-
            })
-
            .unwrap();
-
        remote_events
-
            .iter()
-
            .find(|e| {
-
                matches!(
-
                    e, Event::PeerConnected { nid } if nid == &self.id
-
                )
-
            })
-
            .unwrap();
-

-
        self
-
    }
-

-
    pub fn disconnect(&mut self, remote: &NodeHandle<G>) {
-
        self.handle.disconnect(remote.id).unwrap();
-
    }
-

-
    /// Shutdown node.
-
    pub fn shutdown(self) {
-
        drop(self)
-
    }
-

-
    /// Get the full address of this node.
-
    pub fn address(&self) -> ConnectAddress {
-
        (self.id, node::Address::from(self.addr)).into()
-
    }
-

-
    /// Get routing table entries.
-
    pub fn routing(&self) -> impl Iterator<Item = (RepoId, NodeId)> {
-
        use node::routing::Store as _;
-

-
        self.home.routing_mut().unwrap().entries().unwrap()
-
    }
-

-
    pub fn inventory(&self) -> impl Iterator<Item = RepoId> + '_ {
-
        self.routing()
-
            .filter(|(_, n)| *n == self.id)
-
            .map(|(r, _)| r)
-
    }
-

-
    /// Get sync status of a repo.
-
    pub fn synced_seeds(&self, rid: &RepoId) -> Vec<node::seed::SyncedSeed> {
-
        let db = Database::reader(self.home.node().join(node::NODE_DB_FILE)).unwrap();
-
        let seeds = db.seeds_for(rid).unwrap();
-

-
        seeds.into_iter().collect::<Result<Vec<_>, _>>().unwrap()
-
    }
-

-
    /// Wait until this node's routing table matches the remotes.
-
    pub fn converge<'a>(
-
        &'a self,
-
        remotes: impl IntoIterator<Item = &'a NodeHandle<G>>,
-
    ) -> BTreeSet<(RepoId, NodeId)> {
-
        converge(iter::once(self).chain(remotes))
-
    }
-

-
    /// Wait until this node's routing table contains the given routes.
-
    #[track_caller]
-
    pub fn routes_to(&self, routes: &[(RepoId, NodeId)]) {
-
        log::debug!(target: "test", "Waiting for {} to route to {:?}", self.id, routes);
-
        let events = self.handle.events();
-

-
        loop {
-
            let mut remaining: BTreeSet<_> = routes.iter().collect();
-

-
            for (rid, nid) in self.routing() {
-
                if !remaining.remove(&(rid, nid)) {
-
                    log::debug!(target: "test", "Found unexpected route for {}: ({rid}, {nid})", self.id);
-
                }
-
            }
-
            if remaining.is_empty() {
-
                break;
-
            }
-
            events
-
                .wait(
-
                    |e| matches!(e, Event::SeedDiscovered { .. }).then_some(()),
-
                    time::Duration::from_secs(6),
-
                )
-
                .unwrap();
-
        }
-
    }
-

-
    /// Wait until this node is synced with another node, for the given repository.
-
    #[track_caller]
-
    pub fn is_synced_with(&mut self, rid: &RepoId, nid: &NodeId) {
-
        log::debug!(target: "test", "Waiting for {} to be in sync with {nid} for {rid}", self.id);
-

-
        loop {
-
            let seeds = self.handle.seeds(*rid).unwrap();
-
            if seeds.iter().any(|s| s.nid == *nid && s.is_synced()) {
-
                break;
-
            }
-
            thread::sleep(Duration::from_millis(100));
-
        }
-
    }
-

-
    /// Wait until this node has a repository.
-
    #[track_caller]
-
    pub fn has_repository(&self, rid: &RepoId) {
-
        log::debug!(target: "test", "Waiting for {} to have {rid}", self.id);
-
        let events = self.handle.events();
-

-
        loop {
-
            if self.storage.repository(*rid).is_ok() {
-
                log::debug!(target: "test", "Node {} has {rid}", self.id);
-
                break;
-
            }
-
            events
-
                .wait(
-
                    |e| matches!(e, Event::RefsFetched { .. }).then_some(()),
-
                    time::Duration::from_secs(6),
-
                )
-
                .unwrap();
-
        }
-
    }
-

-
    /// Wait until this node has the inventory of another node.
-
    #[track_caller]
-
    pub fn has_remote_of(&self, rid: &RepoId, nid: &NodeId) {
-
        log::debug!(target: "test", "Waiting for {} to have {rid}/{nid}", self.id);
-
        let events = self.handle.events();
-

-
        loop {
-
            if let Ok(repo) = self.storage.repository(*rid) {
-
                if repo.remote(nid).is_ok() {
-
                    log::debug!(target: "test", "Node {} has {rid}/{nid}", self.id);
-
                    break;
-
                }
-
            }
-
            events
-
                .wait(
-
                    |e| matches!(e, Event::RefsFetched { .. }).then_some(()),
-
                    time::Duration::from_secs(6),
-
                )
-
                .unwrap();
-
        }
-
    }
-

-
    /// Clone a repo into a directory.
-
    pub fn clone<P: AsRef<Path>>(&self, rid: RepoId, cwd: P) -> io::Result<()> {
-
        self.rad("clone", &[rid.to_string().as_str()], cwd)
-
    }
-

-
    /// Fork a repo.
-
    pub fn fork<P: AsRef<Path>>(&self, rid: RepoId, cwd: P) -> io::Result<()> {
-
        self.clone(rid, &cwd)?;
-
        self.rad("fork", &[rid.to_string().as_str()], &cwd)?;
-
        self.announce(rid, 1, &cwd)?;
-

-
        Ok(())
-
    }
-

-
    /// Announce a repo.
-
    pub fn announce<P: AsRef<Path>>(&self, rid: RepoId, replicas: usize, cwd: P) -> io::Result<()> {
-
        self.rad(
-
            "sync",
-
            &[
-
                rid.to_string().as_str(),
-
                "--announce",
-
                "--replicas",
-
                replicas.to_string().as_str(),
-
            ],
-
            cwd,
-
        )
-
    }
-

-
    /// Init a repo.
-
    pub fn init<P: AsRef<Path>>(&self, name: &str, desc: &str, cwd: P) -> io::Result<()> {
-
        self.rad(
-
            "init",
-
            &[
-
                "--name",
-
                name,
-
                "--description",
-
                desc,
-
                "--default-branch",
-
                "master",
-
                "--public",
-
            ],
-
            cwd,
-
        )
-
    }
-

-
    /// Run a `rad` CLI command.
-
    pub fn rad<P: AsRef<Path>>(&self, cmd: &str, args: &[&str], cwd: P) -> io::Result<()> {
-
        let cwd = cwd.as_ref();
-
        log::debug!(target: "test", "Running `rad {cmd} {args:?}` in {}..", cwd.display());
-

-
        fs::create_dir_all(cwd)?;
-

-
        let result = process::Command::new(snapbox::cmd::cargo_bin("rad"))
-
            .env_clear()
-
            .envs(env::vars().filter(|(k, _)| k == "PATH"))
-
            .env("GIT_AUTHOR_DATE", "1671125284")
-
            .env("GIT_AUTHOR_EMAIL", "radicle@localhost")
-
            .env("GIT_AUTHOR_NAME", "radicle")
-
            .env("GIT_COMMITTER_DATE", "1671125284")
-
            .env("GIT_COMMITTER_EMAIL", "radicle@localhost")
-
            .env("GIT_COMMITTER_NAME", "radicle")
-
            .env(
-
                env::RAD_HOME,
-
                self.home.path().to_string_lossy().to_string(),
-
            )
-
            .env(env::RAD_PASSPHRASE, "radicle")
-
            .env(env::RAD_LOCAL_TIME, "1671125284")
-
            .env("TZ", "UTC")
-
            .env("LANG", "C")
-
            .envs(git::env::GIT_DEFAULT_CONFIG)
-
            .current_dir(cwd)
-
            .arg(cmd)
-
            .args(args)
-
            .output()?;
-

-
        for line in io::BufReader::new(io::Cursor::new(&result.stdout))
-
            .lines()
-
            .map_while(Result::ok)
-
        {
-
            log::debug!(target: "test", "rad {cmd}: {line}");
-
        }
-

-
        log::debug!(
-
            target: "test",
-
            "Ran command `rad {cmd}` (status={})", result.status.code().unwrap()
-
        );
-

-
        if !result.status.success() {
-
            return Err(io::ErrorKind::Other.into());
-
        }
-
        Ok(())
-
    }
-

-
    /// Create an [`issue::Issue`] in the `NodeHandle`'s storage.
-
    pub fn issue(&self, rid: RepoId, title: &str, desc: &str) -> cob::ObjectId {
-
        let repo = self.storage.repository(rid).unwrap();
-
        let mut issues = issue::Cache::no_cache(&repo).unwrap();
-
        *issues
-
            .create(title, desc, &[], &[], [], &self.signer)
-
            .unwrap()
-
            .id()
-
    }
-
}
-

-
impl Node<MockSigner> {
-
    /// Create a new node.
-
    pub fn init(base: &Path, config: Config) -> Self {
-
        let home = base.join(
-
            iter::repeat_with(fastrand::alphanumeric)
-
                .take(8)
-
                .collect::<String>(),
-
        );
-
        let home = Home::new(home).unwrap();
-
        let signer = Device::mock();
-
        let storage = Storage::open(
-
            home.storage(),
-
            git::UserInfo {
-
                alias: config.alias.clone(),
-
                key: *signer.public_key(),
-
            },
-
        )
-
        .unwrap();
-
        let policies = home.policies_mut().unwrap();
-
        let db = home.database_mut().unwrap();
-
        let db = service::Stores::from(db);
-

-
        log::debug!(target: "test", "Node::init {}: {}", config.alias, signer.public_key());
-
        Self {
-
            id: *signer.public_key(),
-
            home,
-
            signer,
-
            storage,
-
            config,
-
            db,
-
            policies,
-
        }
-
    }
-
}
-

-
impl<G> Node<G>
-
where
-
    G: cyphernet::Ecdh<Pk = NodeId> + crypto::signature::Signer<crypto::Signature> + Clone,
-
{
-
    /// Spawn a node in its own thread.
-
    pub fn spawn(self) -> NodeHandle<G> {
-
        let listen = vec![([0, 0, 0, 0], 0).into()];
-
        let (_, signals) = chan::bounded(1);
-
        let rt = Runtime::init(
-
            self.home.clone(),
-
            self.config,
-
            listen,
-
            signals,
-
            self.signer.clone(),
-
        )
-
        .unwrap();
-
        let addr = *rt.local_addrs.first().unwrap();
-
        let id = *self.signer.public_key();
-
        let handle = ManuallyDrop::new(rt.handle.clone());
-
        let thread = ManuallyDrop::new(runtime::thread::spawn(&id, "runtime", move || rt.run()));
-

-
        NodeHandle {
-
            id,
-
            storage: self.storage,
-
            signer: self.signer,
-
            home: self.home,
-
            addr,
-
            handle,
-
            thread,
-
        }
-
    }
-

-
    /// Populate a storage instance with a project from the given repository.
-
    pub fn project_from(
-
        &mut self,
-
        name: &str,
-
        description: &str,
-
        repo: &git::raw::Repository,
-
    ) -> RepoId {
-
        transport::local::register(self.storage.clone());
-

-
        let branch = refname!("master");
-
        let id = rad::init(
-
            repo,
-
            name.try_into().unwrap(),
-
            description,
-
            branch.clone(),
-
            Visibility::default(),
-
            &self.signer,
-
            &self.storage,
-
        )
-
        .map(|(id, _, _)| id)
-
        .unwrap();
-

-
        assert!(self.policies.seed(&id, node::policy::Scope::All).unwrap());
-

-
        log::debug!(
-
            target: "test",
-
            "Initialized project {id} for node {}", self.signer.public_key()
-
        );
-

-
        // Push local branches to storage.
-
        let mut refs = Vec::<(git::Qualified, git::Qualified)>::new();
-
        for branch in repo.branches(Some(git::raw::BranchType::Local)).unwrap() {
-
            let (branch, _) = branch.unwrap();
-
            let name = git::RefString::try_from(branch.name().unwrap().unwrap()).unwrap();
-

-
            refs.push((
-
                git::lit::refs_heads(&name).into(),
-
                git::lit::refs_heads(&name).into(),
-
            ));
-
        }
-
        git::push(repo, "rad", refs.iter().map(|(a, b)| (a, b))).unwrap();
-

-
        radicle::git::set_upstream(
-
            repo,
-
            &*radicle::rad::REMOTE_NAME,
-
            branch.clone(),
-
            radicle::git::refs::workdir::branch(&branch),
-
        )
-
        .unwrap();
-

-
        self.storage
-
            .repository(id)
-
            .unwrap()
-
            .sign_refs(&self.signer)
-
            .unwrap();
-

-
        id
-
    }
-

-
    /// Populate a storage instance with a project.
-
    pub fn project(&mut self, name: &str, description: &str) -> RepoId {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let (repo, _) = fixtures::repository(tmp.path());
-

-
        self.project_from(name, description, &repo)
-
    }
-
}
-

-
/// Checks whether the nodes have converged in their routing tables.
-
#[track_caller]
-
pub fn converge<'a, G: crypto::signature::Signer<crypto::Signature> + cyphernet::Ecdh + 'static>(
-
    nodes: impl IntoIterator<Item = &'a NodeHandle<G>>,
-
) -> BTreeSet<(RepoId, NodeId)> {
-
    let nodes = nodes.into_iter().collect::<Vec<_>>();
-

-
    let mut all_routes = BTreeSet::<(RepoId, NodeId)>::new();
-
    let mut remaining = BTreeMap::from_iter(nodes.iter().map(|node| (node.id, node)));
-

-
    // First build the set of all routes.
-
    for node in &nodes {
-
        // Routes from the routing table.
-
        for (rid, seed_id) in node.routing() {
-
            all_routes.insert((rid, seed_id));
-
        }
-
        // Routes from the local inventory.
-
        for rid in node.inventory() {
-
            all_routes.insert((rid, node.id));
-
        }
-
    }
-

-
    // Then, while there are nodes remaining to converge, check each node to see if
-
    // its routing table has all routes. If so, remove it from the remaining nodes.
-
    while !remaining.is_empty() {
-
        remaining.retain(|_, node| {
-
            let routing = node.routing();
-
            let routes = BTreeSet::from_iter(routing);
-

-
            if routes.is_superset(&all_routes) {
-
                log::debug!(target: "test", "Node {} has converged", node.id);
-
                return false;
-
            } else {
-
                let diff = all_routes.symmetric_difference(&routes).collect::<Vec<_>>();
-
                log::debug!(target: "test", "Node has missing routes: {diff:?}");
-
            }
-
            true
-
        });
-
        thread::sleep(Duration::from_millis(100));
-
    }
-
    all_routes
-
}
modified crates/radicle-node/src/test/handle.rs
@@ -3,7 +3,8 @@ use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time;

-
use radicle::git;
+
use radicle::crypto::PublicKey;
+
use radicle::git::Oid;
use radicle::storage::refs::RefsAt;

use crate::identity::RepoId;
@@ -14,9 +15,10 @@ use radicle::node::NodeId;

#[derive(Default, Clone)]
pub struct Handle {
-
    pub updates: Arc<Mutex<Vec<RepoId>>>,
+
    pub updates: Arc<Mutex<Vec<(RepoId, PublicKey)>>>,
    pub seeding: Arc<Mutex<HashSet<RepoId>>>,
    pub following: Arc<Mutex<HashSet<NodeId>>>,
+
    pub blocked: Arc<Mutex<HashSet<NodeId>>>,
}

impl radicle::node::Handle for Handle {
@@ -54,7 +56,11 @@ impl radicle::node::Handle for Handle {
        unimplemented!();
    }

-
    fn seeds(&mut self, _id: RepoId) -> Result<Seeds, Self::Error> {
+
    fn seeds_for(
+
        &mut self,
+
        _id: RepoId,
+
        _namespaces: impl IntoIterator<Item = PublicKey>,
+
    ) -> Result<Seeds, Self::Error> {
        unimplemented!();
    }

@@ -80,6 +86,7 @@ impl radicle::node::Handle for Handle {
    }

    fn follow(&mut self, id: NodeId, _alias: Option<Alias>) -> Result<bool, Self::Error> {
+
        self.blocked.lock().unwrap().remove(&id);
        Ok(self.following.lock().unwrap().insert(id))
    }

@@ -88,15 +95,29 @@ impl radicle::node::Handle for Handle {
    }

    fn unfollow(&mut self, id: NodeId) -> Result<bool, Self::Error> {
-
        Ok(self.following.lock().unwrap().remove(&id))
+
        let f = self.following.lock().unwrap().remove(&id);
+
        let b = self.blocked.lock().unwrap().remove(&id);
+
        Ok(f || b)
    }

-
    fn announce_refs(&mut self, id: RepoId) -> Result<RefsAt, Self::Error> {
-
        self.updates.lock().unwrap().push(id);
+
    fn block(&mut self, id: NodeId) -> Result<bool, Self::Error> {
+
        self.following.lock().unwrap().remove(&id);
+
        Ok(self.blocked.lock().unwrap().insert(id))
+
    }
+

+
    fn announce_refs_for(
+
        &mut self,
+
        id: RepoId,
+
        namespaces: impl IntoIterator<Item = PublicKey>,
+
    ) -> Result<RefsAt, Self::Error> {
+
        self.updates
+
            .lock()
+
            .unwrap()
+
            .extend(namespaces.into_iter().map(|ns| (id, ns)));

        Ok(RefsAt {
            remote: self.nid()?,
-
            at: git::raw::Oid::zero().into(),
+
            at: Oid::sha1_zero(),
        })
    }

modified crates/radicle-node/src/test/node.rs
@@ -1,5 +1,7 @@
+
use std::fmt::Debug;
use std::io::BufRead as _;
use std::mem::ManuallyDrop;
+
use std::net::Ipv4Addr;
use std::path::Path;
use std::{
    collections::{BTreeMap, BTreeSet},
@@ -16,7 +18,7 @@ use radicle::crypto::ssh::keystore::MemorySigner;
use radicle::crypto::test::signer::MockSigner;
use radicle::crypto::Signature;
use radicle::git;
-
use radicle::git::refname;
+
use radicle::git::fmt::refname;
use radicle::identity::{RepoId, Visibility};
use radicle::node::config::ConnectAddress;
use radicle::node::policy::store as policy;
@@ -97,6 +99,9 @@ impl<G: 'static> Drop for NodeHandle<G> {

impl<G: Signer<Signature> + cyphernet::Ecdh> NodeHandle<G> {
    /// Connect this node to another node, and wait for the connection to be established both ways.
+
    ///
+
    /// If the remote has blocked this node, then the remote event will be
+
    /// [`Event::PeerDisconnected`].
    pub fn connect(&mut self, remote: &NodeHandle<G>) -> &mut Self {
        let local_events = self.handle.events();
        let remote_events = remote.handle.events();
@@ -117,7 +122,9 @@ impl<G: Signer<Signature> + cyphernet::Ecdh> NodeHandle<G> {
            .iter()
            .find(|e| {
                matches!(
-
                    e, Event::PeerConnected { nid } if nid == &self.id
+
                    e,
+
                    Event::PeerConnected { nid } | Event::PeerDisconnected { nid, .. }
+
                    if nid == &self.id
                )
            })
            .unwrap();
@@ -200,7 +207,7 @@ impl<G: Signer<Signature> + cyphernet::Ecdh> NodeHandle<G> {
        log::debug!(target: "test", "Waiting for {} to be in sync with {nid} for {rid}", self.id);

        loop {
-
            let seeds = self.handle.seeds(*rid).unwrap();
+
            let seeds = self.handle.seeds_for(*rid, [self.id]).unwrap();
            if seeds.iter().any(|s| s.nid == *nid && s.is_synced()) {
                break;
            }
@@ -363,7 +370,7 @@ impl<G: Signer<Signature> + cyphernet::Ecdh> NodeHandle<G> {
    /// of the new commit, and the reference will be updated.
    ///
    /// The `rad/sigrefs` are then updated to reflect the new change.
-
    pub fn commit_to(&self, rid: RepoId, refname: impl AsRef<git::RefStr>) {
+
    pub fn commit_to(&self, rid: RepoId, refname: impl AsRef<git::fmt::RefStr>) {
        use radicle::test::arbitrary;

        let refname = refname.as_ref();
@@ -455,11 +462,11 @@ impl Node<MockSigner> {
    }
}

-
impl<G: cyphernet::Ecdh<Pk = NodeId> + Signer<Signature> + Clone> Node<G> {
+
impl<G: cyphernet::Ecdh<Pk = NodeId> + Signer<Signature> + Clone + Debug> Node<G> {
    /// Spawn a node in its own thread.
    pub fn spawn(self) -> NodeHandle<G> {
        let alias = self.config.alias.clone();
-
        let listen = vec![([0, 0, 0, 0], 0).into()];
+
        let listen = vec![(Ipv4Addr::LOCALHOST, 0).into()];
        let (_, signals) = chan::bounded(1);
        let rt = Runtime::init(
            self.home.clone(),
@@ -516,14 +523,14 @@ impl<G: cyphernet::Ecdh<Pk = NodeId> + Signer<Signature> + Clone> Node<G> {
        );

        // Push local branches to storage.
-
        let mut refs = Vec::<(git::Qualified, git::Qualified)>::new();
+
        let mut refs = Vec::<(git::fmt::Qualified, git::fmt::Qualified)>::new();
        for branch in repo.branches(Some(git::raw::BranchType::Local)).unwrap() {
            let (branch, _) = branch.unwrap();
-
            let name = git::RefString::try_from(branch.name().unwrap().unwrap()).unwrap();
+
            let name = git::fmt::RefString::try_from(branch.name().unwrap().unwrap()).unwrap();

            refs.push((
-
                git::lit::refs_heads(&name).into(),
-
                git::lit::refs_heads(&name).into(),
+
                git::fmt::lit::refs_heads(&name).into(),
+
                git::fmt::lit::refs_heads(&name).into(),
            ));
        }
        git::push(repo, "rad", refs.iter().map(|(a, b)| (a, b))).unwrap();
modified crates/radicle-node/src/test/peer.rs
@@ -138,7 +138,7 @@ impl<G: crypto::signature::Signer<crypto::Signature>> Peer<Storage, G> {
            &repo,
            name.try_into().unwrap(),
            description,
-
            radicle::git::refname!("master"),
+
            radicle::git::fmt::refname!("master"),
            Visibility::default(),
            self.signer(),
            self.storage(),
@@ -167,7 +167,7 @@ where
        let local_addr = net::SocketAddr::new(ip, config.rng.u16(..));
        let inventory = storage.repositories().unwrap();

-
        // Make sure the peer address is advertized.
+
        // Make sure the peer address is advertised.
        config.config.external_addresses.push(local_addr.into());
        for repo in &inventory {
            policies.seed(&repo.rid, Scope::Followed).unwrap();
modified crates/radicle-node/src/tests.rs
@@ -8,7 +8,8 @@ use std::sync::Arc;
use std::sync::LazyLock;
use std::time;

-
use crossbeam_channel as chan;
+
use test_log::test;
+

use radicle::cob;
use radicle::identity::Visibility;
use radicle::node::address::Store as _;
@@ -414,15 +415,23 @@ fn test_seeding() {
    let mut alice = Peer::new("alice", [7, 7, 7, 7]);
    let proj_id: identity::RepoId = test::arbitrary::gen(1);

-
    let (sender, receiver) = chan::bounded(1);
-
    alice.command(Command::Seed(proj_id, policy::Scope::default(), sender));
-
    let policy_change = receiver.recv().map_err(runtime::HandleError::from).unwrap();
+
    let (cmd, receiver) = Command::seed(proj_id, policy::Scope::default());
+
    alice.command(cmd);
+
    let policy_change = receiver
+
        .recv()
+
        .map_err(runtime::HandleError::from)
+
        .unwrap()
+
        .unwrap();
    assert!(policy_change);
    assert!(alice.policies().is_seeding(&proj_id).unwrap());

-
    let (sender, receiver) = chan::bounded(1);
-
    alice.command(Command::Unseed(proj_id, sender));
-
    let policy_change = receiver.recv().map_err(runtime::HandleError::from).unwrap();
+
    let (cmd, receiver) = Command::unseed(proj_id);
+
    alice.command(cmd);
+
    let policy_change = receiver
+
        .recv()
+
        .map_err(runtime::HandleError::from)
+
        .unwrap()
+
        .unwrap();
    assert!(policy_change);
    assert!(!alice.policies().is_seeding(&proj_id).unwrap());
}
@@ -734,7 +743,7 @@ fn test_refs_announcement_relay_public() {
        .elapse(service::GOSSIP_INTERVAL);
    assert!(
        alice.messages(eve.id()).next().is_none(),
-
        "The same ref announement is not relayed"
+
        "The same ref announcement is not relayed"
    );

    alice
@@ -929,13 +938,13 @@ fn test_refs_announcement_followed() {
    );

    // Alice starts to track Bob.
-
    let (sender, receiver) = chan::bounded(1);
-
    alice.command(Command::Follow(
-
        bob.id,
-
        Some(node::Alias::new("bob")),
-
        sender,
-
    ));
-
    let policy_change = receiver.recv().map_err(runtime::HandleError::from).unwrap();
+
    let (cmd, receiver) = Command::follow(bob.id, Some(node::Alias::new("bob")));
+
    alice.command(cmd);
+
    let policy_change = receiver
+
        .recv()
+
        .map_err(runtime::HandleError::from)
+
        .unwrap()
+
        .unwrap();
    assert!(policy_change);

    // Bob announces refs again.
@@ -1402,11 +1411,11 @@ fn test_seed_repo_subscribe() {
    let mut alice = Peer::new("alice", [7, 7, 7, 7]);
    let bob = Peer::new("bob", [8, 8, 8, 8]);
    let rid = arbitrary::gen::<RepoId>(1);
-
    let (send, recv) = chan::bounded(1);

    alice.connect_to(&bob);
-
    alice.command(Command::Seed(rid, policy::Scope::default(), send));
-
    assert!(recv.recv().unwrap());
+
    let (cmd, recv) = Command::seed(rid, policy::Scope::default());
+
    alice.command(cmd);
+
    assert!(recv.recv().unwrap().unwrap());

    assert_matches!(
        alice.messages(bob.id).next(),
@@ -1491,16 +1500,16 @@ fn test_queued_fetch_max_capacity() {
    alice.connect_to(&bob);

    // Send the first fetch.
-
    let (send, _recv1) = chan::bounded::<node::FetchResult>(1);
-
    alice.command(Command::Fetch(rid1, bob.id, DEFAULT_TIMEOUT, send));
+
    let (cmd, _recv1) = Command::fetch(rid1, bob.id, DEFAULT_TIMEOUT);
+
    alice.command(cmd);

    // Send the 2nd fetch that will be queued.
-
    let (send2, _recv2) = chan::bounded::<node::FetchResult>(1);
-
    alice.command(Command::Fetch(rid2, bob.id, DEFAULT_TIMEOUT, send2));
+
    let (cmd, _recv2) = Command::fetch(rid2, bob.id, DEFAULT_TIMEOUT);
+
    alice.command(cmd);

    // Send the 3rd fetch that will be queued.
-
    let (send3, _recv3) = chan::bounded::<node::FetchResult>(1);
-
    alice.command(Command::Fetch(rid3, bob.id, DEFAULT_TIMEOUT, send3));
+
    let (cmd, _recv3) = Command::fetch(rid3, bob.id, DEFAULT_TIMEOUT);
+
    alice.command(cmd);

    // The first fetch is initiated.
    assert_matches!(alice.fetches().next(), Some((rid, _)) if rid == rid1);
@@ -1512,15 +1521,16 @@ fn test_queued_fetch_max_capacity() {

    // Finish the 1st fetch.
    alice.fetched(rid1, bob.id, Ok(fetch::FetchResult::new(doc.clone())));
+

    // Now the 1st fetch is done, the 2nd fetch is dequeued.
-
    assert_matches!(alice.fetches().next(), Some((rid, _)) if rid == rid2);
+
    assert_eq!(alice.fetches().next(), Some((rid2, bob.id)));
    // ... but not the third.
    assert_matches!(alice.fetches().next(), None);

    // Finish the 2nd fetch.
    alice.fetched(rid2, bob.id, Ok(fetch::FetchResult::new(doc)));
    // Now the 2nd fetch is done, the 3rd fetch is dequeued.
-
    assert_matches!(alice.fetches().next(), Some((rid, _)) if rid == rid3);
+
    assert_eq!(alice.fetches().next(), Some((rid3, bob.id)));
}

#[test]
@@ -1567,7 +1577,7 @@ fn test_queued_fetch_from_ann_same_rid() {
    let refname = carol
        .id()
        .to_namespace()
-
        .join(git::refname!("refs/sigrefs"));
+
        .join(git::fmt::refname!("refs/sigrefs"));

    // Finish the 1st fetch.
    // Ensure the ref is in the storage and cache.
@@ -1614,16 +1624,16 @@ fn test_queued_fetch_from_command_same_rid() {
    alice.connect_to(&carol);

    // Send the first fetch.
-
    let (send, _recv1) = chan::bounded::<node::FetchResult>(1);
-
    alice.command(Command::Fetch(rid1, bob.id, DEFAULT_TIMEOUT, send));
+
    let (cmd, _recv1) = Command::fetch(rid1, bob.id, DEFAULT_TIMEOUT);
+
    alice.command(cmd);

    // Send the 2nd fetch that will be queued.
-
    let (send2, _recv2) = chan::bounded::<node::FetchResult>(1);
-
    alice.command(Command::Fetch(rid1, eve.id, DEFAULT_TIMEOUT, send2));
+
    let (cmd, _recv2) = Command::fetch(rid1, eve.id, DEFAULT_TIMEOUT);
+
    alice.command(cmd);

    // Send the 3rd fetch that will be queued.
-
    let (send3, _recv3) = chan::bounded::<node::FetchResult>(1);
-
    alice.command(Command::Fetch(rid1, carol.id, DEFAULT_TIMEOUT, send3));
+
    let (cmd, _recv3) = Command::fetch(rid1, carol.id, DEFAULT_TIMEOUT);
+
    alice.command(cmd);

    // Peers Alice will fetch from.
    let mut peers = [bob.id, eve.id, carol.id]
@@ -1749,7 +1759,7 @@ fn test_init_and_seed() {
        &repo,
        "alice".try_into().unwrap(),
        "alice's repo",
-
        git::refname!("master"),
+
        git::fmt::refname!("master"),
        Visibility::default(),
        alice.signer(),
        alice.storage(),
@@ -1770,29 +1780,20 @@ fn test_init_and_seed() {
    assert!(bob.get(proj_id).unwrap().is_none());

    // Bob seeds Alice's project.
-
    let (sender, receiver) = chan::bounded(1);
-
    bob.command(service::Command::Seed(
-
        proj_id,
-
        policy::Scope::default(),
-
        sender,
-
    ));
-
    assert!(receiver.recv().unwrap());
+
    let (cmd, receiver) = service::Command::seed(proj_id, policy::Scope::default());
+
    bob.command(cmd);
+
    assert!(receiver.recv().unwrap().unwrap());

    // Eve seeds Alice's project.
-
    let (sender, receiver) = chan::bounded(1);
-
    eve.command(service::Command::Seed(
-
        proj_id,
-
        policy::Scope::default(),
-
        sender,
-
    ));
-
    assert!(receiver.recv().unwrap());
+
    let (cmd, receiver) = service::Command::seed(proj_id, policy::Scope::default());
+
    eve.command(cmd);
+
    assert!(receiver.recv().unwrap().unwrap());

-
    let (send, _) = chan::bounded(1);
-
    // Alice announces her inventory.
    // We now expect Eve to fetch Alice's project from Alice.
    // Then we expect Bob to fetch Alice's project from Eve.
    alice.elapse(LocalDuration::from_secs(1)); // Make sure our announcement is fresh.
-
    alice.command(service::Command::AddInventory(proj_id, send));
+
    let (cmd, _) = service::Command::add_inventory(proj_id);
+
    alice.command(cmd);

    sim.run_while([&mut alice, &mut bob, &mut eve], |s| !s.is_settled());

@@ -2031,13 +2032,13 @@ fn test_announcement_message_amplification() {
            continue;
        }

-
        let (tx, _) = chan::bounded(1);
        let timestamp = (*alice.clock()).into();
        alice
            .storage_mut()
            .repos
            .insert(rid, gen::<MockRepository>(1));
-
        alice.command(Command::AddInventory(rid, tx));
+
        let (cmd, _) = Command::add_inventory(rid);
+
        alice.command(cmd);

        sim.run_while([&mut alice, &mut bob, &mut eve, &mut zod, &mut tom], |s| {
            s.elapsed() < LocalDuration::from_mins(3)
modified crates/radicle-node/src/tests/e2e.rs
@@ -1,8 +1,11 @@
use std::{collections::HashSet, thread, time};

+
use radicle::cob;
use radicle::cob::Title;
+
use radicle_crypto::test::signer::MockSigner;
use test_log::test;

+
use radicle::git::raw::ErrorExt as _;
use radicle::node::device::Device;
use radicle::node::policy::Scope;
use radicle::node::Event;
@@ -19,7 +22,7 @@ use crate::node::config::Limits;
use crate::node::{Config, ConnectOptions};
use crate::service;
use crate::storage::git::transport;
-
use crate::test::node::{converge, Node};
+
use crate::test::node::{converge, Node, NodeHandle};

mod config {
    use super::*;
@@ -183,7 +186,7 @@ fn test_replication() {
    let updated = alice.handle.seed(acme, Scope::All).unwrap();
    assert!(updated);

-
    let seeds = alice.handle.seeds(acme).unwrap();
+
    let seeds = alice.handle.seeds_for(acme, None).unwrap();
    assert!(seeds.is_connected(&bob.id));

    let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap();
@@ -248,7 +251,7 @@ fn test_replication_ref_in_sigrefs() {
    bob.storage
        .repository_mut(acme)
        .unwrap()
-
        .reference(&bob.id, &git::qualified!("refs/heads/master"))
+
        .reference(&bob.id, &git::fmt::qualified!("refs/heads/master"))
        .unwrap()
        .delete()
        .unwrap();
@@ -271,7 +274,7 @@ fn test_replication_ref_in_sigrefs() {
            .storage
            .repository(acme)
            .unwrap()
-
            .reference(&bob.id, &git::qualified!("refs/heads/master"))
+
            .reference(&bob.id, &git::fmt::qualified!("refs/heads/master"))
            .is_ok(),
        "refs/namespaces/{}/refs/heads/master does not exist",
        bob.id
@@ -292,8 +295,8 @@ fn test_replication_invalid() {
    // Create some unsigned refs for Carol in Bob's storage.
    repo.raw()
        .reference(
-
            &git::qualified!("refs/heads/carol").with_namespace(carol.public_key().into()),
-
            *head,
+
            &git::fmt::qualified!("refs/heads/carol").with_namespace(carol.public_key().into()),
+
            head.into(),
            true,
            &String::default(),
        )
@@ -453,7 +456,7 @@ fn test_fetch_followed_remotes() {
        .collect::<Result<HashSet<_>, _>>()
        .unwrap();

-
    assert!(bob_remotes.len() == followed.len() + 1);
+
    assert_eq!(bob_remotes.len(), followed.len() + 1);
    assert!(bob_remotes.is_superset(&followed));
    assert!(bob_remotes.contains(&alice.id));
}
@@ -549,7 +552,7 @@ fn test_clone() {
    transport::local::register(alice.storage.clone());

    let _ = alice.handle.seed(acme, Scope::All).unwrap();
-
    let seeds = alice.handle.seeds(acme).unwrap();
+
    let seeds = alice.handle.seeds_for(acme, None).unwrap();
    assert!(seeds.is_connected(&bob.id));

    let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap();
@@ -562,6 +565,7 @@ fn test_clone() {
        alice.signer.public_key(),
        tmp.path().join("clone"),
        &alice.storage,
+
        false,
    )
    .unwrap();

@@ -578,7 +582,7 @@ fn test_clone() {
        .canonical_head()
        .unwrap();

-
    assert_eq!(oid, *canonical);
+
    assert_eq!(canonical, oid);

    // Make sure that bob has refs/rad/id set
    assert!(bob
@@ -683,7 +687,9 @@ fn test_concurrent_fetches() {
    let scale = config::scale();
    let repos = scale.max(4);
    let limits = Limits {
-
        // Have one fetch be queued.
+
        // By setting fetch concurrency to one less than the total number of repos,
+
        // we guarantee that at least one fetch will be queued while the others
+
        // are in progress.
        fetch_concurrency: (repos - 1).into(),
        ..Limits::default()
    };
@@ -724,6 +730,10 @@ fn test_concurrent_fetches() {
        bob_repos.insert(rid);
    }

+
    // Clone repositories list for assertions so we don't assert over an empty set.
+
    let all_alice_repos = alice_repos.clone();
+
    let all_bob_repos = bob_repos.clone();
+

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

@@ -740,7 +750,10 @@ fn test_concurrent_fetches() {

    while !bob_repos.is_empty() {
        match alice_events.recv().unwrap() {
+
            // We're looking for a `RefsFetched` event, which signals a completed fetch.
+
            // We also ensure that `updated` is not empty, meaning data was actually received.
            Event::RefsFetched { rid, updated, .. } if !updated.is_empty() => {
+
                // Once a repo is fetched, remove it from our tracking set.
                bob_repos.remove(&rid);
                log::debug!(target: "test", "{} fetched {rid} ({} left)",alice.id, bob_repos.len());
            }
@@ -751,6 +764,7 @@ fn test_concurrent_fetches() {
    while !alice_repos.is_empty() {
        match bob_events.recv().unwrap() {
            Event::RefsFetched { rid, updated, .. } if !updated.is_empty() => {
+
                // Once a repo is fetched, remove it from our tracking set.
                alice_repos.remove(&rid);
                log::debug!(target: "test", "{} fetched {rid} ({} left)", bob.id, alice_repos.len());
            }
@@ -758,7 +772,11 @@ fn test_concurrent_fetches() {
        }
    }

-
    for rid in &bob_repos {
+
    // Positively assert empty sets, not necessary but proves test was previously broken.
+
    assert!(bob_repos.is_empty());
+
    assert!(alice_repos.is_empty());
+

+
    for rid in &all_bob_repos {
        let doc = alice
            .storage
            .repository(*rid)
@@ -769,7 +787,7 @@ fn test_concurrent_fetches() {

        assert!(proj.name().starts_with("bob"));
    }
-
    for rid in &alice_repos {
+
    for rid in &all_alice_repos {
        let doc = bob
            .storage
            .repository(*rid)
@@ -870,7 +888,7 @@ fn test_non_fastforward_sigrefs() {

    converge([&alice, &bob, &eve]);

-
    // Eve fetches the inital project from Bob.
+
    // Eve fetches the initial project from Bob.
    eve.handle.fetch(rid, bob.id, DEFAULT_TIMEOUT).unwrap();
    // Alice fetches it too.
    let old_bob = alice.handle.fetch(rid, bob.id, DEFAULT_TIMEOUT).unwrap();
@@ -1192,7 +1210,6 @@ fn missing_default_branch() {

#[test]
fn missing_delegate_default_branch() {
-
    use radicle::git::raw;
    use radicle::identity::Identity;
    use radicle::storage::git::Repository;
    let tmp = tempfile::tempdir().unwrap();
@@ -1237,7 +1254,7 @@ fn missing_delegate_default_branch() {
        );
        assert!(matches!(
            default_branch,
-
            Err(radicle::git::Error::Git(e)) if e.code() == raw::ErrorCode::NotFound
+
            Err(e) if e.is_not_found()
        ));
    };

@@ -1377,7 +1394,7 @@ fn test_background_foreground_fetch() {
        Title::new("Concurrent fetches").unwrap(),
        "Concurrent fetches are harshing my vibes",
    );
-
    bob.handle.announce_refs(rid).unwrap();
+
    bob.handle.announce_refs_for(rid, [bob.id]).unwrap();
    alice_events
        .wait(
            |e| matches!(e, Event::RefsAnnounced { .. }).then_some(()),
@@ -1426,7 +1443,7 @@ fn test_catchup_on_refs_announcements() {

    log::debug!(target: "test", "Bob creating his issue..");
    bob.issue(acme, Title::new("Bob's issue").unwrap(), "[..]");
-
    bob.handle.announce_refs(acme).unwrap();
+
    bob.handle.announce_refs_for(acme, [bob.id]).unwrap();

    log::debug!(target: "test", "Waiting for seed to fetch Bob's refs from Bob..");
    seed.has_remote_of(&acme, &bob.id); // Seed fetches Bob's refs.
@@ -1527,10 +1544,10 @@ fn test_fetch_emits_canonical_ref_update() {
    let result = bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
    assert!(result.is_success());

-
    let default_branch: git::Qualified = {
+
    let default_branch: git::fmt::Qualified = {
        let repo = alice.storage.repository(rid).unwrap();
        let proj = repo.project().unwrap();
-
        git::lit::refs_heads(proj.default_branch()).into()
+
        git::fmt::lit::refs_heads(proj.default_branch()).into()
    };
    alice.commit_to(rid, &default_branch);

@@ -1544,3 +1561,184 @@ fn test_fetch_emits_canonical_ref_update() {
        )
        .unwrap();
}
+

+
#[test]
+
fn test_non_fastforward_identity_doc() {
+
    use radicle::identity::Identity;
+

+
    let tmp = tempfile::tempdir().unwrap();
+

+
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
+
    let bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let eve = Node::init(tmp.path(), Config::test(Alias::new("eve")));
+
    let alice_laptop = Node::init(tmp.path(), Config::test(Alias::new("alice-laptop")));
+

+
    let rid = alice.project("acme", "");
+

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

+
    let has_issue = |node: &NodeHandle<MockSigner>, issue: &cob::ObjectId| -> bool {
+
        let repo = node.storage.repository(rid).unwrap();
+
        repo.contains(**issue).unwrap()
+
    };
+

+
    alice.connect(&alice_laptop);
+
    alice.connect(&bob);
+
    alice.connect(&eve);
+
    eve.connect(&bob);
+
    eve.connect(&alice_laptop);
+

+
    // Due to permissive relaying, we need to lock down the scope for the RID.
+
    //
+
    // See: [`radicle-protocol::service::Service::relay()`] and
+
    //      [`radicle-protocol::service::Service::relay_announcement()`]
+
    alice.handle.seed(rid, Scope::Followed).unwrap();
+

+
    // Bob and Eve have the same state for the repository
+
    bob.handle.seed(rid, Scope::Followed).unwrap();
+
    bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
+

+
    alice_laptop.handle.seed(rid, Scope::All).unwrap();
+
    alice_laptop
+
        .handle
+
        .fetch(rid, alice.id, DEFAULT_TIMEOUT)
+
        .unwrap();
+
    // Alice pushes new references to her laptop
+
    let issue = alice_laptop.issue(
+
        rid,
+
        "Feature #1".parse().unwrap(),
+
        "Implementing new feature",
+
    );
+

+
    // Eve will fetch these references since her scope is "all"
+
    eve.handle.seed(rid, Scope::All).unwrap();
+
    eve.handle
+
        .fetch(rid, alice_laptop.id, DEFAULT_TIMEOUT)
+
        .unwrap();
+
    assert!(has_issue(&eve, &issue));
+

+
    // Alice updates the identity of the document to include her laptop
+
    let (prev, next) = {
+
        let repo = alice.storage.repository(rid).unwrap();
+
        let mut identity = Identity::load_mut(&repo).unwrap();
+
        let prev = identity.current;
+
        let doc = repo
+
            .identity_doc()
+
            .unwrap()
+
            .doc
+
            .with_edits(|raw| raw.delegate(alice_laptop.id.into()))
+
            .unwrap();
+
        let rev = identity
+
            .update(Title::new("Add Laptop").unwrap(), "", &doc, &alice.signer)
+
            .unwrap();
+
        repo.set_identity_head_to(rev).unwrap();
+
        (prev, rev)
+
    };
+

+
    assert!(!has_issue(&alice, &issue));
+

+
    // Bob fetches from Alice and we see the identity document was updated.
+
    //
+
    // Bob does not have the issue because Alice does not have the updates from
+
    // Alice's Laptop.
+
    let result = bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
+
    assert!(matches!(result, FetchResult::Success { .. }));
+
    assert!(!has_issue(&bob, &issue));
+
    let repo = bob.storage.repository(rid).unwrap();
+
    let identity = Identity::load_mut(&repo).unwrap();
+
    assert_eq!(identity.current, next);
+
    assert_eq!(identity.parent, Some(prev));
+

+
    // Bob fetches from Eve, the identity document should remain the same, but
+
    // since Bob now knows that Alice's Laptop is a delegate, the issue should
+
    // be fetched.
+
    bob.handle.fetch(rid, eve.id, DEFAULT_TIMEOUT).unwrap();
+
    assert!(matches!(result, FetchResult::Success { .. }));
+
    assert!(has_issue(&bob, &issue));
+
    let repo = bob.storage.repository(rid).unwrap();
+
    let identity = Identity::load_mut(&repo).unwrap();
+
    assert_eq!(identity.current, next);
+
    assert_eq!(identity.parent, Some(prev));
+
}
+

+
#[test]
+
fn test_block_active_connection() {
+
    let tmp = tempfile::tempdir().unwrap();
+
    let alice = Node::init(tmp.path(), config::relay("alice"));
+
    let bob = Node::init(tmp.path(), config::relay("bob"));
+

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

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

+
    let events = alice.handle.events();
+
    assert!(alice.handle.block(bob.id).unwrap());
+

+
    events
+
        .wait(
+
            |e| matches!(e, Event::PeerDisconnected { nid, .. } if *nid == bob.id).then_some(()),
+
            DEFAULT_TIMEOUT,
+
        )
+
        .unwrap();
+

+
    let sessions = alice.handle.sessions().unwrap();
+
    assert!(sessions.iter().all(|s| s.nid != bob.id));
+
}
+

+
#[test]
+
fn test_block_prevents_connection() {
+
    let tmp = tempfile::tempdir().unwrap();
+
    let alice = Node::init(tmp.path(), config::relay("alice"));
+
    let bob = Node::init(tmp.path(), config::relay("bob"));
+

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

+
    assert!(alice.handle.block(bob.id).unwrap());
+

+
    let result = alice
+
        .handle
+
        .connect(bob.id, bob.addr.into(), ConnectOptions::default())
+
        .unwrap();
+

+
    assert_matches!(result, ConnectResult::Disconnected { .. });
+

+
    let events = alice.handle.events();
+
    bob.connect(&alice);
+

+
    // Alice receives Bob's inbound connection, but disconnects from him.
+
    events
+
        .wait(
+
            |e| matches!(e, Event::PeerDisconnected { nid, .. } if *nid == bob.id).then_some(()),
+
            time::Duration::from_secs(10),
+
        )
+
        .unwrap();
+

+
    let sessions = alice.handle.sessions().unwrap();
+
    assert!(sessions.iter().all(|s| s.nid != bob.id));
+
}
+

+
#[test]
+
fn test_block_prevents_fetch() {
+
    let tmp = tempfile::tempdir().unwrap();
+
    let alice = Node::init(tmp.path(), config::relay("alice"));
+
    let mut bob = Node::init(tmp.path(), config::relay("bob"));
+
    let rid = bob.project("acme", "");
+

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

+
    assert!(alice.handle.block(bob.id).unwrap());
+

+
    let result = alice
+
        .handle
+
        .fetch(rid, bob.id, time::Duration::from_secs(5))
+
        .unwrap();
+

+
    assert_matches!(result, FetchResult::Failed { .. });
+
}
modified crates/radicle-node/src/wire.rs
@@ -1,27 +1,23 @@
//! Implementation of the transport protocol.
//!
//! We use the Noise XK handshake pattern to establish an encrypted stream with a remote peer.
-
//! The handshake itself is implemented in the external [`cyphernet`] and [`netservices`] crates.
use std::collections::hash_map::Entry;
use std::collections::VecDeque;
-
use std::os::unix::io::{AsRawFd, RawFd};
+
use std::fmt::Debug;
use std::sync::Arc;
+
use std::time::Instant;
use std::{io, net, time};

-
use amplify::Wrapper as _;
use crossbeam_channel as chan;
use cyphernet::addr::{HostName, InetHost, NetAddr};
use cyphernet::encrypt::noise::{HandshakePattern, Keyset, NoiseState};
use cyphernet::proxy::socks5;
use cyphernet::{Digest, EcSk, Ecdh, Sha256};
use localtime::LocalTime;
-
use netservices::resource::{ListenerEvent, NetAccept, NetTransport, SessionEvent};
-
use netservices::session::{NoiseSession, ProtocolArtifact, Socks5Session};
-
use netservices::{NetConnection, NetReader, NetWriter};
+
use mio::net::TcpStream;
use radicle::node::device::Device;
-
use reactor::{ResourceId, ResourceType, Timestamp};

-
use radicle::collections::RandomMap;
+
use radicle::collections::{RandomMap, RandomSet};
use radicle::crypto;
use radicle::node::config::AddressConfig;
use radicle::node::Link;
@@ -33,6 +29,10 @@ pub use radicle_protocol::wire::frame::{Frame, FrameData, StreamId};
pub use radicle_protocol::wire::*;
use radicle_protocol::worker::{FetchRequest, FetchResult};

+
use crate::reactor;
+
use crate::reactor::{Listener, Transport};
+
use crate::reactor::{NoiseSession, ProtocolArtifact, SessionEvent, Socks5Session};
+
use crate::reactor::{Token, Tokens};
use crate::service;
use crate::service::io::Io;
use crate::service::FETCH_TIMEOUT;
@@ -50,9 +50,6 @@ pub const NOISE_XK: HandshakePattern = HandshakePattern {
/// Default time to wait until a network connection is considered inactive.
pub const DEFAULT_CONNECTION_TIMEOUT: time::Duration = time::Duration::from_secs(6);

-
/// Default time to wait when dialing a connection, before the remote is considered unreachable.
-
pub const DEFAULT_DIAL_TIMEOUT: time::Duration = time::Duration::from_secs(6);
-

/// Maximum size of a peer inbox, in bytes.
pub const MAX_INBOX_SIZE: usize = 1024 * 1024 * 2;

@@ -69,14 +66,10 @@ pub enum Control {
}

/// Peer session type.
-
pub type WireSession<G> = NoiseSession<G, Sha256, Socks5Session<net::TcpStream>>;
-
/// Peer session type (read-only).
-
pub type WireReader = NetReader<Socks5Session<net::TcpStream>>;
-
/// Peer session type (write-only).
-
pub type WireWriter<G> = NetWriter<NoiseState<G, Sha256>, Socks5Session<net::TcpStream>>;
+
type WireSession<G> = NoiseSession<G, Sha256, Socks5Session<TcpStream>>;

/// Reactor action.
-
type Action<G> = reactor::Action<NetAccept<WireSession<G>>, NetTransport<WireSession<G>>>;
+
type Action<G> = reactor::Action<Listener, Transport<WireSession<G>>>;

/// A worker stream.
struct Stream {
@@ -175,23 +168,14 @@ impl Streams {
/// The initial state of an outbound peer before handshake is completed.
#[derive(Debug)]
struct Outbound {
-
    /// Resource ID, if registered.
-
    id: Option<ResourceId>,
+
    /// Token for I/O event notification.
+
    token: Token,
    /// Remote address.
    addr: NetAddr<HostName>,
    /// Remote Node ID.
    nid: NodeId,
}

-
/// The initial state of an inbound peer before handshake is completed.
-
#[derive(Debug)]
-
struct Inbound {
-
    /// Resource ID, if registered.
-
    id: Option<ResourceId>,
-
    /// Remote address.
-
    addr: NetAddr<HostName>,
-
}
-

/// Peer connection state machine.
enum Peer {
    /// The state after handshake is completed.
@@ -251,49 +235,49 @@ impl Peer {
}

/// Holds connected peers.
-
struct Peers(RandomMap<ResourceId, Peer>);
+
struct Peers(RandomMap<Token, Peer>);

impl Peers {
-
    fn get_mut(&mut self, id: &ResourceId) -> Option<&mut Peer> {
-
        self.0.get_mut(id)
+
    fn get_mut(&mut self, token: &Token) -> Option<&mut Peer> {
+
        self.0.get_mut(token)
    }

-
    fn entry(&mut self, id: ResourceId) -> Entry<ResourceId, Peer> {
-
        self.0.entry(id)
+
    fn entry(&mut self, token: Token) -> Entry<'_, Token, Peer> {
+
        self.0.entry(token)
    }

-
    fn insert(&mut self, id: ResourceId, peer: Peer) {
-
        if self.0.insert(id, peer).is_some() {
-
            log::warn!(target: "wire", "Replacing existing peer id={id}");
+
    fn insert(&mut self, token: Token, peer: Peer) {
+
        if self.0.insert(token, peer).is_some() {
+
            log::debug!(target: "wire", token=token.0; "Replacing existing peer");
        }
    }

-
    fn remove(&mut self, id: &ResourceId) -> Option<Peer> {
+
    fn remove(&mut self, id: &Token) -> Option<Peer> {
        self.0.remove(id)
    }

-
    fn lookup(&self, node_id: &NodeId) -> Option<(ResourceId, &Peer)> {
+
    fn lookup(&self, id: &NodeId) -> Option<(Token, &Peer)> {
        self.0
            .iter()
-
            .find(|(_, peer)| peer.id() == Some(node_id))
-
            .map(|(fd, peer)| (*fd, peer))
+
            .find(|(_, peer)| peer.id() == Some(id))
+
            .map(|(token, peer)| (*token, peer))
    }

-
    fn lookup_mut(&mut self, node_id: &NodeId) -> Option<(ResourceId, &mut Peer)> {
+
    fn lookup_mut(&mut self, id: &NodeId) -> Option<(Token, &mut Peer)> {
        self.0
            .iter_mut()
-
            .find(|(_, peer)| peer.id() == Some(node_id))
+
            .find(|(_, peer)| peer.id() == Some(id))
            .map(|(fd, peer)| (*fd, peer))
    }

-
    fn active(&self) -> impl Iterator<Item = (ResourceId, &NodeId, Link)> {
+
    fn active(&self) -> impl Iterator<Item = (Token, &NodeId, Link)> {
        self.0.iter().filter_map(|(id, peer)| match peer {
            Peer::Connected { nid, link, .. } => Some((*id, nid, *link)),
            Peer::Disconnecting { .. } => None,
        })
    }

-
    fn connected(&self) -> impl Iterator<Item = (ResourceId, &NodeId)> {
+
    fn connected(&self) -> impl Iterator<Item = (Token, &NodeId)> {
        self.0.iter().filter_map(|(id, peer)| {
            if let Peer::Connected { nid, .. } = peer {
                Some((*id, nid))
@@ -309,7 +293,7 @@ impl Peers {
}

/// Wire protocol implementation for a set of peers.
-
pub struct Wire<D, S, G: crypto::signature::Signer<crypto::Signature> + Ecdh> {
+
pub(crate) struct Wire<D, S, G: crypto::signature::Signer<crypto::Signature> + Ecdh> {
    /// Backing service instance.
    service: Service<D, S, G>,
    /// Worker pool interface.
@@ -321,13 +305,15 @@ pub struct Wire<D, S, G: crypto::signature::Signer<crypto::Signature> + Ecdh> {
    /// Internal queue of actions to send to the reactor.
    actions: VecDeque<Action<G>>,
    /// Outbound attempted peers without a session.
-
    outbound: RandomMap<RawFd, Outbound>,
+
    outbound: RandomMap<Token, Outbound>,
    /// Inbound peers without a session.
-
    inbound: RandomMap<RawFd, Inbound>,
+
    inbound: RandomSet<Token>,
    /// Listening addresses that are not yet registered.
-
    listening: RandomMap<RawFd, net::SocketAddr>,
+
    listening: RandomMap<Token, net::SocketAddr>,
    /// Peer (established) sessions.
    peers: Peers,
+
    /// A (practically) infinite source of tokens to identify transports and listeners.
+
    tokens: Tokens,
}

impl<D, S, G> Wire<D, S, G>
@@ -345,43 +331,45 @@ where
            signer,
            metrics: Metrics::default(),
            actions: VecDeque::new(),
-
            inbound: RandomMap::default(),
+
            inbound: RandomSet::default(),
            outbound: RandomMap::default(),
            listening: RandomMap::default(),
            peers: Peers(RandomMap::default()),
+
            tokens: Tokens::default(),
        }
    }

-
    pub fn listen(&mut self, socket: NetAccept<WireSession<G>>) {
-
        self.listening
-
            .insert(socket.as_raw_fd(), socket.local_addr());
-
        self.actions.push_back(Action::RegisterListener(socket));
+
    pub fn listen(&mut self, socket: Listener) {
+
        let token = self.tokens.advance();
+
        self.listening.insert(token, socket.local_addr());
+
        self.actions
+
            .push_back(Action::RegisterListener(token, socket));
    }

-
    fn disconnect(&mut self, id: ResourceId, reason: DisconnectReason) -> Option<(NodeId, Link)> {
-
        match self.peers.entry(id) {
+
    fn disconnect(&mut self, token: Token, reason: DisconnectReason) -> Option<(NodeId, Link)> {
+
        match self.peers.entry(token) {
            Entry::Vacant(_) => {
                // Connecting peer with no session.
-
                log::debug!(target: "wire", "Disconnecting pending peer with id={id}: {reason}");
-
                self.actions.push_back(Action::UnregisterTransport(id));
+
                log::debug!(target: "wire", token=token.0; "Disconnecting pending peer: {reason}");
+
                self.actions.push_back(Action::UnregisterTransport(token));

                // Check for attempted outbound connections. Unestablished inbound connections don't
                // have an NID yet.
                self.outbound
                    .values()
-
                    .find(|o| o.id == Some(id))
+
                    .find(|o| o.token == token)
                    .map(|o| (o.nid, Link::Outbound))
            }
            Entry::Occupied(mut e) => match e.get_mut() {
                Peer::Disconnecting { nid, link, .. } => {
-
                    log::error!(target: "wire", "Peer with id={id} is already disconnecting");
+
                    log::debug!(target: "wire", token=token.0; "Peer is already disconnecting");

                    nid.map(|n| (n, *link))
                }
                Peer::Connected {
                    nid, streams, link, ..
                } => {
-
                    log::debug!(target: "wire", "Disconnecting peer with id={id}: {reason}");
+
                    log::debug!(target: "wire", token=token.0; "Disconnecting peer: {reason}");
                    let nid = *nid;
                    let link = *link;

@@ -391,7 +379,7 @@ where
                        link,
                        reason,
                    });
-
                    self.actions.push_back(Action::UnregisterTransport(id));
+
                    self.actions.push_back(Action::UnregisterTransport(token));

                    Some((nid, link))
                }
@@ -408,7 +396,7 @@ where

        let nid = task.remote;
        let Some((fd, peer)) = self.peers.lookup_mut(&nid) else {
-
            log::warn!(target: "wire", "Peer {nid} not found; ignoring fetch result");
+
            log::debug!(target: "wire", "Peer {nid} not found; ignoring fetch result");
            return;
        };

@@ -433,7 +421,7 @@ where
        } else {
            // If the peer disconnected, we'll get here, but we still want to let the service know
            // about the fetch result, so we don't return here.
-
            log::warn!(target: "wire", "Peer {nid} is not connected; ignoring fetch result");
+
            log::debug!(target: "wire", "Peer {nid} is not connected; ignoring fetch result");
            return;
        };

@@ -456,11 +444,11 @@ where

    fn flush(&mut self, remote: NodeId, stream: StreamId) {
        let Some((fd, peer)) = self.peers.lookup_mut(&remote) else {
-
            log::warn!(target: "wire", "Peer {remote} is not known; ignoring flush");
+
            log::debug!(target: "wire", "Peer {remote} is not known; ignoring flush");
            return;
        };
        let Peer::Connected { streams, link, .. } = peer else {
-
            log::warn!(target: "wire", "Peer {remote} is not connected; ignoring flush");
+
            log::debug!(target: "wire", "Peer {remote} is not connected; ignoring flush");
            return;
        };
        let Some(s) = streams.get_mut(&stream) else {
@@ -484,33 +472,32 @@ where
        }
    }

-
    fn cleanup(&mut self, id: ResourceId, fd: RawFd) {
-
        if self.inbound.remove(&fd).is_some() {
-
            log::debug!(target: "wire", "Cleaning up inbound peer state with id={id} (fd={fd})");
-
        } else if let Some(outbound) = self.outbound.remove(&fd) {
-
            log::debug!(target: "wire", "Cleaning up outbound peer state with id={id} (fd={fd})");
+
    fn cleanup(&mut self, token: Token) {
+
        if self.inbound.remove(&token) {
+
            log::debug!(target: "wire", token=token.0; "Cleaning up inbound peer state");
+
        } else if let Some(outbound) = self.outbound.remove(&token) {
+
            log::debug!(target: "wire", token=token.0; "Cleaning up outbound peer state");
            self.service.disconnected(
                outbound.nid,
                Link::Outbound,
                &DisconnectReason::connection(),
            );
        } else {
-
            log::debug!(target: "wire", "Tried to cleanup unknown peer with id={id} (fd={fd})");
+
            log::debug!(target: "wire", token=token.0; "Tried to cleanup unknown peer");
        }
    }
}

-
impl<D, S, G> reactor::Handler for Wire<D, S, G>
+
impl<D, S, G> reactor::ReactionHandler for Wire<D, S, G>
where
    D: service::Store + Send,
    S: WriteStorage + Send + 'static,
-
    G: crypto::signature::Signer<crypto::Signature> + Ecdh<Pk = NodeId> + Clone + Send,
+
    G: crypto::signature::Signer<crypto::Signature> + Ecdh<Pk = NodeId> + Clone + Send + Debug,
{
-
    type Listener = NetAccept<WireSession<G>>;
-
    type Transport = NetTransport<WireSession<G>>;
-
    type Command = Control;
+
    type Listener = Listener;
+
    type Transport = Transport<WireSession<G>>;

-
    fn tick(&mut self, time: Timestamp) {
+
    fn tick(&mut self) {
        self.metrics.open_channels = self
            .peers
            .iter()
@@ -523,211 +510,170 @@ where
            })
            .sum();
        self.metrics.worker_queue_size = self.worker.len();
-
        self.service.tick(
-
            LocalTime::from_millis(time.as_millis() as u128),
-
            &self.metrics,
-
        );
+

+
        self.service.tick(LocalTime::now(), &self.metrics);
    }

-
    fn handle_timer(&mut self) {
+
    fn timer_reacted(&mut self) {
        self.service.wake();
    }

-
    fn handle_listener_event(
+
    fn listener_reacted(
        &mut self,
-
        _: ResourceId, // Nb. This is the ID of the listener socket.
-
        event: ListenerEvent<WireSession<G>>,
-
        _: Timestamp,
+
        _: Token, // Note that this is the token of the listener socket.
+
        event: io::Result<(TcpStream, std::net::SocketAddr)>,
+
        _: Instant,
    ) {
        match event {
-
            ListenerEvent::Accepted(connection) => {
-
                let Ok(remote) = connection.remote_addr() else {
-
                    log::warn!(target: "wire", "Accepted connection doesn't have remote address; dropping..");
-
                    drop(connection);
-

-
                    return;
-
                };
+
            Ok((connection, peer)) => {
+
                let remote = NetAddr::from(peer);
                let InetHost::Ip(ip) = remote.host else {
-
                    log::error!(target: "wire", "Unexpected host type for inbound connection {remote}; dropping..");
+
                    log::debug!(target: "wire", "Unexpected host type for inbound connection {remote}; dropping..");
                    drop(connection);

                    return;
                };
-
                let fd = connection.as_raw_fd();
-
                log::debug!(target: "wire", "Inbound connection from {remote} (fd={fd})..");
+
                log::debug!(target: "wire", "Inbound connection from {remote}..");

                // If the service doesn't want to accept this connection,
                // we drop the connection here, which disconnects the socket.
                if !self.service.accepted(ip) {
-
                    log::debug!(target: "wire", "Rejecting inbound connection from {ip} (fd={fd})..");
+
                    log::debug!(target: "wire", "Rejecting inbound connection from {ip}..");
                    drop(connection);

                    return;
                }

-
                let session = match accept::<G>(
+
                let session = accept::<G>(
                    remote.clone().into(),
                    connection,
                    self.signer.clone().into_inner(),
-
                ) {
-
                    Ok(s) => s,
-
                    Err(e) => {
-
                        log::error!(target: "wire", "Error creating session for {ip}: {e}");
-
                        return;
-
                    }
-
                };
-
                let transport = match NetTransport::with_session(
-
                    session,
-
                    netservices::Direction::Inbound,
-
                ) {
+
                );
+
                let transport = match Transport::with_session(session, Link::Inbound) {
                    Ok(transport) => transport,
                    Err(err) => {
-
                        log::error!(target: "wire", "Failed to create transport for accepted connection: {err}");
+
                        log::warn!(target: "wire", "Failed to create transport for accepted connection: {err}");
                        return;
                    }
                };
-
                log::debug!(target: "wire", "Accepted inbound connection from {remote} (fd={fd})..");

-
                self.inbound.insert(
-
                    fd,
-
                    Inbound {
-
                        id: None,
-
                        addr: remote.into(),
-
                    },
-
                );
+
                let token = self.tokens.advance();
+
                log::debug!(target: "wire", token=token.0; "Accepted inbound connection from {remote}..");
+

+
                self.inbound.insert(token);
                self.actions
-
                    .push_back(reactor::Action::RegisterTransport(transport))
+
                    .push_back(reactor::Action::RegisterTransport(token, transport))
            }
-
            ListenerEvent::Failure(err) => {
+
            Err(err) => {
                log::error!(target: "wire", "Error listening for inbound connections: {err}");
            }
        }
    }

-
    fn handle_registered(&mut self, fd: RawFd, id: ResourceId, typ: ResourceType) {
-
        match typ {
-
            ResourceType::Listener => {
-
                if let Some(local_addr) = self.listening.remove(&fd) {
-
                    self.service.listening(local_addr);
-
                }
-
            }
-
            ResourceType::Transport => {
-
                if let Some(outbound) = self.outbound.get_mut(&fd) {
-
                    log::debug!(target: "wire", "Outbound peer resource registered for {} with id={id} (fd={fd})", outbound.nid);
-
                    outbound.id = Some(id);
-
                } else if let Some(inbound) = self.inbound.get_mut(&fd) {
-
                    log::debug!(target: "wire", "Inbound peer resource registered with id={id} (fd={fd})");
-
                    inbound.id = Some(id);
-
                } else {
-
                    log::warn!(target: "wire", "Unknown peer registered with fd={fd} and id={id}");
-
                }
-
            }
+
    fn listener_registered(&mut self, token: Token, _listener: &Self::Listener) {
+
        if let Some(local_addr) = self.listening.remove(&token) {
+
            self.service.listening(local_addr);
        }
    }

-
    fn handle_transport_event(
-
        &mut self,
-
        id: ResourceId,
-
        event: SessionEvent<WireSession<G>>,
-
        _: Timestamp,
-
    ) {
+
    fn transport_registered(&mut self, token: Token, _transport: &Self::Transport) {
+
        if let Some(outbound) = self.outbound.get(&token) {
+
            log::debug!(target: "wire", token=token.0; "Outbound peer resource registered for {}", outbound.nid);
+
        } else if self.inbound.contains(&token) {
+
            log::debug!(target: "wire", token=token.0; "Inbound peer resource registered");
+
        } else {
+
            log::debug!(target: "wire", token=token.0; "Unknown peer registered");
+
        }
+
    }
+

+
    fn transport_reacted(&mut self, token: Token, event: SessionEvent<WireSession<G>>, _: Instant) {
        match event {
-
            SessionEvent::Established(fd, ProtocolArtifact { state, .. }) => {
+
            SessionEvent::Established(ProtocolArtifact { state, session }) => {
                // SAFETY: With the NoiseXK protocol, there is always a remote static key.
                let nid: NodeId = state.remote_static_key.unwrap();
                // Make sure we don't try to connect to ourselves by mistake.
                if &nid == self.signer.public_key() {
-
                    log::error!(target: "wire", "Self-connection detected, disconnecting..");
-
                    self.disconnect(id, DisconnectReason::SelfConnection);
+
                    log::warn!(target: "wire", "Self-connection detected, disconnecting..");
+
                    self.disconnect(token, DisconnectReason::SelfConnection);

                    return;
                }
-
                let (addr, link) = if let Some(peer) = self.inbound.remove(&fd) {
+

+
                let established_addr: NetAddr<HostName> = session.state;
+
                let (addr, link) = if self.inbound.remove(&token) {
                    self.metrics.peer(nid).inbound_connection_attempts += 1;
-
                    (peer.addr, Link::Inbound)
-
                } else if let Some(peer) = self.outbound.remove(&fd) {
+
                    (established_addr, Link::Inbound)
+
                } else if let Some(peer) = self.outbound.remove(&token) {
                    assert_eq!(nid, peer.nid);
                    (peer.addr, Link::Outbound)
                } else {
-
                    log::error!(target: "wire", "Session for {nid} (id={id}) not found");
+
                    log::debug!(target: "wire", token=token.0; "Session for {nid} not found");
                    return;
                };
                log::debug!(
-
                    target: "wire",
-
                    "Session established with {nid} (id={id}) (fd={fd}) ({})",
-
                    if link.is_inbound() { "inbound" } else { "outbound" }
+
                    target: "wire", token=token.0, direction:display=link; "Session established with {nid}"
                );

                // Connections to close.
                let mut disconnect = Vec::new();

                // Handle conflicting connections.
-
                // This is typical when nodes have mutually configured their nodes to connect to
+
                // This is typical when users have mutually configured their nodes to connect to
                // each other on startup. We handle this by deterministically choosing one node
-
                // whos outbound connection is the one that is kept. The other connections are
+
                // whose outbound connection is the one that is kept. The other connections are
                // dropped.
                {
-
                    // Whether we have precedence in case of conflicting connections.
                    // Having precedence means that our outbound connection will win over
                    // the other node's outbound connection.
-
                    let precedence = *self.signer.public_key() > nid;
-

-
                    // Pre-existing connections that conflict with this newly established session.
-
                    // Note that we can't know whether a connection is conflicting before we get the
-
                    // remote static key.
-
                    let mut conflicting = Vec::new();
-

-
                    // Active sessions with the same NID but a different Resource ID are conflicting.
-
                    conflicting.extend(
-
                        self.peers
-
                            .active()
-
                            .filter(|(c_id, d, _)| **d == nid && *c_id != id)
-
                            .map(|(c_id, _, link)| (c_id, link)),
-
                    );
+
                    enum Precedence {
+
                        Ours,
+
                        Theirs,
+
                    }
+

+
                    use Link::*;
+
                    use Precedence::*;
+

+
                    // Whether we have precedence in case of conflicting connections.
+
                    let precedence = if *self.signer.public_key() > nid {
+
                        Ours
+
                    } else {
+
                        Theirs
+
                    };
+

+
                    // Active sessions with the same NID but a different token are conflicting.
+
                    let peers = self.peers.active().filter_map(|(c_id, d, link)| {
+
                        (*d == nid && c_id != token).then_some((c_id, link))
+
                    });

                    // Outbound connection attempts with the same remote key but a different file
                    // descriptor are conflicting.
-
                    conflicting.extend(self.outbound.iter().filter_map(|(c_fd, other)| {
-
                        if other.nid == nid && *c_fd != fd {
-
                            other.id.map(|c_id| (c_id, Link::Outbound))
-
                        } else {
-
                            None
-
                        }
-
                    }));
+
                    let outbound = self.outbound.iter().filter_map(|(c_id, other)| {
+
                        (other.nid == nid && *c_id != token).then_some((*c_id, Outbound))
+
                    });

-
                    for (c_id, c_link) in conflicting {
+
                    for (c_token, c_link) in peers.chain(outbound) {
                        // If we have precedence, the inbound connection is closed.
                        // In the case where both connections are inbound or outbound,
                        // we close the newer connection, ie. the one with the higher
-
                        // resource id.
-
                        let close = match (link, c_link) {
-
                            (Link::Inbound, Link::Outbound) => {
-
                                if precedence {
-
                                    id
-
                                } else {
-
                                    c_id
-
                                }
-
                            }
-
                            (Link::Outbound, Link::Inbound) => {
-
                                if precedence {
-
                                    c_id
-
                                } else {
-
                                    id
-
                                }
-
                            }
-
                            (Link::Inbound, Link::Inbound) => id.max(c_id),
-
                            (Link::Outbound, Link::Outbound) => id.max(c_id),
+
                        // token.
+
                        let close = match (link, c_link, &precedence) {
+
                            (Inbound, Outbound, Ours) => token,
+
                            (Inbound, Outbound, Theirs) => c_token,
+
                            (Outbound, Inbound, Ours) => c_token,
+
                            (Outbound, Inbound, Theirs) => token,
+
                            (Inbound, Inbound, _) => token.max(c_token),
+
                            (Outbound, Outbound, _) => token.max(c_token),
                        };

-
                        log::warn!(
-
                            target: "wire", "Established session (id={id}) conflicts with existing session for {nid} (id={c_id})"
+
                        log::trace!(
+
                            target: "wire", "Established session with token {} conflicts with existing session with token {} for {nid}. Disconnecting session with token {}.", token.0, c_token.0, close.0
                        );
                        disconnect.push(close);
                    }
                }
                for id in &disconnect {
-
                    log::warn!(
-
                        target: "wire", "Closing conflicting session (id={id}) with {nid}.."
+
                    log::info!(
+
                        target: "wire", token=token.0; "Closing conflicting session with {nid}.."
                    );
                    // Disconnect and return the associated NID of the peer, if available.
                    if let Some((nid, link)) = self.disconnect(*id, DisconnectReason::Conflict) {
@@ -738,9 +684,9 @@ where
                            .disconnected(nid, link, &DisconnectReason::Conflict);
                    }
                }
-
                if !disconnect.contains(&id) {
+
                if !disconnect.contains(&token) {
                    self.peers
-
                        .insert(id, Peer::connected(nid, addr.clone(), link));
+
                        .insert(token, Peer::connected(nid, addr.clone(), link));
                    self.service.connected(nid, addr.into(), link);
                }
            }
@@ -750,15 +696,18 @@ where
                    inbox,
                    streams,
                    ..
-
                }) = self.peers.get_mut(&id)
+
                }) = self.peers.get_mut(&token)
                {
                    let metrics = self.metrics.peer(*nid);
                    metrics.received_bytes += data.len();

                    if inbox.input(&data).is_err() {
-
                        log::error!(target: "wire", "Maximum inbox size ({MAX_INBOX_SIZE}) reached for peer {nid}");
-
                        log::error!(target: "wire", "Unable to process messages fast enough for peer {nid}; disconnecting..");
-
                        self.disconnect(id, DisconnectReason::Session(session::Error::Misbehavior));
+
                        log::warn!(target: "wire", "Maximum inbox size ({MAX_INBOX_SIZE}) reached for peer {nid}");
+
                        log::warn!(target: "wire", "Unable to process messages fast enough for peer {nid}; disconnecting..");
+
                        self.disconnect(
+
                            token,
+
                            DisconnectReason::Session(session::Error::Misbehavior),
+
                        );

                        return;
                    }
@@ -778,7 +727,7 @@ where
                                    ChannelsConfig::new(FETCH_TIMEOUT)
                                        .with_reader_limit(reader_limit),
                                ) else {
-
                                    log::warn!(target: "wire", "Peer attempted to open already-open stream stream {stream}");
+
                                    log::debug!(target: "wire", "Peer attempted to open already-open stream stream {stream}");
                                    continue;
                                };

@@ -791,7 +740,7 @@ where
                                    channels,
                                };
                                if let Err(e) = self.worker.try_send(task) {
-
                                    log::error!(
+
                                    log::warn!(
                                        target: "wire",
                                        "Worker pool failed to accept incoming fetch request: {e}"
                                    );
@@ -805,7 +754,7 @@ where
                                    log::debug!(target: "wire", "Received `end-of-file` on stream {stream} from {nid}");

                                    if s.channels.send(ChannelEvent::Eof).is_err() {
-
                                        log::error!(target: "wire", "Worker is disconnected; cannot send `EOF`");
+
                                        log::debug!(target: "wire", "Worker is disconnected; cannot send `EOF`");
                                    }
                                } else {
                                    log::debug!(target: "wire", "Ignoring frame on closed or unknown stream {stream}");
@@ -842,7 +791,7 @@ where
                                    metrics.received_git_bytes += data.len();

                                    if s.channels.send(ChannelEvent::Data(data)).is_err() {
-
                                        log::error!(target: "wire", "Worker is disconnected; cannot send data");
+
                                        log::warn!(target: "wire", "Worker is disconnected; cannot send data");
                                    }
                                } else {
                                    log::debug!(target: "wire", "Ignoring frame on closed or unknown stream {stream}");
@@ -853,13 +802,13 @@ where
                                break;
                            }
                            Err(e) => {
-
                                log::error!(target: "wire", "Invalid gossip message from {nid}: {e}");
+
                                log::warn!(target: "wire", "Invalid gossip message from {nid}: {e}");

                                if !inbox.is_empty() {
                                    log::debug!(target: "wire", "Dropping read buffer for {nid} with {} bytes", inbox.len());
                                }
                                self.disconnect(
-
                                    id,
+
                                    token,
                                    DisconnectReason::Session(session::Error::Misbehavior),
                                );
                                break;
@@ -867,16 +816,16 @@ where
                        }
                    }
                } else {
-
                    log::warn!(target: "wire", "Dropping message from unconnected peer (id={id})");
+
                    log::debug!(target: "wire", token=token.0; "Dropping message from unconnected peer");
                }
            }
            SessionEvent::Terminated(err) => {
-
                self.disconnect(id, DisconnectReason::Connection(Arc::new(err)));
+
                self.disconnect(token, DisconnectReason::Connection(Arc::new(err)));
            }
        }
    }

-
    fn handle_command(&mut self, cmd: Self::Command) {
+
    fn handle_command(&mut self, cmd: Control) {
        match cmd {
            Control::User(cmd) => self.service.command(cmd),
            Control::Worker(result) => self.worker_result(result),
@@ -884,22 +833,18 @@ where
        }
    }

-
    fn handle_error(
-
        &mut self,
-
        err: reactor::Error<NetAccept<WireSession<G>>, NetTransport<WireSession<G>>>,
-
    ) {
+
    fn handle_error(&mut self, err: reactor::Error<Listener, Transport<WireSession<G>>>) {
        match err {
-
            reactor::Error::Poll(err) => {
+
            reactor::Error::Poll(err) | reactor::Error::Registration(err) => {
                // TODO: This should be a fatal error, there's nothing we can do here.
                log::error!(target: "wire", "Can't poll connections: {err}");
            }
-
            reactor::Error::ListenerDisconnect(id, _) => {
+
            reactor::Error::ListenerDisconnect(token, _) => {
                // TODO: This should be a fatal error, there's nothing we can do here.
-
                log::error!(target: "wire", "Listener {id} disconnected");
+
                log::error!(target: "wire", token=token.0; "Listener disconnected");
            }
-
            reactor::Error::TransportDisconnect(id, transport) => {
-
                let fd = transport.as_raw_fd();
-
                log::error!(target: "wire", "Peer id={id} (fd={fd}) disconnected");
+
            reactor::Error::TransportDisconnect(token, transport) => {
+
                log::trace!(target: "wire", token=token.0; "Peer disconnected");

                // We're dropping the TCP connection here.
                drop(transport);
@@ -907,7 +852,7 @@ where
                // The peer transport is already disconnected and removed from the reactor;
                // therefore there is no need to initiate a disconnection. We simply remove
                // the peer from the map.
-
                match self.peers.remove(&id) {
+
                match self.peers.remove(&token) {
                    Some(mut peer) => {
                        if let Peer::Connected { streams, .. } = &mut peer {
                            streams.shutdown();
@@ -923,26 +868,24 @@ where
                            log::debug!(target: "wire", "Inbound disconnection before handshake; ignoring..")
                        }
                    }
-
                    None => self.cleanup(id, fd),
+
                    None => self.cleanup(token),
                }
            }
        }
    }

-
    fn handover_listener(&mut self, id: ResourceId, _listener: Self::Listener) {
-
        log::error!(target: "wire", "Listener handover is not supported (id={id})");
+
    fn handover_listener(&mut self, token: Token, _listener: Self::Listener) {
+
        log::warn!(target: "wire", token=token.0; "Listener handover is not supported");
    }

-
    fn handover_transport(&mut self, id: ResourceId, transport: Self::Transport) {
-
        let fd = transport.as_raw_fd();
-

-
        match self.peers.entry(id) {
+
    fn handover_transport(&mut self, token: Token, transport: Self::Transport) {
+
        match self.peers.entry(token) {
            Entry::Occupied(e) => {
                match e.get() {
                    Peer::Disconnecting {
                        nid, reason, link, ..
                    } => {
-
                        log::debug!(target: "wire", "Transport handover for disconnecting peer with id={id} (fd={fd})");
+
                        log::debug!(target: "wire", token=token.0; "Transport handover for disconnecting peer");

                        // Disconnect TCP stream.
                        drop(transport);
@@ -960,11 +903,11 @@ where
                        e.remove();
                    }
                    Peer::Connected { nid, .. } => {
-
                        panic!("Wire::handover_transport: Unexpected handover of connected peer {nid} with id={id} (fd={fd})");
+
                        panic!("Wire::handover_transport: Unexpected handover of connected peer {nid} with token {}", token.0);
                    }
                }
            }
-
            Entry::Vacant(_) => self.cleanup(id, fd),
+
            Entry::Vacant(_) => self.cleanup(token),
        }
    }
}
@@ -990,7 +933,7 @@ where
                            continue;
                        }
                        None => {
-
                            log::error!(target: "wire", "Dropping {} message(s) to {node_id}: unknown peer", msgs.len());
+
                            log::debug!(target: "wire", "Dropping {} message(s) to {node_id}: unknown peer", msgs.len());
                            continue;
                        }
                    };
@@ -1010,7 +953,7 @@ where
                }
                Io::Connect(node_id, addr) => {
                    if self.peers.connected().any(|(_, id)| id == &node_id) {
-
                        log::error!(
+
                        log::debug!(
                            target: "wire",
                            "Attempt to connect to already connected peer {node_id}"
                        );
@@ -1022,36 +965,33 @@ where
                    self.metrics.peer(node_id).outbound_connection_attempts += 1;

                    match dial::<G>(
-
                        addr.to_inner(),
+
                        (*addr).clone(),
                        node_id,
                        self.signer.clone().into_inner(),
                        self.service.config(),
                    )
                    .and_then(|session| {
-
                        NetTransport::<WireSession<G>>::with_session(
-
                            session,
-
                            netservices::Direction::Outbound,
-
                        )
+
                        Transport::<WireSession<G>>::with_session(session, Link::Outbound)
                    }) {
                        Ok(transport) => {
+
                            let token = self.tokens.advance();
                            self.outbound.insert(
-
                                transport.as_raw_fd(),
+
                                token,
                                Outbound {
-
                                    id: None,
+
                                    token,
                                    nid: node_id,
-
                                    addr: addr.to_inner(),
+
                                    addr: (*addr).clone(),
                                },
                            );
                            log::debug!(
                                target: "wire",
-
                                "Registering outbound transport for {node_id} (fd={})..",
-
                                transport.as_raw_fd()
+
                                "Registering outbound transport for {node_id}.."
                            );
                            self.actions
-
                                .push_back(reactor::Action::RegisterTransport(transport));
+
                                .push_back(reactor::Action::RegisterTransport(token, transport));
                        }
                        Err(err) => {
-
                            log::error!(target: "wire", "Error establishing connection to {addr}: {err}");
+
                            logger::establish_connection(&addr, &err);

                            self.service.disconnected(
                                node_id,
@@ -1067,7 +1007,7 @@ where
                            self.metrics.peer(nid).disconnects += 1;
                        }
                    } else {
-
                        log::warn!(target: "wire", "Peer {nid} is not connected: ignoring disconnect");
+
                        log::debug!(target: "wire", "Peer {nid} is not connected: ignoring disconnect");
                    }
                }
                Io::Wakeup(d) => {
@@ -1089,7 +1029,7 @@ where
                        // is in the service's i/o buffer. Since the service may not purge the
                        // buffer on disconnect, we should just ignore i/o actions that don't
                        // have a connected peer.
-
                        log::error!(target: "wire", "Peer {remote} is not connected: dropping fetch");
+
                        log::debug!(target: "wire", "Peer {remote} is not connected: dropping fetch");
                        continue;
                    };
                    let (stream, channels) =
@@ -1115,7 +1055,7 @@ where
                        );
                    }
                    if let Err(e) = self.worker.try_send(task) {
-
                        log::error!(
+
                        log::warn!(
                            target: "wire",
                            "Worker pool failed to accept outgoing fetch request: {e}"
                        );
@@ -1180,27 +1120,41 @@ pub fn dial<G: Ecdh<Pk = NodeId>>(
            ));
        }
    };
-
    // Nb. This timeout is currently not used by the underlying library due to the
-
    // `socket2` library not supporting non-blocking connect with timeout.
-
    let connection = net::TcpStream::connect_nonblocking(inet_addr, DEFAULT_DIAL_TIMEOUT)?;
+

+
    let addr = {
+
        use std::net::ToSocketAddrs as _;
+

+
        inet_addr
+
            .to_socket_addrs()?
+
            .next()
+
            .ok_or(io::ErrorKind::AddrNotAvailable)?
+
    };
+

+
    // NOTE: Previously, here was a note about setting the timeout for connecting
+
    // to DEFAULT_DIAL_TIMEOUT, for which we have not figured out a way yet.
+
    // Generally, we should understand what happens if the following call to
+
    // `connect` fails. How do we learn about it? Where's the leak?
+

+
    let connection = TcpStream::connect(addr)?;
+

    // Whether to tunnel regular connections through the proxy.
    let force_proxy = config.proxy.is_some();

-
    session::<G>(
+
    Ok(session::<G>(
        remote_addr,
        Some(remote_id),
        connection,
        force_proxy,
        signer,
-
    )
+
    ))
}

/// Accept a new connection.
pub fn accept<G: Ecdh<Pk = NodeId>>(
    remote_addr: NetAddr<HostName>,
-
    connection: net::TcpStream,
+
    connection: TcpStream,
    signer: G,
-
) -> io::Result<WireSession<G>> {
+
) -> WireSession<G> {
    session::<G>(remote_addr, None, connection, false, signer)
}

@@ -1208,42 +1162,79 @@ pub fn accept<G: Ecdh<Pk = NodeId>>(
fn session<G: Ecdh<Pk = NodeId>>(
    remote_addr: NetAddr<HostName>,
    remote_id: Option<NodeId>,
-
    connection: net::TcpStream,
+
    connection: TcpStream,
    force_proxy: bool,
    signer: G,
-
) -> io::Result<WireSession<G>> {
-
    // There are issues with setting TCP_NODELAY on WSL. Not a big deal.
+
) -> WireSession<G> {
    if let Err(e) = connection.set_nodelay(true) {
-
        log::warn!(target: "wire", "Unable to set TCP_NODELAY on fd {}: {e}", connection.as_raw_fd());
+
        log::warn!(target: "wire", "Unable to set TCP_NODELAY on socket {connection:?}: {e}");
+
    }
+

+
    let connection = std::net::TcpStream::from(connection);
+

+
    if let Err(e) = connection.set_read_timeout(Some(DEFAULT_CONNECTION_TIMEOUT)) {
+
        log::warn!(target: "wire", "Unable to set TCP read timeout on socket {connection:?}: {e}");
    }
-
    connection.set_read_timeout(Some(DEFAULT_CONNECTION_TIMEOUT))?;
-
    connection.set_write_timeout(Some(DEFAULT_CONNECTION_TIMEOUT))?;
-

-
    let sock = socket2::Socket::from(connection);
-
    let ka = socket2::TcpKeepalive::new()
-
        .with_time(time::Duration::from_secs(30))
-
        .with_interval(time::Duration::from_secs(10))
-
        .with_retries(3);
-
    if let Err(e) = sock.set_tcp_keepalive(&ka) {
-
        log::warn!(target: "wire", "Unable to set TCP_KEEPALIVE on fd {}: {e}", sock.as_raw_fd());
+

+
    if let Err(e) = connection.set_write_timeout(Some(DEFAULT_CONNECTION_TIMEOUT)) {
+
        log::warn!(target: "wire", "Unable to set TCP write timeout on socket {connection:?}: {e}");
    }

-
    let socks5 = socks5::Socks5::with(remote_addr, force_proxy);
-
    let proxy = Socks5Session::with(sock.into(), socks5);
-
    let pair = G::generate_keypair();
-
    let keyset = Keyset {
-
        e: pair.0,
-
        s: Some(signer),
-
        re: None,
-
        rs: remote_id,
+
    #[cfg(feature = "socket2")]
+
    {
+
        let connection = socket2::SockRef::from(&connection);
+

+
        let ka = socket2::TcpKeepalive::new()
+
            .with_time(time::Duration::from_secs(30))
+
            .with_interval(time::Duration::from_secs(10));
+

+
        #[cfg(not(windows))]
+
        let ka = ka.with_retries(3);
+

+
        if let Err(e) = connection.set_tcp_keepalive(&ka) {
+
            log::warn!(target: "wire", "Failed to set TCP_KEEPALIVE on socket {connection:?}: {e}");
+
        }
+
    }
+

+
    #[cfg(not(feature = "socket2"))]
+
    log::debug!(target: "wire", "Not attempting to set TCP_KEEPALIVE on socket {connection:?}");
+

+
    let connection = TcpStream::from_std(connection);
+

+
    let proxy = {
+
        let socks5 = socks5::Socks5::with(remote_addr, force_proxy);
+
        Socks5Session::new(connection, socks5)
+
    };
+

+
    let noise = {
+
        let pair = G::generate_keypair();
+

+
        let keyset = Keyset {
+
            e: pair.0,
+
            s: Some(signer),
+
            re: None,
+
            rs: remote_id,
+
        };
+

+
        NoiseState::initialize::<{ Sha256::OUTPUT_LEN }>(NOISE_XK, remote_id.is_some(), &[], keyset)
    };
-
    let noise = NoiseState::initialize::<{ Sha256::OUTPUT_LEN }>(
-
        NOISE_XK,
-
        remote_id.is_some(),
-
        &[],
-
        keyset,
-
    );
-
    Ok(WireSession::with(proxy, noise))
+

+
    WireSession::new(proxy, noise)
+
}
+

+
mod logger {
+
    use radicle::node::Address;
+

+
    pub fn establish_connection(addr: &Address, err: &std::io::Error) {
+
        use std::io::ErrorKind::*;
+
        match err.kind() {
+
            ConnectionRefused | ConnectionReset | HostUnreachable | ConnectionAborted
+
            | NotConnected => {
+
                log::info!(target: "wire", "Could not establish connection to {addr}: {err}")
+
            }
+
            _ => log::warn!(target: "wire", "Failed to establish connection to {addr}: {err}"),
+
        }
+
    }
}

#[cfg(test)]
modified crates/radicle-node/src/worker.rs
@@ -141,15 +141,51 @@ impl Worker {

                let timeout = channels.timeout();
                let (mut stream_r, stream_w) = channels.split();
-
                let header = match upload_pack::pktline::git_request(&mut stream_r) {
-
                    Ok(header) => header,
-
                    Err(e) => {
+

+
                let mut iter = gix_packetline::blocking_io::StreamingPeekableIter::new(
+
                    &mut stream_r,
+
                    &[gix_packetline::PacketLineRef::Flush],
+
                    false, /* packet tracing */
+
                );
+

+
                let header = match iter.read_line() {
+
                    None => {
+
                        return FetchResult::Responder {
+
                            rid: None,
+
                            result: Err(UploadError::PacketLine(std::io::Error::new(
+
                                std::io::ErrorKind::UnexpectedEof,
+
                                "unexpected end of stream while reading upload-pack header",
+
                            ))),
+
                        }
+
                    }
+
                    Some(Err(e)) => {
                        return FetchResult::Responder {
                            rid: None,
                            result: Err(UploadError::PacketLine(e)),
                        }
                    }
+
                    Some(Ok(Err(e))) => {
+
                        return FetchResult::Responder {
+
                            rid: None,
+
                            result: Err(UploadError::PacketLine(std::io::Error::new(
+
                                std::io::ErrorKind::InvalidData,
+
                                format!("invalid upload-pack header: {e}"),
+
                            ))),
+
                        }
+
                    }
+
                    Some(Ok(Ok(header))) => header,
                };
+

+
                let Some(header) = upload_pack::GitRequest::from_packetline(header) else {
+
                    return FetchResult::Responder {
+
                        rid: None,
+
                        result: Err(UploadError::PacketLine(std::io::Error::new(
+
                            std::io::ErrorKind::InvalidData,
+
                            "failed to parse upload-pack header",
+
                        ))),
+
                    };
+
                };
+

                log::debug!(target: "worker", "Spawning upload-pack process for {} on stream {stream}..", header.repo);

                if let Err(e) = self.is_authorized(remote, header.repo) {
@@ -240,7 +276,7 @@ impl Worker {
            // N.b. ensure that `git gc` works in debug mode.
            debug_assert!(false, "`git gc` failed: {e}");

-
            log::warn!(target: "worker", "Failed to run `git gc`: {e}");
+
            log::debug!(target: "worker", "Failed to run `git gc`: {e}");
        }
        Ok(result)
    }
modified crates/radicle-node/src/worker/fetch.rs
@@ -1,5 +1,6 @@
use radicle::identity::doc::CanonicalRefsError;
use radicle::identity::CanonicalRefs;
+
use radicle::storage::git::TempRepository;
pub(crate) use radicle_protocol::worker::fetch::error;

use std::collections::BTreeSet;
@@ -27,11 +28,10 @@ use super::channels::ChannelsFlush;

pub enum Handle {
    Clone {
-
        handle: radicle_fetch::Handle<ChannelsFlush>,
-
        tmp: tempfile::TempDir,
+
        handle: radicle_fetch::Handle<TempRepository, ChannelsFlush>,
    },
    Pull {
-
        handle: radicle_fetch::Handle<ChannelsFlush>,
+
        handle: radicle_fetch::Handle<Repository, ChannelsFlush>,
        notifications: node::notifications::StoreWriter,
    },
}
@@ -55,9 +55,9 @@ impl Handle {
                notifications,
            })
        } else {
-
            let (repo, tmp) = storage.lock_repository(rid)?;
+
            let repo = storage.temporary_repository(rid)?;
            let handle = radicle_fetch::Handle::new(local, repo, follow, blocked, channels)?;
-
            Ok(Handle::Clone { handle, tmp })
+
            Ok(Handle::Clone { handle })
        }
    }

@@ -72,11 +72,18 @@ impl Handle {
        refs_at: Option<Vec<RefsAt>>,
    ) -> Result<FetchResult, error::Fetch> {
        let (result, clone, notifs) = match self {
-
            Self::Clone { mut handle, tmp } => {
+
            Self::Clone { mut handle } => {
                log::debug!(target: "worker", "{} cloning from {remote}", handle.local());
-
                let result = radicle_fetch::clone(&mut handle, limit, remote)?;
-
                mv(tmp, storage, &rid)?;
-
                (result, true, None)
+
                match radicle_fetch::clone(&mut handle, limit, remote) {
+
                    Err(err) => {
+
                        handle.into_inner().cleanup();
+
                        return Err(err.into());
+
                    }
+
                    Ok(result) => {
+
                        handle.into_inner().mv(storage.path_of(&rid))?;
+
                        (result, true, None)
+
                    }
+
                }
            }
            Self::Pull {
                mut handle,
@@ -89,7 +96,7 @@ impl Handle {
        };

        for rejected in result.rejected() {
-
            log::warn!(target: "worker", "Rejected update for {}", rejected.refname())
+
            log::debug!(target: "worker", "Rejected update for {}", rejected.refname())
        }

        match result {
@@ -99,7 +106,7 @@ impl Handle {
                validations,
            } => {
                for fail in validations.iter() {
-
                    log::error!(target: "worker", "Validation error: {fail}");
+
                    log::warn!(target: "worker", "Validation error: {fail}");
                }
                Err(error::Fetch::Validation {
                    threshold,
@@ -112,7 +119,7 @@ impl Handle {
                validations,
            } => {
                for warn in validations {
-
                    log::warn!(target: "worker", "Validation error: {warn}");
+
                    log::debug!(target: "worker", "Validation error: {warn}");
                }

                // N.b. We do not go through handle for this since the cloning handle
@@ -126,7 +133,7 @@ impl Handle {
                        }
                    }
                    Err(RepositoryError::Quorum(e)) => {
-
                        log::warn!(target: "worker", "Fetch could not set HEAD: {e}")
+
                        log::warn!(target: "worker", "Fetch could not set HEAD for {rid}: {e}")
                    }
                    Err(e) => return Err(e.into()),
                }
@@ -134,7 +141,7 @@ impl Handle {
                let canonical = match set_canonical_refs(&repo, &applied) {
                    Ok(updates) => updates.unwrap_or_default(),
                    Err(e) => {
-
                        log::warn!(target: "worker", "Failed to set canonical references: {e}");
+
                        log::warn!(target: "worker", "Failed to set canonical references for {rid}: {e}");
                        UpdatedCanonicalRefs::default()
                    }
                };
@@ -164,35 +171,6 @@ impl Handle {
    }
}

-
/// In the case of cloning, we have performed the fetch into a
-
/// temporary directory -- ensuring that no concurrent operations
-
/// see an empty repository.
-
///
-
/// At the end of the clone, we perform a rename of the temporary
-
/// directory to the storage repository.
-
///
-
/// # Errors
-
///   - Will fail if `storage` contains `rid` already.
-
fn mv(tmp: tempfile::TempDir, storage: &Storage, rid: &RepoId) -> Result<(), error::Fetch> {
-
    use std::io::{Error, ErrorKind};
-

-
    let from = tmp.path();
-
    let to = storage.path_of(rid);
-

-
    if !to.exists() {
-
        std::fs::rename(from, to)?;
-
    } else {
-
        log::warn!(target: "worker", "Refusing to move cloned repository {rid} already exists");
-
        return Err(Error::new(
-
            ErrorKind::AlreadyExists,
-
            format!("repository already exists {to:?}"),
-
        )
-
        .into());
-
    }
-

-
    Ok(())
-
}
-

// Post notifications for the given refs.
fn notify(
    rid: &RepoId,
@@ -218,7 +196,7 @@ fn notify(
                // for sigref verification.
                continue;
            }
-
            if let Some(rest) = r.strip_prefix(git::refname!("refs/heads/patches")) {
+
            if let Some(rest) = r.strip_prefix(git::fmt::refname!("refs/heads/patches")) {
                if radicle::cob::ObjectId::from_str(rest.as_str()).is_ok() {
                    // Don't notify about patch branches, since we already get
                    // notifications about patch updates.
@@ -229,7 +207,7 @@ fn notify(
        if let RefUpdate::Skipped { .. } = update {
            // Don't notify about skipped refs.
        } else if let Err(e) = store.insert(rid, update, now) {
-
            log::error!(
+
            log::debug!(
                target: "worker",
                "Failed to update notification store for {rid}: {e}"
            );
@@ -249,8 +227,8 @@ where
        let name = r.name();
        let (namespace, qualified) = match radicle::git::parse_ref_namespaced(name) {
            Err(e) => {
-
                log::error!(target: "worker", "Git reference is invalid: {name:?}: {e}");
-
                log::warn!(target: "worker", "Skipping refs caching for fetch of {repo}");
+
                log::debug!(target: "worker", "Git reference is invalid: {name:?}: {e}");
+
                log::debug!(target: "worker", "Skipping refs caching for fetch of {repo}");
                break;
            }
            Ok((n, q)) => (n, q),
@@ -269,8 +247,8 @@ where
        };

        if let Err(e) = result {
-
            log::error!(target: "worker", "Error updating git refs cache for {name:?}: {e}");
-
            log::warn!(target: "worker", "Skipping refs caching for fetch of {repo}");
+
            log::debug!(target: "worker", "Failed to update git refs cache for {name:?}: {e}");
+
            log::debug!(target: "worker", "Skipping refs caching for fetch of {repo}");
            break;
        }
    }
@@ -348,7 +326,7 @@ where
        }
        Err(e) => {
            // Object was found, but failed to load. Fall-through.
-
            log::error!(target: "fetch", "Error loading COB {tid} from storage: {e}");
+
            log::debug!(target: "fetch", "Failed to load COB {tid} from storage: {e}");
        }
    }
    // The object has either been removed entirely from the repository,
@@ -402,7 +380,7 @@ fn set_canonical_refs(

        let canonical = match canonical.find_objects() {
            Err(err) => {
-
                log::warn!(target: "worker", "Failed to find objects for canonical computation: {err}");
+
                log::warn!(target: "worker", "Failed to find objects for canonical computation of `{name}`: {err}");
                continue;
            }
            Ok(canonical) => canonical,
@@ -412,7 +390,7 @@ fn set_canonical_refs(
            Err(err) => {
                log::warn!(
                    target: "worker",
-
                    "Failed to calculate canonical reference: {err}",
+
                    "Failed to calculate canonical reference `{name}`: {err}",
                );
                continue;
            }
@@ -422,7 +400,7 @@ fn set_canonical_refs(
                let oid = object.id();
                if let Err(e) = repo.backend.reference(
                    refname.clone().as_str(),
-
                    *oid,
+
                    oid.into(),
                    true,
                    "set-canonical-reference from fetch (radicle)",
                ) {
modified crates/radicle-node/src/worker/upload_pack.rs
@@ -25,7 +25,7 @@ pub fn upload_pack<R, W>(
    remote: NodeId,
    storage: &Storage,
    emitter: &Emitter<Event>,
-
    header: &pktline::GitRequest,
+
    header: &GitRequest,
    mut recv: R,
    send: W,
    timeout: Duration,
@@ -84,6 +84,9 @@ where
        cmd.spawn()?
    };

+
    #[cfg(windows)]
+
    let job = radicle_windows::jobs::Job::for_child(&child)?;
+

    let mut stdin = child.stdin.take().unwrap();
    let mut stdout = io::BufReader::new(child.stdout.take().unwrap());
    let reporter = std::sync::Mutex::new(Reporter::new(header.repo, remote, emitter.clone(), send));
@@ -97,7 +100,7 @@ where
                    Ok(n) => {
                        let mut lock = reporter.lock().expect("FATAL: upload_pack poisoned lock");
                        if let Err(e) = lock.write_all(&buffer[..n]) {
-
                            log::warn!(target: "worker", "Error reading stdout to upload-pack reporter: {e}");
+
                            log::debug!(target: "worker", "Failed to write buffer to upload-pack reporter: {e}");
                            emitter.emit(events::UploadPack::error(header.repo, remote, e).into());
                            break;
                        }
@@ -119,7 +122,7 @@ where
                    Ok(0) => break,
                    Ok(n) => {
                        if let Err(e) = stdin.write_all(&buffer[..n]) {
-
                            log::warn!(target: "worker", "Error writing to upload-pack stdin: {e}");
+
                            log::debug!(target: "worker", "Failed to write to upload-pack stdin: {e}");
                            break;
                        }
                    }
@@ -128,7 +131,7 @@ where
                        break;
                    }
                    Err(e) if e.kind() == io::ErrorKind::TimedOut => {
-
                        log::warn!(target: "worker", "Read channel timed out for upload-pack {}", header.repo);
+
                        log::debug!(target: "worker", "Read channel timed out for upload-pack {}", header.repo);
                        // N.b. if the read timed out, ensure that the sender isn't
                        // still sending messages.
                        let lock = reporter.lock().expect("FATAL: upload_pack poisoned lock");
@@ -137,7 +140,7 @@ where
                        }
                    }
                    Err(e) => {
-
                        log::error!(target: "worker", "Error on upload-pack channel read for {}: {e}", header.repo);
+
                        log::debug!(target: "worker", "Failure on upload-pack channel read for {}: {e}", header.repo);
                        emitter.emit(events::UploadPack::error(header.repo, remote, e).into());
                        break;
                    }
@@ -150,8 +153,12 @@ where
        if let Err(e) = reader.join() {
            log::warn!(target: "worker", "Upload pack thread panicked: {e:?}");
        }
-
        child.kill()?;
-
        Ok::<_, io::Error>(())
+

+
        #[cfg(unix)]
+
        return child.kill();
+

+
        #[cfg(windows)]
+
        return job.terminate(3);
    })?;

    let status = child.wait()?;
@@ -232,119 +239,63 @@ where
    }
}

-
pub(super) mod pktline {
-
    use std::io;
-
    use std::io::Read;
-
    use std::str;
-

-
    use radicle::prelude::RepoId;
-

-
    pub const HEADER_LEN: usize = 4;
-

-
    /// Read and parse the `GitRequest` data from the client side.
-
    pub fn git_request<R>(reader: &mut R) -> io::Result<GitRequest>
-
    where
-
        R: io::Read,
-
    {
-
        let mut reader = Reader::new(reader);
-
        let (header, _) = reader.read_request_pktline()?;
-
        Ok(header)
-
    }
-

-
    struct Reader<'a, R> {
-
        stream: &'a mut R,
-
    }
-

-
    impl<'a, R: io::Read> Reader<'a, R> {
-
        /// Create a new packet-line reader.
-
        pub fn new(stream: &'a mut R) -> Self {
-
            Self { stream }
-
        }
-

-
        /// Parse a Git request packet-line.
-
        ///
-
        /// Example: `0032git-upload-pack /project.git\0host=myserver.com\0`
-
        ///
-
        fn read_request_pktline(&mut self) -> io::Result<(GitRequest, Vec<u8>)> {
-
            let mut pktline = [0u8; 1024];
-
            let length = self.read_pktline(&mut pktline)?;
-
            let Some(cmd) = GitRequest::parse(&pktline[4..length]) else {
-
                return Err(io::ErrorKind::InvalidInput.into());
-
            };
-
            Ok((cmd, Vec::from(&pktline[..length])))
-
        }
-

-
        /// Parse a Git packet-line.
-
        fn read_pktline(&mut self, buf: &mut [u8]) -> io::Result<usize> {
-
            self.read_exact(&mut buf[..HEADER_LEN])?;
-

-
            let length = str::from_utf8(&buf[..HEADER_LEN])
-
                .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?;
-
            let length = usize::from_str_radix(length, 16)
-
                .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?;
-

-
            self.read_exact(&mut buf[HEADER_LEN..length])?;
-

-
            Ok(length)
-
        }
-
    }
-

-
    impl<R: io::Read> io::Read for Reader<'_, R> {
-
        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
-
            self.stream.read(buf)
-
        }
-
    }
+
/// The Git request packet-line for a repository.
+
///
+
/// See <https://git-scm.com/docs/pack-protocol.html#_git_transport>.
+
///
+
/// Example: `0032git-upload-pack /rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5.git\0host=myserver.com\0`
+
#[derive(Debug)]
+
pub struct GitRequest {
+
    pub repo: RepoId,
+
    #[allow(dead_code)]
+
    pub path: String,
+
    #[allow(dead_code)]
+
    pub host: Option<(String, Option<u16>)>,
+
    pub extra: Vec<(String, Option<String>)>,
+
}

-
    /// The Git request packet-line for a Heartwood repository.
-
    ///
-
    /// Example: `0032git-upload-pack /rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5.git\0host=myserver.com\0`
-
    #[derive(Debug)]
-
    pub struct GitRequest {
-
        pub repo: RepoId,
-
        #[allow(dead_code)]
-
        pub path: String,
-
        #[allow(dead_code)]
-
        pub host: Option<(String, Option<u16>)>,
-
        pub extra: Vec<(String, Option<String>)>,
+
impl GitRequest {
+
    pub(super) fn from_packetline(
+
        packet_line: gix_packetline::PacketLineRef<'_>,
+
    ) -> Option<GitRequest> {
+
        packet_line.as_slice().and_then(Self::parse)
    }

-
    impl GitRequest {
-
        /// Parse a Git command from a packet-line.
-
        fn parse(input: &[u8]) -> Option<Self> {
-
            let input = str::from_utf8(input).ok()?;
-
            let mut parts = input
-
                .strip_prefix("git-upload-pack ")?
-
                .split_terminator('\0');
-

-
            let path = parts.next()?.to_owned();
-
            let repo = path.strip_prefix('/')?.parse().ok()?;
-
            let host = match parts.next() {
-
                None | Some("") => None,
-
                Some(host) => {
-
                    let host = host.strip_prefix("host=")?;
-
                    match host.split_once(':') {
-
                        None => Some((host.to_owned(), None)),
-
                        Some((host, port)) => {
-
                            let port = port.parse::<u16>().ok()?;
-
                            Some((host.to_owned(), Some(port)))
-
                        }
+
    /// Parse a Git command from a packet-line.
+
    fn parse(input: &[u8]) -> Option<Self> {
+
        let input = std::str::from_utf8(input).ok()?;
+
        let mut parts = input
+
            .strip_prefix("git-upload-pack ")?
+
            .split_terminator('\0');
+

+
        let path = parts.next()?.to_owned();
+
        let repo = path.strip_prefix('/')?.parse().ok()?;
+
        let host = match parts.next() {
+
            None | Some("") => None,
+
            Some(host) => {
+
                let host = host.strip_prefix("host=")?;
+
                match host.split_once(':') {
+
                    None => Some((host.to_owned(), None)),
+
                    Some((host, port)) => {
+
                        let port = port.parse::<u16>().ok()?;
+
                        Some((host.to_owned(), Some(port)))
                    }
                }
-
            };
-
            let extra = parts
-
                .skip_while(|part| part.is_empty())
-
                .map(|part| match part.split_once('=') {
-
                    None => (part.to_owned(), None),
-
                    Some((k, v)) => (k.to_owned(), Some(v.to_owned())),
-
                })
-
                .collect();
-

-
            Some(Self {
-
                repo,
-
                path,
-
                host,
-
                extra,
+
            }
+
        };
+
        let extra = parts
+
            .skip_while(|part| part.is_empty())
+
            .map(|part| match part.split_once('=') {
+
                None => (part.to_owned(), None),
+
                Some((k, v)) => (k.to_owned(), Some(v.to_owned())),
            })
-
        }
+
            .collect();
+

+
        Some(Self {
+
            repo,
+
            path,
+
            host,
+
            extra,
+
        })
    }
}
added crates/radicle-oid/Cargo.toml
@@ -0,0 +1,31 @@
+
[package]
+
name = "radicle-oid"
+
description = "Radicle representation of Git object identifiers"
+
homepage.workspace = true
+
repository.workspace = true
+
version = "0.1.0"
+
edition.workspace = true
+
license.workspace = true
+
keywords = ["radicle", "git", "oid"]
+
rust-version.workspace = true
+

+
# For documentation of features refer to the module documentation in `./lib.rs`
+
[features]
+
default = ["sha1", "std"]
+
gix = ["dep:gix-hash"]
+
std = []
+
sha1 = []
+

+
[dependencies]
+
git2 = { workspace = true, optional = true, default-features = false }
+
gix-hash = { workspace = true, optional = true }
+
qcheck = { workspace = true, optional = true, default-features = false }
+
radicle-git-ref-format = { workspace = true, optional = true, default-features = false }
+
schemars = { workspace = true, optional = true, default-features = false }
+
serde = { workspace = true, optional = true, default-features = false }
+

+
[dev-dependencies]
+
git2 = { workspace = true }
+
gix-hash = { workspace = true }
+
qcheck = { workspace = true }
+
qcheck-macros = { workspace = true }

\ No newline at end of file
added crates/radicle-oid/src/lib.rs
@@ -0,0 +1,578 @@
+
#![no_std]
+

+
//! This is a `no_std` crate which carries the struct [`Oid`] that represents
+
//! Git object identifiers. Currently, only SHA-1 digests are supported.
+
//!
+
//! # Feature Flags
+
//!
+
//! The default features are `sha1` and `std`.
+
//!
+
//! ## `sha1`
+
//!
+
//! Enabled by default, since SHA-1 is commonly used. Currently, this feature is
+
//! also *required* to build the crate. In the future, after support for other
+
//! hashes is added, it might become possible to build the crate without support
+
//! for SHA-1.
+
//!
+
//! ## `std`
+
//!
+
//! [`Hash`]: ::doc_std::hash::Hash
+
//!
+
//! Enabled by default, since it is expected that most dependents will use the
+
//! standard library.
+
//!
+
//! Provides an implementation of [`Hash`].
+
//!
+
//! ## `git2`
+
//!
+
//! [`git2::Oid`]: ::git2::Oid
+
//!
+
//! Provides conversions to/from [`git2::Oid`].
+
//!
+
//! Note that as of version 0.19.0,
+
//!
+
//! ## `gix`
+
//!
+
//! [`ObjectId`]: ::gix_hash::ObjectId
+
//!
+
//! Provides conversions to/from [`ObjectId`].
+
//!
+
//! ## `schemars`
+
//!
+
//! [`JsonSchema`]: ::schemars::JsonSchema
+
//!
+
//! Provides an implementation of [`JsonSchema`].
+
//!
+
//! ## `serde`
+
//!
+
//! [`Serialize`]: ::serde::ser::Serialize
+
//! [`Deserialize`]: ::serde::de::Deserialize
+
//!
+
//! Provides implementations of [`Serialize`] and [`Deserialize`].
+
//!
+
//! ## `qcheck`
+
//!
+
//! [`qcheck::Arbitrary`]: ::qcheck::Arbitrary
+
//!
+
//! Provides an implementation of [`qcheck::Arbitrary`].
+
//!
+
//! ## `radicle-git-ref-format`
+
//!
+
//! [`radicle_git_ref_format::Component`]: ::radicle_git_ref_format::Component
+
//! [`radicle_git_ref_format::RefString`]: ::radicle_git_ref_format::RefString
+
//!
+
//! Conversion to [`radicle_git_ref_format::Component`]
+
//! (and also [`radicle_git_ref_format::RefString`]).
+

+
#[cfg(doc)]
+
extern crate std as doc_std;
+

+
extern crate alloc;
+

+
// Remove this once other hashes (e.g., SHA-256, and potentially others)
+
// are supported, and this crate can build without [`Oid::Sha1`].
+
#[cfg(not(feature = "sha1"))]
+
compile_error!("The `sha1` feature is required.");
+

+
const SHA1_DIGEST_LEN: usize = 20;
+

+
#[derive(PartialEq, Eq, Ord, PartialOrd, Clone, Copy)]
+
#[non_exhaustive]
+
pub enum Oid {
+
    Sha1([u8; SHA1_DIGEST_LEN]),
+
}
+

+
/// Conversions to/from SHA-1.
+
// Note that we deliberately do not implement `From<[u8; 20]>` and `Into<[u8; 20]>`,
+
// for forwards compatibility: What if another hash with digests of the same
+
// length becomes popular?
+
impl Oid {
+
    pub fn from_sha1(digest: [u8; SHA1_DIGEST_LEN]) -> Self {
+
        Self::Sha1(digest)
+
    }
+

+
    pub fn into_sha1(&self) -> Option<[u8; SHA1_DIGEST_LEN]> {
+
        match self {
+
            Oid::Sha1(digest) => Some(*digest),
+
        }
+
    }
+

+
    pub fn sha1_zero() -> Self {
+
        Self::Sha1([0u8; SHA1_DIGEST_LEN])
+
    }
+
}
+

+
/// Interaction with zero.
+
impl Oid {
+
    /// Test whether all bytes in this object identifier are zero.
+
    /// See also [`::git2::Oid::is_zero`].
+
    pub fn is_zero(&self) -> bool {
+
        match self {
+
            Oid::Sha1(ref array) => array.iter().all(|b| *b == 0),
+
        }
+
    }
+
}
+

+
impl AsRef<[u8]> for Oid {
+
    fn as_ref(&self) -> &[u8] {
+
        match self {
+
            Oid::Sha1(ref array) => array,
+
        }
+
    }
+
}
+

+
impl From<Oid> for alloc::boxed::Box<[u8]> {
+
    fn from(oid: Oid) -> Self {
+
        match oid {
+
            Oid::Sha1(array) => alloc::boxed::Box::new(array),
+
        }
+
    }
+
}
+

+
pub mod str {
+
    use super::{Oid, SHA1_DIGEST_LEN};
+
    use core::str;
+

+
    /// Length of the string representation of a SHA-1 digest in hexadecimal notation.
+
    pub(super) const SHA1_DIGEST_STR_LEN: usize = SHA1_DIGEST_LEN * 2;
+

+
    impl str::FromStr for Oid {
+
        type Err = error::ParseOidError;
+

+
        fn from_str(s: &str) -> Result<Self, Self::Err> {
+
            use error::ParseOidError::*;
+

+
            let len = s.len();
+
            if len != SHA1_DIGEST_STR_LEN {
+
                return Err(Len(len));
+
            }
+

+
            let mut bytes = [0u8; SHA1_DIGEST_LEN];
+
            for i in 0..SHA1_DIGEST_LEN {
+
                bytes[i] = u8::from_str_radix(&s[i * 2..=i * 2 + 1], 16)
+
                    .map_err(|source| At { index: i, source })?;
+
            }
+

+
            Ok(Self::Sha1(bytes))
+
        }
+
    }
+

+
    pub mod error {
+
        use core::{fmt, num};
+

+
        use super::SHA1_DIGEST_STR_LEN;
+

+
        pub enum ParseOidError {
+
            Len(usize),
+
            At {
+
                index: usize,
+
                source: num::ParseIntError,
+
            },
+
        }
+

+
        impl fmt::Display for ParseOidError {
+
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
                use ParseOidError::*;
+
                match self {
+
                    Len(len) => {
+
                        write!(f, "invalid length (have {len}, want {SHA1_DIGEST_STR_LEN})")
+
                    }
+
                    At { index, source } => write!(
+
                        f,
+
                        "parse error at byte {index} (characters {} and {}): {source}",
+
                        index * 2,
+
                        index * 2 + 1
+
                    ),
+
                }
+
            }
+
        }
+

+
        impl fmt::Debug for ParseOidError {
+
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
                fmt::Display::fmt(self, f)
+
            }
+
        }
+

+
        impl core::error::Error for ParseOidError {
+
            fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
+
                match self {
+
                    ParseOidError::At { source, .. } => Some(source),
+
                    _ => None,
+
                }
+
            }
+
        }
+
    }
+

+
    pub use error::ParseOidError;
+

+
    #[cfg(test)]
+
    mod test {
+
        use super::*;
+
        use alloc::string::ToString;
+
        use qcheck_macros::quickcheck;
+

+
        #[test]
+
        fn fixture() {
+
            assert_eq!(
+
                "123456789abcdef0123456789abcdef012345678"
+
                    .parse::<Oid>()
+
                    .unwrap(),
+
                Oid::from_sha1([
+
                    0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a,
+
                    0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78,
+
                ])
+
            );
+
        }
+

+
        #[test]
+
        fn zero() {
+
            assert_eq!(
+
                "0000000000000000000000000000000000000000"
+
                    .parse::<Oid>()
+
                    .unwrap(),
+
                Oid::sha1_zero()
+
            );
+
        }
+

+
        #[quickcheck]
+
        fn git2_roundtrip(oid: Oid) {
+
            let other = git2::Oid::from(oid);
+
            let other = other.to_string();
+
            let other = other.parse::<Oid>().unwrap();
+
            assert_eq!(oid, other);
+
        }
+

+
        #[quickcheck]
+
        fn gix_roundrip(oid: Oid) {
+
            let other = gix_hash::ObjectId::from(oid);
+
            let other = other.to_string();
+
            let other = other.parse::<Oid>().unwrap();
+
            assert_eq!(oid, other);
+
        }
+
    }
+
}
+

+
mod fmt {
+
    use alloc::format;
+
    use core::fmt;
+

+
    use super::Oid;
+

+
    impl fmt::Display for Oid {
+
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
            match self {
+
                Oid::Sha1(digest) =>
+
                // SAFETY (for all 20 blocks below): The length of `digest` is
+
                // known to be `SHA1_DIGEST_LEN`, which is 20.
+
                // The indices below are manually verified to not be out of bounds.
+
                format!(
+
                    "{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
+
                    unsafe { digest.get_unchecked(0) },
+
                    unsafe { digest.get_unchecked(1) },
+
                    unsafe { digest.get_unchecked(2) },
+
                    unsafe { digest.get_unchecked(3) },
+
                    unsafe { digest.get_unchecked(4) },
+
                    unsafe { digest.get_unchecked(5) },
+
                    unsafe { digest.get_unchecked(6) },
+
                    unsafe { digest.get_unchecked(7) },
+
                    unsafe { digest.get_unchecked(8) },
+
                    unsafe { digest.get_unchecked(9) },
+
                    unsafe { digest.get_unchecked(10) },
+
                    unsafe { digest.get_unchecked(11) },
+
                    unsafe { digest.get_unchecked(12) },
+
                    unsafe { digest.get_unchecked(13) },
+
                    unsafe { digest.get_unchecked(14) },
+
                    unsafe { digest.get_unchecked(15) },
+
                    unsafe { digest.get_unchecked(16) },
+
                    unsafe { digest.get_unchecked(17) },
+
                    unsafe { digest.get_unchecked(18) },
+
                    unsafe { digest.get_unchecked(19) },
+
                ).fmt(f)
+
            }
+
        }
+
    }
+

+
    impl fmt::Debug for Oid {
+
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
            fmt::Display::fmt(self, f)
+
        }
+
    }
+

+
    #[cfg(test)]
+
    mod test {
+
        use super::*;
+
        use alloc::string::ToString;
+
        use qcheck_macros::quickcheck;
+

+
        #[test]
+
        fn fixture() {
+
            assert_eq!(
+
                Oid::from_sha1([
+
                    0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a,
+
                    0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78,
+
                ])
+
                .to_string(),
+
                "123456789abcdef0123456789abcdef012345678"
+
            );
+
        }
+

+
        #[test]
+
        fn zero() {
+
            assert_eq!(
+
                Oid::sha1_zero().to_string(),
+
                "0000000000000000000000000000000000000000"
+
            );
+
        }
+

+
        #[quickcheck]
+
        fn git2(oid: Oid) {
+
            assert_eq!(oid.to_string(), git2::Oid::from(oid).to_string());
+
        }
+

+
        #[quickcheck]
+
        fn gix(oid: Oid) {
+
            assert_eq!(oid.to_string(), gix_hash::ObjectId::from(oid).to_string());
+
        }
+
    }
+
}
+

+
#[cfg(feature = "std")]
+
mod std {
+
    extern crate std;
+

+
    use super::Oid;
+

+
    mod hash {
+
        use std::hash;
+

+
        use super::*;
+

+
        #[allow(clippy::derived_hash_with_manual_eq)]
+
        impl hash::Hash for Oid {
+
            fn hash<H: hash::Hasher>(&self, state: &mut H) {
+
                let bytes: &[u8] = self.as_ref();
+
                std::hash::Hash::hash(bytes, state)
+
            }
+
        }
+
    }
+
}
+

+
#[cfg(any(feature = "gix", test))]
+
mod gix {
+
    use gix_hash::ObjectId as Other;
+

+
    use super::Oid;
+

+
    impl From<Other> for Oid {
+
        fn from(other: Other) -> Self {
+
            match other {
+
                Other::Sha1(digest) => Self::Sha1(digest),
+
                _ => panic!("unexpected SHA variant was returned for `gix_hash::ObjectId`"),
+
            }
+
        }
+
    }
+

+
    impl From<Oid> for Other {
+
        fn from(oid: Oid) -> Other {
+
            match oid {
+
                Oid::Sha1(digest) => Other::Sha1(digest),
+
            }
+
        }
+
    }
+

+
    impl core::cmp::PartialEq<Other> for Oid {
+
        fn eq(&self, other: &Other) -> bool {
+
            match (self, other) {
+
                (Oid::Sha1(a), Other::Sha1(b)) => a == b,
+
                _ => panic!("unexpected SHA variant was returned for `gix_hash::ObjectId`"),
+
            }
+
        }
+
    }
+

+
    impl AsRef<gix_hash::oid> for Oid {
+
        fn as_ref(&self) -> &gix_hash::oid {
+
            match self {
+
                Oid::Sha1(digest) => gix_hash::oid::from_bytes_unchecked(digest),
+
            }
+
        }
+
    }
+

+
    #[cfg(test)]
+
    mod test {
+
        use super::*;
+
        use gix_hash::Kind;
+

+
        #[test]
+
        fn zero() {
+
            assert!(Oid::sha1_zero() == Other::null(Kind::Sha1));
+
        }
+
    }
+
}
+

+
#[cfg(any(feature = "git2", test))]
+
mod git2 {
+
    use ::git2::Oid as Other;
+

+
    use super::*;
+

+
    const EXPECT: &str = "git2::Oid must be exactly 20 bytes long";
+

+
    impl From<Other> for Oid {
+
        fn from(other: Other) -> Self {
+
            Self::Sha1(other.as_bytes().try_into().expect(EXPECT))
+
        }
+
    }
+

+
    impl From<Oid> for Other {
+
        fn from(oid: Oid) -> Self {
+
            match oid {
+
                Oid::Sha1(array) => Other::from_bytes(&array).expect(EXPECT),
+
            }
+
        }
+
    }
+

+
    impl From<&Oid> for Other {
+
        fn from(oid: &Oid) -> Self {
+
            match oid {
+
                Oid::Sha1(array) => Other::from_bytes(array).expect(EXPECT),
+
            }
+
        }
+
    }
+

+
    impl core::cmp::PartialEq<Other> for Oid {
+
        fn eq(&self, other: &Other) -> bool {
+
            other.as_bytes() == AsRef::<[u8]>::as_ref(&self)
+
        }
+
    }
+

+
    #[cfg(test)]
+
    mod test {
+
        use super::*;
+

+
        #[test]
+
        fn zero() {
+
            assert!(Oid::sha1_zero() == Other::zero());
+
        }
+
    }
+
}
+

+
#[cfg(any(test, feature = "qcheck"))]
+
mod test {
+
    mod qcheck {
+
        use ::qcheck::{Arbitrary, Gen};
+

+
        use crate::*;
+

+
        impl Arbitrary for Oid {
+
            fn arbitrary(g: &mut Gen) -> Self {
+
                let slice = [0u8; SHA1_DIGEST_LEN];
+
                g.fill(slice);
+
                Self::Sha1(slice)
+
            }
+
        }
+
    }
+
}
+

+
#[cfg(feature = "serde")]
+
mod serde {
+
    mod ser {
+
        use ::serde::ser;
+

+
        use crate::*;
+

+
        impl ser::Serialize for Oid {
+
            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
            where
+
                S: ser::Serializer,
+
            {
+
                serializer.collect_str(self)
+
            }
+
        }
+
    }
+

+
    mod de {
+
        use core::fmt;
+

+
        use ::serde::de;
+

+
        use crate::*;
+

+
        impl<'de> de::Deserialize<'de> for Oid {
+
            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+
            where
+
                D: de::Deserializer<'de>,
+
            {
+
                struct OidVisitor;
+

+
                impl<'de> de::Visitor<'de> for OidVisitor {
+
                    type Value = Oid;
+

+
                    fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+
                        use crate::str::SHA1_DIGEST_STR_LEN;
+
                        write!(f, "a Git object identifier (SHA-1 digest in hexadecimal notation; {SHA1_DIGEST_STR_LEN} characters; {SHA1_DIGEST_LEN} bytes)")
+
                    }
+

+
                    fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
+
                    where
+
                        E: de::Error,
+
                    {
+
                        s.parse().map_err(de::Error::custom)
+
                    }
+
                }
+

+
                deserializer.deserialize_str(OidVisitor)
+
            }
+
        }
+
    }
+
}
+

+
#[cfg(feature = "radicle-git-ref-format")]
+
mod radicle_git_ref_format {
+
    use ::radicle_git_ref_format::{Component, RefString};
+

+
    use super::*;
+

+
    impl From<&Oid> for Component<'_> {
+
        fn from(id: &Oid) -> Self {
+
            Component::from_refstr(RefString::from(id))
+
                .expect("Git object identifiers are valid component strings")
+
        }
+
    }
+

+
    impl From<&Oid> for RefString {
+
        fn from(id: &Oid) -> Self {
+
            RefString::try_from(alloc::format!("{id}"))
+
                .expect("Git object identifiers are valid reference strings")
+
        }
+
    }
+
}
+

+
#[cfg(feature = "schemars")]
+
mod schemars {
+
    use alloc::{borrow::Cow, format};
+

+
    use ::schemars::{json_schema, JsonSchema, Schema, SchemaGenerator};
+

+
    use super::Oid;
+

+
    impl JsonSchema for Oid {
+
        fn schema_name() -> Cow<'static, str> {
+
            "Oid".into()
+
        }
+

+
        fn schema_id() -> Cow<'static, str> {
+
            concat!(module_path!(), "::Oid").into()
+
        }
+

+
        fn json_schema(_: &mut SchemaGenerator) -> Schema {
+
            use crate::{str::SHA1_DIGEST_STR_LEN, SHA1_DIGEST_LEN};
+
            json_schema!({
+
                "description": format!("A Git object identifier (SHA-1 digest in hexadecimal notation; {SHA1_DIGEST_STR_LEN} characters; {SHA1_DIGEST_LEN} bytes)"),
+
                "type": "string",
+
                "maxLength": SHA1_DIGEST_STR_LEN,
+
                "minLength": SHA1_DIGEST_STR_LEN,
+
                "pattern":  format!("^[0-9a-fA-F]{{{SHA1_DIGEST_STR_LEN}}}$"),
+
            })
+
        }
+
    }
+
}
added crates/radicle-protocol/CHANGELOG.md
@@ -0,0 +1,52 @@
+
# Changelog
+

+
All notable changes to this project will be documented in this file.
+

+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+

+
## [Unreleased]
+

+
### Added
+

+
### Changed
+

+
### Removed
+

+
### Security
+

+
## 0.5.0
+

+
### Added
+

+
- `radicle_protocol::service::DisconnectReason` added a `Policy` variant.
+
- `radicle_protocol::service::ConnectError` added `UnsupportedAddress` and
+
  `Blocked` variants.
+
- `radicle_protocol::service::command::Command` added a `Block` variant.
+
- `radicle_protocol::service::command::Command::AnnounceRefs` now carries an
+
  additional field for specifying namespaces.
+
- `radicle_protocol::service::command::Command::Seeds` now carries an
+
  additional field for specifying namespaces.
+

+
### Changed
+

+
- `radicle_protocol::service::session::Session::outbound` now takes 4
+
  parameters instead of 5. Fetching information is no longer tracked in the
+
  session.
+
- `radicle_protocol::service::session::Session::inbound` now takes 5
+
  parameters instead of 6. Fetching information is no longer tracked in the
+
  session.
+

+
### Removed
+

+
- `radicle_protocol::service::CommandError` was removed.
+
- `radicle_protocol::service::Error::GitExt` was removed as a variant, where
+
  `Error::Git` now subsumes all Git errors.
+
- The `queue` field was removed from the `Session` struct. Fetching information
+
  is now tracked in the service rather than per-session.
+
- The following methods were removed from `Session`: `is_at_capacity`,
+
  `is_fetching`, `queue_fetch`, `dequeue_fetch`, `fetching`, and `fetched`.
+

+
### Security
+

+
*No security updates.*
modified crates/radicle-protocol/Cargo.toml
@@ -3,7 +3,7 @@ name = "radicle-protocol"
description = "The Radicle Protocol"
homepage.workspace = true
license.workspace = true
-
version = "0.3.0"
+
version = "0.5.0"
authors = ["Radicle Team <team@radicle.xyz>"]
edition.workspace = true
rust-version.workspace = true
@@ -15,20 +15,20 @@ test = ["radicle/test", "radicle-crypto/test", "radicle-crypto/cyphernet", "qche
bloomy = "1.2"
bytes = { workspace = true }
crossbeam-channel = { workspace = true }
-
cyphernet = { workspace = true, features = ["tor"] }
+
cypheraddr = { workspace = true, features = ["serde", "tor"] }
fastrand = { workspace = true }
log = { workspace = true, features = ["std"] }
-
localtime = { workspace = true }
nonempty = { workspace = true, features = ["serialize"] }
qcheck = { workspace = true, optional = true }
radicle = { workspace = true, features = ["logger"] }
+
radicle-core = { workspace = true }
radicle-fetch = { workspace = true }
-
radicle-git-ext = { workspace = true, features = ["serde"] }
+
radicle-localtime = { workspace = true }
sqlite = { workspace = true, features = ["bundled"] }
scrypt = { version = "0.11.0", default-features = false }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["preserve_order"] }
-
thiserror = { workspace = true }
+
thiserror = { workspace = true, default-features = true }

[dev-dependencies]
paste = "1.0.15"
modified crates/radicle-protocol/src/bounded.rs
@@ -160,7 +160,7 @@ impl<T, const N: usize> BoundedVec<T, N> {
    }

    /// Calls [`std::vec::Drain`].
-
    pub fn drain<R: RangeBounds<usize>>(&mut self, range: R) -> std::vec::Drain<T> {
+
    pub fn drain<R: RangeBounds<usize>>(&mut self, range: R) -> std::vec::Drain<'_, T> {
        self.v.drain(range)
    }
}
@@ -180,6 +180,15 @@ impl<T: Clone, const N: usize> BoundedVec<T, N> {
    }
}

+
impl<T, const N: usize> IntoIterator for BoundedVec<T, N> {
+
    type Item = T;
+
    type IntoIter = std::vec::IntoIter<T>;
+

+
    fn into_iter(self) -> Self::IntoIter {
+
        self.v.into_iter()
+
    }
+
}
+

impl<T, const N: usize> ops::Deref for BoundedVec<T, N> {
    type Target = [T];

added crates/radicle-protocol/src/fetcher.rs
@@ -0,0 +1,67 @@
+
use nonempty::NonEmpty;
+
use radicle::storage::refs::RefsAt;
+
use serde::{Deserialize, Serialize};
+

+
pub mod service;
+
pub use service::FetcherService;
+

+
pub mod state;
+
pub use state::{ActiveFetch, Config, FetcherState, MaxQueueSize, Queue, QueueIter, QueuedFetch};
+

+
#[cfg(test)]
+
mod test;
+

+
// TODO(finto): `Service::fetch_refs_at` and the use of `refs_status_of` is a
+
// layer above the `Fetcher` where it would perform I/O, mocked out by a trait,
+
// to check if there are wants and add a fetch to the Fetcher.
+

+
/// Represents references to fetch, in the context of a repository.
+
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+
pub enum RefsToFetch {
+
    /// Indicates that all references should be fetched.
+
    All,
+
    /// Contains a non-empty collection of specific references to fetch.
+
    Refs(NonEmpty<RefsAt>),
+
}
+

+
impl RefsToFetch {
+
    /// Merges another `RefsToFetch` into this one, resulting in a new
+
    /// `RefsToFetch` that represents the combined set of references to fetch.
+
    /// If either `RefsToFetch` is `All`, the result will be `All`. If both are
+
    /// `Refs`, their contents will be combined into a single `Refs` variant.
+
    pub(super) fn merge(self, other: RefsToFetch) -> Self {
+
        match (self, other) {
+
            (RefsToFetch::All, _) | (_, RefsToFetch::All) => RefsToFetch::All,
+
            (RefsToFetch::Refs(mut ours), RefsToFetch::Refs(theirs)) => {
+
                ours.extend(theirs);
+
                RefsToFetch::Refs(ours)
+
            }
+
        }
+
    }
+

+
    #[cfg(test)]
+
    pub fn len(&self) -> Option<std::num::NonZeroUsize> {
+
        match self {
+
            RefsToFetch::All => None,
+
            RefsToFetch::Refs(refs) => std::num::NonZeroUsize::new(refs.len()),
+
        }
+
    }
+
}
+

+
impl From<RefsToFetch> for Vec<RefsAt> {
+
    fn from(val: RefsToFetch) -> Self {
+
        match val {
+
            RefsToFetch::All => Vec::new(),
+
            RefsToFetch::Refs(refs) => refs.into(),
+
        }
+
    }
+
}
+

+
impl From<Vec<RefsAt>> for RefsToFetch {
+
    fn from(refs_at: Vec<RefsAt>) -> Self {
+
        match NonEmpty::from_vec(refs_at) {
+
            Some(refs) => RefsToFetch::Refs(refs),
+
            None => RefsToFetch::All,
+
        }
+
    }
+
}
added crates/radicle-protocol/src/fetcher/service.rs
@@ -0,0 +1,242 @@
+
use std::collections::HashMap;
+

+
use radicle_core::{NodeId, RepoId};
+

+
use crate::fetcher::{
+
    state::{
+
        command::{self},
+
        event, Config, FetcherState, QueuedFetch,
+
    },
+
    RefsToFetch,
+
};
+

+
/// Service layer that wraps [`FetcherState`] and manages subscriber coalescing.
+
///
+
/// When multiple callers request the same fetch, their subscribers are collected
+
/// and all notified when the fetch completes.
+
///
+
/// # Type Parameter
+
/// - `S`: The subscriber type (e.g., `chan::Sender<FetchResult>`).
+
#[derive(Debug)]
+
pub struct FetcherService<S> {
+
    state: FetcherState,
+
    subscribers: HashMap<FetchKey, Vec<S>>,
+
}
+

+
impl<S> FetcherService<S> {
+
    /// Initialize the [`FetcherService`] with the give [`Config`].
+
    pub fn new(config: Config) -> Self {
+
        Self {
+
            state: FetcherState::new(config),
+
            subscribers: HashMap::new(),
+
        }
+
    }
+

+
    /// Provide a reference handle to the [`FetcherState`].
+
    pub fn state(&self) -> &FetcherState {
+
        &self.state
+
    }
+
}
+

+
/// Key for pending subscribers.
+
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+
struct FetchKey {
+
    rid: RepoId,
+
    node: NodeId,
+
    refs: RefsToFetch,
+
}
+

+
impl FetchKey {
+
    fn new(rid: RepoId, node: NodeId, refs: RefsToFetch) -> Self {
+
        Self { rid, node, refs }
+
    }
+
}
+

+
/// The result of calling [`FetcherService::fetch`].
+
#[must_use]
+
#[derive(Debug)]
+
pub struct FetchInitiated<S> {
+
    /// The underlying result from calling [`FetcherState::fetch`].
+
    pub event: event::Fetch,
+
    /// Subscriber returned if fetch was rejected (queue at capacity).
+
    pub rejected: Option<S>,
+
}
+

+
/// The result of calling [`FetcherService::fetched`].
+
#[must_use]
+
#[derive(Debug)]
+
pub struct FetchCompleted<S> {
+
    /// The underlying result from calling [`FetcherState::fetched`].
+
    pub event: event::Fetched,
+
    /// All the subscribers that were interested in this given fetch.
+
    pub subscribers: Vec<S>,
+
}
+

+
/// The result of calling [`FetcherService::cancel`].
+
#[must_use]
+
#[derive(Debug)]
+
pub struct FetchesCancelled<S> {
+
    /// The underlying result from calling [`FetcherState::cancel`].
+
    pub event: event::Cancel,
+
    /// Orphaned subscribers paired with their [`RepoId`].
+
    pub orphaned: Vec<(RepoId, S)>,
+
}
+

+
impl<S> FetcherService<S> {
+
    /// Initiate a fetch, optionally registering a subscriber.
+
    ///
+
    /// Subscribers are coalesced: if the same `(rid, node)` is already being
+
    /// fetched or queued, the subscriber joins the existing waiters.
+
    ///
+
    /// If the fetch could not be initiated, and also could not be queued, then
+
    /// subscriber is returned to notify of the rejection.
+
    ///
+
    /// See [`FetcherState::fetch`].
+
    pub fn fetch(&mut self, cmd: command::Fetch, subscriber: Option<S>) -> FetchInitiated<S> {
+
        let key = FetchKey::new(cmd.rid, cmd.from, cmd.refs.clone());
+
        let event = self.state.fetch(cmd);
+

+
        let rejected = match &event {
+
            event::Fetch::QueueAtCapacity { .. } => subscriber,
+
            _ => {
+
                if let Some(r) = subscriber {
+
                    self.subscribers.entry(key).or_default().push(r);
+
                }
+
                None
+
            }
+
        };
+

+
        FetchInitiated { event, rejected }
+
    }
+

+
    /// Mark a fetch as completed and retrieve waiting subscribers.
+
    ///
+
    /// See [`FetcherState::fetched`].
+
    pub fn fetched(&mut self, cmd: command::Fetched) -> FetchCompleted<S> {
+
        let event = self.state.fetched(cmd);
+
        match event {
+
            // TODO(finto): drop subscribers with this partial key?
+
            e @ event::Fetched::NotFound { .. } => FetchCompleted {
+
                event: e,
+
                subscribers: vec![],
+
            },
+
            ref e @ event::Fetched::Completed { ref refs, .. } => {
+
                let key = FetchKey::new(cmd.rid, cmd.from, refs.clone());
+
                let subscribers = self.subscribers.remove(&key).unwrap_or_default();
+
                FetchCompleted {
+
                    event: e.clone(),
+
                    subscribers,
+
                }
+
            }
+
        }
+
    }
+

+
    /// Cancel all fetches for a disconnected peer, returning any orphaned
+
    /// subscribers.
+
    ///
+
    /// See [`FetcherState::cancel`].
+
    pub fn cancel(&mut self, cmd: command::Cancel) -> FetchesCancelled<S> {
+
        let from = cmd.from;
+
        let event = self.state.cancel(cmd);
+

+
        let mut orphaned = Vec::new();
+
        self.subscribers.retain(|key, subscribers| {
+
            if key.node == from {
+
                orphaned.extend(subscribers.drain(..).map(|r| (key.rid, r)));
+
                false
+
            } else {
+
                true
+
            }
+
        });
+

+
        FetchesCancelled { event, orphaned }
+
    }
+

+
    /// Dequeue the next fetch for a node.
+
    ///
+
    /// See [`FetcherState::dequeue`].
+
    pub fn dequeue(&mut self, from: &NodeId) -> Option<QueuedFetch> {
+
        self.state.dequeue(from)
+
    }
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    use radicle::storage::refs::RefsAt;
+
    use radicle::test::arbitrary;
+
    use std::num::NonZeroUsize;
+
    use std::time::Duration;
+

+
    use super::*;
+

+
    use crate::fetcher::MaxQueueSize;
+

+
    #[test]
+
    fn test_fetch_coalescing_different_refs() {
+
        let config = Config::new()
+
            .with_max_concurrency(NonZeroUsize::new(1).unwrap())
+
            .with_max_capacity(MaxQueueSize::new(NonZeroUsize::new(10).unwrap()));
+
        let mut service = FetcherService::<usize>::new(config);
+
        let node = arbitrary::gen(1);
+
        let repo = arbitrary::gen(1);
+
        let refs_specific: Vec<RefsAt> = arbitrary::vec(2);
+
        let refs_all = vec![];
+
        let timeout = Duration::from_secs(30);
+

+
        // fetch specific refs (Subscriber 1)
+
        let initiated1 = service.fetch(
+
            command::Fetch {
+
                from: node,
+
                rid: repo,
+
                refs: refs_specific.clone().into(),
+
                timeout,
+
            },
+
            Some(1),
+
        );
+

+
        assert!(matches!(initiated1.event, event::Fetch::Started { .. }));
+

+
        // fetch all refs (Subscriber 2)
+
        let initiated2 = service.fetch(
+
            command::Fetch {
+
                from: node,
+
                rid: repo,
+
                refs: refs_all.clone().into(),
+
                timeout,
+
            },
+
            Some(2),
+
        );
+

+
        // should be queued because refs differ
+

+
        assert!(matches!(initiated2.event, event::Fetch::Queued { .. }));
+

+
        // complete the specific refs fetch
+

+
        let completed = service.fetched(command::Fetched {
+
            from: node,
+
            rid: repo,
+
        });
+

+
        match completed.event {
+
            event::Fetched::Completed { ref refs, .. } => {
+
                assert_eq!(refs, &refs_specific.into());
+
            }
+
            _ => panic!("Expected Completed event"),
+
        }
+

+
        // only Subscriber 1 should be notified
+
        assert_eq!(completed.subscribers, vec![1]);
+

+
        // subscriber 2 should still be waiting
+
        assert!(service.subscribers.contains_key(&FetchKey::new(
+
            repo,
+
            node,
+
            refs_all.clone().into()
+
        )));
+

+
        let remaining = &service.subscribers[&FetchKey::new(repo, node, refs_all.into())];
+
        assert_eq!(remaining.len(), 1);
+
        assert_eq!(remaining[0], 2);
+
    }
+
}
added crates/radicle-protocol/src/fetcher/state.rs
@@ -0,0 +1,439 @@
+
//! Logical state for Git fetches happening in the node.
+
//!
+
//! See [`FetcherState`] for more information.
+
//!
+
//! See [`command`]'s for input into [`FetcherState`].
+
//! See [`event`]'s for output from [`FetcherState`].
+

+
pub mod command;
+
pub mod event;
+

+
pub use command::Command;
+
pub use event::Event;
+

+
use std::collections::{BTreeMap, VecDeque};
+
use std::num::NonZeroUsize;
+
use std::time;
+

+
use radicle_core::{NodeId, RepoId};
+

+
use crate::fetcher::RefsToFetch;
+

+
/// Default for the maximum items per fetch queue.
+
pub const MAX_FETCH_QUEUE_SIZE: usize = 128;
+
/// Default for maximum concurrency per node.
+
pub const MAX_CONCURRENCY: NonZeroUsize = NonZeroUsize::MIN;
+

+
/// Logical state for Git fetches happening in the node.
+
///
+
/// A fetch can either be:
+
///   - [`ActiveFetch`]: meaning it is currently being fetched from another node on the network
+
///   - [`QueuedFetch`]: meaning it is expected to be fetched from a given node, but the
+
///     repository is already being fetched, or the node is at capacity.
+
///
+
/// For any given repository, identified by its [`RepoId`], there can only be
+
/// one fetch occurring for it at a given time. This prevents any concurrent
+
/// fetches from clobbering overlapping references.
+
///
+
/// If the repository is actively being fetched, then that fetch will be queued
+
/// for a later attempt.
+
///
+
/// For any given node, there is a configurable capacity so that only `N` number
+
/// of fetches can happen with it concurrently. This does not guarantee that the
+
/// node will actually allow this node to fetch from it – since it will maintain
+
/// its own capacity for connections and load.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct FetcherState {
+
    /// The active fetches that are occurring, ensuring only one fetch per repository.
+
    active: BTreeMap<RepoId, ActiveFetch>,
+
    /// The queued fetches, waiting to happen, where each node maintains its own queue.
+
    queues: BTreeMap<NodeId, Queue>,
+
    /// Configuration for maintaining the fetch state.
+
    config: Config,
+
}
+

+
impl Default for FetcherState {
+
    fn default() -> Self {
+
        Self::new(Config::default())
+
    }
+
}
+

+
impl FetcherState {
+
    /// Initialize the [`FetcherState`] with the given [`Config`].
+
    pub fn new(config: Config) -> Self {
+
        Self {
+
            active: BTreeMap::new(),
+
            queues: BTreeMap::new(),
+
            config,
+
        }
+
    }
+
}
+

+
impl FetcherState {
+
    /// Process the handling of a [`Command`], delegating to its corresponding
+
    /// method, and returning the corresponding [`Event`].
+
    ///
+
    /// This method is useful if the [`FetcherState`] is used in batch
+
    /// processing and does need to be explicit about the underlying method.
+
    pub fn handle(&mut self, command: Command) -> Event {
+
        match command {
+
            Command::Fetch(fetch) => self.fetch(fetch).into(),
+
            Command::Fetched(fetched) => self.fetched(fetched).into(),
+
            Command::Cancel(cancel) => self.cancel(cancel).into(),
+
        }
+
    }
+

+
    /// Process a [`Fetch`] command, which transitions the given fetch to
+
    /// active, if possible.
+
    ///
+
    /// The fetch will only transition to being active if:
+
    ///
+
    ///   - A fetch is not already happening for that repository, in which case it gets queued.
+
    ///   - The node to be fetched from is not already at capacity, again it will be queued.
+
    ///
+
    /// [`Fetch`]: command::Fetch
+
    pub fn fetch(
+
        &mut self,
+
        command::Fetch {
+
            from,
+
            rid,
+
            refs,
+
            timeout,
+
        }: command::Fetch,
+
    ) -> event::Fetch {
+
        if let Some(active) = self.active.get(&rid) {
+
            if active.refs == refs && active.from == from {
+
                return event::Fetch::AlreadyFetching { rid, from };
+
            } else {
+
                return self.enqueue(rid, from, refs, timeout);
+
            }
+
        }
+

+
        if self.is_at_node_capacity(&from) {
+
            self.enqueue(rid, from, refs, timeout)
+
        } else {
+
            self.active.insert(
+
                rid,
+
                ActiveFetch {
+
                    from,
+
                    refs: refs.clone(),
+
                },
+
            );
+
            event::Fetch::Started {
+
                rid,
+
                from,
+
                refs,
+
                timeout,
+
            }
+
        }
+
    }
+

+
    /// Process a [`Fetched`] command, which removes the given fetch from the set of active fetches.
+
    /// Note that this is agnostic of whether the fetch succeeded or failed.
+
    ///
+
    /// The caller will be notified if the completed fetch did not exist in the active set.
+
    ///
+
    /// [`Fetched`]: command::Fetched
+
    pub fn fetched(&mut self, command::Fetched { from, rid }: command::Fetched) -> event::Fetched {
+
        match self.active.remove(&rid) {
+
            None => event::Fetched::NotFound { from, rid },
+
            Some(ActiveFetch { from, refs }) => event::Fetched::Completed { from, rid, refs },
+
        }
+
    }
+

+
    /// Attempt to dequeue a [`QueuedFetch`] for the given node.
+
    ///
+
    /// This will only dequeue the fetch if it is not active, and the given node
+
    /// is not at capacity.
+
    pub fn dequeue(&mut self, from: &NodeId) -> Option<QueuedFetch> {
+
        let is_at_capacity = self.is_at_node_capacity(from);
+
        let queue = self.queues.get_mut(from)?;
+
        let active = &self.active;
+
        queue.try_dequeue(|QueuedFetch { rid, .. }| !is_at_capacity && !active.contains_key(rid))
+
    }
+

+
    /// Process a [`Cancel`] command, which cancels any active and/or queued
+
    /// fetches for that given node.
+
    ///
+
    /// [`Cancel`]: command::Cancel
+
    pub fn cancel(&mut self, command::Cancel { from }: command::Cancel) -> event::Cancel {
+
        let cancelled: Vec<_> = self
+
            .active
+
            .iter()
+
            .filter_map(|(rid, f)| (f.from == from).then_some(*rid))
+
            .collect();
+
        let ongoing: BTreeMap<_, _> = cancelled
+
            .iter()
+
            .filter_map(|rid| self.active.remove(rid).map(|f| (*rid, f)))
+
            .collect();
+
        let ongoing = (!ongoing.is_empty()).then_some(ongoing);
+
        let queued = self.queues.remove(&from).filter(|queue| !queue.is_empty());
+

+
        match (ongoing, queued) {
+
            (None, None) => event::Cancel::Unexpected { from },
+
            (ongoing, queued) => event::Cancel::Canceled {
+
                from,
+
                active: ongoing.unwrap_or_default(),
+
                queued: queued.map(|q| q.queue).unwrap_or_default(),
+
            },
+
        }
+
    }
+

+
    fn enqueue(
+
        &mut self,
+
        rid: RepoId,
+
        from: NodeId,
+
        refs: RefsToFetch,
+
        timeout: time::Duration,
+
    ) -> event::Fetch {
+
        let queue = self
+
            .queues
+
            .entry(from)
+
            .or_insert(Queue::new(self.config.maximum_queue_size));
+
        match queue.enqueue(QueuedFetch { rid, refs, timeout }) {
+
            Enqueue::CapacityReached(QueuedFetch { rid, refs, timeout }) => {
+
                event::Fetch::QueueAtCapacity {
+
                    rid,
+
                    from,
+
                    refs,
+
                    timeout,
+
                    capacity: queue.len(),
+
                }
+
            }
+
            Enqueue::Queued => event::Fetch::Queued { rid, from },
+
            Enqueue::Merged => event::Fetch::Queued { rid, from },
+
        }
+
    }
+
}
+

+
impl FetcherState {
+
    /// Get the set of queued fetches.
+
    pub fn queued_fetches(&self) -> &BTreeMap<NodeId, Queue> {
+
        &self.queues
+
    }
+

+
    /// Get the set of active fetches.
+
    pub fn active_fetches(&self) -> &BTreeMap<RepoId, ActiveFetch> {
+
        &self.active
+
    }
+

+
    /// Get the [`ActiveFetch`] for the provided [`RepoId`], returning `None` if
+
    /// it does not exist.
+
    pub fn get_active_fetch(&self, rid: &RepoId) -> Option<&ActiveFetch> {
+
        self.active.get(rid)
+
    }
+

+
    /// Check if the number of fetches exceeds the maximum number of concurrent
+
    /// fetches for a given [`NodeId`].
+
    ///
+
    /// Returns `true` if the fetcher is fetching the maximum number of
+
    /// repositories, for that node.
+
    fn is_at_node_capacity(&self, node: &NodeId) -> bool {
+
        let count = self.active.values().filter(|f| &f.from == node).count();
+
        count >= self.config.maximum_concurrency.into()
+
    }
+
}
+

+
/// Configuration for the [`FetcherState`].
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+
pub struct Config {
+
    /// Maximum number of concurrent fetches per peer connection.
+
    maximum_concurrency: NonZeroUsize,
+
    /// Maximum fetching queue size for a single node.
+
    maximum_queue_size: MaxQueueSize,
+
}
+

+
impl Config {
+
    pub fn new() -> Self {
+
        Self::default()
+
    }
+

+
    /// Maximum fetching queue size for a single node.
+
    pub fn with_max_capacity(mut self, capacity: MaxQueueSize) -> Self {
+
        self.maximum_queue_size = capacity;
+
        self
+
    }
+

+
    /// Maximum number of concurrent fetches per peer connection.
+
    pub fn with_max_concurrency(mut self, concurrency: NonZeroUsize) -> Self {
+
        self.maximum_concurrency = concurrency;
+
        self
+
    }
+
}
+

+
impl Default for Config {
+
    fn default() -> Self {
+
        Self {
+
            maximum_concurrency: MAX_CONCURRENCY,
+
            maximum_queue_size: MaxQueueSize::default(),
+
        }
+
    }
+
}
+

+
/// An active fetch represents a repository being fetched by a particular node.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct ActiveFetch {
+
    pub from: NodeId,
+
    pub refs: RefsToFetch,
+
}
+

+
impl ActiveFetch {
+
    /// The node from which the repository is being fetched.
+
    pub fn from(&self) -> &NodeId {
+
        &self.from
+
    }
+

+
    /// The set of references that fetch is being performed for.
+
    pub fn refs(&self) -> &RefsToFetch {
+
        &self.refs
+
    }
+
}
+

+
/// A fetch that is waiting to be processed, in the fetch queue.
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+
pub struct QueuedFetch {
+
    /// The repository that will be fetched.
+
    pub rid: RepoId,
+
    /// The references that the fetch is being performed for.
+
    pub refs: RefsToFetch,
+
    /// The timeout given for the fetch request.
+
    pub timeout: time::Duration,
+
}
+

+
/// A queue for keeping track of fetches.
+
///
+
/// It ensures that the queue contains unique items for fetching, and does not
+
/// exceed the provided maximum capacity.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Queue {
+
    queue: VecDeque<QueuedFetch>,
+
    max_queue_size: MaxQueueSize,
+
}
+

+
/// The maximum number of fetches that can be queued for a single node.
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+
pub struct MaxQueueSize(usize);
+

+
impl MaxQueueSize {
+
    /// Minimum queue size is `1`.
+
    pub const MIN: Self = MaxQueueSize(1);
+

+
    /// Create a queue size, that must be larger than `0`.
+
    pub fn new(size: NonZeroUsize) -> Self {
+
        Self(size.into())
+
    }
+

+
    pub fn as_usize(&self) -> usize {
+
        self.0
+
    }
+

+
    /// Checks if the `n` provided exceeds the maximum queue size.
+
    fn is_exceeded_by(&self, n: usize) -> bool {
+
        n >= self.0
+
    }
+
}
+

+
impl Default for MaxQueueSize {
+
    fn default() -> Self {
+
        Self(MAX_FETCH_QUEUE_SIZE)
+
    }
+
}
+

+
/// The result of [`Queue::enqueue`].
+
#[must_use]
+
#[derive(Debug, PartialEq, Eq)]
+
pub(super) enum Enqueue {
+
    /// The capacity of the queue has been exceeded, and the [`QueuedFetch`] is
+
    /// returned.
+
    CapacityReached(QueuedFetch),
+
    /// The [`QueuedFetch`] was successfully queued.
+
    Queued,
+
    Merged,
+
}
+

+
impl Queue {
+
    /// Create the [`Queue`] with the given [`MaxQueueSize`].
+
    pub(super) fn new(max_queue_size: MaxQueueSize) -> Self {
+
        Self {
+
            queue: VecDeque::with_capacity(max_queue_size.0),
+
            max_queue_size,
+
        }
+
    }
+

+
    /// The current number of items in the queue.
+
    pub(super) fn len(&self) -> usize {
+
        self.queue.len()
+
    }
+

+
    /// Returns `true` if the [`Queue`] is empty.
+
    pub(super) fn is_empty(&self) -> bool {
+
        self.queue.is_empty()
+
    }
+

+
    /// Enqueues a fetch onto the back of the queue, and will only succeed if
+
    /// the queue has not reached capacity and if the item is unique.
+
    pub(super) fn enqueue(&mut self, fetch: QueuedFetch) -> Enqueue {
+
        if let Some(existing) = self.queue.iter_mut().find(|qf| qf.rid == fetch.rid) {
+
            existing.refs = existing.refs.clone().merge(fetch.refs);
+
            // Take the longer timeout (more generous)
+
            existing.timeout = existing.timeout.max(fetch.timeout);
+
            return Enqueue::Merged;
+
        }
+

+
        if self.max_queue_size.is_exceeded_by(self.queue.len()) {
+
            Enqueue::CapacityReached(fetch)
+
        } else {
+
            self.queue.push_back(fetch);
+
            Enqueue::Queued
+
        }
+
    }
+

+
    /// Try to dequeue the next [`QueuedFetch`], but only if the `predicate`
+
    /// holds, otherwise it will be pushed back to the front of the queue.
+
    pub(super) fn try_dequeue<P>(&mut self, predicate: P) -> Option<QueuedFetch>
+
    where
+
        P: FnOnce(&QueuedFetch) -> bool,
+
    {
+
        let fetch = self.dequeue()?;
+
        if predicate(&fetch) {
+
            Some(fetch)
+
        } else {
+
            self.queue.push_front(fetch);
+
            None
+
        }
+
    }
+

+
    /// Dequeues a fetch from the front of the queue.
+
    pub(super) fn dequeue(&mut self) -> Option<QueuedFetch> {
+
        self.queue.pop_front()
+
    }
+

+
    /// Return an iterator over the queued fetches.
+
    pub fn iter<'a>(&'a self) -> QueueIter<'a> {
+
        QueueIter {
+
            inner: self.queue.iter(),
+
        }
+
    }
+
}
+

+
/// Iterator of the [`QueuedFetch`]'s
+
pub struct QueueIter<'a> {
+
    inner: std::collections::vec_deque::Iter<'a, QueuedFetch>,
+
}
+

+
impl<'a> Iterator for QueueIter<'a> {
+
    type Item = &'a QueuedFetch;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        self.inner.next()
+
    }
+
}
+

+
impl<'a> IntoIterator for &'a Queue {
+
    type Item = &'a QueuedFetch;
+
    type IntoIter = QueueIter<'a>;
+

+
    fn into_iter(self) -> Self::IntoIter {
+
        self.iter()
+
    }
+
}
added crates/radicle-protocol/src/fetcher/state/command.rs
@@ -0,0 +1,81 @@
+
use std::time;
+

+
use radicle_core::{NodeId, RepoId};
+

+
use crate::fetcher::RefsToFetch;
+

+
/// Commands for transitioning the [`FetcherState`].
+
///
+
/// [`FetcherState`]: super::FetcherState
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum Command {
+
    Fetch(Fetch),
+
    Fetched(Fetched),
+
    Cancel(Cancel),
+
}
+

+
impl From<Fetch> for Command {
+
    fn from(v: Fetch) -> Self {
+
        Self::Fetch(v)
+
    }
+
}
+

+
impl From<Fetched> for Command {
+
    fn from(v: Fetched) -> Self {
+
        Self::Fetched(v)
+
    }
+
}
+

+
impl From<Cancel> for Command {
+
    fn from(v: Cancel) -> Self {
+
        Self::Cancel(v)
+
    }
+
}
+

+
impl Command {
+
    pub fn fetch(from: NodeId, rid: RepoId, refs: RefsToFetch, timeout: time::Duration) -> Self {
+
        Self::from(Fetch {
+
            from,
+
            rid,
+
            refs,
+
            timeout,
+
        })
+
    }
+

+
    pub fn fetched(from: NodeId, rid: RepoId) -> Self {
+
        Self::from(Fetched { from, rid })
+
    }
+

+
    pub fn cancel(from: NodeId) -> Self {
+
        Self::from(Cancel { from })
+
    }
+
}
+

+
/// A fetch wants to be marked as active.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Fetch {
+
    /// The node from which the repository is being fetched from.
+
    pub from: NodeId,
+
    /// The repository to fetch.
+
    pub rid: RepoId,
+
    /// The references to fetch.
+
    pub refs: RefsToFetch,
+
    /// The timeout for the fetch process.
+
    pub timeout: time::Duration,
+
}
+

+
/// A fetch wants to be marked as completed.
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+
pub struct Fetched {
+
    /// The node from which the repository was fetched from.
+
    pub from: NodeId,
+
    /// The repository that was fetched.
+
    pub rid: RepoId,
+
}
+

+
/// Any fetches are canceled for the given node.
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+
pub struct Cancel {
+
    /// The node for which the fetches should be canceled.
+
    pub from: NodeId,
+
}
added crates/radicle-protocol/src/fetcher/state/event.rs
@@ -0,0 +1,113 @@
+
use std::collections::{BTreeMap, VecDeque};
+
use std::time;
+

+
use radicle_core::{NodeId, RepoId};
+

+
use crate::fetcher::RefsToFetch;
+

+
use super::{ActiveFetch, QueuedFetch};
+

+
/// Event returned from [`FetchState::handle`].
+
///
+
/// [`FetchState::handle`]: FetchState::handle.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum Event {
+
    Fetch(Fetch),
+
    Fetched(Fetched),
+
    Cancel(Cancel),
+
}
+

+
impl From<Cancel> for Event {
+
    fn from(v: Cancel) -> Self {
+
        Self::Cancel(v)
+
    }
+
}
+

+
impl From<Fetched> for Event {
+
    fn from(v: Fetched) -> Self {
+
        Self::Fetched(v)
+
    }
+
}
+

+
impl From<Fetch> for Event {
+
    fn from(v: Fetch) -> Self {
+
        Self::Fetch(v)
+
    }
+
}
+

+
/// Events that occur when a repository is requested to be fetched.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum Fetch {
+
    /// The fetch can be started by the caller.
+
    Started {
+
        /// The repository to be fetched.
+
        rid: RepoId,
+
        /// The node to fetch from.
+
        from: NodeId,
+
        /// The references to be fetched.
+
        refs: RefsToFetch,
+
        /// The timeout for the fetch process.
+
        timeout: time::Duration,
+
    },
+
    /// The repository is already being fetched from the given node.
+
    AlreadyFetching {
+
        /// The repository being actively fetched.
+
        rid: RepoId,
+
        /// The node being fetched from.
+
        from: NodeId,
+
    },
+
    /// The queue for the given node is at capacity, and can no longer accept
+
    /// any more fetch requests.
+
    QueueAtCapacity {
+
        /// The rejected repository.
+
        rid: RepoId,
+
        /// The node who's queue is at capacity.
+
        from: NodeId,
+
        /// The references expected to be fetched.
+
        refs: RefsToFetch,
+
        /// The timeout for the fetch process.
+
        timeout: time::Duration,
+
        /// The capacity of the queue.
+
        capacity: usize,
+
    },
+
    /// The fetch was queued for later processing.
+
    Queued {
+
        /// The repository to be fetched.
+
        rid: RepoId,
+
        /// The node to fetch from.
+
        from: NodeId,
+
    },
+
}
+

+
/// Events that occur after a repository has been fetched.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum Fetched {
+
    /// There was no ongoing fetch for the given [`NodeId`] and [`RepoId`].
+
    NotFound { from: NodeId, rid: RepoId },
+
    /// The active fetch was marked as completed and removed from the active
+
    /// set.
+
    Completed {
+
        /// The node the repository was fetched from.
+
        from: NodeId,
+
        /// The repository that was fetched.
+
        rid: RepoId,
+
        /// The references that were fetched.
+
        refs: RefsToFetch,
+
    },
+
}
+

+
/// Events that occur when a fetch was canceled for a given node.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum Cancel {
+
    /// There were no active or queued fetches for the given node.
+
    Unexpected { from: NodeId },
+
    /// The were active or queued fetches that were canceled for the given node.
+
    Canceled {
+
        /// The node which was canceled.
+
        from: NodeId,
+
        /// The active fetches that were canceled.
+
        active: BTreeMap<RepoId, ActiveFetch>,
+
        /// The queued fetched that were canceled.
+
        queued: VecDeque<QueuedFetch>,
+
    },
+
}
added crates/radicle-protocol/src/fetcher/test.rs
@@ -0,0 +1,2 @@
+
mod queue;
+
mod state;
added crates/radicle-protocol/src/fetcher/test/queue.rs
@@ -0,0 +1,34 @@
+
mod helpers;
+
mod properties;
+
mod unit;
+

+
use std::num::NonZeroUsize;
+
use std::time::Duration;
+

+
use qcheck::Arbitrary;
+

+
use radicle::storage::refs::RefsAt;
+
use radicle_core::RepoId;
+

+
use crate::fetcher::state::{MaxQueueSize, QueuedFetch};
+

+
impl Arbitrary for QueuedFetch {
+
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
+
        // Limit refs_at size to avoid slow shrinking
+
        let refs_at_len = usize::arbitrary(g) % 4;
+
        let refs_at: Vec<RefsAt> = (0..refs_at_len).map(|_| RefsAt::arbitrary(g)).collect();
+

+
        QueuedFetch {
+
            rid: RepoId::arbitrary(g),
+
            refs: refs_at.into(),
+
            timeout: Duration::from_secs(u64::arbitrary(g) % 3600),
+
        }
+
    }
+
}
+

+
impl Arbitrary for MaxQueueSize {
+
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
+
        let size = NonZeroUsize::MIN.saturating_add(usize::arbitrary(g) % 255);
+
        MaxQueueSize::new(size)
+
    }
+
}
added crates/radicle-protocol/src/fetcher/test/queue/helpers.rs
@@ -0,0 +1,24 @@
+
use std::{num::NonZeroUsize, time::Duration};
+

+
use radicle::test::arbitrary;
+

+
use crate::fetcher::{MaxQueueSize, Queue, QueuedFetch, RefsToFetch};
+

+
pub fn create_queue(capacity: usize) -> Queue {
+
    Queue::new(MaxQueueSize::new(
+
        NonZeroUsize::new(capacity).expect("capacity must be non-zero"),
+
    ))
+
}
+

+
pub fn create_fetch() -> QueuedFetch {
+
    QueuedFetch {
+
        rid: arbitrary::gen(1),
+
        refs: RefsToFetch::All,
+
        timeout: Duration::from_secs(30),
+
    }
+
}
+

+
/// Generate a vector of unique QueuedFetch items (unique by rid)
+
pub fn unique_fetches(count: usize) -> Vec<QueuedFetch> {
+
    (0..count).map(|_| create_fetch()).collect()
+
}
added crates/radicle-protocol/src/fetcher/test/queue/properties.rs
@@ -0,0 +1,5 @@
+
mod capacity;
+
mod dequeue;
+
mod equality;
+
mod fifo;
+
mod merge;
added crates/radicle-protocol/src/fetcher/test/queue/properties/capacity.rs
@@ -0,0 +1,74 @@
+
use qcheck_macros::quickcheck;
+

+
use crate::fetcher::test::queue::helpers::*;
+
use crate::fetcher::{state::Enqueue, MaxQueueSize};
+
use crate::fetcher::{Queue, QueuedFetch};
+

+
#[quickcheck]
+
fn bounded(max_size: MaxQueueSize, num_enqueues: u8) -> bool {
+
    let mut queue = Queue::new(max_size);
+

+
    for _ in 0..num_enqueues {
+
        let _ = queue.enqueue(create_fetch());
+

+
        // Invariant: length never exceeds capacity
+
        if queue.len() > max_size.as_usize() {
+
            return false;
+
        }
+
    }
+
    true
+
}
+

+
#[quickcheck]
+
fn rejection(max_size: MaxQueueSize) -> bool {
+
    let mut queue = Queue::new(max_size);
+

+
    // Fill to capacity with unique items
+
    let items = unique_fetches(max_size.as_usize());
+
    for item in &items {
+
        if queue.enqueue(item.clone()) != Enqueue::Queued {
+
            return false;
+
        }
+
    }
+

+
    // Next enqueue of a NEW item must be rejected
+
    matches!(queue.enqueue(create_fetch()), Enqueue::CapacityReached(_))
+
}
+

+
#[quickcheck]
+
fn restored_after_dequeue(max_size: MaxQueueSize, dequeue_count: u8) -> bool {
+
    let mut queue = Queue::new(max_size);
+

+
    // Fill to capacity
+
    for _ in 0..max_size.as_usize() {
+
        let _ = queue.enqueue(create_fetch());
+
    }
+

+
    // Dequeue some items
+
    let to_dequeue = (dequeue_count as usize).min(max_size.as_usize());
+
    for _ in 0..to_dequeue {
+
        let _ = queue.dequeue();
+
    }
+

+
    // Should be able to enqueue exactly that many items again
+
    for _ in 0..to_dequeue {
+
        if queue.enqueue(create_fetch()) != Enqueue::Queued {
+
            return false;
+
        }
+
    }
+

+
    // Next enqueue should fail
+
    matches!(queue.enqueue(create_fetch()), Enqueue::CapacityReached(_))
+
}
+

+
#[quickcheck]
+
fn capacity_reached_returns_same_item(item: QueuedFetch) -> bool {
+
    let mut queue = create_queue(1);
+
    let _ = queue.enqueue(create_fetch()); // Fill the queue
+

+
    match queue.enqueue(item.clone()) {
+
        Enqueue::CapacityReached(returned) => returned == item,
+
        Enqueue::Merged => true, // If same rid, merge takes precedence
+
        _ => false,
+
    }
+
}
added crates/radicle-protocol/src/fetcher/test/queue/properties/dequeue.rs
@@ -0,0 +1,56 @@
+
use qcheck_macros::quickcheck;
+

+
use crate::fetcher::state::Enqueue;
+
use crate::fetcher::test::queue::helpers::*;
+
use crate::fetcher::{MaxQueueSize, Queue};
+

+
#[quickcheck]
+
fn enables_reenqueue(count: u8) -> bool {
+
    let count = ((count as usize) % 20).max(1);
+
    let items = unique_fetches(count);
+

+
    let mut queue = create_queue(count); // Exact capacity
+

+
    for item in &items {
+
        let _ = queue.enqueue(item.clone());
+
    }
+

+
    // Queue is full, dequeue first item
+
    let dequeued = queue.dequeue();
+
    if dequeued.is_none() {
+
        return false;
+
    }
+

+
    // Should be able to enqueue a new item now
+
    queue.enqueue(create_fetch()) == Enqueue::Queued
+
}
+

+
#[quickcheck]
+
fn empty_queue_returns_none(max_size: MaxQueueSize, dequeue_attempts: u8) -> bool {
+
    let mut queue = Queue::new(max_size);
+

+
    // Multiple dequeues from empty queue should all return None
+
    for _ in 0..dequeue_attempts {
+
        if queue.dequeue().is_some() {
+
            return false;
+
        }
+
    }
+
    true
+
}
+

+
#[quickcheck]
+
fn drained_queue_returns_none(max_size: MaxQueueSize, fill_count: u8) -> bool {
+
    let mut queue = Queue::new(max_size);
+
    let fill = (fill_count as usize).min(max_size.as_usize());
+

+
    // Fill then drain
+
    for _ in 0..fill {
+
        let _ = queue.enqueue(create_fetch());
+
    }
+
    for _ in 0..fill {
+
        let _ = queue.dequeue();
+
    }
+

+
    // Should return None now
+
    queue.dequeue().is_none()
+
}
added crates/radicle-protocol/src/fetcher/test/queue/properties/equality.rs
@@ -0,0 +1,22 @@
+
use qcheck_macros::quickcheck;
+

+
use crate::fetcher::QueuedFetch;
+

+
#[quickcheck]
+
fn reflexive(item: QueuedFetch) -> bool {
+
    item == item.clone()
+
}
+

+
#[quickcheck]
+
fn symmetric(a: QueuedFetch, b: QueuedFetch) -> bool {
+
    (a == b) == (b == a)
+
}
+

+
#[quickcheck]
+
fn transitive(a: QueuedFetch, b: QueuedFetch, c: QueuedFetch) -> bool {
+
    if a == b && b == c {
+
        a == c
+
    } else {
+
        true
+
    }
+
}
added crates/radicle-protocol/src/fetcher/test/queue/properties/fifo.rs
@@ -0,0 +1,75 @@
+
use qcheck_macros::quickcheck;
+

+
use crate::fetcher::state::Enqueue;
+
use crate::fetcher::test::queue::helpers::*;
+
use crate::fetcher::QueuedFetch;
+

+
#[quickcheck]
+
fn ordering(count: u8) -> bool {
+
    let count = (count as usize) % 50; // Reasonable upper bound
+
    if count == 0 {
+
        return true;
+
    }
+

+
    let items = unique_fetches(count);
+
    let mut queue = create_queue(count);
+

+
    // Enqueue all items
+
    for item in &items {
+
        if queue.enqueue(item.clone()) != Enqueue::Queued {
+
            return false;
+
        }
+
    }
+

+
    // Dequeue and verify order
+
    for expected in items {
+
        match queue.dequeue() {
+
            Some(actual) if actual.rid == expected.rid => continue,
+
            _ => return false,
+
        }
+
    }
+

+
    queue.is_empty()
+
}
+

+
#[quickcheck]
+
fn interleaved_operations(ops: Vec<bool>) -> bool {
+
    // Limit operations to avoid slow tests
+
    let ops: Vec<_> = ops.into_iter().take(100).collect();
+
    let capacity = ops.len().max(1);
+

+
    let mut queue = create_queue(capacity);
+
    let mut expected_order: Vec<QueuedFetch> = Vec::new();
+
    let mut dequeue_index = 0;
+

+
    for op in ops {
+
        if op {
+
            // Enqueue
+
            let item = create_fetch();
+
            match queue.enqueue(item.clone()) {
+
                Enqueue::Queued => expected_order.push(item),
+
                Enqueue::CapacityReached(_) => {} // Expected when full
+
                Enqueue::Merged => {}             // Can happen if same rid generated
+
            }
+
        } else {
+
            // Dequeue
+
            match queue.dequeue() {
+
                Some(item) => {
+
                    if dequeue_index >= expected_order.len()
+
                        || item.rid != expected_order[dequeue_index].rid
+
                    {
+
                        return false;
+
                    }
+
                    dequeue_index += 1;
+
                }
+
                None => {
+
                    // Should only happen if we've dequeued everything we enqueued
+
                    if dequeue_index != expected_order.len() {
+
                        return false;
+
                    }
+
                }
+
            }
+
        }
+
    }
+
    true
+
}
added crates/radicle-protocol/src/fetcher/test/queue/properties/merge.rs
@@ -0,0 +1,211 @@
+
use std::num::NonZeroUsize;
+
use std::time::Duration;
+

+
use qcheck_macros::quickcheck;
+
use radicle::storage::refs::RefsAt;
+
use radicle::test::arbitrary;
+
use radicle_core::RepoId;
+

+
use crate::fetcher::state::Enqueue;
+
use crate::fetcher::test::queue::helpers::*;
+
use crate::fetcher::RefsToFetch;
+
use crate::fetcher::{MaxQueueSize, Queue, QueuedFetch};
+

+
#[quickcheck]
+
fn same_rid_merges_anywhere_in_queue(max_size: MaxQueueSize, merge_index: usize) -> bool {
+
    if max_size.as_usize() < 2 {
+
        return true; // Need at least 2 slots to test properly
+
    }
+

+
    let mut queue = Queue::new(max_size);
+
    let items = unique_fetches(max_size.as_usize() - 1); // Leave room for potential new item
+

+
    for item in &items {
+
        let _ = queue.enqueue(item.clone());
+
    }
+

+
    if items.is_empty() {
+
        return true;
+
    }
+

+
    // Try to enqueue an item with same rid as one already in queue
+
    let target_index = merge_index % items.len();
+
    let same_rid_item = QueuedFetch {
+
        rid: items[target_index].rid,
+
        refs: vec![arbitrary::gen(1)].into(),
+
        timeout: Duration::from_secs(60),
+
    };
+

+
    matches!(queue.enqueue(same_rid_item), Enqueue::Merged)
+
}
+

+
#[quickcheck]
+
fn combines_refs(base_refs_count: u8, merge_refs_count: u8) -> bool {
+
    let base_refs_count = (base_refs_count as usize) % 5;
+
    let merge_refs_count = (merge_refs_count as usize) % 5;
+

+
    let mut queue = create_queue(10);
+

+
    let rid: RepoId = arbitrary::gen(1);
+
    let base_refs: Vec<RefsAt> = (0..base_refs_count).map(|_| arbitrary::gen(1)).collect();
+
    let merge_refs: Vec<RefsAt> = (0..merge_refs_count).map(|_| arbitrary::gen(1)).collect();
+

+
    let base_item = QueuedFetch {
+
        rid,
+
        refs: base_refs.clone().into(),
+
        timeout: Duration::from_secs(30),
+
    };
+

+
    let merge_item = QueuedFetch {
+
        rid,
+
        refs: merge_refs.clone().into(),
+
        timeout: Duration::from_secs(30),
+
    };
+

+
    let _ = queue.enqueue(base_item);
+
    let result = queue.enqueue(merge_item);
+

+
    if result != Enqueue::Merged {
+
        return false;
+
    }
+

+
    let dequeued = queue.dequeue().unwrap();
+

+
    // If either was empty, result should be empty (fetch everything)
+
    if base_refs.is_empty() || merge_refs.is_empty() {
+
        dequeued.refs == RefsToFetch::All
+
    } else {
+
        // Otherwise refs should be combined
+
        dequeued.refs.len() == Some(NonZeroUsize::new(base_refs_count + merge_refs_count).unwrap())
+
    }
+
}
+

+
#[quickcheck]
+
fn empty_refs_fetches_all() -> bool {
+
    let mut queue = create_queue(10);
+
    let rid: RepoId = arbitrary::gen(1);
+

+
    // First enqueue with specific refs
+
    let item_with_refs = QueuedFetch {
+
        rid,
+
        refs: vec![arbitrary::gen(1), arbitrary::gen(1)].into(),
+
        timeout: Duration::from_secs(30),
+
    };
+

+
    // Second enqueue with empty refs (fetch everything)
+
    let item_empty_refs = QueuedFetch {
+
        rid,
+
        refs: RefsToFetch::All,
+
        timeout: Duration::from_secs(30),
+
    };
+

+
    let _ = queue.enqueue(item_with_refs);
+
    let _ = queue.enqueue(item_empty_refs);
+

+
    let dequeued = queue.dequeue().unwrap();
+
    dequeued.refs == RefsToFetch::All // Should fetch everything
+
}
+

+
#[quickcheck]
+
fn longer_timeout_preserved(short_secs: u16, long_secs: u16) -> bool {
+
    let short = Duration::from_secs(short_secs.min(long_secs) as u64);
+
    let long = Duration::from_secs(short_secs.max(long_secs) as u64);
+

+
    let mut queue = create_queue(10);
+
    let rid: RepoId = arbitrary::gen(1);
+

+
    let item_short = QueuedFetch {
+
        rid,
+
        refs: RefsToFetch::All,
+
        timeout: short,
+
    };
+

+
    let item_long = QueuedFetch {
+
        rid,
+
        refs: RefsToFetch::All,
+
        timeout: long,
+
    };
+

+
    // Test both orderings
+
    let _ = queue.enqueue(item_short.clone());
+
    let _ = queue.enqueue(item_long.clone());
+
    let dequeued1 = queue.dequeue().unwrap();
+

+
    let mut queue2 = create_queue(10);
+
    let _ = queue2.enqueue(item_long);
+
    let _ = queue2.enqueue(item_short);
+
    let dequeued2 = queue2.dequeue().unwrap();
+

+
    dequeued1.timeout == long && dequeued2.timeout == long
+
}
+

+
#[quickcheck]
+
fn does_not_increase_queue_length() -> bool {
+
    let mut queue = create_queue(10);
+
    let rid: RepoId = arbitrary::gen(1);
+

+
    let item1 = QueuedFetch {
+
        rid,
+
        refs: vec![arbitrary::gen(1)].into(),
+
        timeout: Duration::from_secs(30),
+
    };
+

+
    let item2 = QueuedFetch {
+
        rid,
+
        refs: vec![arbitrary::gen(1)].into(),
+
        timeout: Duration::from_secs(60),
+
    };
+

+
    let _ = queue.enqueue(item1);
+
    let len_after_first = queue.len();
+

+
    let _ = queue.enqueue(item2);
+
    let len_after_merge = queue.len();
+

+
    len_after_first == 1 && len_after_merge == 1
+
}
+

+
#[quickcheck]
+
fn different_rid_accepted(base_item: QueuedFetch) -> bool {
+
    let mut queue = create_queue(10);
+
    let _ = queue.enqueue(base_item.clone());
+

+
    // Item with different rid should be queued (not merged)
+
    let different_rid = QueuedFetch {
+
        rid: arbitrary::gen(1),
+
        ..base_item
+
    };
+

+
    queue.enqueue(different_rid) == Enqueue::Queued
+
}
+

+
#[quickcheck]
+
fn succeed_when_at_capacity() -> bool {
+
    // When queue is at capacity, merging with existing item should still work
+
    let mut queue = create_queue(2);
+
    let rid: RepoId = arbitrary::gen(1);
+

+
    let item1 = QueuedFetch {
+
        rid,
+
        refs: RefsToFetch::All,
+
        timeout: Duration::from_secs(30),
+
    };
+

+
    let item2 = QueuedFetch {
+
        rid: arbitrary::gen(1), // Different rid
+
        refs: RefsToFetch::All,
+
        timeout: Duration::from_secs(30),
+
    };
+

+
    let merge_item = QueuedFetch {
+
        rid, // Same as item1
+
        refs: vec![arbitrary::gen(1)].into(),
+
        timeout: Duration::from_secs(60),
+
    };
+

+
    let _ = queue.enqueue(item1);
+
    let _ = queue.enqueue(item2);
+

+
    // Queue is now at capacity, but merge should still work
+
    queue.enqueue(merge_item) == Enqueue::Merged
+
}
added crates/radicle-protocol/src/fetcher/test/queue/unit.rs
@@ -0,0 +1,105 @@
+
use std::time::Duration;
+

+
use radicle::test::arbitrary;
+
use radicle_core::RepoId;
+

+
use crate::fetcher::state::Enqueue;
+
use crate::fetcher::test::queue::helpers::*;
+
use crate::fetcher::QueuedFetch;
+
use crate::fetcher::RefsToFetch;
+

+
#[test]
+
fn zero_timeout_accepted() {
+
    let mut queue = create_queue(10);
+
    let item = QueuedFetch {
+
        rid: arbitrary::gen(1),
+
        refs: RefsToFetch::All,
+
        timeout: Duration::ZERO,
+
    };
+
    assert_eq!(queue.enqueue(item), Enqueue::Queued);
+
}
+

+
#[test]
+
fn max_timeout_accepted() {
+
    let mut queue = create_queue(10);
+
    let item = QueuedFetch {
+
        rid: arbitrary::gen(1),
+
        refs: RefsToFetch::All,
+
        timeout: Duration::MAX,
+
    };
+
    assert_eq!(queue.enqueue(item), Enqueue::Queued);
+
}
+

+
#[test]
+
fn empty_refs_items_can_be_equal() {
+
    let rid: RepoId = arbitrary::gen(1);
+
    let timeout = Duration::from_secs(30);
+

+
    let item1 = QueuedFetch {
+
        rid,
+
        refs: RefsToFetch::All,
+
        timeout,
+
    };
+
    let item2 = QueuedFetch {
+
        rid,
+
        refs: RefsToFetch::All,
+
        timeout,
+
    };
+

+
    assert_eq!(item1, item2);
+
}
+

+
#[test]
+
fn merge_preserves_position_in_queue() {
+
    let mut queue = create_queue(10);
+

+
    let rid_first: RepoId = arbitrary::gen(1);
+
    let rid_second: RepoId = arbitrary::gen(2);
+
    let rid_third: RepoId = arbitrary::gen(3);
+

+
    // Enqueue three items
+
    let _ = queue.enqueue(QueuedFetch {
+
        rid: rid_first,
+
        refs: RefsToFetch::All,
+
        timeout: Duration::from_secs(30),
+
    });
+
    let _ = queue.enqueue(QueuedFetch {
+
        rid: rid_second,
+
        refs: RefsToFetch::All,
+
        timeout: Duration::from_secs(30),
+
    });
+
    let _ = queue.enqueue(QueuedFetch {
+
        rid: rid_third,
+
        refs: RefsToFetch::All,
+
        timeout: Duration::from_secs(30),
+
    });
+

+
    // Merge into the second item
+
    let result = queue.enqueue(QueuedFetch {
+
        rid: rid_second,
+
        refs: vec![arbitrary::gen(1)].into(),
+
        timeout: Duration::from_secs(60),
+
    });
+
    assert_eq!(result, Enqueue::Merged);
+

+
    // Order should be preserved: first, second (merged), third
+
    assert_eq!(queue.dequeue().unwrap().rid, rid_first);
+
    assert_eq!(queue.dequeue().unwrap().rid, rid_second);
+
    assert_eq!(queue.dequeue().unwrap().rid, rid_third);
+
}
+

+
#[test]
+
fn capacity_takes_precedence_over_merge_for_new_items() {
+
    let mut queue = create_queue(2);
+

+
    // Fill to capacity with unique items
+
    let _ = queue.enqueue(create_fetch());
+
    let _ = queue.enqueue(create_fetch());
+

+
    // New item (different rid) should be rejected
+
    let new_item = create_fetch();
+
    match queue.enqueue(new_item.clone()) {
+
        Enqueue::CapacityReached(returned) => assert_eq!(returned, new_item),
+
        _ => panic!("Expected CapacityReached"),
+
    }
+
}
added crates/radicle-protocol/src/fetcher/test/state.rs
@@ -0,0 +1,7 @@
+
mod command;
+
mod concurrent;
+
mod config;
+
mod dequeue;
+
mod helpers;
+
mod invariant;
+
mod multinode;
added crates/radicle-protocol/src/fetcher/test/state/command.rs
@@ -0,0 +1,3 @@
+
mod cancel;
+
mod fetch;
+
mod fetched;
added crates/radicle-protocol/src/fetcher/test/state/command/cancel.rs
@@ -0,0 +1,129 @@
+
use std::time::Duration;
+

+
use radicle::test::arbitrary;
+
use radicle_core::{NodeId, RepoId};
+

+
use crate::fetcher::state::{command, event};
+
use crate::fetcher::test::state::helpers;
+
use crate::fetcher::{ActiveFetch, FetcherState};
+

+
#[test]
+
fn single_ongoing() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let refs_1 = helpers::gen_refs(1);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: refs_1.clone(),
+
        timeout,
+
    });
+

+
    let event = state.cancel(command::Cancel { from: node_a });
+

+
    match event {
+
        event::Cancel::Canceled {
+
            from,
+
            active: ongoing,
+
            queued,
+
        } => {
+
            assert_eq!(from, node_a);
+
            assert_eq!(ongoing.len(), 1);
+
            assert_eq!(
+
                ongoing.get(&repo_1),
+
                Some(&ActiveFetch {
+
                    from: node_a,
+
                    refs: refs_1,
+
                })
+
            );
+
            assert!(queued.is_empty());
+
        }
+
        _ => panic!("Expected Canceled event"),
+
    }
+
    assert!(state.get_active_fetch(&repo_1).is_none());
+
}
+

+
#[test]
+
fn ongoing_and_queued() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let repo_3: RepoId = arbitrary::gen(1);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_3,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    let event = state.cancel(command::Cancel { from: node_a });
+

+
    match event {
+
        event::Cancel::Canceled {
+
            active: ongoing,
+
            queued,
+
            ..
+
        } => {
+
            assert_eq!(ongoing.len(), 1);
+
            assert!(ongoing.contains_key(&repo_1));
+
            assert_eq!(queued.len(), 2);
+
        }
+
        _ => panic!("Expected Canceled event"),
+
    }
+
}
+

+
#[test]
+
fn non_existent_returns_unexpected() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_unknown: NodeId = arbitrary::gen(1);
+

+
    let event = state.cancel(command::Cancel { from: node_unknown });
+

+
    assert_eq!(event, event::Cancel::Unexpected { from: node_unknown });
+
}
+

+
#[test]
+
fn cancellation_is_isolated() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let node_b: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    state.fetch(command::Fetch {
+
        from: node_b,
+
        rid: repo_2,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    state.cancel(command::Cancel { from: node_a });
+

+
    assert!(state.get_active_fetch(&repo_1).is_none());
+
    assert!(state.get_active_fetch(&repo_2).is_some());
+
}
added crates/radicle-protocol/src/fetcher/test/state/command/fetch.rs
@@ -0,0 +1,425 @@
+
use std::time::Duration;
+

+
use radicle::test::arbitrary;
+
use radicle_core::{NodeId, RepoId};
+

+
use crate::fetcher::state::{command, event};
+
use crate::fetcher::test::state::helpers;
+
use crate::fetcher::RefsToFetch;
+
use crate::fetcher::{ActiveFetch, FetcherState};
+

+
#[test]
+
fn fetch_start_first_fetch_for_node() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let refs_1 = helpers::gen_refs(2);
+
    let timeout = Duration::from_secs(30);
+

+
    let event = state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: refs_1.clone(),
+
        timeout,
+
    });
+

+
    assert_eq!(
+
        event,
+
        event::Fetch::Started {
+
            rid: repo_1,
+
            from: node_a,
+
            refs: refs_1.clone(),
+
            timeout,
+
        }
+
    );
+
    assert_eq!(
+
        state.get_active_fetch(&repo_1),
+
        Some(&ActiveFetch {
+
            from: node_a,
+
            refs: refs_1,
+
        })
+
    );
+
}
+

+
#[test]
+
fn fetch_different_repo_same_node_within_capacity() {
+
    let mut state = FetcherState::new(helpers::config(2, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let timeout = Duration::from_secs(30);
+

+
    let event1 = state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    assert!(matches!(event1, event::Fetch::Started { .. }));
+

+
    let event2 = state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    assert!(matches!(event2, event::Fetch::Started { rid, .. } if rid == repo_2));
+
    assert!(state.get_active_fetch(&repo_1).is_some());
+
    assert!(state.get_active_fetch(&repo_2).is_some());
+
}
+

+
#[test]
+
fn fetch_same_repo_different_nodes_queues_second() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let node_b: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let refs_1 = helpers::gen_refs(1);
+
    let timeout = Duration::from_secs(30);
+

+
    let event1 = state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: refs_1.clone(),
+
        timeout,
+
    });
+
    assert!(matches!(event1, event::Fetch::Started { .. }));
+

+
    // Same repo from different node - gets queued since repo_1 is already active
+
    let event2 = state.fetch(command::Fetch {
+
        from: node_b,
+
        rid: repo_1,
+
        refs: refs_1.clone(),
+
        timeout,
+
    });
+

+
    assert!(
+
        matches!(event2, event::Fetch::Queued { rid, from } if rid == repo_1 && from == node_b)
+
    );
+
    // Only node_a's fetch is active
+
    let active = state.get_active_fetch(&repo_1);
+
    assert!(active.is_some());
+
    assert_eq!(*active.unwrap().from(), node_a);
+
}
+

+
#[test]
+
fn fetch_duplicate_returns_already_fetching() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let refs_1 = helpers::gen_refs(2);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: refs_1.clone(),
+
        timeout,
+
    });
+

+
    let event = state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: refs_1.clone(),
+
        timeout,
+
    });
+

+
    assert_eq!(
+
        event,
+
        event::Fetch::AlreadyFetching {
+
            rid: repo_1,
+
            from: node_a,
+
        }
+
    );
+
}
+

+
#[test]
+
fn fetch_same_repo_different_refs_enqueues() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let refs_1 = helpers::gen_refs(1);
+
    let refs_2 = helpers::gen_refs(2);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: refs_1.clone(),
+
        timeout,
+
    });
+

+
    let event = state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: refs_2.clone(),
+
        timeout,
+
    });
+

+
    assert_eq!(
+
        event,
+
        event::Fetch::Queued {
+
            rid: repo_1,
+
            from: node_a,
+
        }
+
    );
+
}
+

+
#[test]
+
fn fetch_at_capacity_enqueues() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    let event = state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    assert_eq!(
+
        event,
+
        event::Fetch::Queued {
+
            rid: repo_2,
+
            from: node_a,
+
        }
+
    );
+
    assert!(state.get_active_fetch(&repo_1).is_some());
+
    assert!(state.get_active_fetch(&repo_2).is_none());
+
}
+

+
#[test]
+
fn fetch_queue_rejected_capacity_reached() {
+
    let mut state = FetcherState::new(helpers::config(1, 2));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let repo_3: RepoId = arbitrary::gen(1);
+
    let repo_4: RepoId = arbitrary::gen(1);
+
    let timeout = Duration::from_secs(30);
+

+
    // Fill concurrency
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    // Fill queue (capacity 2)
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_3,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    // Exceed queue capacity
+
    let refs_4 = helpers::gen_refs(1);
+
    let event = state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_4,
+
        refs: refs_4.clone(),
+
        timeout,
+
    });
+

+
    assert_eq!(
+
        event,
+
        event::Fetch::QueueAtCapacity {
+
            rid: repo_4,
+
            from: node_a,
+
            refs: refs_4,
+
            timeout,
+
            capacity: 2,
+
        }
+
    );
+
}
+

+
#[test]
+
fn fetch_queue_merges_already_queued() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let refs_2a = helpers::gen_refs(1);
+
    let refs_2b = helpers::gen_refs(1);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: refs_2a.clone(),
+
        timeout,
+
    });
+

+
    // Second fetch for same queued repo - should merge refs
+
    let event = state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: refs_2b.clone(),
+
        timeout,
+
    });
+

+
    // Returns Queued (merged)
+
    assert_eq!(
+
        event,
+
        event::Fetch::Queued {
+
            rid: repo_2,
+
            from: node_a,
+
        }
+
    );
+

+
    // Dequeue and verify refs were merged
+
    state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_1,
+
    });
+
    let queued = state.dequeue(&node_a).unwrap();
+
    assert_eq!(queued.rid, repo_2);
+
    // `queued.refs` should be the union of both sets of refs.
+
    assert_eq!(
+
        queued.refs.len(),
+
        Some(
+
            refs_2a
+
                .len()
+
                .unwrap()
+
                .saturating_add(refs_2b.len().unwrap().into())
+
        )
+
    );
+
}
+

+
#[test]
+
fn fetch_queue_merge_empty_refs_fetches_all() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let refs_2 = helpers::gen_refs(2);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    // Queue with specific refs
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: refs_2.clone(),
+
        timeout,
+
    });
+

+
    // Queue again with empty refs (fetch everything)
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: RefsToFetch::All,
+
        timeout,
+
    });
+

+
    // Dequeue and verify refs became empty (fetch all)
+
    state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_1,
+
    });
+
    let queued = state.dequeue(&node_a).unwrap();
+
    assert_eq!(queued.rid, repo_2);
+
    assert_eq!(queued.refs, RefsToFetch::All);
+
}
+

+
#[test]
+
fn fetch_queue_merge_takes_longer_timeout() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let short_timeout = Duration::from_secs(10);
+
    let long_timeout = Duration::from_secs(60);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout: short_timeout,
+
    });
+

+
    // Queue with short timeout
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: helpers::gen_refs(1),
+
        timeout: short_timeout,
+
    });
+

+
    // Queue again with longer timeout
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: helpers::gen_refs(1),
+
        timeout: long_timeout,
+
    });
+

+
    state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_1,
+
    });
+
    // Dequeue and verify timeout is the longer one
+
    let queued = state.dequeue(&node_a).unwrap();
+
    assert_eq!(queued.timeout, long_timeout);
+
}
+

+
#[test]
+
fn fetch_after_previous_completed() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let refs_1 = helpers::gen_refs(1);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: refs_1.clone(),
+
        timeout,
+
    });
+
    state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_1,
+
    });
+

+
    let event = state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: refs_1.clone(),
+
        timeout,
+
    });
+

+
    assert!(matches!(event, event::Fetch::Started { .. }));
+
}
added crates/radicle-protocol/src/fetcher/test/state/command/fetched.rs
@@ -0,0 +1,144 @@
+
use std::time::Duration;
+

+
use radicle::test::arbitrary;
+
use radicle_core::{NodeId, RepoId};
+

+
use crate::fetcher::state::{command, event};
+
use crate::fetcher::test::state::helpers;
+
use crate::fetcher::FetcherState;
+

+
#[test]
+
fn complete_single_ongoing() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let refs_1 = helpers::gen_refs(2);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: refs_1.clone(),
+
        timeout,
+
    });
+

+
    let event = state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_1,
+
    });
+

+
    assert_eq!(
+
        event,
+
        event::Fetched::Completed {
+
            from: node_a,
+
            rid: repo_1,
+
            refs: refs_1,
+
        }
+
    );
+
    assert!(state.get_active_fetch(&repo_1).is_none());
+
}
+

+
#[test]
+
fn complete_then_dequeue_fifo() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let repo_3: RepoId = arbitrary::gen(1);
+
    let refs_2 = helpers::gen_refs(1);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    // Queue repo_2 first, then repo_3
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: refs_2.clone(),
+
        timeout,
+
    });
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_3,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    let event = state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_1,
+
    });
+

+
    assert!(matches!(event, event::Fetched::Completed { .. }));
+

+
    // Dequeue next - FIFO: repo_2 was queued first
+
    let queued = state.dequeue(&node_a);
+
    assert!(queued.is_some());
+
    let queued = queued.unwrap();
+
    assert_eq!(queued.rid, repo_2);
+
    assert_eq!(queued.refs, refs_2);
+
}
+

+
#[test]
+
fn complete_one_of_multiple() {
+
    let mut state = FetcherState::new(helpers::config(3, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let repo_3: RepoId = arbitrary::gen(1);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_3,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    let event = state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_2,
+
    });
+

+
    assert!(matches!(event, event::Fetched::Completed { rid, .. } if rid == repo_2));
+
    assert!(state.get_active_fetch(&repo_1).is_some());
+
    assert!(state.get_active_fetch(&repo_2).is_none());
+
    assert!(state.get_active_fetch(&repo_3).is_some());
+
}
+

+
#[test]
+
fn non_existent_returns_not_found() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+

+
    let event = state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_1,
+
    });
+

+
    assert_eq!(
+
        event,
+
        event::Fetched::NotFound {
+
            from: node_a,
+
            rid: repo_1,
+
        }
+
    );
+
}
added crates/radicle-protocol/src/fetcher/test/state/concurrent.rs
@@ -0,0 +1,106 @@
+
use std::time::Duration;
+

+
use radicle::test::arbitrary;
+
use radicle_core::{NodeId, RepoId};
+

+
use crate::fetcher::state::{command, event};
+
use crate::fetcher::test::state::helpers;
+
use crate::fetcher::FetcherState;
+

+
#[test]
+
fn interleaved_operations() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let node_b: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let repo_3: RepoId = arbitrary::gen(1);
+
    let timeout = Duration::from_secs(30);
+

+
    // fetch(A, r1)
+
    let e1 = state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    assert!(matches!(e1, event::Fetch::Started { .. }));
+

+
    // fetch(B, r2)
+
    let e2 = state.fetch(command::Fetch {
+
        from: node_b,
+
        rid: repo_2,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    assert!(matches!(e2, event::Fetch::Started { .. }));
+

+
    // fetched(A, r1)
+
    let e3 = state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_1,
+
    });
+
    assert!(matches!(e3, event::Fetched::Completed { .. }));
+

+
    // fetch(A, r3)
+
    let e4 = state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_3,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    assert!(matches!(e4, event::Fetch::Started { .. }));
+

+
    // fetched(B, r2)
+
    let e5 = state.fetched(command::Fetched {
+
        from: node_b,
+
        rid: repo_2,
+
    });
+
    assert!(matches!(e5, event::Fetched::Completed { .. }));
+

+
    // Final state: only r3 from A ongoing
+
    assert!(state.get_active_fetch(&repo_1).is_none());
+
    assert!(state.get_active_fetch(&repo_2).is_none());
+
    assert!(state.get_active_fetch(&repo_3).is_some());
+
}
+

+
#[test]
+
fn fetched_then_cancel() {
+
    let mut state = FetcherState::new(helpers::config(2, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    // Complete repo_1
+
    let e1 = state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_1,
+
    });
+
    assert!(matches!(e1, event::Fetched::Completed { .. }));
+

+
    // Cancel remaining
+
    let e2 = state.cancel(command::Cancel { from: node_a });
+
    match e2 {
+
        event::Cancel::Canceled {
+
            active: ongoing, ..
+
        } => {
+
            assert_eq!(ongoing.len(), 1);
+
            assert!(ongoing.contains_key(&repo_2));
+
        }
+
        _ => panic!("Expected Canceled"),
+
    }
+
}
added crates/radicle-protocol/src/fetcher/test/state/config.rs
@@ -0,0 +1,72 @@
+
use std::time::Duration;
+

+
use radicle::test::arbitrary;
+
use radicle_core::{NodeId, RepoId};
+

+
use crate::fetcher::state::{command, event};
+
use crate::fetcher::test::state::helpers;
+
use crate::fetcher::FetcherState;
+

+
#[test]
+
fn high_concurrency() {
+
    let mut state = FetcherState::new(helpers::config(100, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let timeout = Duration::from_secs(30);
+

+
    for i in 0..100 {
+
        let repo: RepoId = arbitrary::gen(i + 1);
+
        let event = state.fetch(command::Fetch {
+
            from: node_a,
+
            rid: repo,
+
            refs: helpers::gen_refs(1),
+
            timeout,
+
        });
+
        assert!(
+
            matches!(event, event::Fetch::Started { .. }),
+
            "Fetch {} should start",
+
            i
+
        );
+
    }
+

+
    assert_eq!(
+
        state
+
            .active_fetches()
+
            .iter()
+
            .filter(|(_, f)| *f.from() == node_a)
+
            .count(),
+
        100
+
    );
+
}
+

+
#[test]
+
fn min_queue_size() {
+
    let mut state = FetcherState::new(helpers::config(1, 1));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let repo_3: RepoId = arbitrary::gen(1);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    let event1 = state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    assert!(matches!(event1, event::Fetch::Queued { .. }));
+

+
    let event2 = state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_3,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    assert!(matches!(event2, event::Fetch::QueueAtCapacity { .. }));
+
}
added crates/radicle-protocol/src/fetcher/test/state/dequeue.rs
@@ -0,0 +1,111 @@
+
use std::time::Duration;
+

+
use radicle::test::arbitrary;
+
use radicle_core::{NodeId, RepoId};
+

+
use crate::fetcher::state::command;
+
use crate::fetcher::test::state::helpers;
+
use crate::fetcher::FetcherState;
+

+
#[test]
+
fn cannot_dequeue_while_node_at_capacity() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let refs_2 = helpers::gen_refs(3);
+
    let timeout_2 = Duration::from_secs(42);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout: Duration::from_secs(10),
+
    });
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: refs_2.clone(),
+
        timeout: timeout_2,
+
    });
+

+
    let result = state.dequeue(&node_a);
+
    assert!(result.is_none());
+

+
    state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_1,
+
    });
+

+
    let result = state.dequeue(&node_a);
+
    let queued = result.unwrap();
+
    assert_eq!(queued.rid, repo_2);
+
    assert_eq!(queued.refs, refs_2);
+
    assert_eq!(queued.timeout, timeout_2);
+
}
+

+
#[test]
+
fn maintains_fifo_order() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let repo_3: RepoId = arbitrary::gen(1);
+
    let repo_4: RepoId = arbitrary::gen(1);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    // Queue in order: repo_2, repo_3, repo_4
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_3,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_4,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_1,
+
    });
+
    assert_eq!(state.dequeue(&node_a).unwrap().rid, repo_2);
+

+
    state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_2,
+
    });
+
    assert_eq!(state.dequeue(&node_a).unwrap().rid, repo_3);
+

+
    state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_3,
+
    });
+
    assert_eq!(state.dequeue(&node_a).unwrap().rid, repo_4);
+
    assert!(state.dequeue(&node_a).is_none());
+
}
+

+
#[test]
+
fn empty_queue_returns_none() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+

+
    assert!(state.dequeue(&node_a).is_none());
+
}
added crates/radicle-protocol/src/fetcher/test/state/helpers.rs
@@ -0,0 +1,18 @@
+
use std::num::NonZeroUsize;
+

+
use radicle::test::arbitrary;
+

+
use crate::fetcher::{Config, MaxQueueSize, RefsToFetch};
+

+
pub fn config(max_concurrency: usize, max_queue_size: usize) -> Config {
+
    Config::new()
+
        .with_max_concurrency(NonZeroUsize::new(max_concurrency).unwrap())
+
        .with_max_capacity(MaxQueueSize::new(
+
            NonZeroUsize::new(max_queue_size).unwrap(),
+
        ))
+
}
+

+
pub fn gen_refs(count: usize) -> RefsToFetch {
+
    let refs: Vec<_> = (0..count).map(|_| arbitrary::gen(1)).collect();
+
    refs.into()
+
}
added crates/radicle-protocol/src/fetcher/test/state/invariant.rs
@@ -0,0 +1,53 @@
+
use std::time::Duration;
+

+
use radicle::test::arbitrary;
+
use radicle_core::{NodeId, RepoId};
+

+
use crate::fetcher::state::command;
+
use crate::fetcher::test::state::helpers;
+
use crate::fetcher::FetcherState;
+

+
#[test]
+
fn queue_integrity_after_merge() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let repo_1: RepoId = arbitrary::gen(1);
+
    let repo_2: RepoId = arbitrary::gen(1);
+
    let refs_2a = helpers::gen_refs(1);
+
    let refs_2b = helpers::gen_refs(1);
+
    let timeout = Duration::from_secs(30);
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_1,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: refs_2a.clone(),
+
        timeout,
+
    });
+

+
    // Second fetch for same repo - should merge
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_2,
+
        refs: refs_2b.clone(),
+
        timeout,
+
    });
+

+
    // Queue should have exactly one repo_2 entry (merged)
+
    state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_1,
+
    });
+
    let first = state.dequeue(&node_a);
+
    assert!(first.is_some());
+
    assert_eq!(first.unwrap().rid, repo_2);
+

+
    let second = state.dequeue(&node_a);
+
    assert!(second.is_none());
+
}
added crates/radicle-protocol/src/fetcher/test/state/multinode.rs
@@ -0,0 +1,83 @@
+
use std::time::Duration;
+

+
use radicle::test::arbitrary;
+
use radicle_core::{NodeId, RepoId};
+

+
use crate::fetcher::state::{command, event};
+
use crate::fetcher::test::state::helpers;
+
use crate::fetcher::FetcherState;
+

+
#[test]
+
fn independent_queues() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let node_a: NodeId = arbitrary::gen(1);
+
    let node_b: NodeId = arbitrary::gen(1);
+
    let repo_a_active: RepoId = arbitrary::gen(1);
+
    let repo_b_active: RepoId = arbitrary::gen(2);
+
    let repo_a_queued: RepoId = arbitrary::gen(10);
+
    let repo_b_queued: RepoId = arbitrary::gen(20);
+
    let timeout = Duration::from_secs(30);
+

+
    // Fill capacity for both nodes
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_a_active,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    state.fetch(command::Fetch {
+
        from: node_b,
+
        rid: repo_b_active,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    // Queue for both
+
    state.fetch(command::Fetch {
+
        from: node_a,
+
        rid: repo_a_queued,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+
    state.fetch(command::Fetch {
+
        from: node_b,
+
        rid: repo_b_queued,
+
        refs: helpers::gen_refs(1),
+
        timeout,
+
    });
+

+
    // Dequeue from A doesn't affect B
+
    state.fetched(command::Fetched {
+
        from: node_a,
+
        rid: repo_a_active,
+
    });
+
    let a_item = state.dequeue(&node_a);
+
    assert_eq!(a_item.unwrap().rid, repo_a_queued);
+

+
    state.fetched(command::Fetched {
+
        from: node_b,
+
        rid: repo_b_active,
+
    });
+
    let b_item = state.dequeue(&node_b);
+
    assert_eq!(b_item.unwrap().rid, repo_b_queued);
+
}
+

+
#[test]
+
fn high_count() {
+
    let mut state = FetcherState::new(helpers::config(1, 10));
+
    let timeout = Duration::from_secs(30);
+

+
    for i in 0..100 {
+
        let node: NodeId = arbitrary::gen(i + 1);
+
        let repo: RepoId = arbitrary::gen(i + 1);
+
        let event = state.fetch(command::Fetch {
+
            from: node,
+
            rid: repo,
+
            refs: helpers::gen_refs(1),
+
            timeout,
+
        });
+
        assert!(matches!(event, event::Fetch::Started { .. }));
+
    }
+

+
    assert_eq!(state.active_fetches().len(), 100);
+
}
modified crates/radicle-protocol/src/lib.rs
@@ -1,8 +1,11 @@
pub mod bounded;
pub mod deserializer;
+
pub mod fetcher;
pub mod service;
pub mod wire;
pub mod worker;

/// Peer-to-peer protocol version.
pub const PROTOCOL_VERSION: u8 = 1;
+

+
extern crate radicle_localtime as localtime;
modified crates/radicle-protocol/src/service.rs
@@ -2,6 +2,9 @@
#![allow(clippy::collapsible_match)]
#![allow(clippy::collapsible_if)]
#![warn(clippy::unwrap_used)]
+
pub mod command;
+
pub use command::{Command, QueryState};
+

pub mod filter;
pub mod gossip;
pub mod io;
@@ -33,11 +36,15 @@ use radicle::node::refs::Store as _;
use radicle::node::routing::Store as _;
use radicle::node::seed;
use radicle::node::seed::Store as _;
-
use radicle::node::{ConnectOptions, Penalty, Severity};
+
use radicle::node::{Penalty, Severity};
use radicle::storage::refs::SIGREFS_BRANCH;
use radicle::storage::RepositoryError;
use radicle_fetch::policy::SeedingPolicy;

+
use crate::fetcher;
+
use crate::fetcher::service::FetcherService;
+
use crate::fetcher::FetcherState;
+
use crate::fetcher::RefsToFetch;
use crate::service::gossip::Store as _;
use crate::service::message::{
    Announcement, AnnouncementMessage, Info, NodeAnnouncement, Ping, RefsAnnouncement, RefsStatus,
@@ -47,9 +54,7 @@ use radicle::identity::RepoId;
use radicle::node::events::Emitter;
use radicle::node::routing;
use radicle::node::routing::InsertResult;
-
use radicle::node::{
-
    Address, Alias, Features, FetchResult, HostName, Seed, Seeds, SyncStatus, SyncedAt,
-
};
+
use radicle::node::{Address, Features, FetchResult, HostName, Seed, Seeds, SyncStatus, SyncedAt};
use radicle::prelude::*;
use radicle::storage;
use radicle::storage::{refs::RefsAt, Namespaces, ReadStorage};
@@ -186,8 +191,6 @@ pub enum Error {
    #[error(transparent)]
    Git(#[from] radicle::git::raw::Error),
    #[error(transparent)]
-
    GitExt(#[from] radicle::git::ext::Error),
-
    #[error(transparent)]
    Storage(#[from] storage::Error),
    #[error(transparent)]
    Gossip(#[from] gossip::Error),
@@ -223,6 +226,12 @@ pub enum ConnectError {
    SelfConnection,
    #[error("outbound connection limit reached when attempting {nid} ({addr})")]
    LimitReached { nid: NodeId, addr: Address },
+
    #[error(
+
        "attempted connection to {nid}, via {addr} but addresses of this kind are not supported"
+
    )]
+
    UnsupportedAddress { nid: NodeId, addr: Address },
+
    #[error("attempted connection with blocked peer {nid}")]
+
    Blocked { nid: NodeId },
}

/// A store for all node data.
@@ -233,92 +242,6 @@ pub trait Store:

impl Store for radicle::node::Database {}

-
/// Function used to query internal service state.
-
pub type QueryState = dyn Fn(&dyn ServiceState) -> Result<(), CommandError> + Send + Sync;
-

-
/// Commands sent to the service by the operator.
-
pub enum Command {
-
    /// Announce repository references for given repository to peers.
-
    AnnounceRefs(RepoId, chan::Sender<RefsAt>),
-
    /// Announce local repositories to peers.
-
    AnnounceInventory,
-
    /// Add repository to local inventory.
-
    AddInventory(RepoId, chan::Sender<bool>),
-
    /// Connect to node with the given address.
-
    Connect(NodeId, Address, ConnectOptions),
-
    /// Disconnect from node.
-
    Disconnect(NodeId),
-
    /// Get the node configuration.
-
    Config(chan::Sender<Config>),
-
    /// Get the node's listen addresses.
-
    ListenAddrs(chan::Sender<Vec<std::net::SocketAddr>>),
-
    /// Lookup seeds for the given repository in the routing table.
-
    Seeds(RepoId, chan::Sender<Seeds>),
-
    /// Fetch the given repository from the network.
-
    Fetch(RepoId, NodeId, time::Duration, chan::Sender<FetchResult>),
-
    /// Seed the given repository.
-
    Seed(RepoId, Scope, chan::Sender<bool>),
-
    /// Unseed the given repository.
-
    Unseed(RepoId, chan::Sender<bool>),
-
    /// Follow the given node.
-
    Follow(NodeId, Option<Alias>, chan::Sender<bool>),
-
    /// Unfollow the given node.
-
    Unfollow(NodeId, chan::Sender<bool>),
-
    /// Query the internal service state.
-
    QueryState(Arc<QueryState>, chan::Sender<Result<(), CommandError>>),
-
}
-

-
impl fmt::Debug for Command {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        match self {
-
            Self::AnnounceRefs(id, _) => write!(f, "AnnounceRefs({id})"),
-
            Self::AnnounceInventory => write!(f, "AnnounceInventory"),
-
            Self::AddInventory(rid, _) => write!(f, "AddInventory({rid})"),
-
            Self::Connect(id, addr, opts) => write!(f, "Connect({id}, {addr}, {opts:?})"),
-
            Self::Disconnect(id) => write!(f, "Disconnect({id})"),
-
            Self::Config(_) => write!(f, "Config"),
-
            Self::ListenAddrs(_) => write!(f, "ListenAddrs"),
-
            Self::Seeds(id, _) => write!(f, "Seeds({id})"),
-
            Self::Fetch(id, node, _, _) => write!(f, "Fetch({id}, {node})"),
-
            Self::Seed(id, scope, _) => write!(f, "Seed({id}, {scope})"),
-
            Self::Unseed(id, _) => write!(f, "Unseed({id})"),
-
            Self::Follow(id, _, _) => write!(f, "Follow({id})"),
-
            Self::Unfollow(id, _) => write!(f, "Unfollow({id})"),
-
            Self::QueryState { .. } => write!(f, "QueryState(..)"),
-
        }
-
    }
-
}
-

-
/// Command-related errors.
-
#[derive(thiserror::Error, Debug)]
-
pub enum CommandError {
-
    #[error(transparent)]
-
    Storage(#[from] storage::Error),
-
    #[error(transparent)]
-
    Routing(#[from] routing::Error),
-
    #[error(transparent)]
-
    Policy(#[from] policy::Error),
-
}
-

-
/// Error returned by [`Service::try_fetch`].
-
#[derive(thiserror::Error, Debug)]
-
enum TryFetchError<'a> {
-
    #[error("ongoing fetch for repository exists")]
-
    AlreadyFetching(&'a mut FetchState),
-
    #[error("peer is not connected; cannot initiate fetch")]
-
    SessionNotConnected,
-
    #[error("peer fetch capacity reached; cannot initiate fetch")]
-
    SessionCapacityReached,
-
    #[error(transparent)]
-
    Namespaces(Box<NamespacesError>),
-
}
-

-
impl From<NamespacesError> for TryFetchError<'_> {
-
    fn from(e: NamespacesError) -> Self {
-
        Self::Namespaces(Box::new(e))
-
    }
-
}
-

/// Fetch state for an ongoing fetch.
#[derive(Debug)]
pub struct FetchState {
@@ -330,15 +253,6 @@ pub struct FetchState {
    pub subscribers: Vec<chan::Sender<FetchResult>>,
}

-
impl FetchState {
-
    /// Add a subscriber to this fetch.
-
    fn subscribe(&mut self, c: chan::Sender<FetchResult>) {
-
        if !self.subscribers.iter().any(|s| s.same_channel(&c)) {
-
            self.subscribers.push(c);
-
        }
-
    }
-
}
-

/// Holds all node stores.
#[derive(Debug)]
pub struct Stores<D>(D);
@@ -438,8 +352,7 @@ pub struct Service<D, S, G> {
    inventory: InventoryAnnouncement,
    /// Source of entropy.
    rng: Rng,
-
    /// Ongoing fetches.
-
    fetching: HashMap<RepoId, FetchState>,
+
    fetcher: FetcherService<command::Responder<FetchResult>>,
    /// Request/connection rate limiter.
    limiter: RateLimiter,
    /// Current seeded repositories bloom filter.
@@ -507,7 +420,15 @@ where
        let last_timestamp = node.timestamp;
        let clock = LocalTime::default(); // Updated on initialize.
        let inventory = gossip::inventory(clock.into(), []); // Updated on initialize.
-

+
        let fetcher = {
+
            let config = fetcher::Config::new()
+
                .with_max_concurrency(
+
                    std::num::NonZeroUsize::new(config.limits.fetch_concurrency.into())
+
                        .expect("fetch concurrency was zero, must be at least 1"),
+
                )
+
                .with_max_capacity(fetcher::MaxQueueSize::default());
+
            FetcherService::new(config)
+
        };
        Self {
            config,
            storage,
@@ -521,7 +442,7 @@ where
            outbox: Outbox::default(),
            limiter,
            sessions,
-
            fetching: HashMap::new(),
+
            fetcher,
            filter: Filter::empty(),
            relayed_by: HashMap::default(),
            last_idle: LocalTime::default(),
@@ -573,7 +494,7 @@ where
            self.filter = Filter::allowed_by(self.policies.seed_policies()?);
            // Update and announce new inventory.
            if let Err(e) = self.remove_inventory(id) {
-
                error!(target: "service", "Error updating inventory after unseed: {e}");
+
                warn!(target: "service", "Failed to update inventory after unseed: {e}");
            }
        }
        Ok(updated)
@@ -622,6 +543,10 @@ where
        Events::from(self.emitter.subscribe())
    }

+
    pub fn fetcher(&self) -> &FetcherState {
+
        self.fetcher.state()
+
    }
+

    /// Get I/O outbox.
    pub fn outbox(&mut self) -> &mut Outbox {
        &mut self.outbox
@@ -661,7 +586,7 @@ where
            Ok(Some(last)) => Some(last.to_local_time()),
            Ok(None) => None,
            Err(e) => {
-
                error!(target: "service", "Error getting the lastest gossip message from db: {e}");
+
                warn!(target: "service", "Failed to get the latest gossip message from db: {e}");
                None
            }
        };
@@ -672,11 +597,13 @@ where
            Ok(0) => {
                info!(target: "service", "Empty refs database, populating from storage..");
                if let Err(e) = self.db.refs_mut().populate(&self.storage) {
-
                    error!(target: "service", "Failed to populate refs database: {e}");
+
                    warn!(target: "service", "Failed to populate refs database: {e}");
                }
            }
            Ok(n) => debug!(target: "service", "Refs database has {n} cached references"),
-
            Err(e) => error!(target: "service", "Error checking refs database: {e}"),
+
            Err(e) => {
+
                warn!(target: "service", "Failed to retrieve count of refs from database: {e}")
+
            }
        }

        let announced = self
@@ -692,7 +619,7 @@ where

            // If we're not seeding this repo, just skip it.
            if !self.policies.is_seeding(&rid)? {
-
                warn!(target: "service", "Local repository {rid} is not seeded");
+
                debug!(target: "service", "Local repository {rid} is not seeded");
                continue;
            }
            // Add public repositories to inventory.
@@ -749,7 +676,7 @@ where
        let addrs = self.config.connect.clone();
        for (id, addr) in addrs.into_iter().map(|ca| ca.into()) {
            if let Err(e) = self.connect(id, addr) {
-
                error!(target: "service", "Service::initialization connection error: {e}");
+
                debug!(target: "service", "Service::initialization connection error: {e}");
            }
        }
        // Try to establish some connections.
@@ -805,7 +732,7 @@ where
            trace!(target: "service", "Running 'gossip' task...");

            if let Err(e) = self.relay_announcements() {
-
                error!(target: "service", "Error relaying stored announcements: {e}");
+
                warn!(target: "service", "Failed to relay stored announcements: {e}");
            }
            self.outbox.wakeup(GOSSIP_INTERVAL);
            self.last_gossip = now;
@@ -814,7 +741,7 @@ where
            trace!(target: "service", "Running 'sync' task...");

            if let Err(e) = self.fetch_missing_repositories() {
-
                error!(target: "service", "Error fetching missing inventory: {e}");
+
                warn!(target: "service", "Failed to fetch missing inventory: {e}");
            }
            self.outbox.wakeup(SYNC_INTERVAL);
            self.last_sync = now;
@@ -830,14 +757,14 @@ where
            trace!(target: "service", "Running 'prune' task...");

            if let Err(err) = self.prune_routing_entries(&now) {
-
                error!(target: "service", "Error pruning routing entries: {err}");
+
                warn!(target: "service", "Failed to prune routing entries: {err}");
            }
            if let Err(err) = self
                .db
                .gossip_mut()
                .prune((now - LocalDuration::from(self.config.limits.gossip_max_age)).into())
            {
-
                error!(target: "service", "Error pruning gossip entries: {err}");
+
                warn!(target: "service", "Failed to prune gossip entries: {err}");
            }

            self.outbox.wakeup(PRUNE_INTERVAL);
@@ -875,12 +802,12 @@ where
                self.outbox.disconnect(nid, DisconnectReason::Command);
            }
            Command::Config(resp) => {
-
                resp.send(self.config.clone()).ok();
+
                resp.ok(self.config.clone()).ok();
            }
            Command::ListenAddrs(resp) => {
-
                resp.send(self.listening.clone()).ok();
+
                resp.ok(self.listening.clone()).ok();
            }
-
            Command::Seeds(rid, resp) => match self.seeds(&rid) {
+
            Command::Seeds(rid, namespaces, resp) => match self.seeds(&rid, namespaces) {
                Ok(seeds) => {
                    let (connected, disconnected) = seeds.partition();
                    debug!(
@@ -888,21 +815,22 @@ where
                        "Found {} connected seed(s) and {} disconnected seed(s) for {}",
                        connected.len(), disconnected.len(),  rid
                    );
-
                    resp.send(seeds).ok();
+
                    resp.ok(seeds).ok();
                }
                Err(e) => {
-
                    error!(target: "service", "Error getting seeds for {rid}: {e}");
+
                    warn!(target: "service", "Failed to get seeds for {rid}: {e}");
+
                    resp.err(e).ok();
                }
            },
            Command::Fetch(rid, seed, timeout, resp) => {
-
                self.fetch(rid, seed, timeout, Some(resp));
+
                self.fetch(rid, seed, vec![], timeout, Some(resp));
            }
            Command::Seed(rid, scope, resp) => {
                // Update our seeding policy.
                let seeded = self
                    .seed(&rid, scope)
                    .expect("Service::command: error seeding repository");
-
                resp.send(seeded).ok();
+
                resp.ok(seeded).ok();

                // Let all our peers know that we're interested in this repo from now on.
                self.outbox.broadcast(
@@ -914,45 +842,65 @@ where
                let updated = self
                    .unseed(&id)
                    .expect("Service::command: error unseeding repository");
-
                resp.send(updated).ok();
+
                resp.ok(updated).ok();
            }
            Command::Follow(id, alias, resp) => {
                let seeded = self
                    .policies
                    .follow(&id, alias.as_ref())
                    .expect("Service::command: error following node");
-
                resp.send(seeded).ok();
+
                resp.ok(seeded).ok();
            }
            Command::Unfollow(id, resp) => {
                let updated = self
                    .policies
                    .unfollow(&id)
                    .expect("Service::command: error unfollowing node");
+
                resp.ok(updated).ok();
+
            }
+
            Command::Block(id, resp) => {
+
                let updated = self
+
                    .policies
+
                    .set_follow_policy(&id, policy::Policy::Block)
+
                    .expect("Service::command: error blocking node");
+
                if updated {
+
                    self.outbox.disconnect(id, DisconnectReason::Policy);
+
                }
                resp.send(updated).ok();
            }
-
            Command::AnnounceRefs(id, resp) => {
+
            Command::AnnounceRefs(id, namespaces, resp) => {
                let doc = match self.storage.get(id) {
                    Ok(Some(doc)) => doc,
                    Ok(None) => {
-
                        error!(target: "service", "Error announcing refs: repository {id} not found");
+
                        warn!(target: "service", "Failed to announce refs: repository {id} not found");
+
                        resp.err(command::Error::custom(format!("repository {id} not found")))
+
                            .ok();
                        return;
                    }
                    Err(e) => {
-
                        error!(target: "service", "Error announcing refs: doc error: {e}");
+
                        warn!(target: "service", "Failed to announce refs: doc error: {e}");
+
                        resp.err(e).ok();
                        return;
                    }
                };

-
                match self.announce_own_refs(id, doc) {
-
                    Ok(refs) => match refs.as_slice() {
-
                        &[refs] => {
-
                            resp.send(refs).ok();
+
                match self.announce_own_refs(id, doc, namespaces) {
+
                    Ok((refs, _timestamp)) => {
+
                        // TODO(finto): currently the command caller only
+
                        // expects one `RefsAt`, this should be fixed in the
+
                        // trait, eventually.
+
                        if let Some(refs) = refs.first() {
+
                            resp.ok(*refs).ok();
+
                        } else {
+
                            resp.err(command::Error::custom(format!(
+
                                "no refs were announced for {id}"
+
                            )))
+
                            .ok();
                        }
-
                        // SAFETY: Since we passed in one NID, we should get exactly one item back.
-
                        [..] => panic!("Service::command: unexpected refs returned"),
-
                    },
+
                    }
                    Err(err) => {
-
                        error!(target: "service", "Error announcing refs: {err}");
+
                        warn!(target: "service", "Failed to announce refs: {err}");
+
                        resp.err(err).ok();
                    }
                }
            }
@@ -961,10 +909,11 @@ where
            }
            Command::AddInventory(rid, resp) => match self.add_inventory(rid) {
                Ok(updated) => {
-
                    resp.send(updated).ok();
+
                    resp.ok(updated).ok();
                }
                Err(e) => {
-
                    error!(target: "service", "Error adding {rid} to inventory: {e}");
+
                    warn!(target: "service", "Failed to add {rid} to inventory: {e}");
+
                    resp.err(e).ok();
                }
            },
            Command::QueryState(query, sender) => {
@@ -982,267 +931,190 @@ where
        refs: NonEmpty<RefsAt>,
        scope: Scope,
        timeout: time::Duration,
-
        channel: Option<chan::Sender<FetchResult>>,
    ) -> bool {
        match self.refs_status_of(rid, refs, &scope) {
            Ok(status) => {
                if status.want.is_empty() {
                    debug!(target: "service", "Skipping fetch for {rid}, all refs are already in storage");
                } else {
-
                    return self._fetch(rid, from, status.want, timeout, channel);
+
                    self.fetch(rid, from, status.want, timeout, None);
+
                    return true;
                }
            }
            Err(e) => {
-
                error!(target: "service", "Error getting the refs status of {rid}: {e}");
+
                warn!(target: "service", "Failed to get the refs status of {rid}: {e}");
            }
        }
        // We didn't try to fetch anything.
        false
    }

-
    /// Initiate an outgoing fetch for some repository.
    fn fetch(
        &mut self,
        rid: RepoId,
        from: NodeId,
-
        timeout: time::Duration,
-
        channel: Option<chan::Sender<FetchResult>>,
-
    ) -> bool {
-
        self._fetch(rid, from, vec![], timeout, channel)
-
    }
-

-
    fn _fetch(
-
        &mut self,
-
        rid: RepoId,
-
        from: NodeId,
        refs_at: Vec<RefsAt>,
        timeout: time::Duration,
-
        channel: Option<chan::Sender<FetchResult>>,
-
    ) -> bool {
-
        match self.try_fetch(rid, &from, refs_at.clone(), timeout) {
-
            Ok(fetching) => {
+
        channel: Option<command::Responder<FetchResult>>,
+
    ) {
+
        let session = {
+
            let reason = format!("peer {from} is not connected; cannot initiate fetch");
+
            let Some(session) = self.sessions.get_mut(&from) else {
                if let Some(c) = channel {
-
                    fetching.subscribe(c);
-
                }
-
                return true;
-
            }
-
            Err(TryFetchError::AlreadyFetching(fetching)) => {
-
                // If we're already fetching the same refs from the requested peer, there's nothing
-
                // to do, we simply add the supplied channel to the list of subscribers so that it
-
                // is notified on completion. Otherwise, we queue a fetch with the requested peer.
-
                if fetching.from == from && fetching.refs_at == refs_at {
-
                    debug!(target: "service", "Ignoring redundant fetch of {rid} from {from}");
-

-
                    if let Some(c) = channel {
-
                        fetching.subscribe(c);
-
                    }
-
                } else {
-
                    let fetch = QueuedFetch {
-
                        rid,
-
                        refs_at,
-
                        from,
-
                        timeout,
-
                        channel,
-
                    };
-
                    debug!(target: "service", "Queueing fetch for {rid} with {from} (already fetching)..");
-

-
                    self.queue_fetch(fetch);
+
                    c.ok(FetchResult::Failed { reason }).ok();
                }
-
            }
-
            Err(TryFetchError::SessionCapacityReached) => {
-
                debug!(target: "service", "Fetch capacity reached for {from}, queueing {rid}..");
-
                self.queue_fetch(QueuedFetch {
-
                    rid,
-
                    refs_at,
-
                    from,
-
                    timeout,
-
                    channel,
-
                });
-
            }
-
            Err(e) => {
+
                return;
+
            };
+
            if !session.is_connected() {
                if let Some(c) = channel {
-
                    c.send(FetchResult::Failed {
-
                        reason: e.to_string(),
-
                    })
-
                    .ok();
+
                    c.ok(FetchResult::Failed { reason }).ok();
                }
+
                return;
            }
-
        }
-
        false
-
    }
-

-
    fn queue_fetch(&mut self, fetch: QueuedFetch) {
-
        let Some(s) = self.sessions.get_mut(&fetch.from) else {
-
            log::error!(target: "service", "Cannot queue fetch for unknown session {}", fetch.from);
-
            return;
+
            session
        };
-
        if let Err(e) = s.queue_fetch(fetch) {
-
            let fetch = e.inner();
-
            log::debug!(target: "service", "Unable to queue fetch for {} with {}: {e}", &fetch.rid, &fetch.from);
-
        }
-
    }

-
    // TODO: Buffer/throttle fetches.
-
    fn try_fetch(
-
        &mut self,
-
        rid: RepoId,
-
        from: &NodeId,
-
        refs_at: Vec<RefsAt>,
-
        timeout: time::Duration,
-
    ) -> Result<&mut FetchState, TryFetchError> {
-
        let from = *from;
-
        let Some(session) = self.sessions.get_mut(&from) else {
-
            return Err(TryFetchError::SessionNotConnected);
+
        let cmd = fetcher::state::command::Fetch {
+
            from,
+
            rid,
+
            refs: refs_at.into(),
+
            timeout,
        };
-
        let fetching = self.fetching.entry(rid);
+
        let fetcher::service::FetchInitiated { event, rejected } = self.fetcher.fetch(cmd, channel);

-
        trace!(target: "service", "Trying to fetch {refs_at:?} for {rid}..");
+
        if let Some(c) = rejected {
+
            c.ok(FetchResult::Failed {
+
                reason: "fetch queue at capacity".to_string(),
+
            })
+
            .ok();
+
        }

-
        let fetching = match fetching {
-
            Entry::Vacant(fetching) => fetching,
-
            Entry::Occupied(fetching) => {
-
                // We're already fetching this repo from some peer.
-
                return Err(TryFetchError::AlreadyFetching(fetching.into_mut()));
+
        match event {
+
            fetcher::state::event::Fetch::Started {
+
                rid,
+
                from,
+
                refs: refs_at,
+
                timeout,
+
            } => {
+
                debug!(target: "service", "Starting fetch for {rid} from {from}");
+
                self.outbox.fetch(
+
                    session,
+
                    rid,
+
                    refs_at.into(),
+
                    timeout,
+
                    self.config.limits.fetch_pack_receive,
+
                );
+
            }
+
            fetcher::state::event::Fetch::Queued { rid, from } => {
+
                debug!(target: "service", "Queued fetch for {rid} from {from}");
+
            }
+
            fetcher::state::event::Fetch::AlreadyFetching { rid, from } => {
+
                debug!(target: "service", "Already fetching {rid} from {from}");
+
            }
+
            fetcher::state::event::Fetch::QueueAtCapacity { rid, from, .. } => {
+
                debug!(target: "service", "Queue at capacity for {from}, rejected {rid}");
            }
-
        };
-
        // Sanity check: We shouldn't be fetching from this session, since we return above if we're
-
        // fetching from any session.
-
        debug_assert!(!session.is_fetching(&rid));
-

-
        if !session.is_connected() {
-
            // This can happen if a session disconnects in the time between asking for seeds to
-
            // fetch from, and initiating the fetch from one of those seeds.
-
            return Err(TryFetchError::SessionNotConnected);
-
        }
-
        if session.is_at_capacity() {
-
            // If we're already fetching multiple repos from this peer.
-
            return Err(TryFetchError::SessionCapacityReached);
        }
-

-
        let fetching = fetching.insert(FetchState {
-
            from,
-
            refs_at: refs_at.clone(),
-
            subscribers: vec![],
-
        });
-
        self.outbox.fetch(
-
            session,
-
            rid,
-
            refs_at,
-
            timeout,
-
            self.config.limits.fetch_pack_receive,
-
        );
-

-
        Ok(fetching)
    }

    pub fn fetched(
        &mut self,
        rid: RepoId,
-
        remote: NodeId,
+
        from: NodeId,
        result: Result<crate::worker::fetch::FetchResult, crate::worker::FetchError>,
    ) {
-
        let Some(fetching) = self.fetching.remove(&rid) else {
-
            error!(target: "service", "Received unexpected fetch result for {rid}, from {remote}");
-
            return;
-
        };
-
        debug_assert_eq!(fetching.from, remote);
+
        let cmd = fetcher::state::command::Fetched { from, rid };
+
        let fetcher::service::FetchCompleted { event, subscribers } = self.fetcher.fetched(cmd);

-
        if let Some(s) = self.sessions.get_mut(&remote) {
-
            // Mark this RID as fetched for this session.
-
            s.fetched(rid);
-
        }
-

-
        // Notify all fetch subscribers of the fetch result. This is used when the user requests
-
        // a fetch via the CLI, for example.
-
        for sub in &fetching.subscribers {
-
            debug!(target: "service", "Found existing fetch request from {remote}, sending result..");
+
        // Dequeue next fetches
+
        self.dequeue_fetches();

-
            let result = match &result {
-
                Ok(success) => FetchResult::Success {
-
                    updated: success.updated.clone(),
-
                    namespaces: success.namespaces.clone(),
-
                    clone: success.clone,
-
                },
-
                Err(e) => FetchResult::Failed {
-
                    reason: e.to_string(),
-
                },
-
            };
-
            if sub.send(result).is_err() {
-
                error!(target: "service", "Error sending fetch result for {rid} from {remote}..");
-
            } else {
-
                debug!(target: "service", "Sent fetch result for {rid} from {remote}..");
-
            }
-
        }
-

-
        match result {
-
            Ok(crate::worker::fetch::FetchResult {
-
                updated,
-
                canonical,
-
                namespaces,
-
                clone,
-
                doc,
-
            }) => {
-
                info!(target: "service", "Fetched {rid} from {remote} successfully");
-
                // Update our routing table in case this fetch was user-initiated and doesn't
-
                // come from an announcement.
-
                self.seed_discovered(rid, remote, self.clock.into());
-

-
                for update in &updated {
-
                    if update.is_skipped() {
-
                        trace!(target: "service", "Ref skipped: {update} for {rid}");
-
                    } else {
-
                        debug!(target: "service", "Ref updated: {update} for {rid}");
-
                    }
+
        match event {
+
            fetcher::state::event::Fetched::NotFound { from, rid } => {
+
                debug!(target: "service", "Unexpected fetch result for {rid} from {from}");
+
            }
+
            fetcher::state::event::Fetched::Completed { from, rid, refs: _ } => {
+
                // Notify responders
+
                let fetch_result = match &result {
+
                    Ok(success) => FetchResult::Success {
+
                        updated: success.updated.clone(),
+
                        namespaces: success.namespaces.clone(),
+
                        clone: success.clone,
+
                    },
+
                    Err(e) => FetchResult::Failed {
+
                        reason: e.to_string(),
+
                    },
+
                };
+
                for responder in subscribers {
+
                    responder.ok(fetch_result.clone()).ok();
                }
-
                self.emitter.emit(Event::RefsFetched {
-
                    remote,
-
                    rid,
-
                    updated: updated.clone(),
-
                });
-
                self.emitter.emit_all(
-
                    canonical
-
                        .into_iter()
-
                        .map(|(refname, target)| Event::CanonicalRefUpdated {
+
                match result {
+
                    Ok(crate::worker::fetch::FetchResult {
+
                        updated,
+
                        canonical,
+
                        namespaces,
+
                        clone,
+
                        doc,
+
                    }) => {
+
                        info!(target: "service", "Fetched {rid} from {from} successfully");
+
                        // Update our routing table in case this fetch was user-initiated and doesn't
+
                        // come from an announcement.
+
                        self.seed_discovered(rid, from, self.clock.into());
+

+
                        for update in &updated {
+
                            if update.is_skipped() {
+
                                trace!(target: "service", "Ref skipped: {update} for {rid}");
+
                            } else {
+
                                debug!(target: "service", "Ref updated: {update} for {rid}");
+
                            }
+
                        }
+
                        self.emitter.emit(Event::RefsFetched {
+
                            remote: from,
                            rid,
-
                            refname,
-
                            target,
-
                        })
-
                        .collect(),
-
                );
+
                            updated: updated.clone(),
+
                        });
+
                        self.emitter
+
                            .emit_all(canonical.into_iter().map(|(refname, target)| {
+
                                Event::CanonicalRefUpdated {
+
                                    rid,
+
                                    refname,
+
                                    target,
+
                                }
+
                            }));

-
                // Announce our new inventory if this fetch was a full clone.
-
                // Only update and announce inventory for public repositories.
-
                if clone && doc.is_public() {
-
                    debug!(target: "service", "Updating and announcing inventory for cloned repository {rid}..");
+
                        // Announce our new inventory if this fetch was a full clone.
+
                        // Only update and announce inventory for public repositories.
+
                        if clone && doc.is_public() {
+
                            debug!(target: "service", "Updating and announcing inventory for cloned repository {rid}..");

-
                    if let Err(e) = self.add_inventory(rid) {
-
                        error!(target: "service", "Error announcing inventory for {rid}: {e}");
-
                    }
-
                }
+
                            if let Err(e) = self.add_inventory(rid) {
+
                                warn!(target: "service", "Failed to announce inventory for {rid}: {e}");
+
                            }
+
                        }

-
                // It's possible for a fetch to succeed but nothing was updated.
-
                if updated.is_empty() || updated.iter().all(|u| u.is_skipped()) {
-
                    debug!(target: "service", "Nothing to announce, no refs were updated..");
-
                } else {
-
                    // Finally, announce the refs. This is useful for nodes to know what we've synced,
-
                    // beyond just knowing that we have added an item to our inventory.
-
                    if let Err(e) = self.announce_refs(rid, doc.into(), namespaces) {
-
                        error!(target: "service", "Failed to announce new refs: {e}");
+
                        // It's possible for a fetch to succeed but nothing was updated.
+
                        if updated.is_empty() || updated.iter().all(|u| u.is_skipped()) {
+
                            debug!(target: "service", "Nothing to announce, no refs were updated..");
+
                        } else {
+
                            // Finally, announce the refs. This is useful for nodes to know what we've synced,
+
                            // beyond just knowing that we have added an item to our inventory.
+
                            if let Err(e) = self.announce_refs(rid, doc.into(), namespaces, false) {
+
                                warn!(target: "service", "Failed to announce new refs: {e}");
+
                            }
+
                        }
                    }
-
                }
-
            }
-
            Err(err) => {
-
                error!(target: "service", "Fetch failed for {rid} from {remote}: {err}");
+
                    Err(err) => {
+
                        warn!(target: "service", "Fetch failed for {rid} from {from}: {err}");

-
                // For now, we only disconnect the remote in case of timeout. In the future,
-
                // there may be other reasons to disconnect.
-
                if err.is_timeout() {
-
                    self.outbox.disconnect(remote, DisconnectReason::Fetch(err));
+
                        // For now, we only disconnect the from in case of timeout. In the future,
+
                        // there may be other reasons to disconnect.
+
                        if err.is_timeout() {
+
                            self.outbox.disconnect(from, DisconnectReason::Fetch(err));
+
                        }
+
                    }
                }
            }
        }
-
        // We can now try to dequeue more fetches.
-
        self.dequeue_fetches();
    }

    /// Attempt to dequeue fetches from all peers.
@@ -1259,37 +1131,43 @@ where
            .map(|(k, _)| *k)
            .collect::<Vec<_>>();

-
        // Try to dequeue once per session.
        for nid in sessions {
-
            // SAFETY: All the keys we are iterating on exist.
            #[allow(clippy::unwrap_used)]
            let sess = self.sessions.get_mut(&nid).unwrap();
-
            if !sess.is_connected() || sess.is_at_capacity() {
+
            if !sess.is_connected() {
                continue;
            }

-
            if let Some(QueuedFetch {
+
            let Some(fetcher::QueuedFetch {
                rid,
-
                from,
-
                refs_at,
+
                refs: refs_at,
                timeout,
-
                channel,
-
            }) = sess.dequeue_fetch()
-
            {
-
                debug!(target: "service", "Dequeued fetch for {rid} from session {from}..");
+
            }) = self.fetcher.dequeue(&nid)
+
            else {
+
                continue;
+
            };

-
                if let Some(refs) = NonEmpty::from_vec(refs_at) {
-
                    let repo_entry = self.policies.seed_policy(&rid).expect(
-
                        "Service::dequeue_fetch: error accessing repo seeding configuration",
-
                    );
-
                    let SeedingPolicy::Allow { scope } = repo_entry.policy else {
-
                        debug!(target: "service", "Repository {rid} is no longer seeded, skipping..");
-
                        continue;
-
                    };
-
                    self.fetch_refs_at(rid, from, refs, scope, timeout, channel);
-
                } else {
-
                    // If no refs are specified, always do a full fetch.
-
                    self.fetch(rid, from, timeout, channel);
+
            // Check seeding policy
+
            let repo_entry = self
+
                .policies
+
                .seed_policy(&rid)
+
                .expect("error accessing repo seeding configuration");
+

+
            let SeedingPolicy::Allow { scope } = repo_entry.policy else {
+
                debug!(target: "service", "Repository {} no longer seeded, skipping", rid);
+
                continue;
+
            };
+

+
            debug!(target: "service", "Dequeued fetch for {} from {}", rid, nid);
+

+
            match refs_at {
+
                RefsToFetch::Refs(refs) => {
+
                    self.fetch_refs_at(rid, nid, refs, scope, timeout);
+
                }
+
                RefsToFetch::All => {
+
                    // Channel is `None` since they will already be
+
                    // registered with the fetcher service.
+
                    self.fetch(rid, nid, vec![], timeout, None);
                }
            }
        }
@@ -1313,10 +1191,10 @@ where
                    return false;
                }
            }
-
            Err(e) => error!(target: "service", "Error querying ban status for {ip}: {e}"),
+
            Err(e) => warn!(target: "service", "Failed to query ban status for {ip}: {e}"),
        }
        let host: HostName = ip.into();
-
        let tokens = RateLimit::from(self.config.limits.rate.inbound.clone());
+
        let tokens = self.config.limits.rate.inbound;

        if self.limiter.limit(host.clone(), None, &tokens, self.clock) {
            trace!(target: "service", "Rate limiting inbound connection from {host}..");
@@ -1343,6 +1221,16 @@ where
    }

    pub fn connected(&mut self, remote: NodeId, addr: Address, link: Link) {
+
        if let Ok(true) = self.policies.is_blocked(&remote) {
+
            self.emitter.emit(Event::PeerDisconnected {
+
                nid: remote,
+
                reason: format!("{remote} is blocked"),
+
            });
+
            info!(target: "service", "Disconnecting blocked inbound peer {remote}");
+
            self.outbox.disconnect(remote, DisconnectReason::Policy);
+
            return;
+
        }
+

        info!(target: "service", "Connected to {remote} ({addr}) ({link:?})");
        self.emitter.emit(Event::PeerConnected { nid: remote });

@@ -1382,7 +1270,7 @@ where
                                    .addresses_mut()
                                    .record_ip(&remote, ip, self.clock.into())
                            {
-
                                log::error!(target: "service", "Error recording IP address for {remote}: {e}");
+
                                log::debug!(target: "service", "Failed to record IP address for {remote}: {e}");
                            }
                        }
                    }
@@ -1392,7 +1280,6 @@ where
                        self.config.is_persistent(&remote),
                        self.rng.clone(),
                        self.clock,
-
                        self.config.limits.clone(),
                    ));
                    self.outbox.write_all(peer, msgs);
                }
@@ -1423,22 +1310,33 @@ where
        let link = session.link;
        let addr = session.addr.clone();

-
        self.fetching.retain(|_, fetching| {
-
            if fetching.from != remote {
-
                return true;
+
        let cmd = fetcher::state::command::Cancel { from: remote };
+
        let fetcher::service::FetchesCancelled { event, orphaned } = self.fetcher.cancel(cmd);
+

+
        match event {
+
            fetcher::state::event::Cancel::Unexpected { from } => {
+
                debug!(target: "service", "No fetches to cancel for {from}");
            }
-
            // Remove and fail any pending fetches from this remote node.
-
            for resp in &fetching.subscribers {
-
                resp.send(FetchResult::Failed {
-
                    reason: format!("disconnected: {reason}"),
+
            fetcher::state::event::Cancel::Canceled {
+
                from,
+
                active,
+
                queued,
+
            } => {
+
                debug!(target: "service", "Cancelled {} ongoing, {} queued for {from}", active.len(), queued.len());
+
            }
+
        }
+

+
        // Notify orphaned responders
+
        for (rid, responder) in orphaned {
+
            responder
+
                .ok(FetchResult::Failed {
+
                    reason: format!("failed fetch to {rid}, peer disconnected: {reason}"),
                })
                .ok();
-
            }
-
            false
-
        });
+
        }

        // Attempt to re-connect to persistent peers.
-
        if self.config.peer(&remote).is_some() {
+
        if self.config.is_persistent(&remote) {
            let delay = LocalDuration::from_secs(2u64.saturating_pow(session.attempts() as u32))
                .clamp(MIN_RECONNECTION_DELTA, MAX_RECONNECTION_DELTA);

@@ -1468,6 +1366,7 @@ where
                DisconnectReason::Session(e) => e.severity(),
                DisconnectReason::Command
                | DisconnectReason::Conflict
+
                | DisconnectReason::Policy
                | DisconnectReason::SelfConnection => Severity::Low,
            };

@@ -1476,7 +1375,7 @@ where
                .addresses_mut()
                .disconnected(&remote, &addr, severity)
            {
-
                error!(target: "service", "Error updating address store: {e}");
+
                debug!(target: "service", "Failed to update address store: {e}");
            }
            // Only re-attempt outbound connections, since we don't care if an inbound connection
            // is dropped.
@@ -1547,7 +1446,7 @@ where
                    }
                }
                Err(e) => {
-
                    error!(target: "service", "Error looking up node in address book: {e}");
+
                    debug!(target: "service", "Failed to look up node in address book: {e}");
                    return Ok(None);
                }
            }
@@ -1580,7 +1479,7 @@ where
                return Ok(None);
            }
            Err(e) => {
-
                error!(target: "service", "Error updating gossip entry from {announcer}: {e}");
+
                debug!(target: "service", "Failed to update gossip entry from {announcer}: {e}");
                return Ok(None);
            }
        };
@@ -1605,7 +1504,7 @@ where
                        }
                    }
                    Err(e) => {
-
                        error!(target: "service", "Error processing inventory from {announcer}: {e}");
+
                        debug!(target: "service", "Failed to process inventory from {announcer}: {e}");
                        return Ok(None);
                    }
                }
@@ -1636,7 +1535,7 @@ where
                                        missing.push(*id);
                                    }
                                }
-
                                Err(e) => error!(
+
                                Err(e) => debug!(
                                    target: "service",
                                    "Error checking local inventory for {id}: {e}"
                                ),
@@ -1652,7 +1551,7 @@ where

                for rid in missing {
                    debug!(target: "service", "Missing seeded inventory {rid}; initiating fetch..");
-
                    self.fetch(rid, *announcer, FETCH_TIMEOUT, None);
+
                    self.fetch(rid, *announcer, vec![], FETCH_TIMEOUT, None);
                }
                return Ok(relay);
            }
@@ -1706,7 +1605,7 @@ where
                            }
                        }
                        Err(e) => {
-
                            error!(target: "service", "Error updating sync status for {}: {e}", message.rid);
+
                            debug!(target: "service", "Failed to update sync status for {}: {e}", message.rid);
                        }
                    }
                }
@@ -1721,9 +1620,18 @@ where
                    );
                    return Ok(None);
                };
-
                // Refs can be relayed by peers who don't have the data in storage,
-
                // therefore we only check whether we are connected to the *announcer*,
-
                // which is required by the protocol to only announce refs it has.
+
                // Ref announcements may be relayed by peers who don't have the
+
                // actual refs in storage, therefore we only check whether we
+
                // are connected to the *announcer*, which is required by the
+
                // protocol to only announce refs it has.
+
                //
+
                // TODO(Ade): Perhaps it makes sense to establish connections to
+
                // followed but unconnected peers. Consider:
+
                //   Connections: Alice ←→ Bob ←→ Eve
+
                //   Follows:     Alice ←→ Eve
+
                // Eve announces refs, and Bob relays these announcements to Alice.
+
                // Then, Alice might determine that Bob does not have Eve's refs,
+
                // and therefore connect directly to Eve in order to fetch.
                let Some(remote) = self.sessions.get(announcer).cloned() else {
                    trace!(
                        target: "service",
@@ -1733,7 +1641,7 @@ where
                    return Ok(relay);
                };
                // Finally, start the fetch.
-
                self.fetch_refs_at(message.rid, remote.id, refs, scope, FETCH_TIMEOUT, None);
+
                self.fetch_refs_at(message.rid, remote.id, refs, scope, FETCH_TIMEOUT);

                return Ok(relay);
            }
@@ -1784,7 +1692,7 @@ where
                    }
                    Err(err) => {
                        // An error here is due to a fault in our address store.
-
                        error!(target: "service", "Error processing node announcement from {announcer}: {err}");
+
                        warn!(target: "service", "Failed to process node announcement from {announcer}: {err}");
                    }
                }
            }
@@ -1816,14 +1724,14 @@ where
        let local = self.node_id();
        let relay = self.config.is_relay();
        let Some(peer) = self.sessions.get_mut(remote) else {
-
            warn!(target: "service", "Session not found for {remote}");
+
            debug!(target: "service", "Session not found for {remote}");
            return Ok(());
        };
        peer.last_active = self.clock;

        let limit: RateLimit = match peer.link {
-
            Link::Outbound => self.config.limits.rate.outbound.clone().into(),
-
            Link::Inbound => self.config.limits.rate.inbound.clone().into(),
+
            Link::Outbound => self.config.limits.rate.outbound.into(),
+
            Link::Inbound => self.config.limits.rate.inbound.into(),
        };
        if self
            .limiter
@@ -1874,7 +1782,7 @@ where
                                .gossip_mut()
                                .set_relay(id, gossip::RelayStatus::Relay)
                            {
-
                                error!(target: "service", "Error setting relay flag for message: {e}");
+
                                warn!(target: "service", "Failed to set relay flag for message: {e}");
                                return Ok(());
                            }
                        } else {
@@ -1895,7 +1803,7 @@ where
                            let ann = match ann {
                                Ok(a) => a,
                                Err(e) => {
-
                                    error!(target: "service", "Error reading gossip message from store: {e}");
+
                                    debug!(target: "service", "Failed to read gossip message from store: {e}");
                                    continue;
                                }
                            };
@@ -1910,7 +1818,7 @@ where
                        }
                    }
                    Err(e) => {
-
                        error!(target: "service", "Error querying gossip messages from store: {e}");
+
                        warn!(target: "service", "Failed to query gossip messages from store: {e}");
                    }
                }
                peer.subscribe = Some(subscribe);
@@ -1987,7 +1895,7 @@ where
        if let Ok(result) = self.db.routing_mut().add_inventory([&rid], nid, time) {
            if let &[(_, InsertResult::SeedAdded)] = result.as_slice() {
                self.emitter.emit(Event::SeedDiscovered { rid, nid });
-
                info!(target: "service", "Routing table updated for {rid} with seed {nid}");
+
                debug!(target: "service", "Routing table updated for {rid} with seed {nid}");
            }
        }
    }
@@ -2047,7 +1955,7 @@ where
        let now = self.timestamp();

        if !self.storage.contains(&rid)? {
-
            error!(target: "service", "Attempt to add non-existing inventory {rid}: repository not found in storage");
+
            debug!(target: "service", "Attempt to add non-existing inventory {rid}: repository not found in storage");
            return Ok(false);
        }
        // Add to our local inventory.
@@ -2072,7 +1980,7 @@ where

    /// Get our local inventory.
    ///
-
    /// A node's inventory is the advertized list of repositories offered by a node.
+
    /// A node's inventory is the advertised list of repositories offered by a node.
    ///
    /// A node's inventory consists of *public* repositories that are seeded and available locally
    /// in the node's storage. We use the routing table as the canonical state of all inventories,
@@ -2098,6 +2006,7 @@ where
    ) -> Result<SyncedRouting, Error> {
        let mut synced = SyncedRouting::default();
        let included = inventory.into_iter().collect::<BTreeSet<_>>();
+
        let mut events = Vec::new();

        for (rid, result) in
            self.db
@@ -2106,8 +2015,8 @@ where
        {
            match result {
                InsertResult::SeedAdded => {
-
                    info!(target: "service", "Routing table updated for {rid} with seed {from}");
-
                    self.emitter.emit(Event::SeedDiscovered { rid, nid: from });
+
                    debug!(target: "service", "Routing table updated for {rid} with seed {from}");
+
                    events.push(Event::SeedDiscovered { rid, nid: from });

                    if self
                        .policies
@@ -2125,14 +2034,26 @@ where
                InsertResult::NotUpdated => {}
            }
        }
-
        for rid in self.db.routing().get_inventory(&from)?.into_iter() {
-
            if !included.contains(&rid) {
-
                if self.db.routing_mut().remove_inventory(&rid, &from)? {
-
                    synced.removed.push(rid);
-
                    self.emitter.emit(Event::SeedDropped { rid, nid: from });
-
                }
-
            }
-
        }
+

+
        synced.removed.extend(
+
            self.db
+
                .routing()
+
                .get_inventory(&from)?
+
                .into_iter()
+
                .filter(|rid| !included.contains(rid)),
+
        );
+
        self.db
+
            .routing_mut()
+
            .remove_inventories(&synced.removed, &from)?;
+
        events.extend(
+
            synced
+
                .removed
+
                .iter()
+
                .map(|&rid| Event::SeedDropped { rid, nid: from }),
+
        );
+

+
        self.emitter.emit_all(events);
+

        Ok(synced)
    }

@@ -2167,16 +2088,21 @@ where
    }

    /// Announce our own refs for the given repo.
-
    fn announce_own_refs(&mut self, rid: RepoId, doc: Doc) -> Result<Vec<RefsAt>, Error> {
-
        let (refs, timestamp) = self.announce_refs(rid, doc, [self.node_id()])?;
+
    fn announce_own_refs(
+
        &mut self,
+
        rid: RepoId,
+
        doc: Doc,
+
        namespaces: impl IntoIterator<Item = NodeId>,
+
    ) -> Result<(Vec<RefsAt>, Timestamp), Error> {
+
        let (refs, timestamp) = self.announce_refs(rid, doc, namespaces, true)?;

        // Update refs database with our signed refs branches.
        // This isn't strictly necessary for now, as we only use the database for fetches, and
        // we don't fetch our own refs that are announced, but it's for good measure.
-
        if let &[r] = refs.as_slice() {
+
        for r in refs.iter() {
            self.emitter.emit(Event::LocalRefsAnnounced {
                rid,
-
                refs: r,
+
                refs: *r,
                timestamp,
            });
            if let Err(e) = self.database_mut().refs_mut().set(
@@ -2186,14 +2112,14 @@ where
                r.at,
                timestamp.to_local_time(),
            ) {
-
                error!(
+
                warn!(
                    target: "service",
                    "Error updating refs database for `rad/sigrefs` of {} in {rid}: {e}",
                    r.remote
                );
            }
        }
-
        Ok(refs)
+
        Ok((refs, timestamp))
    }

    /// Announce local refs for given repo.
@@ -2202,6 +2128,7 @@ where
        rid: RepoId,
        doc: Doc,
        remotes: impl IntoIterator<Item = NodeId>,
+
        own: bool,
    ) -> Result<(Vec<RefsAt>, Timestamp), Error> {
        let (ann, refs) = self.refs_announcement_for(rid, remotes)?;
        let timestamp = ann.timestamp();
@@ -2209,19 +2136,14 @@ where

        // Update our sync status for our own refs. This is useful for determining if refs were
        // updated while the node was stopped.
-
        if let Some(refs) = refs.iter().find(|r| r.remote == ann.node) {
+
        for r in refs.iter().filter(|r| own || r.remote == ann.node) {
            info!(
                target: "service",
-
                "Announcing own refs for {rid} to peers ({}) (t={timestamp})..",
-
                refs.at
+
                "Announcing refs {rid}/{r} to peers (t={timestamp})..",
            );
            // Update our local node's sync status to mark the refs as announced.
-
            if let Err(e) = self
-
                .db
-
                .seeds_mut()
-
                .synced(&rid, &ann.node, refs.at, timestamp)
-
            {
-
                error!(target: "service", "Error updating sync status for local node: {e}");
+
            if let Err(e) = self.db.seeds_mut().synced(&rid, &ann.node, r.at, timestamp) {
+
                warn!(target: "service", "Failed to update sync status for local node: {e}");
            } else {
                debug!(target: "service", "Saved local sync status for {rid}..");
            }
@@ -2254,6 +2176,12 @@ where
        if nid == self.node_id() {
            return Err(ConnectError::SelfConnection);
        }
+
        if let Ok(true) = self.policies.is_blocked(&nid) {
+
            return Err(ConnectError::Blocked { nid });
+
        }
+
        if !self.is_supported_address(&addr) {
+
            return Err(ConnectError::UnsupportedAddress { nid, addr });
+
        }
        if self.sessions.contains_key(&nid) {
            return Err(ConnectError::SessionExists { nid });
        }
@@ -2264,31 +2192,30 @@ where
        let timestamp: Timestamp = self.clock.into();

        if let Err(e) = self.db.addresses_mut().attempted(&nid, &addr, timestamp) {
-
            error!(target: "service", "Error updating address book with connection attempt: {e}");
+
            warn!(target: "service", "Failed to update address book with connection attempt: {e}");
        }
        self.sessions.insert(
            nid,
-
            Session::outbound(
-
                nid,
-
                addr.clone(),
-
                persistent,
-
                self.rng.clone(),
-
                self.config.limits.clone(),
-
            ),
+
            Session::outbound(nid, addr.clone(), persistent, self.rng.clone()),
        );
        self.outbox.connect(nid, addr);

        Ok(())
    }

-
    fn seeds(&self, rid: &RepoId) -> Result<Seeds, Error> {
+
    fn seeds(&self, rid: &RepoId, namespaces: HashSet<PublicKey>) -> Result<Seeds, Error> {
        let mut seeds = Seeds::new(self.rng.clone());

-
        // First build a list from peers that have synced our own refs, if any.
-
        // This step is skipped if we don't have the repository yet, or don't have
-
        // our own refs.
+
        // First, build a list of peers that have synced refs for `namespaces`, if any.
+
        // This step is skipped:
+
        //  1. For the repository (and thus all `namespaces`), if it not exist in storage.
+
        //  2. For each `namespace` in `namespaces`, which does not exist in storage.
        if let Ok(repo) = self.storage.repository(*rid) {
-
            if let Ok(local) = RefsAt::new(&repo, self.node_id()) {
+
            for namespace in namespaces.iter() {
+
                let Ok(local) = RefsAt::new(&repo, *namespace) else {
+
                    continue;
+
                };
+

                for seed in self.db.seeds().seeds_for(rid)? {
                    let seed = seed?;
                    let state = self.sessions.get(&seed.nid).map(|s| s.state.clone());
@@ -2311,7 +2238,7 @@ where
        // These peers have announced that they seed the repository via an inventory
        // announcement, but we haven't received any ref announcements from them.
        for nid in self.db.routing().get(rid)? {
-
            if nid == self.node_id() {
+
            if namespaces.contains(&nid) {
                continue;
            }
            if seeds.contains(&nid) {
@@ -2485,8 +2412,10 @@ where
                    .filter(|entry| !entry.address.banned)
                    .filter(|entry| !entry.penalty.is_connect_threshold_reached())
                    .filter(|entry| !self.sessions.contains_key(&entry.node))
+
                    .filter(|entry| !self.policies.is_blocked(&entry.node).unwrap_or(false))
                    .filter(|entry| !self.config.external_addresses.contains(&entry.address.addr))
                    .filter(|entry| &entry.node != self.nid())
+
                    .filter(|entry| self.is_supported_address(&entry.address.addr))
                    .fold(HashMap::new(), |mut acc, entry| {
                        acc.entry(entry.node)
                            .and_modify(|e: &mut Peer| e.addresses.push(entry.address.clone()))
@@ -2503,7 +2432,7 @@ where
                peers
            }
            Err(e) => {
-
                error!(target: "service", "Unable to lookup available peers in address book: {e}");
+
                warn!(target: "service", "Unable to lookup available peers in address book: {e}");
                Vec::new()
            }
        }
@@ -2516,7 +2445,7 @@ where
            let policy = match policy {
                Ok(policy) => policy,
                Err(err) => {
-
                    log::error!(target: "protocol::filter", "Failed to read seed policy: {err}");
+
                    debug!(target: "protocol::filter", "Failed to read seed policy: {err}");
                    continue;
                }
            };
@@ -2526,14 +2455,22 @@ where
            if !policy.is_allow() {
                continue;
            }
-
            if self.storage.contains(&rid)? {
-
                continue;
+
            match self.storage.contains(&rid) {
+
                Ok(exists) => {
+
                    if exists {
+
                        continue;
+
                    }
+
                }
+
                Err(err) => {
+
                    log::debug!(target: "protocol::filter", "Failed to check if {rid} exists: {err}");
+
                    continue;
+
                }
            }
-
            match self.seeds(&rid) {
+
            match self.seeds(&rid, [self.node_id()].into()) {
                Ok(seeds) => {
                    if let Some(connected) = NonEmpty::from_vec(seeds.connected().collect()) {
                        for seed in connected {
-
                            self.fetch(rid, seed.nid, FETCH_TIMEOUT, None);
+
                            self.fetch(rid, seed.nid, vec![], FETCH_TIMEOUT, None);
                        }
                    } else {
                        // TODO: We should make sure that this fetch is retried later, either
@@ -2549,7 +2486,7 @@ where
                    }
                }
                Err(e) => {
-
                    error!(target: "service", "Couldn't fetch missing repo {rid}: failed to lookup seeds: {e}");
+
                    debug!(target: "service", "Couldn't fetch missing repo {rid}: failed to lookup seeds: {e}");
                }
            }
        }
@@ -2568,7 +2505,7 @@ where
                        .addresses_mut()
                        .connected(&sess.id, &sess.addr, self.clock.into())
                {
-
                    error!(target: "service", "Error updating address book with connection: {e}");
+
                    warn!(target: "service", "Failed to update address book with connection: {e}");
                }
            }
        }
@@ -2616,11 +2553,7 @@ where
                    })
                    .map(|ka| (peer.nid, ka))
            })
-
            .filter(|(_, ka)| match AddressType::from(&ka.addr) {
-
                // Only consider onion addresses if configured.
-
                AddressType::Onion => self.config.onion.is_some(),
-
                AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
-
            });
+
            .filter(|(_, ka)| self.is_supported_address(&ka.addr));

        // Peers we are going to attempt connections to.
        let connect = available.take(wanted).collect::<Vec<_>>();
@@ -2633,7 +2566,7 @@ where
        }
        for (id, ka) in connect {
            if let Err(e) = self.connect(id, ka.addr.clone()) {
-
                error!(target: "service", "Service::maintain_connections connection error: {e}");
+
                warn!(target: "service", "Service::maintain_connections connection error: {e}");
            }
        }
    }
@@ -2646,13 +2579,16 @@ where
        let mut reconnect = Vec::new();

        for (nid, session) in self.sessions.iter_mut() {
-
            if let Some(addr) = self.config.peer(nid) {
+
            if self.config.is_persistent(nid) {
+
                if self.policies.is_blocked(nid).unwrap_or(false) {
+
                    continue;
+
                }
                if let session::State::Disconnected { retry_at, .. } = &mut session.state {
                    // TODO: Try to reconnect only if the peer was attempted. A disconnect without
                    // even a successful attempt means that we're unlikely to be able to reconnect.

                    if now >= *retry_at {
-
                        reconnect.push((*nid, addr.clone(), session.attempts()));
+
                        reconnect.push((*nid, session.addr.clone(), session.attempts()));
                    }
                }
            }
@@ -2664,6 +2600,24 @@ where
            }
        }
    }
+

+
    /// Checks if the given [`Address`] is supported for connecting to.
+
    ///
+
    /// # IPv4/IPv6/DNS
+
    ///
+
    /// Always returns `true`.
+
    ///
+
    /// # Tor
+
    ///
+
    /// If the [`Address`] is an `.onion` address and the service supports onion
+
    /// routing then this will return `true`.
+
    fn is_supported_address(&self, address: &Address) -> bool {
+
        match AddressType::from(address) {
+
            // Only consider onion addresses if configured.
+
            AddressType::Onion => self.config.onion.is_some(),
+
            AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
+
        }
+
    }
}

/// Gives read access to the service state.
@@ -2673,7 +2627,7 @@ pub trait ServiceState {
    /// Get the existing sessions.
    fn sessions(&self) -> &Sessions;
    /// Get fetch state.
-
    fn fetching(&self) -> &HashMap<RepoId, FetchState>;
+
    fn fetching(&self) -> &FetcherState;
    /// Get outbox.
    fn outbox(&self) -> &Outbox;
    /// Get rate limiter.
@@ -2706,8 +2660,8 @@ where
        &self.sessions
    }

-
    fn fetching(&self) -> &HashMap<RepoId, FetchState> {
-
        &self.fetching
+
    fn fetching(&self) -> &FetcherState {
+
        self.fetcher.state()
    }

    fn outbox(&self) -> &Outbox {
@@ -2746,7 +2700,7 @@ where
/// Disconnect reason.
#[derive(Debug)]
pub enum DisconnectReason {
-
    /// Error while dialing the remote. This error occures before a connection is
+
    /// Error while dialing the remote. This error occurs before a connection is
    /// even established. Errors of this kind are usually not transient.
    Dial(Arc<dyn std::error::Error + Sync + Send>),
    /// Error with an underlying established connection. Sometimes, reconnecting
@@ -2760,6 +2714,8 @@ pub enum DisconnectReason {
    Conflict,
    /// Connection to self.
    SelfConnection,
+
    /// Peer is blocked by policy
+
    Policy,
    /// User requested disconnect
    Command,
}
@@ -2788,6 +2744,7 @@ impl fmt::Display for DisconnectReason {
            Self::Command => write!(f, "command"),
            Self::SelfConnection => write!(f, "self-connection"),
            Self::Conflict => write!(f, "conflict"),
+
            Self::Policy => write!(f, "policy"),
            Self::Session(err) => write!(f, "{err}"),
            Self::Fetch(err) => write!(f, "fetch: {err}"),
        }
added crates/radicle-protocol/src/service/command.rs
@@ -0,0 +1,230 @@
+
use std::{collections::HashSet, fmt, sync::Arc, time};
+

+
use crossbeam_channel::Receiver;
+
use crossbeam_channel::SendError;
+
use crossbeam_channel::Sender;
+
use radicle::crypto::PublicKey;
+
use radicle::node::policy::Scope;
+
use radicle::node::FetchResult;
+
use radicle::node::Seeds;
+
use radicle::node::{Address, Alias, Config, ConnectOptions};
+
use radicle::storage::refs::RefsAt;
+
use radicle_core::{NodeId, RepoId};
+
use thiserror::Error;
+

+
use super::ServiceState;
+

+
/// Function used to query internal service state.
+
pub type QueryState = dyn Fn(&dyn ServiceState) -> Result<()> + Send + Sync;
+

+
/// A result returned from processing a [`Command`].
+
///
+
/// It is a type synonym for a [`std::result::Result`]
+
pub type Result<T> = std::result::Result<T, Error>;
+

+
/// A [`Responder`] returns results after processing a service [`Command`].
+
///
+
/// To construct a [`Responder`], use [`Responder::oneshot`], which also returns its
+
/// corresponding [`Receiver`].
+
///
+
/// To send results, use either:
+
/// - [`Responder::send`]
+
/// - [`Responder::ok`]
+
/// - [`Responder::err`]
+
#[derive(Debug)]
+
pub struct Responder<T> {
+
    channel: Sender<Result<T>>,
+
}
+

+
impl<T> Responder<T> {
+
    /// Construct a new [`Responder`] and its corresponding [`Receiver`].
+
    pub fn oneshot() -> (Self, Receiver<Result<T>>) {
+
        let (sender, receiver) = crossbeam_channel::bounded(1);
+
        (Self { channel: sender }, receiver)
+
    }
+

+
    /// Send a [`Result`] to the receiver.
+
    pub fn send(self, result: Result<T>) -> std::result::Result<(), SendError<Result<T>>> {
+
        self.channel.send(result)
+
    }
+

+
    /// Send a [`Result::Ok`] to the receiver.
+
    pub fn ok(self, value: T) -> std::result::Result<(), SendError<Result<T>>> {
+
        self.send(Ok(value))
+
    }
+

+
    /// Send a [`Result::Err`] to the receiver.
+
    pub fn err<E>(self, error: E) -> std::result::Result<(), SendError<Result<T>>>
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        self.send(Err(Error::other(error)))
+
    }
+
}
+

+
/// Commands sent to the service by the operator.
+
///
+
/// Each variant has a corresponding helper constructor, e.g. [`Command::Seed`]
+
/// and [`Command::seed`]. These constructors will hide the construction of the
+
/// [`Responder`], and return the corresponding [`Receiver`] to receive the
+
/// result of the command process.
+
///
+
/// If the command does not return a [`Responder`], then it will only return the
+
/// [`Command`] variant, e.g. [`Command::AnnounceInventory`].
+
pub enum Command {
+
    /// Announce repository references for given repository and namespaces to peers.
+
    AnnounceRefs(RepoId, HashSet<PublicKey>, Responder<RefsAt>),
+
    /// Announce local repositories to peers.
+
    AnnounceInventory,
+
    /// Add repository to local inventory.
+
    AddInventory(RepoId, Responder<bool>),
+
    /// Connect to node with the given address.
+
    Connect(NodeId, Address, ConnectOptions),
+
    /// Disconnect from node.
+
    Disconnect(NodeId),
+
    /// Get the node configuration.
+
    Config(Responder<Config>),
+
    /// Get the node's listen addresses.
+
    ListenAddrs(Responder<Vec<std::net::SocketAddr>>),
+
    /// Lookup seeds for the given repository in the routing table, and report
+
    /// sync status for given namespaces.
+
    Seeds(RepoId, HashSet<PublicKey>, Responder<Seeds>),
+
    /// Fetch the given repository from the network.
+
    Fetch(RepoId, NodeId, time::Duration, Responder<FetchResult>),
+
    /// Seed the given repository.
+
    Seed(RepoId, Scope, Responder<bool>),
+
    /// Unseed the given repository.
+
    Unseed(RepoId, Responder<bool>),
+
    /// Follow the given node.
+
    Follow(NodeId, Option<Alias>, Responder<bool>),
+
    /// Unfollow the given node.
+
    Unfollow(NodeId, Responder<bool>),
+
    /// Block the given node.
+
    Block(NodeId, Sender<bool>),
+
    /// Query the internal service state.
+
    QueryState(Arc<QueryState>, Sender<Result<()>>),
+
}
+

+
impl Command {
+
    pub fn announce_refs(
+
        rid: RepoId,
+
        keys: HashSet<PublicKey>,
+
    ) -> (Self, Receiver<Result<RefsAt>>) {
+
        let (responder, receiver) = Responder::oneshot();
+
        (Self::AnnounceRefs(rid, keys, responder), receiver)
+
    }
+

+
    pub fn announce_inventory() -> Self {
+
        Self::AnnounceInventory
+
    }
+

+
    pub fn add_inventory(rid: RepoId) -> (Self, Receiver<Result<bool>>) {
+
        let (responder, receiver) = Responder::oneshot();
+
        (Self::AddInventory(rid, responder), receiver)
+
    }
+

+
    pub fn connect(node_id: NodeId, address: Address, options: ConnectOptions) -> Self {
+
        Self::Connect(node_id, address, options)
+
    }
+

+
    pub fn disconnect(node_id: NodeId) -> Self {
+
        Self::Disconnect(node_id)
+
    }
+

+
    pub fn config() -> (Self, Receiver<Result<Config>>) {
+
        let (responder, receiver) = Responder::oneshot();
+
        (Self::Config(responder), receiver)
+
    }
+

+
    pub fn listen_addrs() -> (Self, Receiver<Result<Vec<std::net::SocketAddr>>>) {
+
        let (responder, receiver) = Responder::oneshot();
+
        (Self::ListenAddrs(responder), receiver)
+
    }
+

+
    pub fn seeds(rid: RepoId, keys: HashSet<PublicKey>) -> (Self, Receiver<Result<Seeds>>) {
+
        let (responder, receiver) = Responder::oneshot();
+
        (Self::Seeds(rid, keys, responder), receiver)
+
    }
+

+
    pub fn fetch(
+
        rid: RepoId,
+
        node_id: NodeId,
+
        duration: time::Duration,
+
    ) -> (Self, Receiver<Result<FetchResult>>) {
+
        let (responder, receiver) = Responder::oneshot();
+
        (Self::Fetch(rid, node_id, duration, responder), receiver)
+
    }
+

+
    pub fn seed(rid: RepoId, scope: Scope) -> (Self, Receiver<Result<bool>>) {
+
        let (responder, receiver) = Responder::oneshot();
+
        (Self::Seed(rid, scope, responder), receiver)
+
    }
+

+
    pub fn unseed(rid: RepoId) -> (Self, Receiver<Result<bool>>) {
+
        let (responder, receiver) = Responder::oneshot();
+
        (Self::Unseed(rid, responder), receiver)
+
    }
+

+
    pub fn follow(node_id: NodeId, alias: Option<Alias>) -> (Self, Receiver<Result<bool>>) {
+
        let (responder, receiver) = Responder::oneshot();
+
        (Self::Follow(node_id, alias, responder), receiver)
+
    }
+

+
    pub fn unfollow(node_id: NodeId) -> (Self, Receiver<Result<bool>>) {
+
        let (responder, receiver) = Responder::oneshot();
+
        (Self::Unfollow(node_id, responder), receiver)
+
    }
+

+
    pub fn query_state(state: Arc<QueryState>, sender: Sender<Result<()>>) -> Self {
+
        Self::QueryState(state, sender)
+
    }
+
}
+

+
impl fmt::Debug for Command {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        match self {
+
            Self::AnnounceRefs(id, _, _) => write!(f, "AnnounceRefs({id})"),
+
            Self::AnnounceInventory => write!(f, "AnnounceInventory"),
+
            Self::AddInventory(rid, _) => write!(f, "AddInventory({rid})"),
+
            Self::Connect(id, addr, opts) => write!(f, "Connect({id}, {addr}, {opts:?})"),
+
            Self::Disconnect(id) => write!(f, "Disconnect({id})"),
+
            Self::Config(_) => write!(f, "Config"),
+
            Self::ListenAddrs(_) => write!(f, "ListenAddrs"),
+
            Self::Seeds(id, _, _) => write!(f, "Seeds({id})"),
+
            Self::Fetch(id, node, _, _) => write!(f, "Fetch({id}, {node})"),
+
            Self::Seed(id, scope, _) => write!(f, "Seed({id}, {scope})"),
+
            Self::Unseed(id, _) => write!(f, "Unseed({id})"),
+
            Self::Follow(id, _, _) => write!(f, "Follow({id})"),
+
            Self::Unfollow(id, _) => write!(f, "Unfollow({id})"),
+
            Self::Block(id, _) => write!(f, "Block({id})"),
+
            Self::QueryState { .. } => write!(f, "QueryState(..)"),
+
        }
+
    }
+
}
+

+
/// An error that occurred when processing a service [`Command`].
+
#[non_exhaustive]
+
#[derive(Debug, Error)]
+
pub enum Error {
+
    #[error("{0}")]
+
    Other(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
+
}
+

+
impl Error {
+
    pub(super) fn other<E>(error: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::Other(Box::new(error))
+
    }
+

+
    pub(super) fn custom(message: String) -> Self {
+
        Self::other(Custom { message })
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
#[error("{message}")]
+
struct Custom {
+
    message: String,
+
}
modified crates/radicle-protocol/src/service/filter.rs
@@ -70,7 +70,7 @@ impl Filter {
            let seed = match seed {
                Ok(seed) => seed,
                Err(err) => {
-
                    log::error!(target: "protocol::filter", "Failed to read seed policy: {err}");
+
                    log::debug!(target: "protocol::filter", "Failed to read seed policy: {err}");
                    continue;
                }
            };
@@ -130,6 +130,7 @@ impl qcheck::Arbitrary for Filter {
    }
}

+
#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod test {
    use super::*;
@@ -190,4 +191,49 @@ mod test {
        let hs = arbitrary::set::<RepoId>(42..=42);
        assert_eq!(hs.iter().size_hint(), (42, Some(42)));
    }
+

+
    /// Checks that a particular filter extracted from a live deployment of
+
    /// `radicle-node` at `release/1.5.0`, which is known to contain
+
    /// "heartwood", actually also evaluates to contain "heartwood".
+
    ///
+
    /// This is to catch regressions in the [`std::hash::Hash`] implementation
+
    /// [`RepoId`] and other breaking changes to [`Filter`].
+
    #[test]
+
    fn compatible() {
+
        let filter = {
+
            let mut filter = [0u8; FILTER_SIZE_S];
+

+
            #[rustfmt::skip]
+
            const COMPRESSED_FIXTURE: [(usize, u8); 100] = [
+
                (0x002, 0xa8), (0x010, 0x08), (0x016, 0x40), (0x01b, 0x20), (0x04d, 0x04),
+
                (0x050, 0x04), (0x05a, 0x02), (0x05e, 0x80), (0x06d, 0x40), (0x075, 0x08),
+
                (0x082, 0x80), (0x084, 0x80), (0x089, 0x01), (0x08b, 0x08), (0x099, 0x04),
+
                (0x0a1, 0x40), (0x0a7, 0x40), (0x0be, 0x40), (0x0d3, 0x01), (0x0e2, 0x01),
+
                (0x0ee, 0x08), (0x0f2, 0x04), (0x109, 0x08), (0x119, 0x10), (0x15b, 0x40),
+
                (0x160, 0x44), (0x163, 0x01), (0x168, 0x08), (0x16b, 0x01), (0x16d, 0x04),
+
                (0x176, 0x80), (0x17e, 0x40), (0x189, 0x20), (0x18f, 0x04), (0x19f, 0x20),
+
                (0x1b2, 0x08), (0x1b5, 0x04), (0x1b8, 0x20), (0x1ed, 0x10), (0x1f1, 0x40),
+
                (0x1f3, 0x04), (0x1fa, 0x40), (0x20b, 0x08), (0x20e, 0x04), (0x218, 0x01),
+
                (0x231, 0x02), (0x23d, 0x80), (0x248, 0x10), (0x24e, 0x04), (0x250, 0x01),
+
                (0x251, 0x01), (0x255, 0x04), (0x25a, 0x10), (0x265, 0x20), (0x27c, 0x01),
+
                (0x284, 0x04), (0x285, 0x20), (0x28d, 0x81), (0x29f, 0x01), (0x2a6, 0x10),
+
                (0x2ac, 0x40), (0x2ad, 0x10), (0x2b4, 0x04), (0x2b8, 0x02), (0x2cb, 0x01),
+
                (0x2d1, 0x80), (0x2d4, 0x01), (0x2d7, 0x40), (0x2ed, 0x80), (0x2f7, 0x01),
+
                (0x302, 0x80), (0x303, 0x40), (0x307, 0x40), (0x309, 0x04), (0x318, 0x04),
+
                (0x31e, 0x10), (0x335, 0x01), (0x336, 0x40), (0x338, 0x40), (0x351, 0x80),
+
                (0x353, 0x10), (0x359, 0x0c), (0x360, 0x40), (0x367, 0x01), (0x36b, 0x08),
+
                (0x36c, 0x40), (0x37b, 0x10), (0x37d, 0x40), (0x399, 0x02), (0x39f, 0x02),
+
                (0x3a6, 0x02), (0x3a9, 0x04), (0x3ab, 0x01), (0x3cb, 0x04), (0x3e2, 0x01),
+
                (0x3e5, 0x10), (0x3ea, 0x40), (0x3ed, 0x40), (0x3f2, 0x02), (0x3f5, 0x80),
+
            ];
+

+
            for (i, v) in COMPRESSED_FIXTURE.into_iter() {
+
                filter[i] = v;
+
            }
+

+
            Filter(BloomFilter::from(filter.to_vec()))
+
        };
+

+
        assert!(filter.contains(&"rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5".parse().unwrap()),);
+
    }
}
modified crates/radicle-protocol/src/service/gossip.rs
@@ -44,7 +44,7 @@ pub fn inventory(
) -> InventoryAnnouncement {
    let inventory = inventory.into_iter().collect::<Vec<_>>();
    if inventory.len() > INVENTORY_LIMIT {
-
        error!(
+
        warn!(
            target: "service",
            "inventory announcement limit ({}) exceeded, other nodes will see only some of your projects",
            inventory.len()
modified crates/radicle-protocol/src/service/io.rs
@@ -62,7 +62,11 @@ impl Outbox {
    }

    pub fn write(&mut self, remote: &Session, msg: Message) {
-
        msg.log(log::Level::Debug, &remote.id, Link::Outbound);
+
        let level = match &msg {
+
            Message::Ping(_) | Message::Pong { .. } => log::Level::Trace,
+
            _ => log::Level::Debug,
+
        };
+
        msg.log(level, &remote.id, Link::Outbound);
        trace!(target: "service", "Write {:?} to {}", &msg, remote);

        self.io.push_back(Io::Write(remote.id, vec![msg]));
@@ -78,7 +82,7 @@ impl Outbox {
        // Store our announcement so that it can be retrieved from us later, just like
        // announcements we receive from peers.
        if let Err(e) = gossip.announced(&ann.node, &ann) {
-
            error!(target: "service", "Error updating our gossip store with announced message: {e}");
+
            warn!(target: "service", "Failed to update gossip store with announced message: {e}");
        }

        for peer in peers {
@@ -134,8 +138,6 @@ impl Outbox {
        timeout: time::Duration,
        reader_limit: FetchPackSizeLimit,
    ) {
-
        peer.fetching(rid);
-

        let refs_at = (!refs_at.is_empty()).then_some(refs_at);

        if let Some(refs_at) = &refs_at {
modified crates/radicle-protocol/src/service/limiter.rs
@@ -74,6 +74,26 @@ impl AsTokens for config::RateLimit {
    }
}

+
impl AsTokens for config::LimitRateInbound {
+
    fn capacity(&self) -> usize {
+
        config::RateLimit::from(*self).capacity()
+
    }
+

+
    fn rate(&self) -> f64 {
+
        config::RateLimit::from(*self).rate()
+
    }
+
}
+

+
impl AsTokens for config::LimitRateOutbound {
+
    fn capacity(&self) -> usize {
+
        config::RateLimit::from(*self).capacity()
+
    }
+

+
    fn rate(&self) -> f64 {
+
        config::RateLimit::from(*self).rate()
+
    }
+
}
+

#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenBucket {
@@ -135,7 +155,7 @@ mod test {
    }

    #[test]
-
    fn test_limitter_refill() {
+
    fn test_limiter_refill() {
        let mut r = RateLimiter::default();
        let t = (3, 0.2); // Three tokens burst. One token every 5 seconds.
        let a = HostName::Dns(String::from("seed.radicle.example.com"));
@@ -167,7 +187,7 @@ mod test {

    #[test]
    #[rustfmt::skip]
-
    fn test_limitter_multi() {
+
    fn test_limiter_multi() {
        let t = (1, 1.0); // One token per second. One token burst.
        let n = arbitrary::gen::<NodeId>(1);
        let n = Some(&n);
@@ -187,7 +207,7 @@ mod test {

    #[test]
    #[rustfmt::skip]
-
    fn test_limitter_different_rates() {
+
    fn test_limiter_different_rates() {
        let t1 = (1, 1.0); // One token per second. One token burst.
        let t2 = (2, 2.0); // Two tokens per second. Two token burst.
        let n = arbitrary::gen::<NodeId>(1);
modified crates/radicle-protocol/src/service/message.rs
@@ -50,7 +50,7 @@ impl Subscribe {
pub struct NodeAnnouncement {
    /// Supported protocol version.
    pub version: u8,
-
    /// Advertized features.
+
    /// Advertised features.
    pub features: node::Features,
    /// Monotonic timestamp.
    pub timestamp: Timestamp,
@@ -217,9 +217,9 @@ impl RefsStatus {
                self.want.push(theirs);
            }
            Err(e) => {
-
                log::warn!(
+
                log::debug!(
                    target: "service",
-
                    "Error getting cached ref of {repo} for refs status: {e}"
+
                    "Failed to get cached 'rad/sigrefs' of {} in {repo} for refs status: {e}", theirs.remote,
                );
            }
        }
modified crates/radicle-protocol/src/service/session.rs
@@ -1,8 +1,7 @@
-
use std::collections::{HashSet, VecDeque};
+
use std::collections::VecDeque;
use std::{fmt, time};

use crossbeam_channel as chan;
-
use radicle::node::config::Limits;
use radicle::node::{FetchResult, Severity};
use radicle::node::{Link, Timestamp};
pub use radicle::node::{PingState, State};
@@ -111,8 +110,6 @@ pub struct Session {
    pub subscribe: Option<message::Subscribe>,
    /// Last time a message was received from the peer.
    pub last_active: LocalTime,
-
    /// Fetch queue.
-
    pub queue: VecDeque<QueuedFetch>,

    /// Connection attempts. For persistent peers, Tracks
    /// how many times we've attempted to connect. We reset this to zero
@@ -120,8 +117,6 @@ pub struct Session {
    attempts: usize,
    /// Source of entropy.
    rng: Rng,
-
    /// Protocol limits.
-
    limits: Limits,
}

impl fmt::Display for Session {
@@ -159,7 +154,7 @@ impl From<&Session> for radicle::node::Session {
}

impl Session {
-
    pub fn outbound(id: NodeId, addr: Address, persistent: bool, rng: Rng, limits: Limits) -> Self {
+
    pub fn outbound(id: NodeId, addr: Address, persistent: bool, rng: Rng) -> Self {
        Self {
            id,
            addr,
@@ -168,28 +163,18 @@ impl Session {
            subscribe: None,
            persistent,
            last_active: LocalTime::default(),
-
            queue: VecDeque::with_capacity(MAX_FETCH_QUEUE_SIZE),
            attempts: 1,
            rng,
-
            limits,
        }
    }

-
    pub fn inbound(
-
        id: NodeId,
-
        addr: Address,
-
        persistent: bool,
-
        rng: Rng,
-
        time: LocalTime,
-
        limits: Limits,
-
    ) -> Self {
+
    pub fn inbound(id: NodeId, addr: Address, persistent: bool, rng: Rng, time: LocalTime) -> Self {
        Self {
            id,
            addr,
            state: State::Connected {
                since: time,
                ping: PingState::default(),
-
                fetching: HashSet::default(),
                latencies: VecDeque::default(),
                stable: false,
            },
@@ -197,10 +182,8 @@ impl Session {
            subscribe: None,
            persistent,
            last_active: time,
-
            queue: VecDeque::new(),
            attempts: 0,
            rng,
-
            limits,
        }
    }

@@ -224,41 +207,6 @@ impl Session {
        matches!(self.state, State::Initial)
    }

-
    pub fn is_at_capacity(&self) -> bool {
-
        if let State::Connected { fetching, .. } = &self.state {
-
            if fetching.len() >= self.limits.fetch_concurrency.into() {
-
                return true;
-
            }
-
        }
-
        false
-
    }
-

-
    pub fn is_fetching(&self, rid: &RepoId) -> bool {
-
        if let State::Connected { fetching, .. } = &self.state {
-
            return fetching.contains(rid);
-
        }
-
        false
-
    }
-

-
    /// Queue a fetch. Returns `true` if it was added to the queue, and `false` if
-
    /// it already was present in the queue.
-
    pub fn queue_fetch(&mut self, fetch: QueuedFetch) -> Result<(), QueueError> {
-
        assert_eq!(fetch.from, self.id);
-

-
        if self.queue.len() >= MAX_FETCH_QUEUE_SIZE {
-
            return Err(QueueError::CapacityReached(fetch));
-
        } else if self.queue.contains(&fetch) {
-
            return Err(QueueError::Duplicate(fetch));
-
        }
-
        self.queue.push_back(fetch);
-

-
        Ok(())
-
    }
-

-
    pub fn dequeue_fetch(&mut self) -> Option<QueuedFetch> {
-
        self.queue.pop_front()
-
    }
-

    pub fn attempts(&self) -> usize {
        self.attempts
    }
@@ -279,33 +227,6 @@ impl Session {
        }
    }

-
    /// Mark this session as fetching the given RID.
-
    ///
-
    /// # Panics
-
    ///
-
    /// If it is already fetching that RID, or the session is disconnected.
-
    pub fn fetching(&mut self, rid: RepoId) {
-
        if let State::Connected { fetching, .. } = &mut self.state {
-
            assert!(
-
                fetching.insert(rid),
-
                "Session must not already be fetching {rid}"
-
            );
-
        } else {
-
            panic!(
-
                "Attempting to fetch {rid} from disconnected session {}",
-
                self.id
-
            );
-
        }
-
    }
-

-
    pub fn fetched(&mut self, rid: RepoId) {
-
        if let State::Connected { fetching, .. } = &mut self.state {
-
            if !fetching.remove(&rid) {
-
                log::warn!(target: "service", "Fetched unknown repository {rid}");
-
            }
-
        }
-
    }
-

    pub fn to_attempted(&mut self) {
        assert!(
            self.is_initial(),
@@ -319,12 +240,11 @@ impl Session {
        self.last_active = since;

        if let State::Connected { .. } = &self.state {
-
            log::error!(target: "service", "Session {} is already in 'connected' state, resetting..", self.id);
+
            log::debug!(target: "service", "Session {} is already in 'connected' state, resetting..", self.id);
        };
        self.state = State::Connected {
            since,
            ping: PingState::default(),
-
            fetching: HashSet::default(),
            latencies: VecDeque::default(),
            stable: false,
        };
modified crates/radicle-protocol/src/wire.rs
@@ -15,11 +15,12 @@ use std::string::FromUtf8Error;

use bytes::{Buf, BufMut};

-
use cyphernet::addr::tor;
+
use cypheraddr::tor;

use radicle::crypto::{PublicKey, Signature, Unverified};
use radicle::git;
use radicle::git::fmt;
+
use radicle::git::raw;
use radicle::identity::RepoId;
use radicle::node;
use radicle::node::Alias;
@@ -256,7 +257,7 @@ impl Encode for Refs {
    }
}

-
impl Encode for cyphernet::addr::tor::OnionAddrV3 {
+
impl Encode for cypheraddr::tor::OnionAddrV3 {
    fn encode(&self, buf: &mut impl BufMut) {
        self.into_raw_bytes().encode(buf)
    }
@@ -285,7 +286,7 @@ where
    }
}

-
impl Encode for git::RefString {
+
impl Encode for git::fmt::RefString {
    fn encode(&self, buf: &mut impl BufMut) {
        self.as_str().encode(buf)
    }
@@ -300,7 +301,8 @@ impl Encode for Signature {
impl Encode for git::Oid {
    fn encode(&self, buf: &mut impl BufMut) {
        // Nb. We use length-encoding here to support future SHA-2 object ids.
-
        self.as_bytes().encode(buf)
+
        let bytes: &[u8] = self.as_ref();
+
        bytes.encode(buf)
    }
}

@@ -321,7 +323,7 @@ impl Decode for Refs {

        for _ in 0..len {
            let name = String::decode(buf)?;
-
            let name = git::RefString::try_from(name).map_err(Invalid::from)?;
+
            let name = git::fmt::RefString::try_from(name).map_err(Invalid::from)?;
            let oid = git::Oid::decode(buf)?;

            refs.insert(name, oid);
@@ -330,10 +332,10 @@ impl Decode for Refs {
    }
}

-
impl Decode for git::RefString {
+
impl Decode for git::fmt::RefString {
    fn decode(buf: &mut impl Buf) -> Result<Self, Error> {
        let ref_str = String::decode(buf)?;
-
        Ok(git::RefString::try_from(ref_str).map_err(Invalid::from)?)
+
        Ok(git::fmt::RefString::try_from(ref_str).map_err(Invalid::from)?)
    }
}

@@ -365,7 +367,7 @@ where

impl Decode for git::Oid {
    fn decode(buf: &mut impl Buf) -> Result<Self, Error> {
-
        const LEN_EXPECTED: usize = mem::size_of::<git::raw::Oid>();
+
        const LEN_EXPECTED: usize = mem::size_of::<raw::Oid>();

        let len = Size::decode(buf)? as usize;

@@ -378,7 +380,7 @@ impl Decode for git::Oid {
        }

        let buf: [u8; LEN_EXPECTED] = Decode::decode(buf)?;
-
        let oid = git::raw::Oid::from_bytes(&buf).expect("the buffer is exactly the right size");
+
        let oid = raw::Oid::from_bytes(&buf).expect("the buffer is exactly the right size");
        let oid = git::Oid::from(oid);

        Ok(oid)
@@ -627,7 +629,7 @@ mod tests {

    #[quickcheck]
    fn prop_oid(input: [u8; 20]) {
-
        roundtrip(git::Oid::try_from(input.as_slice()).unwrap());
+
        roundtrip(git::Oid::from_sha1(input));
    }

    #[test]
modified crates/radicle-protocol/src/wire/frame.rs
@@ -54,7 +54,7 @@ impl wire::Decode for Version {
/// bit set to `1` for all streams she creates, while Bob will have it set to `0`.
///
/// This ensures that Stream IDs never collide.
-
/// Additionally, Stream IDs must never be re-used within a connection.
+
/// Additionally, Stream IDs must never be reused within a connection.
///
/// +=======+==================================+
/// | Bits  | Stream Type                      |
modified crates/radicle-protocol/src/wire/message.rs
@@ -2,7 +2,7 @@ use std::{mem, net};

use bytes::Buf;
use bytes::BufMut;
-
use cyphernet::addr::{tor, HostName, NetAddr};
+
use cypheraddr::{tor, HostName, NetAddr};
use radicle::crypto::Signature;
use radicle::git::Oid;
use radicle::identity::RepoId;
modified crates/radicle-protocol/src/worker/fetch.rs
@@ -36,12 +36,12 @@ impl FetchResult {
/// corresponding targets.
#[derive(Clone, Default, Debug)]
pub struct UpdatedCanonicalRefs {
-
    inner: BTreeMap<git::Qualified<'static>, git::Oid>,
+
    inner: BTreeMap<git::fmt::Qualified<'static>, git::Oid>,
}

impl IntoIterator for UpdatedCanonicalRefs {
-
    type Item = (git::Qualified<'static>, git::Oid);
-
    type IntoIter = std::collections::btree_map::IntoIter<git::Qualified<'static>, git::Oid>;
+
    type Item = (git::fmt::Qualified<'static>, git::Oid);
+
    type IntoIter = std::collections::btree_map::IntoIter<git::fmt::Qualified<'static>, git::Oid>;

    fn into_iter(self) -> Self::IntoIter {
        self.inner.into_iter()
@@ -51,12 +51,12 @@ impl IntoIterator for UpdatedCanonicalRefs {
impl UpdatedCanonicalRefs {
    /// Insert a new updated entry for the canonical reference identified by
    /// `refname` and its new `target.`
-
    pub fn updated(&mut self, refname: git::Qualified<'static>, target: git::Oid) {
+
    pub fn updated(&mut self, refname: git::fmt::Qualified<'static>, target: git::Oid) {
        self.inner.insert(refname, target);
    }

    /// Return an iterator of all the updates.
-
    pub fn iter(&self) -> impl Iterator<Item = (&git::Qualified<'static>, &git::Oid)> {
+
    pub fn iter(&self) -> impl Iterator<Item = (&git::fmt::Qualified<'static>, &git::Oid)> {
        self.inner.iter()
    }
}
modified crates/radicle-protocol/src/worker/fetch/error.rs
@@ -2,7 +2,7 @@ use std::io;

use thiserror::Error;

-
use radicle::{cob, git, identity, storage};
+
use radicle::{cob, git::raw, identity, storage};
use radicle_fetch as fetch;

#[derive(Debug, Error)]
@@ -10,7 +10,7 @@ pub enum Fetch {
    #[error(transparent)]
    Run(#[from] fetch::Error),
    #[error(transparent)]
-
    Git(#[from] git::raw::Error),
+
    Git(#[from] raw::Error),
    #[error(transparent)]
    Storage(#[from] storage::Error),
    #[error(transparent)]
modified crates/radicle-remote-helper/Cargo.toml
@@ -3,7 +3,7 @@ name = "radicle-remote-helper"
description = "Radicle git remote helper"
homepage.workspace = true
license.workspace = true
-
version = "0.13.0"
+
version = "0.14.0"
authors = ["cloudhead <cloudhead@radicle.xyz>"]
edition.workspace = true
build = "build.rs"
@@ -11,7 +11,7 @@ rust-version.workspace = true

[[bin]]
name = "git-remote-rad"
-
path = "src/git-remote-rad.rs"
+
path = "src/main.rs"

[dependencies]
dunce = { workspace = true }
@@ -19,5 +19,4 @@ log = { workspace = true }
radicle = { workspace = true }
radicle-cli = { workspace = true }
radicle-crypto = { workspace = true }
-
radicle-git-ext = { workspace = true }
-
thiserror = { workspace = true }

\ No newline at end of file
+
thiserror = { workspace = true, default-features = true }

\ No newline at end of file
modified crates/radicle-remote-helper/src/fetch.rs
@@ -1,57 +1,70 @@
-
use std::io;
-
use std::path::Path;
use std::str::FromStr;
+
use std::{io, process::ExitStatus};

use thiserror::Error;

use radicle::git;
-
use radicle::storage::ReadRepository;

-
use crate::read_line;
+
use crate::service::GitService;
+
use crate::Verbosity;

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

+
    /// Error fetching pack from storage to working copy.
+
    #[error("`git fetch-pack` failed with exit status {status}, stderr and stdout follow:\n{stderr}\n{stdout}")]
+
    FetchPackFailed {
+
        status: ExitStatus,
+
        stderr: String,
+
        stdout: String,
+
    },
+

+
    /// Received an unexpected command after the first `fetch` command.
+
    #[error("unexpected command after first `fetch`: {0:?}")]
+
    UnexpectedCommand(crate::protocol::Command),
}

/// Run a git fetch command.
-
pub fn run<R: ReadRepository>(
-
    mut refs: Vec<(git::Oid, git::RefString)>,
-
    working: &Path,
-
    stored: R,
-
    stdin: &io::Stdin,
+
pub(super) fn run<G: GitService>(
+
    mut refs: Vec<(git::Oid, git::fmt::RefString)>,
+
    stored: &radicle::storage::git::Repository,
+
    git: &G,
+
    command_reader: &mut crate::protocol::LineReader<impl io::Read>,
+
    verbosity: Verbosity,
) -> Result<(), Error> {
    // Read all the `fetch` lines.
-
    let mut line = String::new();
-
    loop {
-
        let tokens = read_line(stdin, &mut line)?;
-
        match tokens.as_slice() {
-
            ["fetch", oid, refstr] => {
-
                let oid = git::Oid::from_str(oid)?;
-
                let refstr = git::RefString::try_from(*refstr)?;
-

+
    for line in command_reader.by_ref() {
+
        match line?? {
+
            crate::protocol::Line::Valid(crate::protocol::Command::Fetch { oid, refstr }) => {
+
                let oid = git::Oid::from_str(&oid)?;
+
                let refstr = git::fmt::RefString::try_from(refstr)?;
                refs.push((oid, refstr));
            }
-
            // An empty line means end of input.
-
            [] => break,
-
            // Once the first `fetch` command is received, we don't expect anything else.
-
            _ => return Err(Error::InvalidCommand(line.trim().to_owned())),
+
            crate::protocol::Line::Blank => {
+
                // An empty line means end of input.
+
                break;
+
            }
+
            crate::protocol::Line::Valid(command) => return Err(Error::UnexpectedCommand(command)),
        }
    }

    // Verify them and prepare the final refspecs.
-
    let oids = refs.into_iter().map(|(oid, _)| oid);
+
    let oids = refs.into_iter().map(|(oid, _)| oid).collect();
+

+
    // Rely on the environment variable `GIT_DIR` pointing at the repository.
+
    let working = None;

    // N.b. we shell out to `git`, avoiding using `git2`. This is to
    // avoid an issue where somewhere within the fetch there is an
@@ -62,10 +75,15 @@ pub fn run<R: ReadRepository>(
    // used in the working copy, this will always result in the object
    // missing. This seems to only be an issue with `libgit2`/`git2`
    // and not `git` itself.
-
    git::process::fetch_local(working, &stored, oids)?;
+
    let output = git.fetch_pack(working, stored, oids, verbosity.into())?;

-
    // Nb. An empty line means we're done.
-
    println!();
+
    if !output.status.success() {
+
        return Err(Error::FetchPackFailed {
+
            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
+
            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
+
            status: output.status,
+
        });
+
    }

    Ok(())
}
deleted crates/radicle-remote-helper/src/git-remote-rad.rs
@@ -1,42 +0,0 @@
-
use std::env;
-
use std::process;
-

-
use radicle::version::Version;
-

-
pub const VERSION: Version = Version {
-
    name: "git-remote-rad",
-
    commit: env!("GIT_HEAD"),
-
    version: env!("RADICLE_VERSION"),
-
    timestamp: env!("SOURCE_DATE_EPOCH"),
-
};
-

-
fn main() {
-
    let mut args = env::args();
-

-
    if let Some(lvl) = radicle::logger::env_level() {
-
        let logger = radicle::logger::StderrLogger::new(lvl);
-
        log::set_boxed_logger(Box::new(logger))
-
            .expect("no other logger should have been set already");
-
        log::set_max_level(lvl.to_level_filter());
-
    }
-
    if args.nth(1).as_deref() == Some("--version") {
-
        if let Err(e) = VERSION.write(std::io::stdout()) {
-
            eprintln!("error: {e}");
-
            process::exit(1);
-
        };
-
        process::exit(0);
-
    }
-

-
    let profile = match radicle::Profile::load() {
-
        Ok(profile) => profile,
-
        Err(err) => {
-
            eprintln!("error: couldn't load profile: {err}");
-
            process::exit(1);
-
        }
-
    };
-

-
    if let Err(err) = radicle_remote_helper::run(profile) {
-
        eprintln!("error: {err}");
-
        process::exit(1);
-
    }
-
}
deleted crates/radicle-remote-helper/src/lib.rs
@@ -1,296 +0,0 @@
-
#![warn(clippy::unwrap_used)]
-
//! The Radicle Git remote helper.
-
//!
-
//! Communication with the user is done via `stderr` (`eprintln`).
-
//! Communication with Git tooling is done via `stdout` (`println`).
-
mod fetch;
-
mod list;
-
mod push;
-

-
use std::path::PathBuf;
-
use std::str::FromStr;
-
use std::{env, fmt, io};
-

-
use thiserror::Error;
-

-
use radicle::prelude::NodeId;
-
use radicle::storage::git::transport::local::{Url, UrlError};
-
use radicle::storage::{ReadRepository, WriteStorage};
-
use radicle::{cob, profile};
-
use radicle::{git, storage, Profile};
-
use radicle_cli::git::Rev;
-
use radicle_cli::terminal as cli;
-

-
#[derive(Debug, Error)]
-
pub enum Error {
-
    /// Failed to parse `base`.
-
    #[error("failed to parse base revision: {0}")]
-
    Base(Box<dyn std::error::Error>),
-
    /// Remote repository not found (or empty).
-
    #[error("remote repository `{0}` not found")]
-
    RepositoryNotFound(PathBuf),
-
    /// Invalid command received.
-
    #[error("invalid command `{0}`")]
-
    InvalidCommand(String),
-
    /// Invalid arguments received.
-
    #[error("invalid arguments: {0:?}")]
-
    InvalidArguments(Vec<String>),
-
    /// Unknown push option received.
-
    #[error("unknown push option {0:?}")]
-
    UnsupportedPushOption(String),
-
    /// Error with the remote url.
-
    #[error("invalid remote url: {0}")]
-
    RemoteUrl(#[from] UrlError),
-
    /// I/O error.
-
    #[error("i/o error: {0}")]
-
    Io(#[from] io::Error),
-
    /// The `GIT_DIR` env var is not set.
-
    #[error("the `GIT_DIR` environment variable is not set")]
-
    NoGitDir,
-
    /// No parent of `GIT_DIR` was found.
-
    #[error("expected parent of .git but found {path:?}")]
-
    NoWorkingCopy { path: PathBuf },
-
    /// Git error.
-
    #[error("git: {0}")]
-
    Git(#[from] git::raw::Error),
-
    /// Invalid reference name.
-
    #[error("invalid ref: {0}")]
-
    InvalidRef(#[from] radicle::git::fmt::Error),
-
    /// Repository error.
-
    #[error(transparent)]
-
    Repository(#[from] radicle::storage::RepositoryError),
-
    /// Fetch error.
-
    #[error(transparent)]
-
    Fetch(#[from] fetch::Error),
-
    /// Push error.
-
    #[error(transparent)]
-
    Push(#[from] push::Error),
-
    /// List error.
-
    #[error(transparent)]
-
    List(#[from] list::Error),
-
}
-

-
#[derive(Debug, Default, Clone)]
-
pub struct Options {
-
    /// Don't sync after push.
-
    no_sync: bool,
-
    /// Sync debugging.
-
    sync_debug: bool,
-
    /// Enable hints.
-
    hints: bool,
-
    /// Open patch in draft mode.
-
    draft: bool,
-
    /// Patch base to use, when opening or updating a patch.
-
    base: Option<Rev>,
-
    /// Patch message.
-
    message: cli::patch::Message,
-
}
-

-
/// Run the radicle remote helper using the given profile.
-
pub fn run(profile: radicle::Profile) -> Result<(), Error> {
-
    // Since we're going to be writing user output to `stderr`, make sure the paint
-
    // module is aware of that.
-
    cli::Paint::set_terminal(cli::TerminalFile::Stderr);
-

-
    let (remote, url): (Option<git::RefString>, Url) = {
-
        let args = env::args().skip(1).take(2).collect::<Vec<_>>();
-

-
        match args.as_slice() {
-
            [url] => (None, url.parse()?),
-
            [remote, url] => (git::RefString::try_from(remote.as_str()).ok(), url.parse()?),
-

-
            _ => {
-
                return Err(Error::InvalidArguments(args));
-
            }
-
        }
-
    };
-

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

-
    // `GIT_DIR` is set by Git tooling, if we're in a working copy.
-
    let working = env::var("GIT_DIR").map(PathBuf::from);
-
    // Whether we should output debug logs.
-
    let debug = radicle::profile::env::debug();
-

-
    let stdin = io::stdin();
-
    let mut line = String::new();
-
    let mut opts = Options::default();
-

-
    if let Err(e) = radicle::io::set_file_limit(4096) {
-
        if debug {
-
            eprintln!("git-remote-rad: unable to set open file limit: {e}");
-
        }
-
    }
-

-
    loop {
-
        let tokens = read_line(&stdin, &mut line)?;
-

-
        if debug {
-
            eprintln!("git-remote-rad: {:?}", &tokens);
-
        }
-

-
        match tokens.as_slice() {
-
            ["capabilities"] => {
-
                println!("option");
-
                println!("push"); // Implies `list` command.
-
                println!("fetch");
-
                println!();
-
            }
-
            ["option", "verbosity"] => {
-
                println!("ok");
-
            }
-
            ["option", "push-option", args @ ..] => {
-
                // Nb. Git documentation says that we can print `error <msg>` or `unsupported`
-
                // for options that are not supported, but this results in Git saying that
-
                // "push-option" itself is an unsupported option, which is not helpful or correct.
-
                // Hence, we just exit with an error in this case.
-
                push_option(args, &mut opts)?;
-
                println!("ok");
-
            }
-
            ["option", "progress", ..] => {
-
                println!("unsupported");
-
            }
-
            ["option", ..] => {
-
                println!("unsupported");
-
            }
-
            ["fetch", oid, refstr] => {
-
                let oid = git::Oid::from_str(oid)?;
-
                let refstr = git::RefString::try_from(*refstr)?;
-

-
                // N.b. `working` is the `.git` folder and `fetch::run`
-
                // requires the working directory.
-
                let working = dunce::canonicalize(working.map_err(|_| Error::NoGitDir)?)?;
-
                let working = working.parent().ok_or_else(|| Error::NoWorkingCopy {
-
                    path: working.clone(),
-
                })?;
-

-
                return fetch::run(vec![(oid, refstr)], working, stored, &stdin)
-
                    .map_err(Error::from);
-
            }
-
            ["push", refspec] => {
-
                // We have to be in a working copy to push.
-
                let working = working.map_err(|_| Error::NoGitDir)?;
-

-
                return push::run(
-
                    vec![refspec.to_string()],
-
                    &working,
-
                    // N.b. assume the default remote if there was no remote
-
                    remote.unwrap_or((*radicle::rad::REMOTE_NAME).clone()),
-
                    url,
-
                    &stored,
-
                    &profile,
-
                    &stdin,
-
                    opts,
-
                )
-
                .map_err(Error::from);
-
            }
-
            ["list"] => {
-
                list::for_fetch(&url, &profile, &stored)?;
-
            }
-
            ["list", "for-push"] => {
-
                list::for_push(&profile, &stored)?;
-
            }
-
            [] => {
-
                return Ok(());
-
            }
-
            _ => {
-
                return Err(Error::InvalidCommand(line.trim().to_owned()));
-
            }
-
        }
-
    }
-
}
-

-
/// Parse a single push option. Returns `Ok` if it was successful.
-
/// Note that some push options can contain spaces, eg. `patch.message="Hello World!"`,
-
/// hence the arguments are passed as a slice.
-
fn push_option(args: &[&str], opts: &mut Options) -> Result<(), Error> {
-
    match args {
-
        ["hints"] => opts.hints = true,
-
        ["sync"] => opts.no_sync = false,
-
        ["sync.debug"] => opts.sync_debug = true,
-
        ["no-sync"] => opts.no_sync = true,
-
        ["patch.draft"] => opts.draft = true,
-
        _ => {
-
            let args = args.join(" ");
-

-
            if let Some((key, val)) = args.split_once('=') {
-
                match key {
-
                    "patch.message" => {
-
                        opts.message.append(val);
-
                    }
-
                    "patch.base" => {
-
                        let base =
-
                            cli::args::rev(&val.into()).map_err(|e| Error::Base(e.into()))?;
-
                        opts.base = Some(base);
-
                    }
-
                    other => {
-
                        return Err(Error::UnsupportedPushOption(other.to_owned()));
-
                    }
-
                }
-
            } else {
-
                return Err(Error::UnsupportedPushOption(args.to_owned()));
-
            }
-
        }
-
    }
-
    Ok(())
-
}
-

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

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

-
    Ok(tokens)
-
}
-

-
/// Write a hint to the user.
-
pub(crate) fn hint(s: impl fmt::Display) {
-
    eprintln!("{}", cli::format::hint(format!("hint: {s}")));
-
}
-

-
/// Write a warning to the user.
-
pub(crate) fn warn(s: impl fmt::Display) {
-
    eprintln!("{}", cli::format::hint(format!("warn: {s}")));
-
}
-

-
/// Get the patch store.
-
pub(crate) fn patches<'a, R: ReadRepository + cob::Store<Namespace = NodeId>>(
-
    profile: &Profile,
-
    repo: &'a R,
-
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, list::Error> {
-
    match profile.patches(repo) {
-
        Ok(patches) => Ok(patches),
-
        Err(err @ profile::Error::CobsCache(cob::cache::Error::OutOfDate)) => {
-
            hint(cli::cob::MIGRATION_HINT);
-
            Err(err.into())
-
        }
-
        Err(err) => Err(err.into()),
-
    }
-
}
-

-
/// Get the mutable patch store.
-
pub(crate) fn patches_mut<'a>(
-
    profile: &Profile,
-
    repo: &'a storage::git::Repository,
-
) -> Result<
-
    cob::patch::Cache<cob::patch::Patches<'a, storage::git::Repository>, cob::cache::StoreWriter>,
-
    push::Error,
-
> {
-
    match profile.patches_mut(repo) {
-
        Ok(patches) => Ok(patches),
-
        Err(err @ profile::Error::CobsCache(cob::cache::Error::OutOfDate)) => {
-
            hint(cli::cob::MIGRATION_HINT);
-
            Err(err.into())
-
        }
-
        Err(err) => Err(err.into()),
-
    }
-
}
modified crates/radicle-remote-helper/src/list.rs
@@ -10,7 +10,7 @@ use radicle::storage::ReadRepository;
use radicle::Profile;

#[derive(Debug, Error)]
-
pub enum Error {
+
pub(super) enum Error {
    /// Storage error.
    #[error(transparent)]
    Storage(#[from] radicle::storage::Error),
@@ -19,7 +19,7 @@ pub enum Error {
    Identity(#[from] radicle::identity::DocError),
    /// Git error.
    #[error(transparent)]
-
    Git(#[from] radicle::git::ext::Error),
+
    Git(#[from] radicle::git::raw::Error),
    /// Profile error.
    #[error(transparent)]
    Profile(#[from] profile::Error),
@@ -35,59 +35,75 @@ pub enum Error {
}

/// List refs for fetching (`git fetch` and `git ls-remote`).
-
pub fn for_fetch<R: ReadRepository + cob::Store<Namespace = NodeId> + 'static>(
+
pub(super) fn for_fetch<R: ReadRepository + cob::Store<Namespace = NodeId> + 'static>(
    url: &Url,
    profile: &Profile,
    stored: &R,
-
) -> Result<(), Error> {
+
) -> Result<Vec<String>, Error> {
+
    let mut lines = Vec::new();
+

    if let Some(namespace) = url.namespace {
        // Listing namespaced refs.
        for (name, oid) in stored.references_of(&namespace)? {
-
            println!("{oid} {name}");
+
            lines.push(format!("{oid} {name}"));
        }
    } else {
-
        // Listing canonical refs.
-
        // We skip over `refs/rad/*`, since those are not meant to be fetched into a working copy.
+
        // List the symbolic reference `HEAD`, which is interpreted by
+
        // Git clients to determine the default branch.
+
        match stored.head() {
+
            Ok((target, _)) => lines.push(format!("@{target} HEAD")),
+
            Err(err) => eprintln!("remote: error resolving HEAD: {err}"),
+
        }
+

+
        // List canonical references.
+
        // Skip over `refs/rad/*`, since those are not meant to be fetched into a working copy.
        for glob in [
-
            git::refspec::pattern!("refs/heads/*"),
-
            git::refspec::pattern!("refs/tags/*"),
+
            git::fmt::pattern!("refs/heads/*"),
+
            git::fmt::pattern!("refs/tags/*"),
        ] {
            for (name, oid) in stored.references_glob(&glob)? {
-
                println!("{oid} {name}");
+
                lines.push(format!("{oid} {name}"));
            }
        }
-
        // List the patch refs, but don't abort if there's an error, as this would break
-
        // all fetch behavior. Instead, just output an error to the user.
-
        if let Err(e) = patch_refs(profile, stored) {
-
            eprintln!("remote: error listing patch refs: {e}");
+

+
        // List the patch refs, but do not abort if there is an error,
+
        // as this would break all fetch behavior.
+
        // Instead, just output an error to the user.
+
        match patch_refs(profile, stored) {
+
            Ok(mut refs) => lines.append(&mut refs),
+
            Err(e) => eprintln!("remote: error listing patch refs: {e}"),
        }
    }
-
    println!();

-
    Ok(())
+
    Ok(lines)
}

/// List refs for pushing (`git push`).
-
pub fn for_push<R: ReadRepository>(profile: &Profile, stored: &R) -> Result<(), Error> {
+
pub(super) fn for_push<R: ReadRepository>(
+
    profile: &Profile,
+
    stored: &R,
+
) -> Result<Vec<String>, Error> {
+
    let mut lines = Vec::new();
+

    // Only our own refs can be pushed to.
    for (name, oid) in stored.references_of(profile.id())? {
        // Only branches and tags can be pushed to.
-
        if name.starts_with(git::refname!("refs/heads").as_str())
-
            || name.starts_with(git::refname!("refs/tags").as_str())
+
        if name.starts_with(git::fmt::refname!("refs/heads").as_str())
+
            || name.starts_with(git::fmt::refname!("refs/tags").as_str())
        {
-
            println!("{oid} {name}");
+
            lines.push(format!("{oid} {name}"));
        }
    }
-
    println!();

-
    Ok(())
+
    Ok(lines)
}

/// List canonical patch references. These are magic refs that can be used to pull patch updates.
fn patch_refs<R: ReadRepository + cob::Store<Namespace = NodeId> + 'static>(
    profile: &Profile,
    stored: &R,
-
) -> Result<(), Error> {
+
) -> Result<Vec<String>, Error> {
+
    let mut lines = Vec::new();
    let patches = crate::patches(profile, stored)?;
    for patch in patches.list()? {
        let Ok((id, patch)) = patch else {
@@ -97,8 +113,8 @@ fn patch_refs<R: ReadRepository + cob::Store<Namespace = NodeId> + 'static>(
        let head = patch.head();

        if patch.is_open() && stored.commit(*head).is_ok() {
-
            println!("{} {}", patch.head(), git::refs::patch(&id));
+
            lines.push(format!("{} {}", patch.head(), git::refs::patch(&id)));
        }
    }
-
    Ok(())
+
    Ok(lines)
}
added crates/radicle-remote-helper/src/main.rs
@@ -0,0 +1,491 @@
+
//! A Git remote helper for interacting with Radicle storage and notifying
+
//! `radicle-node`.
+
//!
+
//! Refer to <https://git-scm.com/docs/gitremote-helpers.html> for documentation
+
//! on Git remote helpers.
+
//!
+
//! Usage of standard streams:
+
//!  - Standard Error ([`eprintln`]) is used for communicating with the user.
+
//!  - Standard Output ([`println`]) is used for communicating with Git tooling.
+
//!
+
//! This process assumes that the environment variable `GIT_DIR` is set
+
//! appropriately (to the repository being pushed from or fetched to), as
+
//! mentioned in the documentation on Git remote helpers.
+
//!
+
//! For example, the following two mechanisms rely on `GIT_DIR` being set:
+
//!  - [`git::raw::Repository::open_from_env`] to open the repository
+
//!  - [`radicle::git::run`] (with [`None`] as first argument) to invoke `git`
+

+
mod fetch;
+
mod list;
+
mod protocol;
+
mod push;
+
mod service;
+

+
use std::io::{self, BufRead, Write};
+
use std::path::PathBuf;
+
use std::process;
+
use std::str::FromStr;
+
use std::{env, fmt};
+

+
use thiserror::Error;
+

+
use radicle::prelude::NodeId;
+
use radicle::storage::git::transport::local::{Url, UrlError};
+
use radicle::storage::{ReadRepository, WriteStorage};
+
use radicle::version::Version;
+
use radicle::{cob, profile};
+
use radicle::{git, storage, Profile};
+
use radicle_cli::terminal as cli;
+

+
use crate::protocol::{Command, Line, LineReader};
+

+
const VERSION: Version = Version {
+
    name: env!("CARGO_BIN_NAME"),
+
    commit: env!("GIT_HEAD"),
+
    version: env!("RADICLE_VERSION"),
+
    timestamp: env!("SOURCE_DATE_EPOCH"),
+
};
+

+
fn main() {
+
    let mut args = env::args();
+

+
    if let Some(lvl) = radicle::logger::env_level() {
+
        let logger = radicle::logger::StderrLogger::new(lvl);
+
        log::set_boxed_logger(Box::new(logger))
+
            .expect("no other logger should have been set already");
+
        log::set_max_level(lvl.to_level_filter());
+
    }
+
    if args.nth(1).as_deref() == Some("--version") {
+
        if let Err(e) = VERSION.write(std::io::stdout()) {
+
            eprintln!("error: {e}");
+
            process::exit(1);
+
        };
+
        process::exit(0);
+
    }
+

+
    let profile = match radicle::Profile::load() {
+
        Ok(profile) => profile,
+
        Err(err) => {
+
            eprintln!("error: couldn't load profile: {err}");
+
            process::exit(1);
+
        }
+
    };
+

+
    if let Err(err) = run(profile) {
+
        eprintln!("error: {err}");
+
        process::exit(1);
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
enum Error {
+
    /// Failed to parse `base`.
+
    #[error("failed to parse base revision: {0}")]
+
    Base(#[source] git::raw::Error),
+
    /// Base is not a commit.
+
    #[error("base must be of type 'commit' but it is of type '{actual_type}'")]
+
    BaseNotCommit { actual_type: String },
+
    /// Remote repository not found (or empty).
+
    #[error("remote repository `{0}` not found")]
+
    RepositoryNotFound(PathBuf),
+
    /// Invalid arguments received.
+
    #[error("invalid arguments: {0:?}")]
+
    InvalidArguments(Vec<String>),
+
    /// Unknown push option received.
+
    #[error("unknown push option {0:?}")]
+
    UnsupportedPushOption(String),
+
    /// Error with the remote url.
+
    #[error("invalid remote url: {0}")]
+
    RemoteUrl(#[from] UrlError),
+
    /// I/O error.
+
    #[error("i/o error: {0}")]
+
    Io(#[from] io::Error),
+
    /// Git error.
+
    #[error("git: {0}")]
+
    Git(#[from] git::raw::Error),
+
    /// Invalid reference name.
+
    #[error("invalid ref: {0}")]
+
    InvalidRef(#[from] radicle::git::fmt::Error),
+
    /// Repository error.
+
    #[error(transparent)]
+
    Repository(#[from] radicle::storage::RepositoryError),
+
    /// Fetch error.
+
    #[error(transparent)]
+
    Fetch(#[from] fetch::Error),
+
    /// Push error.
+
    #[error(transparent)]
+
    Push(#[from] push::Error),
+
    /// List error.
+
    #[error(transparent)]
+
    List(#[from] list::Error),
+
    /// Invalid object ID.
+
    #[error("invalid oid: {0}")]
+
    InvalidOid(#[from] radicle::git::ParseOidError),
+
    /// Protocol error.
+
    #[error(transparent)]
+
    Protocol(#[from] protocol::Error),
+
}
+

+
/// Models values for the `verbosity` option, see
+
/// <https://git-scm.com/docs/gitremote-helpers#Documentation/gitremote-helpers.txt-optionverbosityn>.
+
#[derive(Copy, Clone, Debug)]
+
struct Verbosity(u8);
+

+
impl From<Verbosity> for radicle::git::Verbosity {
+
    /// Converts the verbosity option passed to a Git remote helper to
+
    /// one that can be passed to other Git commands via command line.
+
    /// Note that these scales are one off: While the default verbosity
+
    /// for remote helpers is 1, the default verbosity via command line
+
    /// (omitting the flag) is 0.
+
    /// This implementation also cuts off verbosities greater than [`i8::MAX`].
+
    fn from(val: Verbosity) -> Self {
+
        radicle::git::Verbosity::from(i8::try_from(val.0).unwrap_or(i8::MAX) - 1)
+
    }
+
}
+

+
/// The documentation on Git remote helpers, see
+
/// <https://git-scm.com/docs/gitremote-helpers#Documentation/gitremote-helpers.txt-optionverbosityn>
+
/// says: "1 is the default level of verbosity".
+
impl Default for Verbosity {
+
    fn default() -> Self {
+
        Self(1)
+
    }
+
}
+

+
impl FromStr for Verbosity {
+
    type Err = std::num::ParseIntError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        u8::from_str(s).map(Self)
+
    }
+
}
+

+
/// Branch creation options when creating a patch.
+
#[derive(Debug, Default, Clone)]
+
enum Branch {
+
    /// Don't create a new branch.
+
    #[default]
+
    None,
+
    /// Create a branch with the same name as the upstream branch (i.e. `patches/<patch id>`).
+
    MirrorUpstream,
+
    /// Create a branch with the provided name.
+
    Provided(git::fmt::RefString),
+
}
+

+
impl Branch {
+
    /// Return the branch name to be used for the local branch when creating a
+
    /// patch.
+
    fn into_branch_name(self, object: &radicle::patch::PatchId) -> Option<git::fmt::Qualified<'_>> {
+
        match self {
+
            Self::None => None,
+
            Self::MirrorUpstream => Some(git::refs::patch(object)),
+
            Self::Provided(name) => match name.clone().into_qualified() {
+
                None => Some(git::fmt::lit::refs_heads(&name).into()),
+
                // Ensure that if the reference is already qualified we do not
+
                // add `refs/heads`
+
                Some(name) => Some(name),
+
            },
+
        }
+
    }
+
}
+

+
#[derive(Debug, Default, Clone)]
+
struct Options {
+
    /// Don't sync after push.
+
    no_sync: bool,
+
    /// Sync debugging.
+
    sync_debug: bool,
+
    /// Enable hints.
+
    hints: bool,
+
    /// Open patch in draft mode.
+
    draft: bool,
+
    /// Patch base to use, when opening or updating a patch.
+
    base: Option<git::Oid>,
+
    /// Patch message.
+
    message: cli::patch::Message,
+
    /// Create a branch and set its upstream when opening a patch.
+
    branch: Branch,
+
    verbosity: Verbosity,
+
}
+

+
/// Run the radicle remote helper using the given profile.
+
fn run(profile: radicle::Profile) -> Result<(), Error> {
+
    // Since we're going to be writing user output to `stderr`, make sure the paint
+
    // module is aware of that.
+
    cli::Paint::set_terminal(cli::TerminalFile::Stderr);
+

+
    let (remote, url): (Option<git::fmt::RefString>, Url) = {
+
        let args = env::args().skip(1).take(2).collect::<Vec<_>>();
+

+
        match args.as_slice() {
+
            [url] => (None, url.parse()?),
+
            [remote, url] => (
+
                git::fmt::RefString::try_from(remote.as_str()).ok(),
+
                url.parse()?,
+
            ),
+

+
            _ => {
+
                return Err(Error::InvalidArguments(args));
+
            }
+
        }
+
    };
+

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

+
    // Whether we should output debug logs.
+
    let debug = radicle::profile::env::debug();
+

+
    let stdin = io::stdin();
+
    let stdout = io::stdout();
+
    let git = service::RealGitService;
+
    let mut node = service::RealNodeSession::new(&profile);
+

+
    if let Err(e) = radicle::io::set_file_limit(4096) {
+
        if debug {
+
            eprintln!("{}: unable to set open file limit: {e}", VERSION.name);
+
        }
+
    }
+

+
    run_loop(
+
        stdin.lock(),
+
        stdout.lock(),
+
        &git,
+
        &mut node,
+
        &stored,
+
        &profile,
+
        remote,
+
        url,
+
    )
+
}
+

+
#[allow(clippy::too_many_arguments)]
+
fn run_loop<R: BufRead, W: Write, G: service::GitService, N: service::NodeSession>(
+
    mut input: R,
+
    mut output: W,
+
    git: &G,
+
    node: &mut N,
+
    stored: &storage::git::Repository,
+
    profile: &Profile,
+
    remote: Option<git::fmt::RefString>,
+
    url: Url,
+
) -> Result<(), Error> {
+
    let mut opts = Options::default();
+
    let mut expected_refs = Vec::new();
+
    let debug = radicle::profile::env::debug();
+

+
    let mut command_reader = LineReader::new(&mut input);
+

+
    while let Some(line) = command_reader.next() {
+
        let line = line??;
+

+
        if debug {
+
            eprintln!("{}: {:?}", VERSION.name, line);
+
        }
+

+
        match line {
+
            Line::Valid(Command::Capabilities) => {
+
                writeln!(output, "option")?;
+
                writeln!(output, "push")?; // Implies `list` command.
+
                writeln!(output, "fetch")?;
+
                writeln!(output)?;
+
            }
+
            Line::Valid(Command::Option { key, value }) => match key.as_str() {
+
                "verbosity" => {
+
                    if let Some(val) = value {
+
                        match val.parse::<Verbosity>() {
+
                            Ok(verbosity) => {
+
                                opts.verbosity = verbosity;
+
                                writeln!(output, "ok")?;
+
                            }
+
                            Err(err) => {
+
                                writeln!(output, "error {err}")?;
+
                            }
+
                        }
+
                    } else {
+
                        writeln!(output, "error missing value for verbosity")?;
+
                    }
+
                }
+
                "push-option" => {
+
                    if let Some(val) = value {
+
                        let args = val.split(' ').collect::<Vec<_>>();
+
                        // Nb. Git documentation says that we can print `error <msg>` or `unsupported`
+
                        // for options that are not supported, but this results in Git saying that
+
                        // "push-option" itself is an unsupported option, which is not helpful or correct.
+
                        // Hence, we just exit with an error in this case.
+
                        push_option(&args, &mut opts)?;
+
                        writeln!(output, "ok")?;
+
                    } else {
+
                        writeln!(output, "error missing value for push-option")?;
+
                    }
+
                }
+
                "cas" => {
+
                    if let Some(val) = value {
+
                        expected_refs.push(val);
+
                        writeln!(output, "ok")?;
+
                    } else {
+
                        writeln!(output, "error missing value for cas")?;
+
                    }
+
                }
+
                "progress" => {
+
                    writeln!(output, "unsupported")?;
+
                }
+
                _ => {
+
                    writeln!(output, "unsupported")?;
+
                }
+
            },
+
            Line::Valid(Command::Fetch { oid, refstr }) => {
+
                let oid = git::Oid::from_str(&oid)?;
+
                let refstr = git::fmt::RefString::try_from(refstr.as_str())?;
+

+
                fetch::run(
+
                    vec![(oid, refstr)],
+
                    stored,
+
                    git,
+
                    &mut command_reader,
+
                    opts.verbosity,
+
                )?;
+

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

+
                return Ok(());
+
            }
+
            Line::Valid(Command::Push(refspec)) => {
+
                let result = push::run(
+
                    vec![refspec],
+
                    remote.clone(),
+
                    url.clone(),
+
                    stored,
+
                    profile,
+
                    &mut command_reader,
+
                    opts.clone(),
+
                    &expected_refs,
+
                    git,
+
                    node,
+
                )?;
+

+
                for line in result {
+
                    writeln!(output, "{line}")?;
+
                }
+
                writeln!(output)?;
+

+
                return Ok(());
+
            }
+
            Line::Valid(Command::List) => {
+
                let refs = list::for_fetch(&url, profile, stored)?;
+
                for line in refs {
+
                    writeln!(output, "{line}")?;
+
                }
+
                writeln!(output)?;
+
            }
+
            Line::Valid(Command::ListForPush) => {
+
                let refs = list::for_push(profile, stored)?;
+
                for line in refs {
+
                    writeln!(output, "{line}")?;
+
                }
+
                writeln!(output)?;
+
            }
+
            Line::Blank => {
+
                break;
+
            }
+
        }
+
    }
+

+
    Ok(())
+
}
+

+
/// Parse a single push option. Returns `Ok` if it was successful.
+
/// Note that some push options can contain spaces, eg. `patch.message="Hello World!"`,
+
/// hence the arguments are passed as a slice.
+
fn push_option(args: &[&str], opts: &mut Options) -> Result<(), Error> {
+
    match args {
+
        ["hints"] => opts.hints = true,
+
        ["sync"] => opts.no_sync = false,
+
        ["sync.debug"] => opts.sync_debug = true,
+
        ["no-sync"] => opts.no_sync = true,
+
        ["patch.draft"] => opts.draft = true,
+
        ["patch.branch"] => opts.branch = Branch::MirrorUpstream,
+
        _ => {
+
            let args = args.join(" ");
+

+
            let (key, val) = args
+
                .split_once('=')
+
                .ok_or_else(|| Error::UnsupportedPushOption(args.to_owned()))?;
+

+
            match key {
+
                "patch.message" => {
+
                    opts.message.append(val);
+
                }
+
                "patch.base" => {
+
                    let repo = git::raw::Repository::open_from_env().map_err(Error::Base)?;
+
                    let commit = repo
+
                        .revparse_single(val)
+
                        .map_err(Error::Base)?
+
                        .into_commit()
+
                        .map_err(|object| Error::BaseNotCommit {
+
                            actual_type: object
+
                                .kind()
+
                                .map(|kind| kind.to_string())
+
                                .unwrap_or_else(|| "<unknown type encountered>".to_string()),
+
                        })?;
+

+
                    opts.base = Some(git::Oid::from(commit.id()));
+
                }
+
                "patch.branch" => {
+
                    opts.branch = Branch::Provided(git::fmt::RefString::try_from(val)?)
+
                }
+
                other => {
+
                    return Err(Error::UnsupportedPushOption(other.to_owned()));
+
                }
+
            }
+
        }
+
    }
+
    Ok(())
+
}
+

+
/// Write a hint to the user.
+
pub(crate) fn hint(s: impl fmt::Display) {
+
    eprintln!("{}", cli::format::hint(format!("hint: {s}")));
+
}
+

+
/// Write a warning to the user.
+
pub(crate) fn warn(s: impl fmt::Display) {
+
    eprintln!("{}", cli::format::hint(format!("warn: {s}")));
+
}
+

+
/// Get the patch store.
+
pub(crate) fn patches<'a, R: ReadRepository + cob::Store<Namespace = NodeId>>(
+
    profile: &Profile,
+
    repo: &'a R,
+
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, list::Error> {
+
    match profile.patches(repo) {
+
        Ok(patches) => Ok(patches),
+
        Err(err @ profile::Error::CobsCache(cob::cache::Error::OutOfDate)) => {
+
            hint(cli::cob::MIGRATION_HINT);
+
            Err(err.into())
+
        }
+
        Err(err) => Err(err.into()),
+
    }
+
}
+

+
/// Get the mutable patch store.
+
pub(crate) fn patches_mut<'a>(
+
    profile: &Profile,
+
    repo: &'a storage::git::Repository,
+
) -> Result<
+
    cob::patch::Cache<cob::patch::Patches<'a, storage::git::Repository>, cob::cache::StoreWriter>,
+
    push::Error,
+
> {
+
    match profile.patches_mut(repo) {
+
        Ok(patches) => Ok(patches),
+
        Err(err @ profile::Error::CobsCache(cob::cache::Error::OutOfDate)) => {
+
            hint(cli::cob::MIGRATION_HINT);
+
            Err(err.into())
+
        }
+
        Err(err) => Err(err.into()),
+
    }
+
}
added crates/radicle-remote-helper/src/protocol.rs
@@ -0,0 +1,230 @@
+
use thiserror::Error;
+

+
#[derive(Debug, Error)]
+
pub(super) enum Error {
+
    #[error("invalid command `{0}`")]
+
    InvalidCommand(String),
+
}
+

+
#[derive(Debug, PartialEq, Eq)]
+
pub(super) enum Command {
+
    Capabilities,
+
    List,
+
    ListForPush,
+
    Fetch { oid: String, refstr: String },
+
    Push(String),
+
    Option { key: String, value: Option<String> },
+
}
+

+
#[derive(Debug, PartialEq, Eq)]
+
pub(super) enum Line {
+
    Valid(Command),
+
    Blank,
+
}
+

+
impl Command {
+
    pub(super) fn parse_line(line: &str) -> Result<Line, Error> {
+
        let line = line.trim();
+
        if line.is_empty() {
+
            return Ok(Line::Blank);
+
        }
+

+
        // Split the command verb from the rest of the line.
+
        let (cmd, args) = line.split_once(' ').unwrap_or((line, ""));
+
        let args = args.trim();
+

+
        match cmd {
+
            "capabilities" => Ok(Line::Valid(Command::Capabilities)),
+
            "list" => {
+
                if args == "for-push" {
+
                    Ok(Line::Valid(Command::ListForPush))
+
                } else if args.is_empty() {
+
                    Ok(Line::Valid(Command::List))
+
                } else {
+
                    Err(Error::InvalidCommand(line.to_owned()))
+
                }
+
            }
+
            "fetch" => {
+
                // fetch <oid> <name>
+
                // Use split_whitespace to handle multiple spaces between OID and Ref,
+
                // which is permitted.
+
                let mut parts = args.split_whitespace();
+
                let oid = parts
+
                    .next()
+
                    .ok_or_else(|| Error::InvalidCommand(line.to_owned()))?;
+
                let refstr = parts
+
                    .next()
+
                    .ok_or_else(|| Error::InvalidCommand(line.to_owned()))?;
+
                Ok(Line::Valid(Command::Fetch {
+
                    oid: oid.to_owned(),
+
                    refstr: refstr.to_owned(),
+
                }))
+
            }
+
            "push" => Ok(Line::Valid(Command::Push(args.to_owned()))),
+
            "option" => {
+
                // option <key> [value]
+
                // Use split_once to preserve whitespace in the value.
+
                let (key, val) = args.split_once(' ').unwrap_or((args, ""));
+
                let value = if val.is_empty() {
+
                    None
+
                } else {
+
                    Some(val.to_owned())
+
                };
+
                Ok(Line::Valid(Command::Option {
+
                    key: key.to_owned(),
+
                    value,
+
                }))
+
            }
+
            _ => Err(Error::InvalidCommand(line.to_owned())),
+
        }
+
    }
+
}
+

+
mod io {
+
    use std::io::{self, prelude::*};
+

+
    use super::*;
+

+
    pub(crate) struct LineReader<R: Read> {
+
        inner: io::BufReader<R>,
+
    }
+

+
    impl<R: Read> LineReader<R> {
+
        pub(crate) fn new(reader: R) -> Self {
+
            Self {
+
                inner: io::BufReader::new(reader),
+
            }
+
        }
+

+
        pub(crate) fn read_line(&mut self) -> io::Result<Result<Line, Error>> {
+
            let mut line = String::new();
+
            if self.inner.read_line(&mut line)? == 0 {
+
                // EOF reached
+
                return Ok(Ok(Line::Blank));
+
            }
+
            Ok(Command::parse_line(&line))
+
        }
+
    }
+

+
    impl<R: Read> Iterator for LineReader<R> {
+
        type Item = io::Result<Result<Line, Error>>;
+

+
        fn next(&mut self) -> Option<Self::Item> {
+
            match self.read_line() {
+
                Ok(line) => Some(Ok(line)),
+
                Err(e) => Some(Err(e)),
+
            }
+
        }
+
    }
+
}
+

+
pub(crate) use io::*;
+

+
#[cfg(test)]
+
mod tests {
+
    use super::*;
+

+
    #[test]
+
    fn test_capabilities() {
+
        assert_eq!(
+
            Command::parse_line("capabilities").unwrap(),
+
            Line::Valid(Command::Capabilities)
+
        );
+
    }
+

+
    #[test]
+
    fn test_list() {
+
        assert_eq!(
+
            Command::parse_line("list").unwrap(),
+
            Line::Valid(Command::List)
+
        );
+
    }
+

+
    #[test]
+
    fn test_list_for_push() {
+
        assert_eq!(
+
            Command::parse_line("list for-push").unwrap(),
+
            Line::Valid(Command::ListForPush)
+
        );
+
    }
+

+
    #[test]
+
    fn test_fetch() {
+
        assert_eq!(
+
            Command::parse_line("fetch oid ref").unwrap(),
+
            Line::Valid(Command::Fetch {
+
                oid: "oid".to_owned(),
+
                refstr: "ref".to_owned()
+
            })
+
        );
+
    }
+

+
    #[test]
+
    fn test_fetch_whitespace() {
+
        assert_eq!(
+
            Command::parse_line("fetch   oid     ref").unwrap(),
+
            Line::Valid(Command::Fetch {
+
                oid: "oid".to_owned(),
+
                refstr: "ref".to_owned()
+
            })
+
        );
+
    }
+

+
    #[test]
+
    fn test_push() {
+
        assert_eq!(
+
            Command::parse_line("push src:dst").unwrap(),
+
            Line::Valid(Command::Push("src:dst".to_owned()))
+
        );
+
    }
+

+
    #[test]
+
    fn test_push_force() {
+
        assert_eq!(
+
            Command::parse_line("push +src:dst").unwrap(),
+
            Line::Valid(Command::Push("+src:dst".to_owned()))
+
        );
+
    }
+

+
    #[test]
+
    fn test_push_delete() {
+
        assert_eq!(
+
            Command::parse_line("push :dst").unwrap(),
+
            Line::Valid(Command::Push(":dst".to_owned()))
+
        );
+
    }
+

+
    #[test]
+
    fn test_option() {
+
        assert_eq!(
+
            Command::parse_line("option verbosity 2").unwrap(),
+
            Line::Valid(Command::Option {
+
                key: "verbosity".to_owned(),
+
                value: Some("2".to_owned())
+
            })
+
        );
+
    }
+

+
    #[test]
+
    fn test_option_whitespace_preservation() {
+
        assert_eq!(
+
            Command::parse_line("option patch.message Fix:  whitespace").unwrap(),
+
            Line::Valid(Command::Option {
+
                key: "patch.message".to_owned(),
+
                value: Some("Fix:  whitespace".to_owned())
+
            })
+
        );
+
    }
+

+
    #[test]
+
    fn test_empty() {
+
        assert_eq!(Command::parse_line("").unwrap(), Line::Blank);
+
        assert_eq!(Command::parse_line("   ").unwrap(), Line::Blank);
+
    }
+

+
    #[test]
+
    fn test_invalid() {
+
        assert!(Command::parse_line("invalid command").is_err());
+
        assert!(Command::parse_line("list invalid").is_err());
+
    }
+
}
modified crates/radicle-remote-helper/src/push.rs
@@ -4,8 +4,7 @@ mod canonical;
mod error;

use std::collections::HashMap;
-
use std::io::IsTerminal;
-
use std::path::{Path, PathBuf};
+
use std::process::ExitStatus;
use std::str::FromStr;
use std::{assert_eq, io};

@@ -22,23 +21,20 @@ use radicle::crypto;
use radicle::explorer::ExplorerResource;
use radicle::identity::{CanonicalRefs, Did};
use radicle::node;
-
use radicle::node::{Handle, NodeId};
+
use radicle::node::NodeId;
use radicle::storage;
use radicle::storage::git::transport::local::Url;
use radicle::storage::{ReadRepository, SignRepository as _, WriteRepository};
use radicle::Profile;
use radicle::{git, rad};
-
use radicle_cli as cli;
use radicle_cli::terminal as term;

-
use crate::{hint, read_line, Options};
+
use crate::service::GitService;
+
use crate::service::NodeSession;
+
use crate::{hint, warn, Options, Verbosity};

#[derive(Debug, Error)]
-
pub enum Error {
-
    #[error(
-
        "the Git repository found at {path:?} is a bare repository, expected a working directory"
-
    )]
-
    BareRepository { path: PathBuf },
+
pub(super) enum Error {
    /// Public key doesn't match the remote namespace we're pushing to.
    #[error("cannot push to remote namespace owned by {0}")]
    KeyMismatch(Did),
@@ -47,37 +43,34 @@ pub enum Error {
    NoKey,
    /// User tried to delete the canonical branch.
    #[error("refusing to delete default branch ref '{0}'")]
-
    DeleteForbidden(git::RefString),
+
    DeleteForbidden(git::fmt::RefString),
    /// Identity document error.
    #[error("doc: {0}")]
    Doc(#[from] radicle::identity::doc::DocError),
    /// Identity payload error.
    #[error("payload: {0}")]
    Payload(#[from] radicle::identity::doc::PayloadError),
-
    /// Invalid command received.
-
    #[error("invalid command `{0}`")]
-
    InvalidCommand(String),
+
    /// Protocol error.
+
    #[error("protocol error: {0}")]
+
    Protocol(#[from] crate::protocol::Error),
    /// I/O error.
    #[error("i/o error: {0}")]
    Io(#[from] io::Error),
-
    /// A command exited with an error code.
-
    #[error("command '{0}' failed with status {1}")]
-
    CommandFailed(String, i32),
    /// Invalid reference name.
    #[error("invalid ref: {0}")]
    InvalidRef(#[from] radicle::git::fmt::Error),
    /// Git error.
    #[error("git: {0}")]
    Git(#[from] git::raw::Error),
-
    /// Git extension error.
-
    #[error("git: {0}")]
-
    GitExt(#[from] git::ext::Error),
    /// Storage error.
    #[error(transparent)]
    Storage(#[from] radicle::storage::Error),
    /// Profile error.
    #[error(transparent)]
    Profile(#[from] radicle::profile::Error),
+
    /// Signer error.
+
    #[error(transparent)]
+
    Signer(#[from] radicle::profile::SignerError),
    /// Parse error for object IDs.
    #[error(transparent)]
    ParseObjectId(#[from] ParseObjectId),
@@ -102,9 +95,6 @@ pub enum Error {
    /// Patch is empty.
    #[error("patch commits are already included in the base branch")]
    EmptyPatch,
-
    /// Missing canonical head.
-
    #[error("the canonical head is missing from your working copy; please pull before pushing")]
-
    MissingCanonicalHead(git::Oid),
    /// COB store error.
    #[error(transparent)]
    Cob(#[from] radicle::cob::store::Error),
@@ -124,18 +114,33 @@ pub enum Error {
    UnknownObjectType { oid: git::Oid },
    #[error(transparent)]
    FindObjects(#[from] git::canonical::error::FindObjectsError),
+

+
    /// Error sending pack from the working copy to storage.
+
    #[error("`git send-pack` failed with exit status {status}, stderr and stdout follow:\n{stderr}\n{stdout}")]
+
    SendPackFailed {
+
        status: ExitStatus,
+
        stderr: String,
+
        stdout: String,
+
    },
+

+
    /// Received an unexpected command after the first `push` command.
+
    #[error("unexpected command after first `push`: {0:?}")]
+
    UnexpectedCommand(crate::protocol::Command),
+

+
    #[error(transparent)]
+
    CommandError(#[from] CommandError),
}

/// Push command.
enum Command {
    /// Update ref.
-
    Push(git::Refspec<git::Oid, git::RefString>),
+
    Push(git::fmt::refspec::Refspec<git::Oid, git::fmt::RefString>),
    /// Delete ref.
-
    Delete(git::RefString),
+
    Delete(git::fmt::RefString),
}

#[derive(Debug, thiserror::Error)]
-
enum CommandError {
+
pub(super) enum CommandError {
    #[error("expected refspec of the form `[<src>]:<dst>`, got {rev}")]
    Empty { rev: String },
    #[error("failed to parse destination reference ({rev}): {err}")]
@@ -166,7 +171,7 @@ impl Command {
        let Some((src, dst)) = s.split_once(':') else {
            return Err(CommandError::Empty { rev: s.to_string() });
        };
-
        let dst = git::RefString::try_from(dst).map_err(|err| CommandError::Delete {
+
        let dst = git::fmt::RefString::try_from(dst).map_err(|err| CommandError::Delete {
            rev: dst.to_string(),
            err,
        })?;
@@ -188,12 +193,12 @@ impl Command {
                .id()
                .into();

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

    /// Return the destination refname.
-
    fn dst(&self) -> &git::RefStr {
+
    fn dst(&self) -> &git::fmt::RefStr {
        match self {
            Self::Push(rs) => rs.dst.as_refstr(),
            Self::Delete(rs) => rs,
@@ -204,30 +209,30 @@ impl Command {
enum PushAction {
    OpenPatch,
    UpdatePatch {
-
        dst: git::Qualified<'static>,
+
        dst: git::fmt::Qualified<'static>,
        patch: patch::PatchId,
    },
    PushRef {
-
        dst: git::Qualified<'static>,
+
        dst: git::fmt::Qualified<'static>,
    },
}

impl PushAction {
-
    fn new(dst: &git::RefString) -> Result<Self, error::PushAction> {
+
    fn new(dst: &git::fmt::RefString) -> Result<Self, error::PushAction> {
        if dst == &*rad::PATCHES_REFNAME {
            Ok(Self::OpenPatch)
        } else {
-
            let dst = git::Qualified::from_refstr(dst)
+
            let dst = git::fmt::Qualified::from_refstr(dst)
                .ok_or_else(|| error::PushAction::InvalidRef {
                    refname: dst.clone(),
                })?
                .to_owned();

-
            if let Some(oid) = dst.strip_prefix(git::refname!("refs/heads/patches")) {
+
            if let Some(oid) = dst.strip_prefix(git::fmt::refname!("refs/heads/patches")) {
                let patch = git::Oid::from_str(oid)
-
                    .map_err(|err| error::PushAction::InvalidPatchId {
+
                    .map_err(|source| error::PushAction::InvalidPatchId {
                        suffix: oid.to_string(),
-
                        source: err,
+
                        source,
                    })
                    .map(patch::PatchId::from)?;
                Ok(Self::UpdatePatch { dst, patch })
@@ -239,16 +244,18 @@ impl PushAction {
}

/// Run a git push command.
-
pub fn run(
+
pub(super) fn run(
    mut specs: Vec<String>,
-
    working: &Path,
-
    remote: git::RefString,
+
    remote: Option<git::fmt::RefString>,
    url: Url,
    stored: &storage::git::Repository,
    profile: &Profile,
-
    stdin: &io::Stdin,
+
    command_reader: &mut crate::protocol::LineReader<impl io::Read>,
    opts: Options,
-
) -> Result<(), Error> {
+
    expected_refs: &[String],
+
    git: &impl GitService,
+
    node: &mut impl NodeSession,
+
) -> Result<Vec<String>, Error> {
    // Don't allow push if either of these conditions is true:
    //
    // 1. Our key is not in ssh-agent, which means we won't be able to sign the refs.
@@ -261,38 +268,38 @@ pub fn run(
            .ok_or(Error::KeyMismatch(ns.into()))
    })?;
    let signer = profile.signer()?;
-
    let mut line = String::new();
    let mut ok = HashMap::new();
    let hints = opts.hints || profile.hints();
+
    let mut output = Vec::new();

    assert_eq!(signer.public_key(), &nid);

    // Read all the `push` lines.
-
    loop {
-
        let tokens = read_line(stdin, &mut line)?;
-
        match tokens.as_slice() {
-
            ["push", spec] => {
-
                specs.push(spec.to_string());
+
    for line in command_reader.by_ref() {
+
        match line?? {
+
            crate::protocol::Line::Blank => {
+
                // An empty line means end of input.
+
                break;
+
            }
+
            crate::protocol::Line::Valid(crate::protocol::Command::Push(spec)) => {
+
                specs.push(spec);
            }
-
            // An empty line means end of input.
-
            [] => break,
-
            // Once the first `push` command is received, we don't expect anything else.
-
            _ => return Err(Error::InvalidCommand(line.trim().to_owned())),
+
            crate::protocol::Line::Valid(command) => return Err(Error::UnexpectedCommand(command)),
        }
    }
    let delegates = stored.delegates()?;
    let identity = stored.identity()?;
    let project = identity.project()?;
    let canonical_ref = git::refs::branch(project.default_branch());
-
    let mut set_canonical_refs: Vec<(git::Qualified, git::canonical::Object)> =
+
    let mut set_canonical_refs: Vec<(git::fmt::Qualified, git::canonical::Object)> =
        Vec::with_capacity(specs.len());
-
    let working = git::raw::Repository::open(working)?;
+

+
    // Rely on the environment variable `GIT_DIR`.
+
    let working = git::raw::Repository::open_from_env()?;

    // For each refspec, push a ref or delete a ref.
    for spec in specs {
-
        let Ok(cmd) = Command::parse(&spec, &working) else {
-
            return Err(Error::InvalidCommand(format!("push {spec}")));
-
        };
+
        let cmd = Command::parse(&spec, &working)?;
        let result = match &cmd {
            Command::Delete(dst) => {
                // Delete refs.
@@ -309,7 +316,7 @@ pub fn run(
                    .map(|_| None)
                    .map_err(Error::from)
            }
-
            Command::Push(git::Refspec { src, dst, force }) => {
+
            Command::Push(git::fmt::refspec::Refspec { src, dst, force }) => {
                let patches = crate::patches_mut(profile, stored)?;
                let action = PushAction::new(dst)?;

@@ -324,6 +331,7 @@ pub fn run(
                        &signer,
                        profile,
                        opts.clone(),
+
                        git,
                    ),
                    PushAction::UpdatePatch { dst, patch } => patch_update(
                        src,
@@ -336,6 +344,8 @@ pub fn run(
                        patches,
                        &signer,
                        opts.clone(),
+
                        expected_refs,
+
                        git,
                    ),
                    PushAction::PushRef { dst } => {
                        let identity = stored.identity()?;
@@ -346,8 +356,19 @@ pub fn run(
                        let rules = crefs.rules();
                        let me = Did::from(nid);

-
                        let explorer =
-
                            push(src, &dst, *force, &nid, &working, stored, patches, &signer)?;
+
                        let explorer = push(
+
                            src,
+
                            &dst,
+
                            *force,
+
                            &nid,
+
                            &working,
+
                            stored,
+
                            patches,
+
                            &signer,
+
                            opts.verbosity,
+
                            expected_refs,
+
                            git,
+
                        )?;
                        // If we're trying to update the canonical head, make sure
                        // we don't diverge from the current head. This only applies
                        // to repos with more than one delegate.
@@ -356,7 +377,7 @@ pub fn run(
                        // canonical branch.
                        if let Some(canonical) = rules.canonical(dst.clone(), stored) {
                            let object = working
-
                                .find_object(**src, None)
+
                                .find_object(src.into(), None)
                                .map(|obj| git::canonical::Object::new(&obj))?
                                .ok_or(Error::UnknownObjectType { oid: *src })?;

@@ -375,11 +396,11 @@ pub fn run(
        match result {
            // Let Git tooling know that this ref has been pushed.
            Ok(resource) => {
-
                println!("ok {}", cmd.dst());
+
                output.push(format!("ok {}", cmd.dst()));
                ok.insert(spec, resource);
            }
            // Let Git tooling know that there was an error pushing the ref.
-
            Err(e) => println!("error {} {e}", cmd.dst()),
+
            Err(e) => output.push(format!("error {} {e}", cmd.dst())),
        }
    }

@@ -412,10 +433,10 @@ pub fn run(
            }

            match stored.backend.refname_to_id(refname.as_str()) {
-
                Ok(new) if new != *oid => {
+
                Ok(new) if oid != new => {
                    stored.backend.reference(
                        refname.as_str(),
-
                        *oid,
+
                        oid.into(),
                        true,
                        "set-canonical-reference from git-push (radicle)",
                    )?;
@@ -424,7 +445,7 @@ pub fn run(
                Err(e) if e.code() == git::raw::ErrorCode::NotFound => {
                    stored.backend.reference(
                        refname.as_str(),
-
                        *oid,
+
                        oid.into(),
                        true,
                        "set-canonical-reference from git-push (radicle)",
                    )?;
@@ -439,10 +460,10 @@ pub fn run(
                // Connect to local node and announce refs to the network.
                // If our node is not running, we simply skip this step, as the
                // refs will be announced eventually, when the node restarts.
-
                let node = radicle::Node::new(profile.socket());
                if node.is_running() {
                    // Nb. allow this to fail. The push to local storage was still successful.
-
                    sync(stored, ok.into_values().flatten(), opts, node, profile).ok();
+
                    node.sync(stored, ok.into_values().flatten().collect(), opts, profile)
+
                        .ok();
                } else if hints {
                    hint("offline push, your node is not running");
                    hint("to sync with the network, run `rad node start`");
@@ -453,16 +474,91 @@ pub fn run(
        }
    }

-
    // Done.
-
    println!();
+
    Ok(output)
+
}

-
    Ok(())
+
fn patch_base(
+
    head: &git::Oid,
+
    opts: &Options,
+
    stored: &storage::git::Repository,
+
) -> Result<git::Oid, Error> {
+
    Ok(if let Some(base) = opts.base {
+
        base
+
    } else {
+
        // Computation of the canonical head is required only if the user
+
        // did not specify a base explicitly. This allows the user to
+
        // continue updating patches even while the canonical head cannot
+
        // be computed, e.g. while they wait for their fellow delegates
+
        // to converge and sync.
+
        let (_, target) = stored.canonical_head()?;
+
        stored.merge_base(&target, head)?
+
    })
+
}
+

+
/// Before opening or updating patches, we want to evaluate the merge base of the
+
/// patch and the default branch. In order to do that, the respective heads must
+
/// be present in the same Git repository.
+
///
+
/// Unfortunately, we don't have an easy way to transfer the objects without
+
/// creating a reference (be it in storage or working copy).
+
///
+
/// We choose to push a temporary reference to storage, which gets deleted on
+
/// [`Drop::drop`].
+
struct TempPatchRef<'a, G> {
+
    stored: &'a storage::git::Repository,
+
    reference: git::fmt::Namespaced<'a>,
+
    git: &'a G,
+
}
+

+
impl<'a, G: GitService> TempPatchRef<'a, G> {
+
    fn new(
+
        stored: &'a storage::git::Repository,
+
        head: &git::Oid,
+
        nid: &NodeId,
+
        git: &'a G,
+
    ) -> Self {
+
        let reference = git::refs::storage::staging::patch(nid, *head);
+
        Self {
+
            stored,
+
            reference,
+
            git,
+
        }
+
    }
+

+
    fn push(&self, src: &git::Oid, verbosity: Verbosity) -> Result<(), Error> {
+
        push_ref(
+
            src,
+
            &self.reference,
+
            false,
+
            self.stored.raw(),
+
            verbosity,
+
            &[],
+
            self.git,
+
        )
+
    }
+
}
+

+
impl<'a, G> Drop for TempPatchRef<'a, G> {
+
    fn drop(&mut self) {
+
        if let Err(err) = self
+
            .stored
+
            .raw()
+
            .find_reference(&self.reference)
+
            .and_then(|mut r| r.delete())
+
        {
+
            eprintln!(
+
                "{} Failed to delete temporary reference {} in storage: {err}",
+
                term::PREFIX_WARNING,
+
                term::format::tertiary(&self.reference),
+
            );
+
        }
+
    }
}

/// Open a new patch.
-
fn patch_open<G>(
-
    src: &git::Oid,
-
    upstream: &git::RefString,
+
fn patch_open<G, S>(
+
    head: &git::Oid,
+
    upstream: &Option<git::fmt::RefString>,
    nid: &NodeId,
    working: &git::raw::Repository,
    stored: &storage::git::Repository,
@@ -473,33 +569,25 @@ fn patch_open<G>(
    signer: &Device<G>,
    profile: &Profile,
    opts: Options,
+
    git: &S,
) -> Result<Option<ExplorerResource>, Error>
where
    G: crypto::signature::Signer<crypto::Signature>,
+
    S: GitService,
{
-
    let head = *src;
-
    let dst = git::refs::storage::staging::patch(nid, head);
-

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

-
    let (_, target) = stored.canonical_head()?;
-
    let base = if let Some(base) = opts.base {
-
        base.resolve(working)?
-
    } else {
-
        stored.merge_base(&target, &head)?
-
    };
-
    if base == head {
+
    let temp = TempPatchRef::new(stored, head, nid, git);
+
    temp.push(head, opts.verbosity)?;
+
    let base = patch_base(head, &opts, stored)?;
+

+
    if base == *head {
+
        warn(format!(
+
            "attempted to create a patch using the commit {head}, but this commit is already included in the base branch"
+
        ));
        return Err(Error::EmptyPatch);
    }
+

    let (title, description) =
-
        term::patch::get_create_message(opts.message, &stored.backend, &base, &head)?;
+
        term::patch::get_create_message(opts.message, &stored.backend, &base.into(), &head.into())?;

    let patch = if opts.draft {
        patches.draft(
@@ -507,7 +595,7 @@ where
            &description,
            patch::MergeTarget::default(),
            base,
-
            head,
+
            *head,
            &[],
            signer,
        )
@@ -517,72 +605,98 @@ where
            &description,
            patch::MergeTarget::default(),
            base,
-
            head,
+
            *head,
            &[],
            signer,
        )
+
    }?;
+

+
    let action = if patch.is_draft() {
+
        "drafted"
+
    } else {
+
        "opened"
    };
-
    let result = match patch {
-
        Ok(patch) => {
-
            let action = if patch.is_draft() {
-
                "drafted"
-
            } else {
-
                "opened"
-
            };
-
            let patch = patch.id;
+
    let patch = patch.id;

-
            eprintln!(
-
                "{} Patch {} {action}",
-
                term::PREFIX_SUCCESS,
-
                term::format::tertiary(patch),
-
            );
+
    eprintln!(
+
        "{} Patch {} {action}",
+
        term::PREFIX_SUCCESS,
+
        term::format::tertiary(patch),
+
    );
+

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

+
    if let Some(upstream) = upstream {
+
        if let Some(local_branch) = opts.branch.into_branch_name(&patch) {
+
            fn strip_refs_heads(qualified: git::fmt::Qualified) -> git::fmt::RefString {
+
                let (_refs, _heads, x, xs) = qualified.non_empty_components();
+
                std::iter::once(x).chain(xs).collect()
+
            }

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

-
            // Setup current branch so that pushing updates the patch.
-
            if let Some(branch) = rad::setup_patch_upstream(&patch, head, working, upstream, false)?
-
            {
-
                if let Some(name) = branch.name()? {
-
                    if profile.hints() {
-
                        // Remove the remote portion of the name, i.e.
-
                        // rad/patches/deadbeef -> patches/deadbeef
-
                        let name = name.split('/').skip(1).collect::<Vec<_>>().join("/");
-
                        hint(format!(
-
                            "to update, run `git push` or `git push rad -f HEAD:{name}`"
-
                        ));
-
                    }
+
            let remote_branch = git::refs::workdir::patch_upstream(&patch);
+
            let remote_branch = working.reference(
+
                &remote_branch,
+
                head.into(),
+
                true,
+
                "Create remote tracking branch for patch",
+
            )?;
+
            debug_assert!(remote_branch.is_remote());
+

+
            let local_branch = strip_refs_heads(local_branch);
+
            let upstream_branch = git::refs::patch(&patch);
+
            git::set_upstream(working, upstream, &local_branch, &upstream_branch)?;
+

+
            eprintln!(
+
                "{} Branch {} created",
+
                term::PREFIX_SUCCESS,
+
                term::format::tertiary(&local_branch),
+
            );
+
            hint(format!(
+
                "to update, run `git push {upstream} {local_branch}`"
+
            ));
+
        }
+
        // Setup current branch so that pushing updates the patch.
+
        else if let Some(branch) =
+
            rad::setup_patch_upstream(&patch, *head, working, upstream, false)?
+
        {
+
            if let Some(name) = branch.name()? {
+
                if profile.hints() {
+
                    // Remove the remote portion of the name, i.e.
+
                    // rad/patches/deadbeef -> patches/deadbeef
+
                    let name = name.split_once('/').unwrap_or_default().1;
+
                    hint(format!(
+
                        "to update, run `git push` or `git push {upstream} --force-with-lease HEAD:{name}`"
+
                    ));
                }
            }
-
            Ok(Some(ExplorerResource::Patch { id: patch }))
        }
-
        Err(e) => Err(e),
-
    };
-

-
    // Delete short-lived patch head reference.
-
    stored
-
        .raw()
-
        .find_reference(&dst)
-
        .map(|mut r| r.delete())
-
        .ok();
+
    }

-
    result.map_err(Error::from)
+
    Ok(Some(ExplorerResource::Patch { id: patch }))
}

/// Update an existing patch.
#[allow(clippy::too_many_arguments)]
-
fn patch_update<G>(
-
    src: &git::Oid,
-
    dst: &git::Qualified,
+
fn patch_update<G, S>(
+
    head: &git::Oid,
+
    dst: &git::fmt::Qualified,
    force: bool,
    patch_id: patch::PatchId,
    nid: &NodeId,
@@ -594,39 +708,49 @@ fn patch_update<G>(
    >,
    signer: &Device<G>,
    opts: Options,
+
    expected_refs: &[String],
+
    git: &S,
) -> Result<Option<ExplorerResource>, Error>
where
    G: crypto::signature::Signer<crypto::Signature>,
+
    S: GitService,
{
-
    let commit = *src;
-
    let dst = dst.with_namespace(nid.into());
-

-
    push_ref(src, &dst, force, working, stored.raw())?;
-

    let Ok(Some(patch)) = patches.get(&patch_id) else {
        return Err(Error::NotFound(patch_id));
    };

-
    // Don't update patch if it already has a revision matching this commit.
-
    if patch.revisions().any(|(_, r)| r.head() == commit) {
+
    let temp = TempPatchRef::new(stored, head, nid, git);
+
    temp.push(head, opts.verbosity)?;
+

+
    let base = patch_base(head, &opts, stored)?;
+

+
    // Don't update patch if it already has a matching revision.
+
    if patch
+
        .revisions()
+
        .any(|(_, r)| r.head() == *head && *r.base() == base)
+
    {
        return Ok(None);
    }

    let (latest_id, latest) = patch.latest();
    let latest = latest.clone();

-
    let message = term::patch::get_update_message(opts.message, &stored.backend, &latest, &commit)?;
+
    let message =
+
        term::patch::get_update_message(opts.message, &stored.backend, &latest, &head.into())?;

-
    let (_, target) = stored.canonical_head()?;
-
    let head: git::Oid = commit;
-
    let base = if let Some(base) = opts.base {
-
        base.resolve(working)?
-
    } else {
-
        stored.merge_base(&target, &head)?
-
    };
+
    let dst = dst.with_namespace(nid.into());
+
    push_ref(
+
        head,
+
        &dst,
+
        force,
+
        stored.raw(),
+
        opts.verbosity,
+
        expected_refs,
+
        git,
+
    )?;

    let mut patch_mut = patch::PatchMut::new(patch_id, patch, &mut patches);
-
    let revision = patch_mut.update(message, base, head, signer)?;
+
    let revision = patch_mut.update(message, base, *head, signer)?;
    let Some(revision) = patch_mut.revision(&revision).cloned() else {
        return Err(Error::RevisionNotFound(revision));
    };
@@ -644,8 +768,8 @@ where
    // This can happen if for eg. a patch commit is amended, the patch branch is merged
    // and pushed, but the patch hasn't yet been updated. On push to the patch branch,
    // it'll seem like the patch is "empty", because the changes are already in the base branch.
-
    if base == head && patch_mut.is_open() {
-
        patch_merge(patch_mut, revision.id(), head, working, signer)?;
+
    if base == *head && patch_mut.is_open() {
+
        patch_merge(patch_mut, revision.id(), *head, working, signer)?;
    } else {
        eprintln!(
            "To compare against your previous revision {}, run:\n\n   {}\n",
@@ -659,9 +783,9 @@ where
    Ok(Some(ExplorerResource::Patch { id: patch_id }))
}

-
fn push<G>(
+
fn push<G, S>(
    src: &git::Oid,
-
    dst: &git::Qualified,
+
    dst: &git::fmt::Qualified,
    force: bool,
    nid: &NodeId,
    working: &git::raw::Repository,
@@ -671,20 +795,32 @@ fn push<G>(
        cob::cache::StoreWriter,
    >,
    signer: &Device<G>,
+
    verbosity: Verbosity,
+
    expected_refs: &[String],
+
    git: &S,
) -> Result<Option<ExplorerResource>, Error>
where
    G: crypto::signature::Signer<crypto::Signature>,
+
    S: GitService,
{
    let head = *src;
    let dst = dst.with_namespace(nid.into());
    // It's ok for the destination reference to be unknown, eg. when pushing a new branch.
    let old = stored.backend.find_reference(dst.as_str()).ok();

-
    push_ref(src, &dst, force, working, stored.raw())?;
+
    push_ref(
+
        src,
+
        &dst,
+
        force,
+
        stored.raw(),
+
        verbosity,
+
        expected_refs,
+
        git,
+
    )?;

    if let Some(old) = old {
        let proj = stored.project()?;
-
        let master = &*git::Qualified::from(git::lit::refs_heads(proj.default_branch()));
+
        let master = &*git::fmt::Qualified::from(git::fmt::lit::refs_heads(proj.default_branch()));

        // If we're pushing to the project's default branch, we want to see if any patches got
        // merged or reverted, and if so, update the patch COB.
@@ -716,8 +852,8 @@ where
{
    // Find all commits reachable from the old OID but not from the new OID.
    let mut revwalk = stored.revwalk()?;
-
    revwalk.push(*old)?;
-
    revwalk.hide(*new)?;
+
    revwalk.push(old.into())?;
+
    revwalk.hide(new.into())?;

    // List of commits that have been dropped.
    let dropped = revwalk
@@ -859,87 +995,43 @@ where
/// Push a single reference to storage.
fn push_ref(
    src: &git::Oid,
-
    dst: &git::Namespaced,
+
    dst: &git::fmt::Namespaced,
    force: bool,
-
    working: &git::raw::Repository,
    stored: &git::raw::Repository,
+
    verbosity: Verbosity,
+
    expected_refs: &[String],
+
    git: &impl GitService,
) -> Result<(), Error> {
-
    let url = git::url::File::new(stored.path()).to_string();
+
    let path = dunce::canonicalize(stored.path())?.display().to_string();
    // Nb. The *force* indicator (`+`) is processed by Git tooling before we even reach this code.
    // This happens during the `list for-push` phase.
-
    let refspec = git::Refspec { src, dst, force };
-
    let repo = working.workdir().unwrap_or_else(|| working.path());
-

-
    radicle::git::run::<_, _, &str, &str>(
-
        repo,
-
        [
-
            "push",
-
            url.to_string().as_str(),
-
            refspec.to_string().as_str(),
-
        ],
-
        [],
-
    )
-
    .map_err(|err| {
-
        Error::Io(std::io::Error::other(format!(
-
            "failed to run `git push {url} {refspec}` in {:?}: {err}",
-
            working.path()
-
        )))
-
    })?;
-

-
    Ok(())
-
}
+
    let refspec = git::fmt::refspec::Refspec { src, dst, force };

-
/// Sync with the network.
-
fn sync(
-
    repo: &storage::git::Repository,
-
    updated: impl Iterator<Item = ExplorerResource>,
-
    opts: Options,
-
    mut node: radicle::Node,
-
    profile: &Profile,
-
) -> Result<(), cli::node::SyncError> {
-
    let progress = if io::stderr().is_terminal() {
-
        cli::node::SyncWriter::Stderr(io::stderr())
-
    } else {
-
        cli::node::SyncWriter::Sink
-
    };
-
    let result = cli::node::announce(
-
        repo,
-
        cli::node::SyncSettings::default().with_profile(profile),
-
        cli::node::SyncReporting {
-
            progress,
-
            completion: cli::node::SyncWriter::Stderr(io::stderr()),
-
            debug: opts.sync_debug,
-
        },
-
        &mut node,
-
        profile,
-
    )?;
+
    let mut args = vec!["send-pack".to_string()];

-
    let mut urls = Vec::new();
+
    let verbosity: git::Verbosity = verbosity.into();
+
    args.extend(verbosity.into_flag());

-
    if let Some(result) = result {
-
        for seed in profile.config.preferred_seeds.iter() {
-
            if result.is_synced(&seed.id) {
-
                for resource in updated {
-
                    let url = profile
-
                        .config
-
                        .public_explorer
-
                        .url(seed.addr.host.clone(), repo.id)
-
                        .resource(resource);
+
    args.extend([path.to_string(), refspec.to_string()]);

-
                    urls.push(url);
-
                }
-
                break;
-
            }
-
        }
+
    for expected in expected_refs {
+
        args.push(format!(
+
            "--force-with-lease=refs/namespaces/{}/{expected}",
+
            dst.namespace()
+
        ));
    }

-
    // Print URLs to the updated resources.
-
    if !urls.is_empty() {
-
        eprintln!();
-
        for url in urls {
-
            eprintln!("  {}", term::format::dim(url));
-
        }
-
        eprintln!();
+
    // Rely on the environment variable `GIT_DIR`.
+
    let working = None;
+

+
    let output = git.send_pack(working, &args)?;
+

+
    if !output.status.success() {
+
        return Err(Error::SendPackFailed {
+
            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
+
            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
+
            status: output.status,
+
        });
    }

    Ok(())
modified crates/radicle-remote-helper/src/push/canonical.rs
@@ -14,7 +14,7 @@ impl<'a, 'b, 'r, R> Canonical<'a, 'b, 'r, R>
where
    R: effects::Ancestry + effects::FindMergeBase + effects::FindObjects,
{
-
    pub fn new(
+
    pub(super) fn new(
        me: Did,
        object: canonical::Object,
        canonical: canonical::Canonical<'a, 'b, 'r, R, canonical::Initial>,
@@ -27,12 +27,12 @@ where

    /// Calculates the quorum of the [`git::canonical::Canonical`] provided.
    ///
-
    /// In some cases, it ensures that the [`head`] is attempting to converge
+
    /// In some cases, it ensures that the head commit is attempting to converge
    /// with the set of commits of the other [`Did`]s.
    ///
-
    /// If a quorum is found, then it is also ensured that the new [`head`] is a
-
    /// descendant of the current canonical commit, otherwise the commits are
-
    /// considered diverging.
+
    /// If a quorum is found, then it is also ensured that the new head commit
+
    /// is a descendant of the current canonical commit, otherwise the commits
+
    /// are considered diverging.
    ///
    /// # Errors
    ///
@@ -40,9 +40,9 @@ where
    /// copy, and that checks that any two commits are related in the graph.
    ///
    /// Ensures that the new head and the canonical commit do not diverge.
-
    ///
-
    /// [`head`]: crate::push::canonical::Canonical::head
-
    pub fn quorum(self) -> Result<(git::Qualified<'a>, canonical::Object), QuorumError> {
+
    pub(super) fn quorum(
+
        self,
+
    ) -> Result<(git::fmt::Qualified<'a>, canonical::Object), QuorumError> {
        self.canonical
            .quorum()
            .map(|QuorumWithConvergence { quorum, .. }| (quorum.refname, quorum.object))
@@ -58,7 +58,7 @@ pub(crate) mod io {
    /// Handle recoverable errors, printing relevant information to the
    /// terminal. Otherwise, convert the error into an unrecoverable error
    /// [`error::CanonicalUnrecoverable`].
-
    pub fn handle_error(e: QuorumError) -> Result<(), error::CanonicalUnrecoverable> {
+
    pub(crate) fn handle_error(e: QuorumError) -> Result<(), error::CanonicalUnrecoverable> {
        match e {
            QuorumError::Convergence(err) => Err(err.into()),
            QuorumError::MergeBase(err) => Err(err.into()),
modified crates/radicle-remote-helper/src/push/error.rs
@@ -3,7 +3,7 @@ use radicle::git::canonical;
use thiserror::Error;

#[derive(Debug, Error)]
-
pub enum CanonicalUnrecoverable {
+
pub(crate) enum CanonicalUnrecoverable {
    #[error(transparent)]
    GraphDescendant(#[from] GraphDescendant),
    #[error(transparent)]
@@ -14,13 +14,11 @@ pub enum CanonicalUnrecoverable {
    FindObjects(#[from] canonical::error::FindObjectsError),
    #[error(transparent)]
    HeadsDiverge(#[from] HeadsDiverge),
-
    #[error("failure while computing canonical reference: {source}")]
-
    Git { source: git::raw::Error },
}

#[derive(Debug, Error)]
#[error("failed to check if {head} is an ancestor of {canonical} due to: {source}")]
-
pub struct GraphDescendant {
+
pub(crate) struct GraphDescendant {
    head: git::Oid,
    canonical: git::Oid,
    source: git::raw::Error,
@@ -29,18 +27,18 @@ pub struct GraphDescendant {
#[derive(Debug, Error)]
/// Head being pushed diverges from canonical head.
#[error("refusing to update canonical reference to commit that is not a descendant of current canonical head")]
-
pub struct HeadsDiverge {
+
pub(crate) struct HeadsDiverge {
    head: git::Oid,
    canonical: git::Oid,
}

#[derive(Debug, Error)]
-
pub enum PushAction {
+
pub(crate) enum PushAction {
    #[error("invalid reference {refname}, expected qualified reference starting with `refs/`")]
-
    InvalidRef { refname: git::RefString },
-
    #[error("found refs/heads/patches/{suffix} where {suffix} was an invalid Patch ID")]
+
    InvalidRef { refname: git::fmt::RefString },
+
    #[error("found refs/heads/patches/{suffix} where {suffix} was an invalid Patch ID: {source}")]
    InvalidPatchId {
        suffix: String,
-
        source: git::raw::Error,
+
        source: radicle::git::ParseOidError,
    },
}
added crates/radicle-remote-helper/src/service.rs
@@ -0,0 +1,133 @@
+
use std::io;
+
use std::io::IsTerminal;
+
use std::path::Path;
+
use std::process;
+

+
use radicle::explorer::ExplorerResource;
+
use radicle::git;
+
use radicle::node::Handle;
+
use radicle::storage;
+
use radicle::Profile;
+
use radicle_cli::node::{SyncError, SyncReporting, SyncSettings};
+
use radicle_cli::terminal as term;
+

+
/// Abstraction for Git subprocess calls.
+
pub(super) trait GitService {
+
    /// Run `git fetch-pack`.
+
    fn fetch_pack(
+
        &self,
+
        working: Option<&Path>,
+
        stored: &storage::git::Repository,
+
        oids: Vec<git::Oid>,
+
        verbosity: git::Verbosity,
+
    ) -> io::Result<process::Output>;
+

+
    /// Run `git send-pack` (via `radicle::git::run`).
+
    fn send_pack(&self, working: Option<&Path>, args: &[String]) -> io::Result<process::Output>;
+
}
+

+
/// Production implementation using real Git subprocesses.
+
pub(super) struct RealGitService;
+

+
impl GitService for RealGitService {
+
    fn fetch_pack(
+
        &self,
+
        working: Option<&Path>,
+
        stored: &storage::git::Repository,
+
        oids: Vec<git::Oid>,
+
        verbosity: git::Verbosity,
+
    ) -> io::Result<process::Output> {
+
        git::process::fetch_pack(working, stored, oids, verbosity)
+
    }
+

+
    fn send_pack(&self, working: Option<&Path>, args: &[String]) -> io::Result<process::Output> {
+
        git::run(working, args)
+
    }
+
}
+

+
/// Abstraction for Node interaction.
+
pub(super) trait NodeSession {
+
    fn is_running(&self) -> bool;
+

+
    fn sync(
+
        &mut self,
+
        repo: &storage::git::Repository,
+
        updated: Vec<ExplorerResource>,
+
        opts: crate::Options,
+
        profile: &Profile,
+
    ) -> Result<(), SyncError>;
+
}
+

+
pub(super) struct RealNodeSession {
+
    node: radicle::Node,
+
}
+

+
impl RealNodeSession {
+
    pub(super) fn new(profile: &Profile) -> Self {
+
        Self {
+
            node: radicle::Node::new(profile.socket()),
+
        }
+
    }
+
}
+

+
impl NodeSession for RealNodeSession {
+
    fn is_running(&self) -> bool {
+
        self.node.is_running()
+
    }
+

+
    fn sync(
+
        &mut self,
+
        repo: &storage::git::Repository,
+
        updated: Vec<ExplorerResource>,
+
        opts: crate::Options,
+
        profile: &Profile,
+
    ) -> Result<(), SyncError> {
+
        let progress = if io::stderr().is_terminal() {
+
            term::PaintTarget::Stderr
+
        } else {
+
            term::PaintTarget::Hidden
+
        };
+

+
        let result = radicle_cli::node::announce(
+
            repo,
+
            SyncSettings::default().with_profile(profile),
+
            SyncReporting {
+
                progress,
+
                completion: term::PaintTarget::Stderr,
+
                debug: opts.sync_debug,
+
            },
+
            &mut self.node,
+
            profile,
+
        )?;
+

+
        let mut urls = Vec::new();
+

+
        if let Some(result) = result {
+
            for seed in profile.config.preferred_seeds.iter() {
+
                if result.is_synced(&seed.id) {
+
                    for resource in updated {
+
                        let url = profile
+
                            .config
+
                            .public_explorer
+
                            .url(seed.addr.host.clone(), repo.id)
+
                            .resource(resource);
+

+
                        urls.push(url);
+
                    }
+
                    break;
+
                }
+
            }
+
        }
+

+
        // Print URLs to the updated resources.
+
        if !urls.is_empty() {
+
            eprintln!();
+
            for url in urls {
+
                eprintln!("  {}", term::format::dim(url));
+
            }
+
            eprintln!();
+
        }
+

+
        Ok(())
+
    }
+
}
modified crates/radicle-schemars/Cargo.toml
@@ -5,7 +5,7 @@ homepage.workspace = true
repository.workspace = true
license.workspace = true
edition.workspace = true
-
version = "0.5.0"
+
version = "0.6.0"
rust-version.workspace = true

[[bin]]
modified crates/radicle-schemars/src/main.rs
@@ -83,11 +83,8 @@ fn print_schema() -> io::Result<()> {
            #[schemars(untagged)]
            #[allow(dead_code)]
            enum CommandResult {
-
                Nid(
-
                    #[schemars(with = "radicle::schemars_ext::crypto::PublicKey")]
-
                    radicle::node::NodeId,
-
                ),
-
                Config(radicle::node::Config),
+
                Nid(radicle::node::NodeId),
+
                Config(Box<radicle::node::Config>),
                ListenAddrs(ListenAddrs),
                ConnectResult(radicle::node::ConnectResult),
                Success(radicle::node::Success),
modified crates/radicle-signals/Cargo.toml
@@ -8,7 +8,16 @@ edition.workspace = true
version = "0.11.0"
rust-version.workspace = true

-
[target.'cfg(unix)'.dependencies]
+
[dependencies]
crossbeam-channel = { workspace = true }
+

+
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
signals_receipts = { version = "0.2.0", features = ["channel_notify_facility"] }
+

+
[target.'cfg(windows)'.dependencies.windows]
+
workspace = true
+
features = [
+
    "Win32_Foundation",
+
    "Win32_System_Console",
+
]

\ No newline at end of file
modified crates/radicle-signals/src/lib.rs
@@ -1,9 +1,17 @@
+
use std::io;
+

#[cfg(unix)]
mod unix;

#[cfg(unix)]
pub use unix::*;

+
#[cfg(windows)]
+
mod windows;
+

+
#[cfg(windows)]
+
pub use windows::*;
+

/// Operating system signal.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum Signal {
@@ -16,3 +24,12 @@ pub enum Signal {
    /// `SIGWINCH`.
    WindowChanged,
}
+

+
/// Return an error indicating that signal handling is already installed.
+
#[inline(always)]
+
fn already_installed() -> io::Error {
+
    io::Error::new(
+
        io::ErrorKind::AlreadyExists,
+
        "signal handling is already installed",
+
    )
+
}
modified crates/radicle-signals/src/unix.rs
@@ -57,10 +57,7 @@ pub fn install(notify: chan::Sender<Signal>) -> io::Result<()> {
    }

    SignalsChannel::install_with_outside_channel(ChanSender(notify)).map_err(|e| match e {
-
        InstallError::AlreadyInstalled { unused_notify: _ } => io::Error::new(
-
            io::ErrorKind::AlreadyExists,
-
            "signal handling is already installed",
-
        ),
+
        InstallError::AlreadyInstalled { unused_notify: _ } => already_installed(),
        _ => io::Error::other(e), // The error type is non-exhaustive.
    })
}
added crates/radicle-signals/src/windows.rs
@@ -0,0 +1,50 @@
+
use std::io;
+
use std::sync::OnceLock;
+

+
use crossbeam_channel as chan;
+

+
use ::windows::core::BOOL;
+
use ::windows::Win32::System::Console::{
+
    SetConsoleCtrlHandler, CTRL_BREAK_EVENT, CTRL_CLOSE_EVENT, CTRL_C_EVENT, CTRL_LOGOFF_EVENT,
+
    CTRL_SHUTDOWN_EVENT,
+
};
+

+
use crate::{already_installed, Signal};
+

+
static NOTIFY: OnceLock<chan::Sender<Signal>> = OnceLock::new();
+

+
/// Callback function, called by the system when a control signal is to be received.
+
/// See <https://learn.microsoft.com/en-us/windows/console/handlerroutine>.
+
unsafe extern "system" fn handler(ctrltype: u32) -> BOOL {
+
    match ctrltype {
+
        CTRL_C_EVENT | CTRL_BREAK_EVENT | CTRL_CLOSE_EVENT | CTRL_SHUTDOWN_EVENT => {
+
            if let Some(notify) = NOTIFY.get() {
+
                if notify.send(Signal::Terminate).is_ok() {
+
                    return true.into();
+
                }
+
            } else {
+
                // Do nothing, since we do not have a channel to send notifications to.
+
            }
+
        }
+
        CTRL_LOGOFF_EVENT => {
+
            // Do nothing, since we do not know which user is logging off.
+
        }
+
        _ => {
+
            // Do nothing, since we received an unknown control signal.
+
        }
+
    }
+

+
    false.into()
+
}
+

+
/// Install global signal handlers, with notifications sent to the given
+
/// `notify` channel.
+
pub fn install(notify: chan::Sender<Signal>) -> io::Result<()> {
+
    if let Err(_) = NOTIFY.set(notify) {
+
        return Err(already_installed());
+
    }
+

+
    // SAFETY: Our handler function is sane.
+
    let result = unsafe { SetConsoleCtrlHandler(Some(handler), true) };
+
    result.map_err(|_| io::Error::last_os_error())
+
}
modified crates/radicle-ssh/Cargo.toml
@@ -14,7 +14,7 @@ edition.workspace = true
rust-version.workspace = true

[dependencies]
-
thiserror = { workspace = true }
+
thiserror = { workspace = true, default-features = true }
zeroize = { workspace = true }

[target.'cfg(windows)'.dependencies]
modified crates/radicle-ssh/src/agent/client.rs
@@ -52,7 +52,20 @@ pub enum Error {

impl Error {
    pub fn is_not_running(&self) -> bool {
-
        matches!(self, Self::EnvVar { .. } | Self::BadAuthSock { .. })
+
        match self {
+
            Self::EnvVar { .. } | Self::BadAuthSock { .. } => true,
+
            #[cfg(windows)]
+
            Self::Connect { source, .. }
+
                if source.kind() == std::io::ErrorKind::ConnectionRefused =>
+
            {
+
                // On Windows, a named pipe might be used, and if no
+
                // agent is running, we might get a "connection refused"
+
                // error, even though the `SSH_AUTH_SOCK` environment
+
                // variable is set and the named pipe exists.
+
                true
+
            }
+
            _ => false,
+
        }
    }
}

@@ -104,24 +117,20 @@ impl AgentClient<Stream> {
    pub fn connect_env() -> Result<Self, Error> {
        const SSH_AUTH_SOCK: &str = "SSH_AUTH_SOCK";

-
        let path = match std::env::var(SSH_AUTH_SOCK) {
-
            Ok(var) => var,
-
            Err(err) => {
-
                if cfg!(windows) {
-
                    // Windows uses a named pipe for the SSH agent, which
-
                    // we fall back to in case reading the environment
-
                    // variable fails.
-
                    "\\\\.\\pipe\\openssh-ssh-agent".to_string()
-
                } else {
-
                    return Err(Error::EnvVar {
-
                        var: SSH_AUTH_SOCK.to_string(),
-
                        source: err,
-
                    });
-
                }
-
            }
-
        };
+
        let var = std::env::var(SSH_AUTH_SOCK);
+

+
        #[cfg(windows)]
+
        let var = var.or({
+
            // Windows uses a named pipe for the SSH agent, which
+
            // we fall back to in case reading the environment
+
            // variable fails.
+
            Ok(r"\\.\pipe\openssh-ssh-agent".to_string())
+
        });

-
        Self::connect(path)
+
        Self::connect(var.map_err(|err| Error::EnvVar {
+
            var: SSH_AUTH_SOCK.to_string(),
+
            source: err,
+
        })?)
    }
}

modified crates/radicle-ssh/src/encoding.rs
@@ -52,11 +52,11 @@ pub trait Encoding {
    fn write_empty_list(&mut self);
    /// Write the buffer length at the beginning of the buffer.
    fn write_len(&mut self);
-
    /// Push a [`usize`] as an SSH-encoded unsiged 32-bit integer.
+
    /// Push a [`usize`] as an SSH-encoded unsigned 32-bit integer.
    /// May panic if the argument is greater than [`u32::MAX`].
-
    /// This is a convience method, to spare callers casting or converting
+
    /// This is a convenience method, to spare callers casting or converting
    /// [`usize`] to [`u32`]. If callers end up in a situation where they
-
    /// need to push a 32-bit unisgned integer, but the value they would
+
    /// need to push a 32-bit unsigned integer, but the value they would
    /// like to push does not fit 32 bits, then the implementation will not
    /// comply with the SSH format anyway.
    fn extend_usize(&mut self, u: usize) {
@@ -167,11 +167,11 @@ impl Encoding for Buffer {
/// A cursor-like trait to read SSH-encoded things.
pub trait Reader {
    /// Create an SSH reader for `self`.
-
    fn reader(&self, starting_at: usize) -> Cursor;
+
    fn reader(&self, starting_at: usize) -> Cursor<'_>;
}

impl Reader for Buffer {
-
    fn reader(&self, starting_at: usize) -> Cursor {
+
    fn reader(&self, starting_at: usize) -> Cursor<'_> {
        Cursor {
            s: self,
            position: starting_at,
@@ -180,7 +180,7 @@ impl Reader for Buffer {
}

impl Reader for [u8] {
-
    fn reader(&self, starting_at: usize) -> Cursor {
+
    fn reader(&self, starting_at: usize) -> Cursor<'_> {
        Cursor {
            s: self,
            position: starting_at,
modified crates/radicle-systemd/Cargo.toml
@@ -5,11 +5,13 @@ homepage.workspace = true
repository.workspace = true
license.workspace = true
edition.workspace = true
-
version = "0.10.0"
+
version = "0.12.0"
rust-version.workspace = true

[dependencies]
log = { workspace = true, optional = true }
+

+
[target.'cfg(target_os = "linux")'.dependencies]
systemd-journal-logger = { version = "2.2.2", optional = true }

[features]
added crates/radicle-systemd/src/credential.rs
@@ -0,0 +1,46 @@
+
use std::env::{var, VarError::*};
+
use std::ffi::OsString;
+
use std::fmt;
+
use std::path::{is_separator, PathBuf};
+

+
const CREDENTIALS_DIRECTORY: &str = "CREDENTIALS_DIRECTORY";
+

+
/// Takes a systemd credential ID. If the environment variable
+
/// `CREDENTIALS_DIRECTORY` is set and valid Unicode, and the file corresponding
+
/// to the credential exists, returns the path of the file corresponding to the
+
/// credential.
+
///
+
/// Absence of the environment variable and inexistence of the file are handled
+
/// gracefully returning `Ok(None)`.
+
pub fn path(id: &str) -> Result<Option<PathBuf>, PathError> {
+
    use PathError::*;
+

+
    if id.contains(is_separator) {
+
        return Err(InvalidCredentialId { id: id.to_owned() });
+
    }
+

+
    let credential = match var(CREDENTIALS_DIRECTORY) {
+
        Err(NotUnicode(os)) => return Err(EnvVarNotUnicode { os }),
+
        Err(NotPresent) => return Ok(None),
+
        Ok(env) => PathBuf::from(env).join(id),
+
    };
+

+
    Ok(credential.exists().then_some(credential))
+
}
+

+
/// The error returned by [`path`].
+
#[derive(Debug)]
+
pub enum PathError {
+
    InvalidCredentialId { id: String },
+
    EnvVarNotUnicode { os: OsString },
+
}
+

+
impl fmt::Display for PathError {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        use PathError::*;
+
        match self {
+
		InvalidCredentialId { id } => write!(f, "The systemd credential ID '{id}' is invalid."),
+
		EnvVarNotUnicode { os } => write!(f, "The value of environment variable '{CREDENTIALS_DIRECTORY}' is not valid Unicode (it lossily translates to '{}').", os.to_string_lossy()),
+
	}
+
    }
+
}
modified crates/radicle-systemd/src/journal.rs
@@ -1,23 +1,20 @@
-
use systemd_journal_logger::{connected_to_journal, current_exe_identifier, JournalLog};
+
use systemd_journal_logger::{connected_to_journal, JournalLog};

/// If the current process is directly connected to the systemd journal,
/// return a logger that will write to it.
-
pub fn logger<K, V, I>(
-
    default_identifier: String,
-
    extra_fields: I,
-
) -> std::io::Result<Option<Box<dyn log::Log>>>
+
pub fn logger<K, V, I>(identifier: String, extra_fields: I) -> std::io::Result<Box<dyn log::Log>>
where
    I: IntoIterator<Item = (K, V)>,
    K: AsRef<str>,
    V: AsRef<[u8]>,
{
-
    if !connected_to_journal() {
-
        return Ok(None);
-
    }
-

-
    Ok(Some(Box::new(
+
    Ok(Box::new(
        JournalLog::new()?
-
            .with_syslog_identifier(current_exe_identifier().unwrap_or(default_identifier))
+
            .with_syslog_identifier(identifier)
            .with_extra_fields(extra_fields),
-
    )))
+
    ))
+
}
+

+
pub fn connected() -> bool {
+
    connected_to_journal()
}
modified crates/radicle-systemd/src/lib.rs
@@ -1,7 +1,9 @@
//! Library for interaction with systemd, specialized for Radicle.

-
#[cfg(feature = "journal")]
+
#[cfg(all(feature = "journal", target_os = "linux"))]
pub mod journal;

-
#[cfg(feature = "listen")]
+
#[cfg(all(feature = "listen", unix))]
pub mod listen;
+

+
pub mod credential;
modified crates/radicle-term/Cargo.toml
@@ -4,7 +4,7 @@ description = "Terminal library used by the Radicle CLI"
homepage.workspace = true
repository.workspace = true
license.workspace = true
-
version = "0.15.0"
+
version = "0.17.0"
authors = ["cloudhead <cloudhead@radicle.xyz>"]
edition.workspace = true
rust-version.workspace = true
@@ -15,18 +15,25 @@ default = ["git2"]
[dependencies]
anstyle-query = "1.0.0"
crossterm = "0.29.0"
-
inquire = { version = "0.7.4", default-features = false, features = ["crossterm", "editor"] }
-
thiserror = { workspace = true }
+
indicatif = { version = "0.18.0" }
+
inquire = { version = "0.7.4", default-features = false, features = [
+
    "crossterm",
+
    "editor",
+
] }
+
thiserror = { workspace = true, default-features = true }
unicode-display-width = "0.3.0"
unicode-segmentation = "1.7.1"
zeroize = { workspace = true }
-
git2 = { workspace = true, features = ["vendored-libgit2"], optional = true }
-
shlex = { workspace = true }
+
git2 = { workspace = true, optional = true }

[target.'cfg(unix)'.dependencies]
crossbeam-channel = { workspace = true }
libc = { workspace = true }
radicle-signals = { workspace = true }
+
shlex = { workspace = true }
+

+
[target.'cfg(windows)'.dependencies]
+
winsplit = { workspace = true }

[dev-dependencies]
pretty_assertions = { workspace = true }
modified crates/radicle-term/src/editor.rs
@@ -110,12 +110,20 @@ impl Editor {
                "editor not configured: the `EDITOR` environment variable is not set",
            ));
        };
-
        let Some(parts) = shlex::split(cmd.to_string_lossy().as_ref()) else {
+

+
        let lossy = cmd.to_string_lossy();
+

+
        #[cfg(unix)]
+
        let Some(parts) = shlex::split(&lossy) else {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                format!("invalid editor command {cmd:?}"),
            ));
        };
+

+
        #[cfg(windows)]
+
        let parts = winsplit::split(&lossy);
+

        let Some((program, args)) = parts.split_first() else {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
@@ -203,26 +211,47 @@ fn default_editor() -> Option<OsString> {
            return Some(editor.into());
        }
    }
+

    // Check Git. The user might have configured their editor there.
-
    #[cfg(feature = "git2")]
+
    // On Windows, custom editors configured via Git are not supported,
+
    // because of the complexity surrounding how the editor command is
+
    // parsed and executed. See also <https://stackoverflow.com/a/773973/1835188>.
+
    #[cfg(all(feature = "git2", not(windows)))]
    if let Ok(path) = git2::Config::open_default().and_then(|cfg| cfg.get_path("core.editor")) {
        return Some(path.into_os_string());
    }
+

    // On macOS, `nano` is installed by default and it's what most users are used to
    // in the terminal.
-
    if cfg!(target_os = "macos") && exists("nano") {
+
    #[cfg(target_os = "macos")]
+
    if exists("nano") {
        return Some("nano".into());
    }
+

+
    // On Windows, `edit` is available by default, see <https://learn.microsoft.com/windows/edit>.
+
    #[cfg(windows)]
+
    if exists("edit.exe") {
+
        return Some("edit.exe".into());
+
    }
+

+
    // On Windows, `notepad` is commonly available for decades, see <https://apps.microsoft.com/detail/9msmlrh6lzf3>.
+
    #[cfg(windows)]
+
    if exists("notepad.exe") {
+
        return Some("notepad.exe".into());
+
    }
+

    // If all else fails, we try `vi`. It's usually installed on most unix-based systems.
    if exists("vi") {
        return Some("vi".into());
    }
+

    None
}

-
/// Check whether a binary can be found in the most common paths.
-
/// We don't bother checking the $PATH variable, as we're only looking for very standard tools
+
/// Check whether a binary can be found in the most common paths on Unix-like systems.
+
/// We don't bother checking the `$PATH` variable, as we're only looking for very standard tools
/// and prefer not to make this too complex.
+
#[cfg(unix)]
fn exists(cmd: &str) -> bool {
    // Some common paths where system-installed binaries are found.
    const PATHS: &[&str] = &["/usr/local/bin", "/usr/bin", "/bin"];
@@ -234,3 +263,17 @@ fn exists(cmd: &str) -> bool {
    }
    false
}
+

+
/// Check whether a binary can be found on `$PATH`.
+
/// See:
+
///  - <https://devblogs.microsoft.com/scripting/weekend-scripter-where-exethe-what-why-and-how/>
+
///  - <https://learn.microsoft.com/windows-server/administration/windows-commands/where>
+
#[cfg(windows)]
+
fn exists(cmd: &str) -> bool {
+
    std::process::Command::new("where.exe")
+
        .arg("/q")
+
        .arg("$PATH:".to_owned() + cmd)
+
        .output()
+
        .map(|output| output.status.success())
+
        .unwrap_or_default()
+
}
modified crates/radicle-term/src/io.rs
@@ -46,6 +46,28 @@ pub static CONFIG: LazyLock<RenderConfig> = LazyLock::new(|| RenderConfig {
    ..RenderConfig::default_colored()
});

+
/// Target for paint operations.
+
///
+
/// This tells a [`Spinner`] object where to paint to.
+
///
+
/// [`Spinner`]: crate::Spinner
+
#[derive(Clone)]
+
pub enum PaintTarget {
+
    Stdout,
+
    Stderr,
+
    Hidden,
+
}
+

+
impl PaintTarget {
+
    pub fn writer(&self) -> Box<dyn io::Write> {
+
        match self {
+
            PaintTarget::Stdout => Box::new(io::stdout()),
+
            PaintTarget::Stderr => Box::new(io::stderr()),
+
            PaintTarget::Hidden => Box::new(io::sink()),
+
        }
+
    }
+
}
+

#[macro_export]
macro_rules! info {
    ($writer:expr; $($arg:tt)*) => ({
modified crates/radicle-term/src/spinner.rs
@@ -1,13 +1,14 @@
use std::io::IsTerminal;
use std::mem::ManuallyDrop;
-
use std::sync::{Arc, Mutex};
+
use std::sync::{Arc, LazyLock, Mutex};
use std::{fmt, io, thread, time};

-
use crate::io::{PREFIX_ERROR, PREFIX_WARNING};
-
use crate::Paint;
+
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
+

+
use crate::{Paint, PaintTarget};

/// How much time to wait between spinner animation updates.
-
pub const DEFAULT_TICK: time::Duration = time::Duration::from_millis(99);
+
pub const DEFAULT_TICK: time::Duration = time::Duration::from_millis(120);
/// The spinner animation strings.
pub const DEFAULT_STYLE: [Paint<&'static str>; 4] = [
    Paint::magenta("◢"),
@@ -15,9 +16,26 @@ pub const DEFAULT_STYLE: [Paint<&'static str>; 4] = [
    Paint::magenta("◤"),
    Paint::blue("◥"),
];
+
static TEMPLATE: LazyLock<ProgressStyle> =
+
    LazyLock::new(|| ProgressStyle::with_template("{spinner:.blue} {msg}").unwrap());

-
const CLEAR_UNTIL_NEWLINE: crossterm::terminal::Clear =
-
    crossterm::terminal::Clear(crossterm::terminal::ClearType::UntilNewLine);
+
impl From<PaintTarget> for ProgressDrawTarget {
+
    fn from(value: PaintTarget) -> Self {
+
        match value {
+
            PaintTarget::Stdout => ProgressDrawTarget::stdout(),
+
            PaintTarget::Stderr => ProgressDrawTarget::stderr(),
+
            PaintTarget::Hidden => ProgressDrawTarget::hidden(),
+
        }
+
    }
+
}
+

+
enum State {
+
    Running,
+
    Canceled,
+
    Done,
+
    Warn,
+
    Error,
+
}

struct Progress {
    state: State,
@@ -27,20 +45,12 @@ struct Progress {
impl Progress {
    fn new(message: Paint<String>) -> Self {
        Self {
-
            state: State::Running { cursor: 0 },
+
            state: State::Running,
            message,
        }
    }
}

-
enum State {
-
    Running { cursor: usize },
-
    Canceled,
-
    Done,
-
    Warn,
-
    Error,
-
}
-

/// A progress spinner.
pub struct Spinner {
    progress: Arc<Mutex<Progress>>,
@@ -50,10 +60,11 @@ pub struct Spinner {
impl Drop for Spinner {
    fn drop(&mut self) {
        if let Ok(mut progress) = self.progress.lock() {
-
            if let State::Running { .. } = progress.state {
+
            if let State::Running = progress.state {
                progress.state = State::Canceled;
            }
        }
+

        unsafe { ManuallyDrop::take(&mut self.handle) }
            .join()
            .unwrap();
@@ -109,11 +120,10 @@ impl Spinner {
/// failure messages to `stdout`. This function handles signals, with there being only one
/// element handling signals at a time, and is a wrapper to [`spinner_to()`].
pub fn spinner(message: impl ToString) -> Spinner {
-
    let (stdout, stderr) = (io::stdout(), io::stderr());
-
    if stderr.is_terminal() {
-
        spinner_to(message, stdout, stderr)
+
    if io::stderr().is_terminal() {
+
        spinner_to(message, PaintTarget::Stderr, PaintTarget::Stdout)
    } else {
-
        spinner_to(message, stdout, io::sink())
+
        spinner_to(message, PaintTarget::Hidden, PaintTarget::Stdout)
    }
}

@@ -126,11 +136,11 @@ pub fn spinner(message: impl ToString) -> Spinner {
/// handlers, then it will not attempt to install handlers again, and continue running.
pub fn spinner_to(
    message: impl ToString,
-
    mut completion: impl io::Write + Send + 'static,
-
    mut animation: impl io::Write + Send + 'static,
+
    progress_target: PaintTarget,
+
    completion_target: PaintTarget,
) -> Spinner {
    let message = message.to_string();
-
    let progress = Arc::new(Mutex::new(Progress::new(Paint::new(message))));
+
    let progress = Arc::new(Mutex::new(Progress::new(Paint::new(message.clone()))));

    #[cfg(unix)]
    let (sig_tx, sig_rx) = crossbeam_channel::unbounded();
@@ -142,10 +152,18 @@ pub fn spinner_to(
        .name(String::from("spinner"))
        .spawn({
            let progress = progress.clone();
+
            let spinner = ProgressBar::new_spinner();

-
            move || {
-
                write!(animation, "{}", crossterm::cursor::Hide).ok();
+
            spinner.set_draw_target(progress_target.into());
+
            spinner.set_message(message.to_string());
+
            spinner.set_style(TEMPLATE.clone().tick_strings(&[
+
                DEFAULT_STYLE[0].to_string().as_str(),
+
                DEFAULT_STYLE[1].to_string().as_str(),
+
                DEFAULT_STYLE[2].to_string().as_str(),
+
                DEFAULT_STYLE[3].to_string().as_str(),
+
            ]));

+
            move || {
                loop {
                    let Ok(mut progress) = progress.lock() else {
                        break;
@@ -158,15 +176,14 @@ pub fn spinner_to(
                                if sig == radicle_signals::Signal::Interrupt
                                    || sig == radicle_signals::Signal::Terminate =>
                            {
-
                                write!(animation, "\r{CLEAR_UNTIL_NEWLINE}").ok();
+
                                spinner.finish_and_clear();
                                writeln!(
-
                                    completion,
-
                                    "{PREFIX_ERROR} {} {}",
-
                                    &progress.message,
+
                                    completion_target.writer(),
+
                                    "{} {message} {}",
+
                                    super::PREFIX_ERROR,
                                    Paint::red("<canceled>")
                                )
                                .ok();
-
                                drop(animation);
                                std::process::exit(-1);
                            }
                            Ok(_) => {}
@@ -175,51 +192,67 @@ pub fn spinner_to(
                    }
                    match &mut *progress {
                        Progress {
-
                            state: State::Running { cursor },
+
                            state: State::Running,
                            message,
                        } => {
-
                            let spinner = DEFAULT_STYLE[*cursor];
-

-
                            write!(animation, "\r{CLEAR_UNTIL_NEWLINE}{spinner} {message}",).ok();
-

-
                            *cursor += 1;
-
                            *cursor %= DEFAULT_STYLE.len();
+
                            spinner.set_message(message.to_string());
+
                            spinner.inc(1);
                        }
+

                        Progress {
                            state: State::Done,
                            message,
                        } => {
-
                            write!(animation, "\r{CLEAR_UNTIL_NEWLINE}").ok();
-
                            writeln!(completion, "{} {message}", super::PREFIX_SUCCESS).ok();
+
                            spinner.finish_and_clear();
+
                            writeln!(
+
                                completion_target.writer(),
+
                                "{} {message}",
+
                                super::PREFIX_SUCCESS
+
                            )
+
                            .ok();
                            break;
                        }
+

                        Progress {
                            state: State::Canceled,
                            message,
                        } => {
-
                            write!(animation, "\r{CLEAR_UNTIL_NEWLINE}").ok();
+
                            spinner.finish_and_clear();
                            writeln!(
-
                                completion,
-
                                "{PREFIX_ERROR} {message} {}",
+
                                completion_target.writer(),
+
                                "{} {message} {}",
+
                                super::PREFIX_ERROR,
                                Paint::red("<canceled>")
                            )
                            .ok();
                            break;
                        }
+

                        Progress {
                            state: State::Warn,
                            message,
                        } => {
-
                            write!(animation, "\r{CLEAR_UNTIL_NEWLINE}").ok();
-
                            writeln!(completion, "{PREFIX_WARNING} {message}").ok();
+
                            spinner.finish_and_clear();
+
                            writeln!(
+
                                completion_target.writer(),
+
                                "{} {message}",
+
                                super::PREFIX_WARNING
+
                            )
+
                            .ok();
                            break;
                        }
+

                        Progress {
                            state: State::Error,
                            message,
                        } => {
-
                            write!(animation, "\r{CLEAR_UNTIL_NEWLINE}").ok();
-
                            writeln!(completion, "{PREFIX_ERROR} {message}").ok();
+
                            spinner.finish_and_clear();
+
                            writeln!(
+
                                completion_target.writer(),
+
                                "{} {message}",
+
                                super::PREFIX_ERROR
+
                            )
+
                            .ok();
                            break;
                        }
                    }
@@ -227,8 +260,6 @@ pub fn spinner_to(
                    thread::sleep(DEFAULT_TICK);
                }

-
                write!(animation, "{}", crossterm::cursor::Show).ok();
-

                #[cfg(unix)]
                if sig_result.is_ok() {
                    let _ = radicle_signals::uninstall();
modified crates/radicle-term/src/table.rs
@@ -78,6 +78,19 @@ impl<const W: usize, T> Default for Table<W, T> {
    }
}

+
impl<const W: usize, T: Cell> FromIterator<[T; W]> for Table<W, T> {
+
    fn from_iter<I: IntoIterator<Item = [T; W]>>(iter: I) -> Self {
+
        let mut table = Self::default();
+
        table.rows.extend(iter.into_iter().map(|row| {
+
            for (i, cell) in row.iter().enumerate() {
+
                table.widths[i] = table.widths[i].max(cell.width());
+
            }
+
            Row::Data(row)
+
        }));
+
        table
+
    }
+
}
+

impl<const W: usize, T: Cell + fmt::Debug + Send + Sync> Element for Table<W, T>
where
    T::Padded: Into<Line>,
@@ -169,6 +182,11 @@ impl<const W: usize, T: Cell> Table<W, T> {
        }
    }

+
    pub fn with_opts(mut self, opts: TableOptions) -> Self {
+
        self.opts = opts;
+
        self
+
    }
+

    pub fn size(&self, parent: Constraint) -> Size {
        self.outer(parent)
    }
@@ -191,12 +209,6 @@ impl<const W: usize, T: Cell> Table<W, T> {
        self.rows.push(Row::Header(row));
    }

-
    pub fn extend(&mut self, rows: impl IntoIterator<Item = [T; W]>) {
-
        for row in rows.into_iter() {
-
            self.push(row);
-
        }
-
    }
-

    pub fn is_empty(&self) -> bool {
        !self.rows.iter().any(|r| matches!(r, Row::Data { .. }))
    }
@@ -225,6 +237,20 @@ impl<const W: usize, T: Cell> Table<W, T> {
    }
}

+
impl<const W: usize, T: Cell> Extend<[T; W]> for Table<W, T> {
+
    fn extend<I>(&mut self, iter: I)
+
    where
+
        I: IntoIterator<Item = [T; W]>,
+
    {
+
        self.rows.extend(iter.into_iter().map(|row| {
+
            for (i, cell) in row.iter().enumerate() {
+
                self.widths[i] = self.widths[i].max(cell.width());
+
            }
+
            Row::Data(row)
+
        }));
+
    }
+
}
+

#[cfg(test)]
mod test {
    use crate::Element;
added crates/radicle-windows/Cargo.toml
@@ -0,0 +1,23 @@
+
[package]
+
name = "radicle-windows"
+
description = "Radicle integration with Windows"
+
homepage.workspace = true
+
repository.workspace = true
+
license.workspace = true
+
edition.workspace = true
+
version = "0.1.0"
+
rust-version.workspace = true
+

+
[target.'cfg(windows)'.dependencies]
+
thiserror = { workspace = true }
+

+
[target.'cfg(windows)'.dependencies.windows]
+
workspace = true
+
features = [
+
    "Win32_Foundation",
+
    "Win32_Security",
+
    "Win32_System_JobObjects",
+
]
+

+
[package.metadata.docs.rs]
+
default-target = "x86_64-pc-windows-msvc"
added crates/radicle-windows/src/jobs.rs
@@ -0,0 +1,153 @@
+
use std::io;
+
use std::os::windows::io::AsRawHandle as _;
+

+
use windows::{
+
    core::PCWSTR,
+
    Win32::{
+
        Foundation::{CloseHandle, HANDLE},
+
        System::JobObjects::{AssignProcessToJobObject, CreateJobObjectW, TerminateJobObject},
+
    },
+
};
+

+
use thiserror::Error;
+

+
#[derive(Error, Debug)]
+
#[non_exhaustive]
+
pub enum Error {
+
    #[error("Failed to create job: {0}")]
+
    Create(io::Error),
+
    #[error("Failed to assign job: {0}")]
+
    Assign(io::Error),
+
    #[error("Failed to terminate job: {0}")]
+
    Terminate(io::Error),
+
}
+

+
impl From<Error> for io::Error {
+
    fn from(value: Error) -> Self {
+
        use Error::*;
+
        match value {
+
            Create(error) | Assign(error) | Terminate(error) => error,
+
        }
+
    }
+
}
+

+
/// Wraps a handle to a job object.
+
/// See also <https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects>.
+
#[derive(Debug)]
+
#[repr(transparent)]
+
pub struct Job {
+
    handle: HANDLE,
+
}
+

+
impl Job {
+
    /// Create a new, empty, job object.
+
    /// See also <https://learn.microsoft.com/windows/win32/api/jobapi2/nf-jobapi2-createjobobjectw>.
+
    pub fn new() -> Result<Self, Error> {
+
        #[allow(non_snake_case)]
+
        // To match the naming in the documentation of `CreateJobObjectW` for clarity.
+
        let lpJobAttributes = None;
+

+
        #[allow(non_snake_case)]
+
        // To match the naming in the documentation of `CreateJobObjectW` for clarity.
+
        let lpName = PCWSTR::null();
+

+
        // SAFETY: We argue that calling `CrateJobObjectW` is safe based on
+
        // its documentation (see documentation of this function):
+
        // Both parameters take their `null`/empty value.
+
        // For `lpJobAttributes`, this is immediate as we use `None`, and the
+
        // wrapper in the `windows` crate ensures safety.
+
        // For `lpName`, we pass `name`, which was initialized to
+
        // `PCWSTR::null()` (see above) which we rely on to obtain a sane
+
        // `null` value.
+
        // For the case of `null`/empty arguments, the documentation of
+
        // `CreateJobObjectW` does not specify any further preconditions.
+
        let result = unsafe { CreateJobObjectW(lpJobAttributes, lpName) };
+

+
        match result {
+
            Ok(handle) => Ok(Self { handle }),
+
            Err(e) => Err(Error::Create(e.into())),
+
        }
+
    }
+

+
    /// Assign a process to the job object.
+
    /// See also <https://docs.microsoft.com/windows/win32/api/jobapi2/nf-jobapi2-assignprocesstojobobject>.
+
    pub fn assign(&self, child: &std::process::Child) -> Result<(), Error> {
+
        #[allow(non_snake_case)]
+
        // To match the naming in the documentation of `AssignProcessToJobObject` for clarity.
+
        let hJob = self.handle;
+

+
        #[allow(non_snake_case)]
+
        // To match the naming in the documentation of `AssignProcessToJobObject` for clarity.
+
        let hProcess = HANDLE(child.as_raw_handle());
+

+
        // SAFETY: We argue that calling `AssignProcessToJobObject` is safe based on
+
        // its documentation (see documentation of this function):
+
        // First, we argue that the argument
+
        // For `hJob`, consider that its value is the same as `self.handle` (see above),
+
        // and that `self.handle` is only assigned in `Self::new` via a call to `CreateJobObjectW`.
+
        // For `hProcess`, we pass the raw handle of the child process we were given.
+
        // Thus, we rely on `impl std::os::windows::io::AsRawHandle for std::process::Child`
+
        // for its value to be a valid handle to a process.
+
        // Note that the documentation of `AssignProcessToJobObject` specifies further preconditions
+
        // on the arguments, especially concerning:
+
        //  - Job Object Security (see <https://learn.microsoft.com/windows/win32/procthread/job-object-security-and-access-rights>)
+
        //  - Process Security and Access Rights (see <https://learn.microsoft.com/windows/win32/procthread/process-security-and-access-rights>)
+
        // We assume that violations of these preconditions will be reflected in
+
        // the return value of `AssignProcessToJobObject`, and will not result in safety violations.
+
        let result = unsafe { AssignProcessToJobObject(hJob, hProcess) };
+

+
        result.map_err(|e| Error::Assign(e.into()))
+
    }
+

+
    /// Terminate a job object.
+
    /// See also <https://learn.microsoft.com/windows/win32/api/jobapi2/nf-jobapi2-terminatejobobject>.
+
    pub fn terminate(self, exit_code: u32) -> Result<(), Error> {
+
        #[allow(non_snake_case)]
+
        // To match the naming in the documentation of `TerminateJobObject` for clarity.
+
        let hJob = self.handle;
+

+
        #[allow(non_snake_case)]
+
        // To match the naming in the documentation of `TerminateJobObject` for clarity.
+
        let uExitCode = exit_code;
+

+
        // SAFETY: We argue that calling `TerminateJobObject` is safe based on
+
        // its documentation (see documentation of this function):
+
        // For `hJob`, consider that its value is the same as `self.handle`,
+
        // and that `self.handle` is only assigned in `Self::new` via a call to `CreateJobObjectW`.
+
        // For `uExitCode`, there are no preconditions to satisfy.
+
        // Note that the documentation of `TerminateJobObject` specifies further preconditions
+
        // on the arguments, especially concerning:
+
        //  - Job Object Security (see <https://learn.microsoft.com/windows/win32/procthread/job-object-security-and-access-rights>)
+
        //  - Process Security and Access Rights (see <https://learn.microsoft.com/windows/win32/procthread/process-security-and-access-rights>)
+
        // We assume that violations of these preconditions will be reflected in
+
        // the return value of `AssignProcessToJobObject`, and will not result in safety violations.
+
        let result = unsafe { TerminateJobObject(hJob, uExitCode) };
+

+
        result.map_err(|e| Error::Terminate(e.into()))
+
    }
+

+
    /// Convenience method to create a new job and assign a child process to it.
+
    /// See also [`Job::new`] and [`Job::assign`].
+
    pub fn for_child(child: &std::process::Child) -> Result<Self, Error> {
+
        let job = Self::new()?;
+
        job.assign(child)?;
+
        Ok(job)
+
    }
+
}
+

+
impl Drop for Job {
+
    /// Close the handle to the job object.
+
    /// See also <https://learn.microsoft.com/windows/win32/api/handleapi/nf-handleapi-closehandle>.
+
    fn drop(&mut self) {
+
        #[allow(non_snake_case)]
+
        // To match the naming in the documentation of `CloseHandle` for clarity.
+
        let hObject = self.handle;
+

+
        // SAFETY: We argue that calling `CloseHandle` is safe based on
+
        // its documentation (see documentation of this function):
+
        // For `hObject`, consider that its value is the same as `self.handle`,
+
        // and that `self.handle` is only assigned in `Self::new` via a call to `CreateJobObjectW`,
+
        // thus is a valid handle to a job object.
+
        let _ = unsafe { CloseHandle(hObject) };
+
    }
+
}
added crates/radicle-windows/src/lib.rs
@@ -0,0 +1,4 @@
+
//! Library for interaction with Windows, specialized for Radicle.
+

+
#[cfg(windows)]
+
pub mod jobs;
modified crates/radicle/CHANGELOG.md
@@ -9,6 +9,115 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

+
### Changed
+

+
### Removed
+

+
### Security
+

+
## 0.21.0
+

+
### Added
+

+
- `radicle::node::command::Command` added variants `AnnounceRefsFor`,
+
  `SeedsFor`, and `Block`.
+
- `radicle::cob::identity::ApplyError` now contains a new variant
+
  `NonDelegateUnauthorized`.
+
- `radicle::node::Handle` added a `block` method to allow setting the follow
+
  policy to `Policy::Block`.
+
- `radicle::node::Handle::announce_refs_for` now allows specifying for which
+
  namespaces changes should be announced. A corresponding enum variant
+
  `radicle::node::Command::AnnounceRefsFor` is added.
+
- `radicle::node::Handle::seeds_for` now allows specifying for which
+
  namespaces sync status should be reported. A corresponding enum variant
+
  `radicle::node::Command::SeedsFor` is added.
+

+
### Changed
+

+
- The discriminant values for `radicle::node::command::Command` have changed.
+
- `radicle::rad::CheckoutError` is now marked as `non_exhaustive`.
+
- `radicle::storage::git::Storage::repositories_by_id` returns
+
  `impl Iterator<Item = Result<RepositoryInfo, RepositoryError>>` instead of
+
  `Result<Vec<RepositoryInfo>, RepositoryError>`. The method now also requires
+
  one generic type parameter. Allowing callers to handle failures on a
+
  per-repository basis rather than having the entire operation fail if a single
+
  repository lookup fails.
+
- `radicle::node::Node::announce` now takes an additional parameter to specify
+
  for which namespaces changes should be announced.
+
- Re-exports from `git2` at `radicle::git::raw` were limited, using
+
  the heartwood workspace as a filter. Dependents that require members that
+
  are not exported anymore will have to depend on `git2` directly.
+
- Some re-exports from `git-ref-format-core` were moved from `radicle::git`
+
  to `radicle::fmt`.
+
- The crate now re-exports `radicle::git::Oid` from a new `radicle-oid` crate,
+
  in an effort to decrease dependence on `git2` via `radicle-git-ext`. This
+
  new object identifier type does not implement `Deref` anymore. Use `Into`
+
  to convert to a `git2::Oid` as necessary.
+
- Re-exports of `radicle-git-ext` were removed, as this dependency is removed.
+
  Instead of `radicle_git_ext::Error`, use `git2::Error` (re-exported as
+
  `radicle::git::raw::Error`) together with the new extension trait
+
  `radicle::git::raw::ErrorExt`.
+

+
### Deprecated
+

+
- `radicle::node::Handle::announce_refs` is deprecated in favor of
+
  `radicle::node::Handle::announce_refs_for`.
+
- `radicle::node::Handle::seeds` is deprecated in favor of
+
  `radicle::node::Handle::seeds_for`.
+

+
### Removed
+

+
- `radicle::storage::RepositoryError::GitExt` was removed as a variant, where
+
  `RepositoryError::Git` now subsumes all Git errors.
+
- `radicle::identity::doc::DocError::GitExt` was removed as a variant, where
+
  `DocError::Git` now subsumes all Git errors.
+
- `radicle::storage::refs::Error::GitExt` was removed as a variant, where
+
  `Error::Git` now subsumes all Git errors.
+
- `radicle::cob::identity::ApplyError::GitExt` was removed as a variant, where
+
  `ApplyError::Git` now subsumes all Git errors.
+
- `radicle::storage::Error::GitExt` was removed as a variant, where
+
  `Error::Git` now subsumes all Git errors.
+
- `radicle::storage::git::cob::ObjectsError::GitExt` was removed as a variant,
+
  where `ObjectsError::Git` now subsumes all Git errors.
+
- `radicle::git::canonical::rules::CanonicalError::References` was removed as a
+
  variant, as it no longer occurs as an error.
+
- The `radicle::node::State::Connected` variant no longer has a `fetching`
+
  field. Fetching information is now tracked in the service.
+
- The data returned by `Seeds` contains `state`, which in turn contained the
+
  field `fetching` for ongoing fetches of that node, if in the `Connected`
+
  state. `Connected` no longer contains that field.
+
- `radicle::identity::doc::RepoId` was removed, along with its re-exports at
+
  `radicle::identity::RepoId` and `radicle::prelude::RepoId`. The type is now
+
  provided by the `radicle-core` crate.
+
- `radicle::identity::doc::IdError` was removed, along with its re-export at
+
  `radicle::identity::IdError`.
+
- `radicle::identity::doc::id::RAD_PREFIX` constant was removed.
+
- `radicle::identity::doc::VersionError::UnkownVersion` variant was renamed to
+
  `UnknownVersion`, correcting the typo.
+
  The typo has been corrected to `UnknownVersion`.
+
- `radicle::storage::git::RefError` was removed.
+
- `radicle::storage::git::UserInfo` was removed.
+
- `radicle::storage::git::NAMESPACES_GLOB`, `radicle::storage::git::CANONICAL_IDENTITY`,
+
  and `radicle::storage::git::SIGREFS_GLOB` static variables were removed.
+
- `radicle::storage::git::trailers::SIGNATURE_TRAILER` constant was removed.
+
- The `radicle::serde_ext::localtime` module and its submodules (`time`,
+
  `option::time`, `duration`) were removed, including all associated
+
  serialize/deserialize functions. The `radicle-localtime` crate is introduced
+
  and provides these helpers.
+
- The `radicle::schemars_ext::crypto` module was removed, including the
+
  `PublicKey` schema type. The schema is now provided by `radicle-crypto`.
+
- The test storage modules under `radicle::test::storage::git` and their
+
  submodules (`transport`, `cob`, `trailers`, `paths`, `temp`) were removed
+
  from the public API, along with all associated types, traits, and functions.
+

+
### Security
+

+
*No security updates.*
+

+
## 0.20.0
+

+
### Added
+

- Introduce a node event for canonical reference updates, `Event::CanonicalRefUpdated`.
  Whenever the node fetches new updates, it checks if canonical references can
  be updated. The node has learned how to return these results and emit them as
@@ -22,8 +131,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `radicle::profile::Home::socket` defaults to the path `\\.\pipe\radicle-node`
  on Windows. The behavior on Unix-like systems has *not* changed.

-
### Deprecated
-

### Removed

- `radicle::node::DEFAULT_SOCKET_NAME`, use `radicle::profile::Home::socket`
modified crates/radicle/Cargo.toml
@@ -4,15 +4,26 @@ description = "Radicle standard library"
homepage.workspace = true
repository.workspace = true
license.workspace = true
-
version = "0.19.0"
+
version = "0.21.0"
authors = ["cloudhead <cloudhead@radicle.xyz>"]
edition.workspace = true
rust-version.workspace = true

[features]
default = []
-
test = ["qcheck", "radicle-crypto/test"]
+
test = ["tempfile", "qcheck", "radicle-crypto/test", "radicle-cob/test"]
logger = ["colored", "chrono"]
+
qcheck = [
+
  "radicle-core/qcheck",
+
  "dep:qcheck"
+
]
+
schemars = [
+
  "radicle-oid/schemars",
+
  "radicle-core/schemars",
+
  "radicle-crypto/schemars",
+
  "radicle-localtime/schemars",
+
  "dep:schemars"
+
]

[dependencies]
amplify = { workspace = true, features = ["std"] }
@@ -24,33 +35,35 @@ crossbeam-channel = { workspace = true }
cyphernet = { workspace = true, features = ["tor", "dns", "p2p-ed25519"] }
dunce = { workspace = true }
fast-glob = { version = "0.3.2" }
-
fastrand = { workspace = true }
+
fastrand = { workspace = true, features = ["std"] }
git2 = { workspace = true, features = ["vendored-libgit2"] }
indexmap = { version = "2", features = ["serde"] }
-
localtime = { workspace = true, features = ["serde"] }
log = { workspace = true, features = ["std"] }
multibase = { workspace = true }
nonempty = { workspace = true, features = ["serialize"] }
qcheck = { workspace = true, optional = true }
-
radicle-cob = { workspace = true }
-
radicle-crypto = { workspace = true, features = ["radicle-git-ext", "ssh", "sqlite", "cyphernet"] }
-
radicle-git-ext = { workspace = true, features = ["serde"] }
+
radicle-cob = { workspace = true, features = ["git2"] }
+
radicle-core = { workspace = true, features = ["git2", "serde", "sqlite"] }
+
radicle-crypto = { workspace = true, features = ["git-ref-format-core", "ssh", "sqlite", "cyphernet"] }
+
radicle-git-ref-format = { workspace = true, features = ["macro", "serde"] }
+
radicle-localtime = { workspace = true, features = ["serde"] }
+
radicle-oid = { workspace = true, features = ["git2", "serde", "std", "sha1"] }
radicle-ssh = { workspace = true }
-
schemars = { workspace = true, optional = true }
+
schemars = { workspace = true, optional = true, features = ["derive", "std"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["preserve_order"] }
serde-untagged = "0.1.7"
siphasher = "1.0.0"
sqlite = { workspace = true, features = ["bundled"] }
-
tempfile = { workspace = true }
-
thiserror = { workspace = true }
+
tempfile = { workspace = true, optional = true }
+
thiserror = { workspace = true, default-features = true }
unicode-normalization = { version = "0.1" }

[target.'cfg(unix)'.dependencies]
libc = { workspace = true }

[target.'cfg(windows)'.dependencies]
-
winpipe = { workspace = true }
+
uds_windows = { workspace = true }

[dev-dependencies]
emojis = "0.6"
@@ -58,5 +71,8 @@ jsonschema = { version = "0.30", default-features = false }
pretty_assertions = { workspace = true }
qcheck = { workspace = true }
qcheck-macros = { workspace = true }
-
radicle-cob = { workspace = true, features = ["stable-commit-ids"] }
+
radicle-cob = { workspace = true, features = ["stable-commit-ids", "test"] }
+
radicle-core = {workspace = true, features = ["qcheck"] }
radicle-crypto = { workspace = true, features = ["test"] }
+
radicle-git-metadata = { workspace = true }
+
tempfile = { workspace = true }
modified crates/radicle/src/canonical.rs
@@ -10,7 +10,7 @@
//! > serde_json cannot necessarily deserialize JSON produced by this
//! > formatter.
//!
-
//! [Canonical JSON]: http://wiki.laptop.org/go/Canonical_JSON
+
//! [Canonical JSON]: https://web.archive.org/web/20250207154955/https://wiki.laptop.org/go/Canonical_JSON
//! [olpc-json]: https://docs.rs/olpc-cjson/0.1.2/olpc_cjson

pub mod formatter;
modified crates/radicle/src/canonical/formatter.rs
@@ -25,7 +25,7 @@ use unicode_normalization::UnicodeNormalization;
/// for distribution convenience. We expressly license the original code under
/// the term of the MIT licence.
///
-
/// [Canonical JSON]: http://wiki.laptop.org/go/Canonical_JSON
+
/// [Canonical JSON]: https://web.archive.org/web/20250207154955/https://wiki.laptop.org/go/Canonical_JSON
/// [RFC 8259]: https://www.rfc-editor.org/rfc/rfc8259.txt
#[derive(Debug, Default)]
pub struct CanonicalFormatter {
modified crates/radicle/src/cob.rs
@@ -13,6 +13,9 @@ pub mod thread;
#[cfg(test)]
pub mod test;

+
#[cfg(test)]
+
pub use radicle_cob::stable;
+

pub use cache::{migrate, MigrateCallback};
pub use common::*;
pub use op::{ActorId, Op};
@@ -21,7 +24,7 @@ pub use radicle_cob::{
    CollaborativeObject, Contents, Create, Embed, Entry, Evaluate, History, Manifest, ObjectId,
    Store, TypeName, Update, Updated, Version,
};
-
pub use radicle_cob::{create, get, git, list, remove, update};
+
pub use radicle_cob::{create, get, list, remove, update};

/// The exact identifier for a particular COB.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
@@ -66,17 +69,17 @@ impl TypedId {
        self.type_name == *identity::TYPENAME
    }

-
    /// Parse a [`crate::git::Namespaced`] refname into a [`TypedId`].
+
    /// Parse a [`crate::git::fmt::Namespaced`] refname into a [`TypedId`].
    ///
    /// All namespaces are stripped before parsing the suffix for the
    /// [`TypedId`] (see [`TypedId::from_qualified`]).
    pub fn from_namespaced(
-
        n: &crate::git::Namespaced,
+
        n: &crate::git::fmt::Namespaced,
    ) -> Result<Option<Self>, ParseIdentifierError> {
        Self::from_qualified(&n.strip_namespace_recursive())
    }

-
    /// Parse a [`crate::git::Qualified`] refname into a [`TypedId`].
+
    /// Parse a [`crate::git::fmt::Qualified`] refname into a [`TypedId`].
    ///
    /// The refname is expected to be of the form:
    ///     `refs/cobs/<type name>/<object id>`
@@ -87,7 +90,9 @@ impl TypedId {
    ///
    /// This will fail if the refname is of the correct form, but the
    /// type name or object id fail to parse.
-
    pub fn from_qualified(q: &crate::git::Qualified) -> Result<Option<Self>, ParseIdentifierError> {
+
    pub fn from_qualified(
+
        q: &crate::git::fmt::Qualified,
+
    ) -> Result<Option<Self>, ParseIdentifierError> {
        match q.non_empty_iter() {
            ("refs", "cobs", type_name, mut id) => {
                let Some(id) = id.next() else {
modified crates/radicle/src/cob/cache.rs
@@ -28,6 +28,7 @@ const DB_WRITE_TIMEOUT: time::Duration = time::Duration::from_secs(6);
const MIGRATIONS: &[Migration] = &[
    Migration::Sql(include_str!("cache/migrations/1.sql")),
    Migration::Native(migrations::_2::run),
+
    Migration::Sql(include_str!("cache/migrations/3.sql")),
];

/// Function signature for native migrations.
@@ -213,13 +214,13 @@ impl Store<Write> {
    }

    /// Migrate this database to the latest version.
-
    /// Returns the verison migrated to.
+
    /// Returns the version migrated to.
    pub fn migrate<M: MigrateCallback>(&mut self, callback: M) -> Result<usize, Error> {
        self.migrate_to(MIGRATIONS.len(), callback)
    }

    /// Migrate this database to the given target version.
-
    /// Returns the verison migrated to.
+
    /// Returns the version migrated to.
    pub fn migrate_to<M: MigrateCallback>(
        &mut self,
        target: usize,
@@ -466,10 +467,13 @@ mod tests {
        assert_eq!(db.migrate_to(2, migrate::ignore).unwrap(), 2); // 1 -> 2
        assert_eq!(db.version().unwrap(), 2);

-
        assert_eq!(db.migrate_to(1, migrate::ignore).unwrap(), 2); // No-op.
-
        assert_eq!(db.version().unwrap(), 2);
+
        assert_eq!(db.migrate_to(3, migrate::ignore).unwrap(), 3); // 2 -> 3
+
        assert_eq!(db.version().unwrap(), 3);

-
        assert_eq!(db.migrate_to(99, migrate::ignore).unwrap(), 2); // No-op.
-
        assert_eq!(db.version().unwrap(), 2);
+
        assert_eq!(db.migrate_to(1, migrate::ignore).unwrap(), 3); // No-op.
+
        assert_eq!(db.version().unwrap(), 3);
+

+
        assert_eq!(db.migrate_to(99, migrate::ignore).unwrap(), 3); // No-op.
+
        assert_eq!(db.version().unwrap(), 3);
    }
}
added crates/radicle/src/cob/cache/migrations/3.sql
@@ -0,0 +1,2 @@
+
create index if not exists 'ix_issues_repo_id' on 'issues' (repo, id);
+
create index if not exists 'ix_patches_repo_id' on 'patches' (repo, id);
modified crates/radicle/src/cob/cache/migrations/samples/patch.v1.json
@@ -41,7 +41,7 @@
        {
          "author": "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
          "timestamp": 1710939751000,
-
          "body": "Instead of disabeling the Share button, adds a min-width param",
+
          "body": "Instead of disabling the Share button, adds a min-width param",
          "embeds": []
        }
      ],
modified crates/radicle/src/cob/cache/migrations/samples/patch.v2.json
@@ -41,7 +41,7 @@
        {
          "author": "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
          "timestamp": 1710939751000,
-
          "body": "Instead of disabeling the Share button, adds a min-width param",
+
          "body": "Instead of disabling the Share button, adds a min-width param",
          "embeds": []
        }
      ],
modified crates/radicle/src/cob/common.rs
@@ -13,10 +13,29 @@ use crate::git::Oid;
use crate::prelude::{Did, PublicKey};

/// Timestamp used for COB operations.
-
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
-
#[serde(transparent)]
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Timestamp(LocalTime);

+
impl Serialize for Timestamp {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: serde::Serializer,
+
    {
+
        serializer.serialize_u64(self.0.as_millis())
+
    }
+
}
+

+
impl<'de> Deserialize<'de> for Timestamp {
+
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+
    where
+
        D: serde::Deserializer<'de>,
+
    {
+
        u128::deserialize(deserializer)
+
            .map(LocalTime::from_millis)
+
            .map(Self)
+
    }
+
}
+

impl Timestamp {
    pub fn from_secs(secs: u64) -> Self {
        Self(LocalTime::from_secs(secs))
@@ -43,7 +62,7 @@ impl Deref for Timestamp {
    }
}

-
#[derive(Error, Debug)]
+
#[derive(Error, Debug, PartialEq, Eq)]
pub enum TitleError {
    #[error("empty title")]
    EmptyTitle,
@@ -70,11 +89,14 @@ impl Title {
    /// characters
    pub fn new(title: &str) -> Result<Self, TitleError> {
        if title.contains('\n') || title.contains('\r') {
-
            Err(TitleError::InvalidTitle)
-
        } else if title.is_empty() {
+
            return Err(TitleError::InvalidTitle);
+
        }
+

+
        let title = title.trim();
+
        if title.is_empty() {
            Err(TitleError::EmptyTitle)
        } else {
-
            Ok(Self(title.trim().to_string()))
+
            Ok(Self(title.into()))
        }
    }
}
@@ -352,7 +374,7 @@ impl From<Oid> for Uri {
    }
}

-
impl TryFrom<&Uri> for Oid {
+
impl TryFrom<&Uri> for crate::git::raw::Oid {
    type Error = Uri;

    fn try_from(value: &Uri) -> Result<Self, Self::Error> {
@@ -365,6 +387,14 @@ impl TryFrom<&Uri> for Oid {
    }
}

+
impl TryFrom<&Uri> for crate::git::Oid {
+
    type Error = Uri;
+

+
    fn try_from(value: &Uri) -> Result<Self, Self::Error> {
+
        crate::git::raw::Oid::try_from(value).map(crate::git::Oid::from)
+
    }
+
}
+

impl std::fmt::Display for Uri {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
@@ -494,6 +524,16 @@ mod test {
    use super::*;

    #[test]
+
    fn test_title() {
+
        assert_eq!(Title::new(""), Err(TitleError::EmptyTitle));
+
        assert_eq!(Title::new(" "), Err(TitleError::EmptyTitle));
+
        assert_eq!(Title::new("\t"), Err(TitleError::EmptyTitle));
+
        assert_eq!(Title::new("foo\nbar"), Err(TitleError::InvalidTitle));
+
        assert_eq!(Title::new("foobar\n"), Err(TitleError::InvalidTitle));
+
        assert_eq!(Title::new(" valid title ").unwrap().0, "valid title");
+
    }
+

+
    #[test]
    fn test_color() {
        let c = Color::from_str("#ffccaa").unwrap();
        assert_eq!(c.to_string(), "#ffccaa".to_owned());
modified crates/radicle/src/cob/external.rs
@@ -225,7 +225,7 @@ impl<R: ReadRepository> Evaluate<R> for External {
        Self::from_root(Op::try_from(entry)?, store)
    }

-
    fn apply<'a, I: Iterator<Item = (&'a radicle_git_ext::Oid, &'a radicle_cob::Entry)>>(
+
    fn apply<'a, I: Iterator<Item = (&'a crate::git::Oid, &'a radicle_cob::Entry)>>(
        &mut self,
        entry: &radicle_cob::Entry,
        concurrent: I,
modified crates/radicle/src/cob/identity.rs
@@ -4,11 +4,11 @@ use std::{fmt, ops::Deref, str::FromStr};

use crypto::{PublicKey, Signature};
use radicle_cob::{Embed, ObjectId, TypeName};
-
use radicle_git_ext as git_ext;
-
use radicle_git_ext::Oid;
use serde::{Deserialize, Serialize};
use thiserror::Error;

+
use crate::git;
+
use crate::git::Oid;
use crate::identity::doc::Doc;
use crate::node::device::Device;
use crate::node::NodeId;
@@ -95,6 +95,7 @@ impl CobAction for Action {
}

/// Error applying an operation onto a state.
+
#[non_exhaustive]
#[derive(Error, Debug)]
pub enum ApplyError {
    /// Causal dependency missing.
@@ -126,11 +127,27 @@ pub enum ApplyError {
    #[error("document does not contain any changes to current identity")]
    DocUnchanged,
    #[error("git: {0}")]
-
    Git(#[from] git2::Error),
-
    #[error("git: {0}")]
-
    GitExt(#[from] git_ext::Error),
+
    Git(#[from] git::raw::Error),
    #[error("identity document error: {0}")]
    Doc(#[from] DocError),
+
    #[error("{author} is not a delegate, and only delegates are allowed to {action}")]
+
    NonDelegateUnauthorized { author: Did, action: String },
+
}
+

+
impl ApplyError {
+
    fn non_delegate_unauthorized(author: Did, action: &Action) -> Self {
+
        let action = match action {
+
            Action::Revision { .. } => "create a revision",
+
            Action::RevisionEdit { .. } => "edit a revision",
+
            Action::RevisionAccept { .. } => "accept a revision",
+
            Action::RevisionReject { .. } => "reject a revision",
+
            Action::RevisionRedact { .. } => "redact a revision",
+
        };
+
        Self::NonDelegateUnauthorized {
+
            author,
+
            action: action.to_string(),
+
        }
+
    }
}

/// Error updating or creating proposals.
@@ -273,7 +290,7 @@ impl Identity {

    pub fn load_mut<R: WriteRepository + cob::Store<Namespace = NodeId>>(
        repo: &R,
-
    ) -> Result<IdentityMut<R>, RepositoryError> {
+
    ) -> Result<IdentityMut<'_, R>, RepositoryError> {
        let oid = repo.identity_root()?;
        let oid = ObjectId::from(oid);

@@ -446,8 +463,9 @@ impl Identity {
    ) -> Result<(), ApplyError> {
        let current = self.current().clone();

-
        if !current.is_delegate(&author.into()) {
-
            return Err(ApplyError::UnexpectedState);
+
        let did = author.into();
+
        if !current.is_delegate(&did) {
+
            return Err(ApplyError::non_delegate_unauthorized(did, &action));
        }
        match action {
            Action::RevisionAccept {
@@ -1323,12 +1341,15 @@ mod test {
            .unwrap();

        bob.repo.fetch(alice);
-
        let a3 = alice_identity.redact(a2, &alice.signer).unwrap();
+
        let a3 = cob::stable::with_advanced_timestamp(|| {
+
            alice_identity.redact(a2, &alice.signer).unwrap()
+
        });
        assert!(alice_identity.revision(&a1).is_some());
        assert_eq!(alice_identity.timeline, vec![a0, a1, a2, a3]);

        let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap();
-
        let b1 = bob_identity.accept(&a2, &bob.signer).unwrap();
+
        let b1 =
+
            cob::stable::with_advanced_timestamp(|| bob_identity.accept(&a2, &bob.signer).unwrap());

        assert_eq!(bob_identity.timeline, vec![a0, a1, a2, b1]);
        assert_eq!(bob_identity.revision(&a2).unwrap().state, State::Accepted);
@@ -1377,15 +1398,14 @@ mod test {
        eve.repo.fetch(bob);

        let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap();
-
        let b1 = cob::git::stable::with_advanced_timestamp(|| {
-
            bob_identity.accept(&a2, &bob.signer).unwrap()
-
        });
+
        let b1 =
+
            cob::stable::with_advanced_timestamp(|| bob_identity.accept(&a2, &bob.signer).unwrap());
        assert_eq!(bob_identity.current, a2);

        let mut eve_identity = Identity::load_mut(&*eve.repo).unwrap();
        let mut eve_doc = eve_identity.doc().clone().edit();
        eve_doc.visibility = Visibility::private([eve.signer.public_key().into()]);
-
        let e1 = cob::git::stable::with_advanced_timestamp(|| {
+
        let e1 = cob::stable::with_advanced_timestamp(|| {
            eve_identity
                .update(
                    cob::Title::new("Change visibility").unwrap(),
@@ -1396,6 +1416,7 @@ mod test {
                .unwrap()
        });
        // Eve's revision is active.
+
        assert_eq!(eve_identity.timeline, vec![a0, a1, a2, e1]);
        assert!(eve_identity.revision(&e1).unwrap().is_active());

        //  b1      (Accept "Remove Eve") 2/2
@@ -1411,7 +1432,7 @@ mod test {
        eve_identity.reload().unwrap();
        // Now that Eve reloaded, since Bob's vote to remove Eve went through first (b1 < e1),
        // her revision is no longer valid.
-
        assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1, e1]);
+
        assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1]);
        assert_eq!(eve_identity.revision(&e1), None);
        assert!(!eve_identity.is_delegate(&eve.signer.public_key().into()));
    }
@@ -1454,11 +1475,13 @@ mod test {

        // Bob accepts alice's revision.
        let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap();
-
        let b1 = bob_identity.accept(&a2, &bob.signer).unwrap();
+
        let b1 =
+
            cob::stable::with_advanced_timestamp(|| bob_identity.accept(&a2, &bob.signer).unwrap());

        // Eve rejects the revision, not knowing.
        let mut eve_identity = Identity::load_mut(&*eve.repo).unwrap();
-
        let e1 = eve_identity.reject(a2, &eve.signer).unwrap();
+
        let e1 =
+
            cob::stable::with_advanced_timestamp(|| eve_identity.reject(a2, &eve.signer).unwrap());
        assert!(eve_identity.revision(&a2).unwrap().is_active());

        // Then she submits a new revision.
@@ -1568,7 +1591,7 @@ mod test {
        assert_eq!(eve_identity.revision(&e1).unwrap().state, State::Active);

        alice_identity.reload().unwrap();
-
        let a2 = cob::git::stable::with_advanced_timestamp(|| {
+
        let a2 = cob::stable::with_advanced_timestamp(|| {
            alice_identity.accept(&b1, &alice.signer).unwrap()
        });

modified crates/radicle/src/cob/issue.rs
@@ -940,7 +940,7 @@ pub enum Action {
        /// Should be the root [`CommentId`] if it's a top-level comment.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        reply_to: Option<CommentId>,
-
        /// Embeded content.
+
        /// Embedded content.
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        embeds: Vec<Embed<Uri>>,
    },
@@ -1390,7 +1390,7 @@ mod test {
            .create(
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
-
                &[ux_label.clone()],
+
                std::slice::from_ref(&ux_label),
                &[],
                [],
                &node.signer,
@@ -1765,7 +1765,7 @@ mod test {
            .unwrap();

        // Comments require references, so adding two of them to the same transaction errors.
-
        let mut tx: Transaction<Issue, test::storage::git::Repository> =
+
        let mut tx: Transaction<Issue, crate::storage::git::Repository> =
            Transaction::<Issue, _>::default();
        tx.comment("First reply", *issue.id, vec![]).unwrap();
        let err = tx.comment("Second reply", *issue.id, vec![]).unwrap_err();
modified crates/radicle/src/cob/op.rs
@@ -8,9 +8,10 @@ use radicle_crypto::PublicKey;

use crate::cob;
use crate::cob::Timestamp;
+
use crate::git;
+
use crate::identity;
use crate::identity::DocAt;
use crate::storage::ReadRepository;
-
use crate::{git, identity};

/// The author of an [`Op`].
pub type ActorId = PublicKey;
@@ -21,7 +22,7 @@ pub enum OpEncodingError {
    #[error("encoding failed: {0}")]
    Encoding(#[from] serde_json::Error),
    #[error("git: {0}")]
-
    Git(#[from] git2::Error),
+
    Git(#[from] git::raw::Error),
}

#[derive(Error, Debug)]
modified crates/radicle/src/cob/patch.rs
@@ -130,7 +130,7 @@ pub enum Error {
    Payload(#[from] PayloadError),
    /// Git error.
    #[error("git: {0}")]
-
    Git(#[from] git::ext::Error),
+
    Git(#[from] git::raw::Error),
    /// Store error.
    #[error("store: {0}")]
    Store(#[from] store::Error),
@@ -217,7 +217,7 @@ pub enum Action {
        /// Should be [`Some`] otherwise.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        reply_to: Option<CommentId>,
-
        /// Embeded content.
+
        /// Embedded content.
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        embeds: Vec<Embed<Uri>>,
    },
@@ -265,7 +265,7 @@ pub enum Action {
    RevisionEdit {
        revision: RevisionId,
        description: String,
-
        /// Embeded content.
+
        /// Embedded content.
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        embeds: Vec<Embed<Uri>>,
    },
@@ -295,7 +295,7 @@ pub enum Action {
        /// Should be the root [`CommentId`] if it's a top-level comment.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        reply_to: Option<CommentId>,
-
        /// Embeded content.
+
        /// Embedded content.
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        embeds: Vec<Embed<Uri>>,
    },
@@ -561,7 +561,7 @@ impl Patch {
        author: &'a PublicKey,
    ) -> impl DoubleEndedIterator<Item = (RevisionId, &'a Revision)> {
        self.revisions()
-
            .filter(move |(_, r)| (r.author.public_key() == author))
+
            .filter(move |(_, r)| r.author.public_key() == author)
    }

    /// List of patch reviews of the given revision.
@@ -601,12 +601,15 @@ impl Patch {
    }

    /// Get the merge base of this patch.
-
    pub fn merge_base<R: ReadRepository>(&self, repo: &R) -> Result<git::Oid, git::ext::Error> {
+
    pub fn merge_base<R: ReadRepository>(
+
        &self,
+
        repo: &R,
+
    ) -> Result<crate::git::Oid, crate::git::raw::Error> {
        repo.merge_base(self.base(), self.head())
    }

    /// Get the commit range of this patch.
-
    pub fn range(&self) -> Result<(git::Oid, git::Oid), git::ext::Error> {
+
    pub fn range(&self) -> Result<(crate::git::Oid, crate::git::Oid), crate::git::raw::Error> {
        Ok((*self.base(), *self.head()))
    }

@@ -2419,7 +2422,7 @@ where
        revision: RevisionId,
        commit: git::Oid,
        signer: &Device<G>,
-
    ) -> Result<Merged<R>, Error>
+
    ) -> Result<Merged<'_, R>, Error>
    where
        G: crypto::signature::Signer<crypto::Signature>,
    {
@@ -3334,7 +3337,7 @@ mod test {
            },
            &alice,
        );
-
        let patch = Patch::from_history(&h0, &repo).unwrap();
+
        let patch: Patch = Patch::from_history(&h0, &repo).unwrap();
        assert_eq!(patch.revisions().count(), 2);

        let mut h1 = h0.clone();
modified crates/radicle/src/cob/store.rs
@@ -125,7 +125,7 @@ pub enum Error {
    Git(git::raw::Error),
    #[error("failed to find reference '{name}': {err}")]
    RefLookup {
-
        name: git::RefString,
+
        name: git::fmt::RefString,
        #[source]
        err: git::raw::Error,
    },
modified crates/radicle/src/cob/stream.rs
@@ -8,6 +8,7 @@ use std::marker::PhantomData;

use serde::Deserialize;

+
use crate::git;
use crate::git::Oid;

use super::{ObjectId, Op, TypeName};
@@ -92,7 +93,7 @@ impl HasRoot for CobRange {
///
/// To construct a `Stream`, use [`Stream::new`].
pub struct Stream<'a, A> {
-
    repo: &'a git2::Repository,
+
    repo: &'a git::raw::Repository,
    range: CobRange,
    typename: TypeName,
    marker: PhantomData<A>,
@@ -101,7 +102,7 @@ pub struct Stream<'a, A> {
impl<'a, A> Stream<'a, A> {
    /// Construct a new stream providing the underlying `repo`, a [`CobRange`],
    /// and the [`TypeName`] of the COB that is being streamed.
-
    pub fn new(repo: &'a git2::Repository, range: CobRange, typename: TypeName) -> Self {
+
    pub fn new(repo: &'a git::raw::Repository, range: CobRange, typename: TypeName) -> Self {
        Self {
            repo,
            range,
@@ -185,7 +186,7 @@ mod tests {
        "xyz.radicle.test".parse::<TypeName>().unwrap()
    }

-
    fn gen_ops(repo: &git2::Repository, signer: &MockSigner) -> Vec<cob::Entry> {
+
    fn gen_ops(repo: &git::raw::Repository, signer: &MockSigner) -> Vec<cob::Entry> {
        // Number of ops
        let n = gen::<u8>(1).clamp(1, 10);
        let mut entries = Vec::with_capacity(n.into());
@@ -213,7 +214,7 @@ mod tests {
    }

    fn create_entry(
-
        repo: &git2::Repository,
+
        repo: &git::raw::Repository,
        signer: &MockSigner,
        contents: NonEmpty<Vec<u8>>,
        parent: Option<Oid>,
modified crates/radicle/src/cob/stream/error.rs
@@ -22,7 +22,7 @@ impl Stream {
#[derive(Debug, Error)]
pub enum Ops {
    #[error("failed to get a commit while iterating over stream: {source}")]
-
    Commit { source: git2::Error },
+
    Commit { source: crate::git::raw::Error },
    #[error("failed to load COB operation: {source}")]
    Load { source: op::LoadError },
    #[error("failed to load COB manifest: {source}")]
modified crates/radicle/src/cob/stream/iter.rs
@@ -3,7 +3,9 @@ use std::marker::PhantomData;
use serde::Deserialize;

use crate::cob::{Op, TypeName};
-
use crate::git::{self, Oid, PatternString};
+
use crate::git;
+
use crate::git::fmt::refspec::PatternString;
+
use crate::git::Oid;

use super::error;
use super::CobRange;
@@ -39,7 +41,7 @@ impl From<PatternString> for Until {
/// from.
pub(super) struct WalkIter<'a> {
    /// Git repository for looking up the commit object during the revwalk.
-
    repo: &'a git2::Repository,
+
    repo: &'a git::raw::Repository,
    /// The root commit that is being walked from.
    ///
    /// N.b. This is required since ranges are non-inclusive in Git, and if the
@@ -47,7 +49,7 @@ pub(super) struct WalkIter<'a> {
    /// error.
    from: Option<Oid>,
    /// The revwalk that is being iterated over.
-
    inner: git2::Revwalk<'a>,
+
    inner: git::raw::Revwalk<'a>,
}

impl From<CobRange> for Walk {
@@ -76,14 +78,14 @@ impl Walk {
    }

    /// Get the iterator for the walk.
-
    pub(super) fn iter(self, repo: &git2::Repository) -> Result<WalkIter<'_>, git2::Error> {
+
    pub(super) fn iter(self, repo: &git::raw::Repository) -> Result<WalkIter<'_>, git::raw::Error> {
        let mut walk = repo.revwalk()?;
        // N.b. ensure that we start from the `self.from` commit.
-
        walk.set_sorting(git2::Sort::TOPOLOGICAL.union(git2::Sort::REVERSE))?;
+
        walk.set_sorting(git::raw::Sort::TOPOLOGICAL.union(git::raw::Sort::REVERSE))?;
        match self.until {
            Until::Tip(tip) => walk.push_range(&format!("{}..{}", self.from, tip))?,
            Until::Glob(glob) => {
-
                walk.push(*self.from)?;
+
                walk.push(self.from.into())?;
                walk.push_glob(glob.as_str())?
            }
        }
@@ -97,13 +99,13 @@ impl Walk {
}

impl<'a> Iterator for WalkIter<'a> {
-
    type Item = Result<git2::Commit<'a>, git2::Error>;
+
    type Item = Result<git::raw::Commit<'a>, git::raw::Error>;

    fn next(&mut self) -> Option<Self::Item> {
        // N.b. ensure that we start using the `from` commit and use the revwalk
        // after that.
        if let Some(from) = self.from.take() {
-
            return Some(self.repo.find_commit(*from));
+
            return Some(self.repo.find_commit(from.into()));
        }
        let oid = self.inner.next()?;
        Some(oid.and_then(|oid| self.repo.find_commit(oid)))
@@ -132,11 +134,18 @@ where
        let commit = self.walk.next()?;
        match commit {
            Ok(commit) => {
-
                let entry = git::Oid::from(commit.id());
+
                let entry = crate::git::Oid::from(commit.id());
                // N.b. mark this commit as seen, so that it is not walked again
                self.walk.inner.hide(commit.id()).ok();
                // Skip any Op that do not match the manifest
-
                self.load(entry).transpose().or_else(|| self.next())
+
                match self.load(entry) {
+
                    Ok(entry) => entry.map(Ok).or_else(|| self.next()),
+
                    Err(err) => match err {
+
                        // This is a parent commit that is not an Op
+
                        error::Ops::Manifest { source: _ } => self.next(),
+
                        err => Some(Err(err)),
+
                    },
+
                }
            }
            // Something was wrong with the commit
            Err(err) => Some(Err(error::Ops::Commit { source: err })),
@@ -155,7 +164,7 @@ impl<'a, A> OpsIter<'a, A> {

    /// Load the `Op` for the given `entry`, ensuring that manifest matches with
    /// the expected manifest.
-
    fn load(&self, entry: git::Oid) -> Result<Option<Op<A>>, error::Ops>
+
    fn load(&self, entry: crate::git::Oid) -> Result<Option<Op<A>>, error::Ops>
    where
        A: for<'de> Deserialize<'de>,
    {
modified crates/radicle/src/cob/test.rs
@@ -12,10 +12,6 @@ use crate::cob::store::encoding;
use crate::cob::{patch, Title};
use crate::cob::{Entry, History, Manifest, Timestamp, Version};
use crate::crypto::Signer;
-
use crate::git;
-
use crate::git::ext::author::Author;
-
use crate::git::ext::commit::headers::Headers;
-
use crate::git::ext::commit::{trailers::OwnedTrailer, Commit};
use crate::git::Oid;
use crate::node::device::Device;
use crate::prelude::Did;
@@ -101,7 +97,7 @@ where
        self.history.merge(other.history);
    }

-
    pub fn commit<G: Signer>(&mut self, action: &T::Action, signer: &G) -> git::ext::Oid {
+
    pub fn commit<G: Signer>(&mut self, action: &T::Action, signer: &G) -> crate::git::Oid {
        let timestamp = self.time;
        let tips = self.tips();
        let revision = arbitrary::oid();
@@ -182,7 +178,8 @@ impl<G: Signer> Actor<G> {
            "nonce": fastrand::u64(..),
        }))
        .unwrap();
-
        let oid = git::raw::Oid::hash_object(git::raw::ObjectType::Blob, &data).unwrap();
+
        let oid =
+
            crate::git::raw::Oid::hash_object(crate::git::raw::ObjectType::Blob, &data).unwrap();
        let id = oid.into();
        let author = *self.signer.public_key();
        let actions = NonEmpty::from_vec(actions).unwrap();
@@ -226,8 +223,8 @@ impl<G: Signer> Actor<G> {
        &mut self,
        title: Title,
        description: impl ToString,
-
        base: git::Oid,
-
        oid: git::Oid,
+
        base: crate::git::Oid,
+
        oid: crate::git::Oid,
        repo: &R,
    ) -> Result<Patch, patch::Error> {
        Patch::from_root(
@@ -252,21 +249,26 @@ impl<G: Signer> Actor<G> {
///
/// Doesn't encode in the same way as we do in production, but attempts to include the same data
/// that feeds into the hash entropy, so that changing any input will change the resulting oid.
-
pub fn encoded<T: Cob, G: Signer>(
+
fn encoded<T: Cob, G: Signer>(
    action: &T::Action,
    timestamp: Timestamp,
    parents: impl IntoIterator<Item = Oid>,
    signer: &G,
-
) -> (Vec<u8>, git::ext::Oid) {
+
) -> (Vec<u8>, crate::git::Oid) {
+
    use radicle_git_metadata::{
+
        author::{Author, Time},
+
        commit::{headers::Headers, trailers::OwnedTrailer, CommitData},
+
    };
+

    let data = encoding::encode(action).unwrap();
-
    let oid = git::raw::Oid::hash_object(git::raw::ObjectType::Blob, &data).unwrap();
-
    let parents = parents.into_iter().map(|o| *o);
+
    let oid = crate::git::raw::Oid::hash_object(crate::git::raw::ObjectType::Blob, &data).unwrap();
+
    let parents = parents.into_iter().map(|o| o.into());
    let author = Author {
        name: "radicle".to_owned(),
        email: signer.public_key().to_human(),
-
        time: git_ext::author::Time::new(timestamp.as_secs() as i64, 0),
+
        time: Time::new(timestamp.as_secs() as i64, 0),
    };
-
    let commit = Commit::new::<_, _, OwnedTrailer>(
+
    let commit = CommitData::<git2::Oid, git2::Oid>::new::<_, _, OwnedTrailer>(
        oid,
        parents,
        author.clone(),
@@ -277,7 +279,9 @@ pub fn encoded<T: Cob, G: Signer>(
    )
    .to_string();

-
    let hash = git::raw::Oid::hash_object(git::raw::ObjectType::Commit, commit.as_bytes()).unwrap();
+
    let hash =
+
        crate::git::raw::Oid::hash_object(crate::git::raw::ObjectType::Commit, commit.as_bytes())
+
            .unwrap();

    (data, hash.into())
}
modified crates/radicle/src/cob/thread.rs
@@ -14,7 +14,7 @@ use crate::git;
use crate::prelude::ReadRepository;

/// Type name of a thread, as well as the domain for all thread operations.
-
/// Note that threads are not usually used standalone. They are embeded into other COBs.
+
/// Note that threads are not usually used standalone. They are embedded into other COBs.
pub static TYPENAME: LazyLock<cob::TypeName> =
    LazyLock::new(|| FromStr::from_str("xyz.radicle.thread").expect("type name is valid"));

@@ -532,9 +532,6 @@ pub fn edit<L>(
    body: String,
    embeds: Vec<Embed<Uri>>,
) -> Result<(), Error> {
-
    if body.is_empty() {
-
        return Err(Error::Edit(id));
-
    }
    debug_assert!(!thread.timeline.contains(&id));
    thread.timeline.push(id);

modified crates/radicle/src/git.rs
@@ -1,35 +1,27 @@
pub mod canonical;
+
pub mod raw;

use std::io;
use std::path::Path;
use std::process::Command;
use std::str::FromStr;
-
use std::sync::LazyLock;

-
use git_ext::ref_format as format;
+
pub use radicle_oid::{str::ParseOidError, Oid};
+

+
pub extern crate radicle_git_ref_format as fmt;

use crate::collections::RandomMap;
use crate::crypto::PublicKey;
use crate::node::Alias;
use crate::rad;
-
use crate::storage;
use crate::storage::refs::Refs;
use crate::storage::RemoteId;

-
pub use ext::is_not_found_err;
-
pub use ext::Error;
-
pub use ext::NotFound;
-
pub use ext::Oid;
-
pub use git2 as raw;
-
pub use git_ext::ref_format as fmt;
-
pub use git_ext::ref_format::{
-
    component, lit, name, qualified, refname, refspec,
-
    refspec::{PatternStr, PatternString, Refspec},
-
    Component, Namespaced, Qualified, RefStr, RefString,
-
};
-
pub use radicle_git_ext as ext;
-
pub use storage::git::transport::local::Url;
-
pub use storage::BranchName;
+
pub use crate::storage::git::transport::local::Url;
+

+
use raw::ErrorExt as _;
+

+
pub type BranchName = crate::git::fmt::RefString;

/// Default port of the `git` transport protocol.
pub const PROTOCOL_PORT: u16 = 9418;
@@ -54,6 +46,50 @@ impl std::fmt::Display for Version {
    }
}

+
/// Verbosity level for Git commands.
+
#[derive(Default, Clone, Copy)]
+
pub struct Verbosity(i8);
+

+
impl Verbosity {
+
    /// Transform into a command line flag, helpful for passing to invocations
+
    /// of `git`.
+
    ///
+
    /// See <https://github.com/git/git/blob/c44beea485f0f2feaf460e2ac87fdd5608d63cf0/builtin/pull.c#L264-L276>
+
    pub fn into_flag(&self) -> Option<String> {
+
        const FLAG_PREFIX: &str = "-";
+
        const FLAG_QUIET: &str = "q";
+
        const FLAG_VERBOSE: &str = "v";
+

+
        let repetitions = self.0.unsigned_abs() as usize;
+

+
        if repetitions == 0 {
+
            return None;
+
        }
+

+
        let flag = if self.0 > 0 { FLAG_VERBOSE } else { FLAG_QUIET };
+

+
        Some(FLAG_PREFIX.to_string() + &flag.repeat(repetitions))
+
    }
+

+
    /// Clamps verbosity to a range, as some commands only accept a specific
+
    /// number of repetitions.
+
    fn clamp(self, min: i8, max: i8) -> Self {
+
        Self(self.0.clamp(min, max))
+
    }
+

+
    /// Clamps verbosity to at most `-v` or `-q`, as some commands do not accept
+
    /// repetitions.
+
    pub fn clamp_one(self) -> Self {
+
        self.clamp(-1, 1)
+
    }
+
}
+

+
impl From<i8> for Verbosity {
+
    fn from(v: i8) -> Self {
+
        Self(v)
+
    }
+
}
+

#[derive(thiserror::Error, Debug)]
pub enum VersionError {
    #[error("malformed git version string")]
@@ -115,37 +151,41 @@ pub enum RefError {
    #[error("ref name is not valid UTF-8")]
    InvalidName,
    #[error("unexpected unqualified ref: {0}")]
-
    Unqualified(RefString),
+
    Unqualified(fmt::RefString),
    #[error("invalid ref format: {0}")]
-
    Format(#[from] format::Error),
+
    Format(#[from] fmt::Error),
    #[error("reference has no target")]
    NoTarget,
    #[error("expected ref to begin with 'refs/namespaces' but found '{0}'")]
-
    MissingNamespace(format::RefString),
+
    MissingNamespace(fmt::RefString),
    #[error("ref name contains invalid namespace identifier '{name}'")]
    InvalidNamespace {
-
        name: format::RefString,
+
        name: fmt::RefString,
        #[source]
        err: Box<dyn std::error::Error + Send + Sync + 'static>,
    },
    #[error(transparent)]
-
    Other(#[from] git2::Error),
+
    Other(#[from] raw::Error),
}

#[derive(thiserror::Error, Debug)]
pub enum ListRefsError {
    #[error("git error: {0}")]
-
    Git(#[from] git2::Error),
+
    Git(#[from] raw::Error),
    #[error("invalid ref: {0}")]
    InvalidRef(#[from] RefError),
}

pub mod refs {
-
    use super::*;
+
    use std::sync::LazyLock;
+

    use radicle_cob as cob;

+
    use super::fmt::*;
+
    use super::*;
+

    /// Try to get a qualified reference from a generic reference.
-
    pub fn qualified_from<'a>(r: &'a git2::Reference) -> Result<(Qualified<'a>, Oid), RefError> {
+
    pub fn qualified_from<'a>(r: &'a raw::Reference) -> Result<(Qualified<'a>, Oid), RefError> {
        let name = r.name().ok_or(RefError::InvalidName)?;
        let refstr = RefStr::try_from_str(name)?;
        let target = r.resolve()?.target().ok_or(RefError::NoTarget)?;
@@ -169,35 +209,28 @@ pub mod refs {
    ///
    pub fn patch<'a>(object_id: &cob::ObjectId) -> Qualified<'a> {
        Qualified::from_components(
-
            name::component!("heads"),
-
            name::component!("patches"),
+
            component!("heads"),
+
            component!("patches"),
            Some(object_id.into()),
        )
    }

    pub mod storage {
-
        use format::{
-
            lit,
-
            name::component,
-
            refspec::{self, PatternString},
-
        };
-

        use super::*;

        /// Where the repo's identity document is stored.
        ///
        /// `refs/rad/id`
        ///
-
        pub static IDENTITY_BRANCH: LazyLock<Qualified> = LazyLock::new(|| {
-
            Qualified::from_components(name::component!("rad"), name::component!("id"), None)
-
        });
+
        pub static IDENTITY_BRANCH: LazyLock<Qualified> =
+
            LazyLock::new(|| Qualified::from_components(component!("rad"), component!("id"), None));

        /// Where the repo's identity root document is stored.
        ///
        /// `refs/rad/root`
        ///
        pub static IDENTITY_ROOT: LazyLock<Qualified> = LazyLock::new(|| {
-
            Qualified::from_components(name::component!("rad"), name::component!("root"), None)
+
            Qualified::from_components(component!("rad"), component!("root"), None)
        });

        /// Where the project's signed references are stored.
@@ -205,7 +238,7 @@ pub mod refs {
        /// `refs/rad/sigrefs`
        ///
        pub static SIGREFS_BRANCH: LazyLock<Qualified> = LazyLock::new(|| {
-
            Qualified::from_components(name::component!("rad"), name::component!("sigrefs"), None)
+
            Qualified::from_components(component!("rad"), component!("sigrefs"), None)
        });

        /// The set of special references used in the Heartwood protocol.
@@ -254,7 +287,7 @@ pub mod refs {
        ///
        /// `refs/namespaces/<remote>/refs/rad/id`
        ///
-
        pub fn id(remote: &RemoteId) -> Namespaced {
+
        pub fn id(remote: &RemoteId) -> Namespaced<'_> {
            IDENTITY_BRANCH.with_namespace(remote.into())
        }

@@ -262,7 +295,7 @@ pub mod refs {
        ///
        /// `refs/namespaces/<remote>/refs/rad/root`
        ///
-
        pub fn id_root(remote: &RemoteId) -> Namespaced {
+
        pub fn id_root(remote: &RemoteId) -> Namespaced<'_> {
            IDENTITY_ROOT.with_namespace(remote.into())
        }

@@ -271,7 +304,7 @@ pub mod refs {
        ///
        /// `refs/namespaces/<remote>/refs/rad/sigrefs`
        ///
-
        pub fn sigrefs(remote: &RemoteId) -> Namespaced {
+
        pub fn sigrefs(remote: &RemoteId) -> Namespaced<'_> {
            SIGREFS_BRANCH.with_namespace(remote.into())
        }

@@ -296,8 +329,8 @@ pub mod refs {
        ///
        /// `refs/namespaces/*/refs/cobs/<typename>/<object_id>`
        ///
-
        pub fn cobs(typename: &cob::TypeName, object_id: &cob::ObjectId) -> PatternString {
-
            refspec::pattern!("refs/namespaces/*")
+
        pub fn cobs(typename: &cob::TypeName, object_id: &cob::ObjectId) -> refspec::PatternString {
+
            pattern!("refs/namespaces/*")
                .join(refname!("refs/cobs"))
                .join(Component::from(typename))
                .join(Component::from(object_id))
@@ -345,8 +378,11 @@ pub mod refs {
            ///
            /// `refs/namespaces/*/refs/drafts/cobs/<typename>/<object_id>`
            ///
-
            pub fn cobs(typename: &cob::TypeName, object_id: &cob::ObjectId) -> PatternString {
-
                refspec::pattern!("refs/namespaces/*")
+
            pub fn cobs(
+
                typename: &cob::TypeName,
+
                object_id: &cob::ObjectId,
+
            ) -> refspec::PatternString {
+
                pattern!("refs/namespaces/*")
                    .join(refname!("refs/drafts/cobs"))
                    .join(Component::from(typename))
                    .join(Component::from(object_id))
@@ -378,7 +414,6 @@ pub mod refs {

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

        /// Create a [`RefString`] that corresponds to `refs/heads/<branch>`.
        pub fn branch(branch: &RefStr) -> RefString {
@@ -418,9 +453,9 @@ pub mod refs {
pub fn remote_refs(url: &Url) -> Result<RandomMap<RemoteId, Refs>, ListRefsError> {
    let url = url.to_string();
    let mut remotes = RandomMap::default();
-
    let mut remote = git2::Remote::create_detached(url)?;
+
    let mut remote = raw::Remote::create_detached(url)?;

-
    remote.connect(git2::Direction::Fetch)?;
+
    remote.connect(raw::Direction::Fetch)?;

    let refs = remote.list()?;
    for r in refs {
@@ -438,7 +473,7 @@ pub fn remote_refs(url: &Url) -> Result<RandomMap<RemoteId, Refs>, ListRefsError
    Ok(remotes)
}

-
/// Parse a [`format::Qualified`] reference string while expecting the reference
+
/// Parse a [`fmt::Qualified`] reference string while expecting the reference
/// to start with `refs/namespaces`. If the namespace is not present, then an
/// error will be returned.
///
@@ -453,7 +488,7 @@ pub fn remote_refs(url: &Url) -> Result<RandomMap<RemoteId, Refs>, ListRefsError
/// The `T` can be specified when calling the function. For example, if you
/// wanted to parse the namespace as a `PublicKey`, then you would the function
/// like so, `parse_ref_namespaced::<PublicKey>(s)`.
-
pub fn parse_ref_namespaced<T>(s: &str) -> Result<(T, format::Qualified), RefError>
+
pub fn parse_ref_namespaced<T>(s: &str) -> Result<(T, fmt::Qualified<'_>), RefError>
where
    T: FromStr,
    T::Err: std::error::Error + Send + Sync + 'static,
@@ -465,7 +500,7 @@ where
    }
}

-
/// Parse a [`format::Qualified`] reference string. It will optionally return
+
/// Parse a [`fmt::Qualified`] reference string. It will optionally return
/// the namespace, if present.
///
/// The qualified form could be of the form: `refs/heads/main`,
@@ -482,15 +517,15 @@ where
/// The `T` can be specified when calling the function. For example, if you
/// wanted to parse the namespace as a `PublicKey`, then you would the function
/// like so, `parse_ref::<PublicKey>(s)`.
-
pub fn parse_ref<T>(s: &str) -> Result<(Option<T>, format::Qualified), RefError>
+
pub fn parse_ref<T>(s: &str) -> Result<(Option<T>, fmt::Qualified<'_>), RefError>
where
    T: FromStr,
    T::Err: std::error::Error + Send + Sync + 'static,
{
-
    let input = format::RefStr::try_from_str(s)?;
+
    let input = fmt::RefStr::try_from_str(s)?;
    match input.to_namespaced() {
        None => {
-
            let refname = Qualified::from_refstr(input)
+
            let refname = fmt::Qualified::from_refstr(input)
                .ok_or_else(|| RefError::Unqualified(input.to_owned()))?;

            Ok((None, refname))
@@ -513,9 +548,9 @@ where

/// Create an initial empty commit.
pub fn initial_commit<'a>(
-
    repo: &'a git2::Repository,
-
    sig: &git2::Signature,
-
) -> Result<git2::Commit<'a>, git2::Error> {
+
    repo: &'a raw::Repository,
+
    sig: &raw::Signature,
+
) -> Result<raw::Commit<'a>, raw::Error> {
    let tree_id = repo.index()?.write_tree()?;
    let tree = repo.find_tree(tree_id)?;
    let oid = repo.commit(None, sig, sig, "Initial commit", &tree, &[])?;
@@ -526,13 +561,13 @@ pub fn initial_commit<'a>(

/// Create a commit and update the given ref to it.
pub fn commit<'a>(
-
    repo: &'a git2::Repository,
-
    parent: &'a git2::Commit,
-
    target: &RefStr,
+
    repo: &'a raw::Repository,
+
    parent: &'a raw::Commit,
+
    target: &fmt::RefStr,
    message: &str,
-
    sig: &git2::Signature,
-
    tree: &git2::Tree,
-
) -> Result<git2::Commit<'a>, git2::Error> {
+
    sig: &raw::Signature,
+
    tree: &raw::Tree,
+
) -> Result<raw::Commit<'a>, raw::Error> {
    let oid = repo.commit(Some(target.as_str()), sig, sig, message, tree, &[parent])?;
    let commit = repo.find_commit(oid)?;

@@ -541,12 +576,12 @@ pub fn commit<'a>(

/// Create an empty commit on top of the parent.
pub fn empty_commit<'a>(
-
    repo: &'a git2::Repository,
-
    parent: &'a git2::Commit,
-
    target: &RefStr,
+
    repo: &'a raw::Repository,
+
    parent: &'a raw::Commit,
+
    target: &fmt::RefStr,
    message: &str,
-
    sig: &git2::Signature,
-
) -> Result<git2::Commit<'a>, git2::Error> {
+
    sig: &raw::Signature,
+
) -> Result<raw::Commit<'a>, raw::Error> {
    let tree = parent.tree()?;
    let oid = repo.commit(Some(target.as_str()), sig, sig, message, &tree, &[parent])?;
    let commit = repo.find_commit(oid)?;
@@ -555,7 +590,7 @@ pub fn empty_commit<'a>(
}

/// Get the repository head.
-
pub fn head(repo: &git2::Repository) -> Result<git2::Commit, git2::Error> {
+
pub fn head(repo: &raw::Repository) -> Result<raw::Commit<'_>, raw::Error> {
    let head = repo.head()?.peel_to_commit()?;

    Ok(head)
@@ -565,8 +600,8 @@ pub fn head(repo: &git2::Repository) -> Result<git2::Commit, git2::Error> {
pub fn write_tree<'r>(
    path: &Path,
    bytes: &[u8],
-
    repo: &'r git2::Repository,
-
) -> Result<git2::Tree<'r>, Error> {
+
    repo: &'r raw::Repository,
+
) -> Result<raw::Tree<'r>, raw::Error> {
    let blob_id = repo.blob(bytes)?;
    let mut builder = repo.treebuilder(None)?;
    builder.insert(path, blob_id, 0o100_644)?;
@@ -580,7 +615,7 @@ pub fn write_tree<'r>(
/// Configure a radicle repository.
///
/// * Sets `push.default = upstream`.
-
pub fn configure_repository(repo: &git2::Repository) -> Result<(), git2::Error> {
+
pub fn configure_repository(repo: &raw::Repository) -> Result<(), raw::Error> {
    let mut cfg = repo.config()?;
    cfg.set_str("push.default", "upstream")?;

@@ -597,13 +632,22 @@ pub fn configure_repository(repo: &git2::Repository) -> Result<(), git2::Error>
///   fetch = +refs/heads/*:refs/remotes/<name>/*
///   fetch = +refs/tags/*:refs/remotes/<name>/tags/*
///   tagOpt = --no-tags
+
///   pruneTags = false
/// ```
+
///
+
/// Because of the `+refs/tags/*:…` refspec, set:
+
///  1. `pruneTags = false` to ensure that `git` does not delete tags because
+
///     the remote does not have them. Tags for a Radicle repository are
+
///     synthesised by canonical refs and thus, the `rad` remote will handle
+
///     fetching them.
+
///  2. `tagOpt = --no-tags` to ensure that tags are not fetched and stored
+
///     under `refs/tags`, again, because these are fetched by the `rad` remote.
pub fn configure_remote<'r>(
-
    repo: &'r git2::Repository,
+
    repo: &'r raw::Repository,
    name: &str,
    fetch: &Url,
    push: &Url,
-
) -> Result<git2::Remote<'r>, git2::Error> {
+
) -> Result<raw::Remote<'r>, raw::Error> {
    let fetchspec = format!("+refs/heads/*:refs/remotes/{name}/*");
    let remote = repo.remote_with_fetch(name, fetch.to_string().as_str(), &fetchspec)?;

@@ -613,11 +657,9 @@ pub fn configure_remote<'r>(
    let tags = format!("+refs/tags/*:refs/remotes/{name}/tags/*");
    repo.remote_add_fetch(name, &tags)?;

-
    // Because of the above, if we don't set `--no-tags` then the tags will be
-
    // fetched into `refs/tags` as well. We don't want to do this *unless* it's
-
    // the `rad` remote, which will have the canonical tags
    if name != (*rad::REMOTE_NAME).as_str() {
        let mut config = repo.config()?;
+
        config.set_bool(&format!("remote.{name}.pruneTags"), false)?;
        config.set_str(&format!("remote.{name}.tagOpt"), "--no-tags")?;
    }

@@ -628,14 +670,14 @@ pub fn configure_remote<'r>(
}

/// Fetch from the given `remote`.
-
pub fn fetch(repo: &git2::Repository, remote: &str) -> Result<(), git2::Error> {
+
pub fn fetch(repo: &raw::Repository, remote: &str) -> Result<(), raw::Error> {
    repo.find_remote(remote)?.fetch::<&str>(
        &[],
        Some(
-
            git2::FetchOptions::new()
+
            raw::FetchOptions::new()
                .update_fetchhead(false)
-
                .prune(git2::FetchPrune::On)
-
                .download_tags(git2::AutotagOption::None),
+
                .prune(raw::FetchPrune::On)
+
                .download_tags(raw::AutotagOption::None),
        ),
        None,
    )
@@ -643,10 +685,10 @@ pub fn fetch(repo: &git2::Repository, remote: &str) -> Result<(), git2::Error> {

/// Push `refspecs` to the given `remote` using the provided `namespace`.
pub fn push<'a>(
-
    repo: &git2::Repository,
+
    repo: &raw::Repository,
    remote: &str,
-
    refspecs: impl IntoIterator<Item = (&'a Qualified<'a>, &'a Qualified<'a>)>,
-
) -> Result<(), git2::Error> {
+
    refspecs: impl IntoIterator<Item = (&'a fmt::Qualified<'a>, &'a fmt::Qualified<'a>)>,
+
) -> Result<(), raw::Error> {
    let refspecs = refspecs
        .into_iter()
        .map(|(src, dst)| format!("{src}:{dst}"));
@@ -668,11 +710,11 @@ pub fn push<'a>(
///     merge = refs/heads/main
/// ```
pub fn set_upstream(
-
    repo: &git2::Repository,
+
    repo: &raw::Repository,
    remote: impl AsRef<str>,
    branch: impl AsRef<str>,
    merge: impl AsRef<str>,
-
) -> Result<(), git2::Error> {
+
) -> Result<(), raw::Error> {
    let remote = remote.as_ref();
    let branch = branch.as_ref();
    let merge = merge.as_ref();
@@ -682,14 +724,14 @@ pub fn set_upstream(
    let branch_merge = format!("branch.{branch}.merge");

    config.remove_multivar(&branch_remote, ".*").or_else(|e| {
-
        if ext::is_not_found_err(&e) {
+
        if e.is_not_found() {
            Ok(())
        } else {
            Err(e)
        }
    })?;
    config.remove_multivar(&branch_merge, ".*").or_else(|e| {
-
        if ext::is_not_found_err(&e) {
+
        if e.is_not_found() {
            Ok(())
        } else {
            Err(e)
@@ -701,14 +743,14 @@ pub fn set_upstream(
    Ok(())
}

-
pub fn init_default_branch(repo: &git2::Repository) -> Result<Option<String>, git2::Error> {
+
pub fn init_default_branch(repo: &raw::Repository) -> Result<Option<String>, raw::Error> {
    let config = repo.config().and_then(|mut c| c.snapshot())?;
    let default_branch = config.get_str("init.defaultbranch")?;
-
    let branch = repo.find_branch(default_branch, git2::BranchType::Local)?;
+
    let branch = repo.find_branch(default_branch, raw::BranchType::Local)?;
    Ok(branch.into_reference().shorthand().map(ToOwned::to_owned))
}

-
pub fn head_refname(repo: &git2::Repository) -> Result<Option<String>, git2::Error> {
+
pub fn head_refname(repo: &raw::Repository) -> Result<Option<String>, raw::Error> {
    let head = repo.head()?;
    match head.shorthand() {
        Some("HEAD") => Ok(None),
@@ -717,34 +759,24 @@ pub fn head_refname(repo: &git2::Repository) -> Result<Option<String>, git2::Err
    }
}

-
/// Execute a git command by spawning a child process.
-
pub fn run<P, S, K, V>(
-
    repo: P,
+
/// Execute a `git` command by spawning a child process and collect its output.
+
/// If `working` is [`Some`], the command is run as if `git` was started in
+
/// `working` instead of the current working directory, by prepending
+
/// `-C <working>` to the command line.
+
pub fn run<S>(
+
    working: Option<&std::path::Path>,
    args: impl IntoIterator<Item = S>,
-
    envs: impl IntoIterator<Item = (K, V)>,
-
) -> Result<String, io::Error>
+
) -> io::Result<std::process::Output>
where
-
    P: AsRef<Path>,
    S: AsRef<std::ffi::OsStr>,
-
    K: AsRef<std::ffi::OsStr>,
-
    V: AsRef<std::ffi::OsStr>,
{
-
    let output = Command::new("git")
-
        .current_dir(repo)
-
        .envs(envs)
-
        .args(args)
-
        .output()?;
+
    let mut cmd = Command::new("git");

-
    if output.status.success() {
-
        let out = if output.stdout.is_empty() {
-
            &output.stderr
-
        } else {
-
            &output.stdout
-
        };
-
        return Ok(String::from_utf8_lossy(out).into());
+
    if let Some(working) = working {
+
        cmd.arg("-C").arg(dunce::canonicalize(working)?);
    }

-
    Err(io::Error::other(String::from_utf8_lossy(&output.stderr)))
+
    cmd.args(args).output()
}

/// Functions that call to the `git` CLI instead of `git2`.
@@ -754,30 +786,26 @@ pub mod process {

    use crate::storage::ReadRepository;

-
    use super::{run, url, Oid};
+
    use super::{run, Oid, Verbosity};

-
    /// Perform a local fetch, i.e. `file://<storage path>`.
+
    /// Perform a local fetch, from storage using `git fetch-pack`.
    ///
    /// `oids` are the set of [`Oid`]s that are being fetched from the
    /// `storage`.
-
    pub fn fetch_local<R>(
-
        working: &Path,
+
    pub fn fetch_pack<R>(
+
        working: Option<&Path>,
        storage: &R,
        oids: impl IntoIterator<Item = Oid>,
-
    ) -> Result<(), io::Error>
+
        verbosity: Verbosity,
+
    ) -> io::Result<std::process::Output>
    where
        R: ReadRepository,
    {
-
        let mut fetch = vec![
-
            "fetch".to_string(),
-
            url::File::new(storage.path()).to_string(),
-
            // N.b. avoid writing fetch head since we're only fetching objects
-
            "--no-write-fetch-head".to_string(),
-
        ];
-
        fetch.extend(oids.into_iter().map(|oid| oid.to_string()));
-
        // N.b. `.` is used since we're fetching within the working copy
-
        run::<_, _, &str, &str>(working, fetch, [])?;
-
        Ok(())
+
        let mut args = vec!["fetch-pack".to_string()];
+
        args.extend(verbosity.clamp_one().into_flag());
+
        args.push(dunce::canonicalize(storage.path())?.display().to_string());
+
        args.extend(oids.into_iter().map(|oid| oid.to_string()));
+
        run(working, args)
    }
}

modified crates/radicle/src/git/canonical.rs
@@ -19,11 +19,12 @@ use std::fmt;
use std::marker::PhantomData;
use std::ops::ControlFlow;

-
use git_ext::ref_format::Namespaced;
+
use crate::git::fmt::Namespaced;

use crate::prelude::Did;

-
use super::{Oid, Qualified};
+
use super::fmt::Qualified;
+
use crate::git::Oid;

/// A marker for the initial state of [`Canonical`], after construction using
/// [`Canonical::new`].
@@ -510,20 +511,19 @@ mod tests {

    /// Test helper to construct a Canonical and get the quorum
    fn quorum(
-
        heads: &[git::raw::Oid],
+
        heads: &[crate::git::Oid],
        threshold: usize,
-
        repo: &git::raw::Repository,
+
        repo: &crate::git::raw::Repository,
    ) -> Result<Oid, QuorumError> {
-
        let refname =
-
            git::refs::branch(git_ext::ref_format::RefStr::try_from_str("master").unwrap());
+
        let refname = git::refs::branch(crate::git::fmt::RefStr::try_from_str("master").unwrap());

        let mut delegates = Vec::new();
        for (i, head) in heads.iter().enumerate() {
            let signer = Device::mock_from_seed([(i + 1) as u8; 32]);
            let did = Did::from(signer.public_key());
            delegates.push(did);
-
            let ns = git::Component::from(signer.public_key());
-
            repo.reference(refname.with_namespace(ns).as_str(), *head, true, "")
+
            let ns = git::fmt::Component::from(signer.public_key());
+
            repo.reference(refname.with_namespace(ns).as_str(), head.into(), true, "")
                .unwrap();
        }

@@ -557,18 +557,18 @@ mod tests {
    fn test_quorum_properties() {
        let tmp = tempfile::tempdir().unwrap();
        let (repo, c0) = fixtures::repository(tmp.path());
-
        let c0: git::Oid = c0.into();
-
        let a1 = fixtures::commit("A1", &[*c0], &repo);
-
        let a2 = fixtures::commit("A2", &[*a1], &repo);
-
        let d1 = fixtures::commit("D1", &[*c0], &repo);
-
        let c1 = fixtures::commit("C1", &[*c0], &repo);
-
        let c2 = fixtures::commit("C2", &[*c1], &repo);
-
        let b2 = fixtures::commit("B2", &[*c1], &repo);
-
        let a1 = fixtures::commit("A1", &[*c0], &repo);
-
        let m1 = fixtures::commit("M1", &[*c2, *b2], &repo);
-
        let m2 = fixtures::commit("M2", &[*a1, *b2], &repo);
+
        let c0: crate::git::Oid = c0.into();
+
        let a1 = fixtures::commit("A1", &[c0.into()], &repo);
+
        let a2 = fixtures::commit("A2", &[a1.into()], &repo);
+
        let d1 = fixtures::commit("D1", &[c0.into()], &repo);
+
        let c1 = fixtures::commit("C1", &[c0.into()], &repo);
+
        let c2 = fixtures::commit("C2", &[c1.into()], &repo);
+
        let b2 = fixtures::commit("B2", &[c1.into()], &repo);
+
        let a1 = fixtures::commit("A1", &[c0.into()], &repo);
+
        let m1 = fixtures::commit("M1", &[c2.into(), b2.into()], &repo);
+
        let m2 = fixtures::commit("M2", &[a1.into(), b2.into()], &repo);
        let mut rng = fastrand::Rng::new();
-
        let choices = [*c0, *c1, *c2, *b2, *a1, *a2, *d1, *m1, *m2];
+
        let choices = [c0, c1, c2, b2, a1, a2, d1, m1, m2];

        for _ in 0..100 {
            let count = rng.usize(1..=choices.len());
@@ -591,11 +591,10 @@ mod tests {
    fn test_quorum_different_types() {
        let tmp = tempfile::tempdir().unwrap();
        let (repo, c0) = fixtures::repository(tmp.path());
-
        let c0: git::Oid = c0.into();
-
        let t0 = fixtures::tag("v1", "", *c0, &repo);
+
        let t0 = fixtures::tag("v1", "", c0, &repo);

        assert_matches!(
-
            quorum(&[*c0, *t0], 1, &repo),
+
            quorum(&[c0.into(), t0], 1, &repo),
            Err(QuorumError::DifferentTypes { .. })
        );
    }
modified crates/radicle/src/git/canonical/effects.rs
@@ -1,7 +1,9 @@
use std::collections::{BTreeMap, BTreeSet};

use crate::git;
-
use crate::git::{Oid, Qualified};
+
use crate::git::fmt::Qualified;
+
use crate::git::raw::ErrorExt as _;
+
use crate::git::Oid;
use crate::prelude::Did;

use super::{FoundObjects, GraphAheadBehind, MergeBase, Object};
@@ -39,7 +41,7 @@ pub enum FindObjectsError {
    },
    #[error("failed to find reference {refname} due to: {source}")]
    FindReference {
-
        refname: git::Namespaced<'static>,
+
        refname: git::fmt::Namespaced<'static>,
        source: Box<dyn std::error::Error + Send + Sync + 'static>,
    },
    #[error("failed to find objects")]
@@ -59,7 +61,7 @@ impl FindObjectsError {
        }
    }

-
    pub fn find_reference<E>(refname: git::Namespaced<'static>, err: E) -> Self
+
    pub fn find_reference<E>(refname: git::fmt::Namespaced<'static>, err: E) -> Self
    where
        E: std::error::Error + Send + Sync + 'static,
    {
@@ -167,9 +169,9 @@ pub struct GraphDescendant {
// `git2` implementations of the above effects
// ===========================================

-
impl FindMergeBase for git2::Repository {
+
impl FindMergeBase for git::raw::Repository {
    fn merge_base(&self, a: Oid, b: Oid) -> Result<MergeBase, MergeBaseError> {
-
        self.merge_base(*a, *b)
+
        self.merge_base(a.into(), b.into())
            .map_err(|err| MergeBaseError {
                a,
                b,
@@ -183,13 +185,13 @@ impl FindMergeBase for git2::Repository {
    }
}

-
impl Ancestry for git2::Repository {
+
impl Ancestry for git::raw::Repository {
    fn graph_ahead_behind(
        &self,
        commit: Oid,
        upstream: Oid,
    ) -> Result<GraphAheadBehind, GraphDescendant> {
-
        self.graph_ahead_behind(*commit, *upstream)
+
        self.graph_ahead_behind(commit.into(), upstream.into())
            .map_err(|err| GraphDescendant {
                commit,
                upstream,
@@ -199,7 +201,7 @@ impl Ancestry for git2::Repository {
    }
}

-
impl FindObjects for git2::Repository {
+
impl FindObjects for git::raw::Repository {
    fn find_objects<'a, 'b, I>(
        &self,
        refname: &Qualified,
@@ -215,7 +217,7 @@ impl FindObjects for git2::Repository {
            let name = &refname.with_namespace(did.as_key().into());
            let reference = match self.find_reference(name.as_str()) {
                Ok(reference) => reference,
-
                Err(e) if git::ext::is_not_found_err(&e) => {
+
                Err(e) if e.is_not_found() => {
                    missing_refs.insert(name.to_owned());
                    continue;
                }
@@ -227,7 +229,7 @@ impl FindObjects for git2::Repository {
                log::warn!(target: "radicle", "Missing target for reference `{name}`");
                continue;
            };
-
            let object = match self.find_object(*oid, None) {
+
            let object = match self.find_object(oid.into(), None) {
                Ok(object) => Object::new(&object).ok_or_else(|| {
                    FindObjectsError::invalid_object_type(
                        *did,
@@ -235,7 +237,7 @@ impl FindObjects for git2::Repository {
                        object.kind().map(|kind| kind.to_string()),
                    )
                }),
-
                Err(err) if git::ext::is_not_found_err(&err) => {
+
                Err(err) if err.is_not_found() => {
                    missing_objects.insert(*did, oid);
                    continue;
                }
modified crates/radicle/src/git/canonical/rules.rs
@@ -22,9 +22,9 @@ use thiserror::Error;
use crate::git;
use crate::git::canonical;
use crate::git::canonical::Canonical;
+
use crate::git::fmt::refspec::QualifiedPattern;
+
use crate::git::fmt::Qualified;
use crate::git::fmt::{refname, RefString};
-
use crate::git::refspec::QualifiedPattern;
-
use crate::git::Qualified;
use crate::identity::{doc, Did};

const ASTERISK: char = '*';
@@ -155,7 +155,7 @@ impl PartialOrd for Pattern {
///     component i of `ψ`, denoted `ψ[i]`. This is the case if:
///      a. `φ[i]` does not contain an asterisk and `ψ[i]` contains an asterisk,
///         i.e. the symbol `*`, e.g. `a < * and abc < a*`.
-
///         Note that this is important to capture specificity accross
+
///         Note that this is important to capture specificity across
///         components, i.e. to conclude that `a/b/* < a/*/c`.
///      b. Both `φ[i]` and `ψ[i]` contain an asterisk.
///          A. The asterisk in `φ[i]` is further right than the asterisk in `φ[i]`,
@@ -214,7 +214,7 @@ impl Ord for Pattern {
            }
        }

-
        use git::refspec::Component;
+
        use git::fmt::refspec::Component;

        fn cmp_component(lhs: Component<'_>, rhs: Component<'_>) -> ComponentOrdering {
            let (l, r) = (lhs.as_str(), rhs.as_str());
@@ -401,7 +401,10 @@ impl ValidRule {
    /// # Errors
    ///
    /// If the `name` reference begins with `refs/rad`.
-
    pub fn default_branch(did: Did, name: &git::RefStr) -> Result<(Pattern, Self), PatternError> {
+
    pub fn default_branch(
+
        did: Did,
+
        name: &git::fmt::RefStr,
+
    ) -> Result<(Pattern, Self), PatternError> {
        let pattern = Pattern::try_from(git::refs::branch(name).to_owned())?;
        let rule = Self {
            allow: ResolvedDelegates::Delegates(doc::Delegates::from(did)),
@@ -511,7 +514,7 @@ pub struct MatchedRule<'a> {

impl MatchedRule<'_> {
    /// Return the reference name that was used for checking if it was a match.
-
    pub fn refname(&self) -> &Qualified {
+
    pub fn refname(&self) -> &Qualified<'_> {
        &self.refname
    }

@@ -732,9 +735,7 @@ pub enum ValidationError {
#[derive(Debug, Error)]
pub enum CanonicalError {
    #[error(transparent)]
-
    Git(#[from] git::raw::Error),
-
    #[error(transparent)]
-
    References(#[from] git::ext::Error),
+
    Git(#[from] crate::git::raw::Error),
}

#[cfg(test)]
@@ -746,8 +747,8 @@ mod tests {

    use crate::crypto::{test::signer::MockSigner, Signer};
    use crate::git;
-
    use crate::git::refspec::qualified_pattern;
-
    use crate::git::RefString;
+
    use crate::git::fmt::qualified_pattern;
+
    use crate::git::fmt::RefString;
    use crate::identity::doc::Doc;
    use crate::identity::Visibility;
    use crate::node::device::Device;
@@ -780,9 +781,9 @@ mod tests {
        doc.delegates().clone()
    }

-
    fn tag(name: RefString, head: git2::Oid, repo: &git2::Repository) -> git::Oid {
+
    fn tag(name: RefString, head: git::raw::Oid, repo: &git::raw::Repository) -> git::Oid {
        let commit = fixtures::commit(name.as_str(), &[head], repo);
-
        let target = repo.find_object(*commit, None).unwrap();
+
        let target = repo.find_object(commit.into(), None).unwrap();
        let tagger = repo.signature().unwrap();
        repo.tag(name.as_str(), &target, &tagger, name.as_str(), false)
            .unwrap()
@@ -894,27 +895,33 @@ mod tests {
    #[test]
    fn test_order() {
        assert!(
-
            pattern(qualified_pattern!("a/b/c/d/*")) < pattern(qualified_pattern!("*/x")),
+
            pattern(qualified_pattern!("refs/heads/a/b/c/d/*"))
+
                < pattern(qualified_pattern!("refs/heads/*/x")),
            "example 1"
        );
        assert!(
-
            pattern(qualified_pattern!("a")) < pattern(qualified_pattern!("*")),
+
            pattern(qualified_pattern!("refs/heads/a"))
+
                < pattern(qualified_pattern!("refs/heads/*")),
            "example 2.a"
        );
        assert!(
-
            pattern(qualified_pattern!("abc")) < pattern(qualified_pattern!("a*")),
+
            pattern(qualified_pattern!("refs/heads/abc"))
+
                < pattern(qualified_pattern!("refs/heads/a*")),
            "example 2.a"
        );
        assert!(
-
            pattern(qualified_pattern!("a/b/*")) < pattern(qualified_pattern!("a/*/c")),
+
            pattern(qualified_pattern!("refs/heads/a/b/*"))
+
                < pattern(qualified_pattern!("refs/heads/a/*/c")),
            "example 2.a"
        );
        assert!(
-
            pattern(qualified_pattern!("aa*")) < pattern(qualified_pattern!("a*")),
+
            pattern(qualified_pattern!("refs/heads/aa*"))
+
                < pattern(qualified_pattern!("refs/heads/a*")),
            "example 2.b.A"
        );
        assert!(
-
            pattern(qualified_pattern!("a*b")) < pattern(qualified_pattern!("a*")),
+
            pattern(qualified_pattern!("refs/heads/a*b"))
+
                < pattern(qualified_pattern!("refs/heads/a*")),
            "example 2.b.B"
        );

@@ -930,17 +937,17 @@ mod tests {

        let pattern09 = pattern(qualified_pattern!("refs/foos/*"));

-
        let pattern10 = pattern(qualified_pattern!("a"));
-
        let pattern11 = pattern(qualified_pattern!("b"));
+
        let pattern10 = pattern(qualified_pattern!("refs/heads/a"));
+
        let pattern11 = pattern(qualified_pattern!("refs/heads/b"));

-
        let pattern12 = pattern(qualified_pattern!("a/*"));
-
        let pattern13 = pattern(qualified_pattern!("b/*"));
+
        let pattern12 = pattern(qualified_pattern!("refs/heads/a/*"));
+
        let pattern13 = pattern(qualified_pattern!("refs/heads/b/*"));

-
        let pattern14 = pattern(qualified_pattern!("a/*/ab"));
-
        let pattern15 = pattern(qualified_pattern!("a/*/a"));
+
        let pattern14 = pattern(qualified_pattern!("refs/heads/a/*/ab"));
+
        let pattern15 = pattern(qualified_pattern!("refs/heads/a/*/a"));

-
        let pattern16 = pattern(qualified_pattern!("a/*/b"));
-
        let pattern17 = pattern(qualified_pattern!("a/*/a"));
+
        let pattern16 = pattern(qualified_pattern!("refs/heads/a/*/b"));
+
        let pattern17 = pattern(qualified_pattern!("refs/heads/a/*/a"));

        // Test priority for path specificity
        assert!(
@@ -1112,7 +1119,7 @@ mod tests {
            &repo,
            "heartwood".try_into().unwrap(),
            "Radicle Heartwood Protocol & Stack",
-
            git::refname!("master"),
+
            git::fmt::refname!("master"),
            Visibility::default(),
            &delegate,
            &storage,
@@ -1126,20 +1133,20 @@ mod tests {
        // Create tags and keep track of their OIDs
        //
        // follows the `refs/tags/release/candidates/*` rule
-
        let failing_tag = git::refname!("release/candidates/v1.0");
+
        let failing_tag = git::fmt::refname!("release/candidates/v1.0");
        let tags = [
            // follows the `refs/tags/*` rule
-
            git::refname!("v1.0"),
+
            git::fmt::refname!("v1.0"),
            // follows the `refs/tags/release/*` rule
-
            git::refname!("release/v1.0"),
+
            git::fmt::refname!("release/v1.0"),
            failing_tag.clone(),
            // follows the `refs/tags/*` rule
-
            git::refname!("qa/v1.0"),
+
            git::fmt::refname!("qa/v1.0"),
        ]
        .into_iter()
        .map(|name| {
            (
-
                git::lit::refs_tags(name.clone()).into(),
+
                git::fmt::lit::refs_tags(name.clone()).into(),
                tag(name, head, &repo),
            )
        })
@@ -1150,20 +1157,20 @@ mod tests {
            &rad::REMOTE_NAME,
            [
                (
-
                    &git::qualified!("refs/tags/v1.0"),
-
                    &git::qualified!("refs/tags/v1.0"),
+
                    &git::fmt::qualified!("refs/tags/v1.0"),
+
                    &git::fmt::qualified!("refs/tags/v1.0"),
                ),
                (
-
                    &git::qualified!("refs/tags/release/v1.0"),
-
                    &git::qualified!("refs/tags/release/v1.0"),
+
                    &git::fmt::qualified!("refs/tags/release/v1.0"),
+
                    &git::fmt::qualified!("refs/tags/release/v1.0"),
                ),
                (
-
                    &git::qualified!("refs/tags/release/candidates/v1.0"),
-
                    &git::qualified!("refs/tags/release/candidates/v1.0"),
+
                    &git::fmt::qualified!("refs/tags/release/candidates/v1.0"),
+
                    &git::fmt::qualified!("refs/tags/release/candidates/v1.0"),
                ),
                (
-
                    &git::qualified!("refs/tags/qa/v1.0"),
-
                    &git::qualified!("refs/tags/qa/v1.0"),
+
                    &git::fmt::qualified!("refs/tags/qa/v1.0"),
+
                    &git::fmt::qualified!("refs/tags/qa/v1.0"),
                ),
            ],
        )
@@ -1194,7 +1201,7 @@ mod tests {
        // All tags should succeed at getting their canonical commit other than the
        // candidates tag.
        let stored = storage.repository(rid).unwrap();
-
        let failing = git::Qualified::from(git::lit::refs_tags(failing_tag));
+
        let failing = git::fmt::Qualified::from(git::fmt::lit::refs_tags(failing_tag));
        for (refname, oid) in tags.into_iter() {
            let canonical = rules
                .canonical(refname.clone(), &stored)
added crates/radicle/src/git/raw.rs
@@ -0,0 +1,53 @@
+
//! This module re-exports selected items from the [`git2`] crate and provides
+
//! an extension trait for its [`git2::Error`] type to more conveniently handle
+
//! errors associated with the code [`git2::ErrorCode::NotFound`].
+
//!
+
// Re-exports created by manually scanning the `heartwood` workspace on 2025-10-04.
+

+
// Re-exports that are only used within this crate.
+
pub(crate) use git2::{
+
    message_trailers_strs, AutotagOption, Blob, FetchOptions, FetchPrune, Object, Revwalk, Sort,
+
};
+

+
#[cfg(unix)]
+
pub(crate) use git2::Config;
+

+
// Re-exports that are only used within this crate for testing.
+
#[cfg(any(test, feature = "test"))]
+
pub(crate) use git2::RemoteCallbacks;
+

+
// Re-exports that are used by other crates in the workspace, including this crate.
+
pub use git2::{
+
    Branch, BranchType, Commit, Direction, Error, ErrorClass, ErrorCode, FileMode, ObjectType, Oid,
+
    Reference, Remote, Repository, RepositoryInitOptions, RepositoryOpenFlags, Signature, Time,
+
    Tree,
+
};
+

+
// Re-exports that are used by other crates in the workspace, but *not* this crate.
+
pub use git2::{
+
    AnnotatedCommit, Diff, DiffFindOptions, DiffOptions, DiffStats, MergeAnalysis, MergeOptions,
+
};
+

+
// Re-exports for `radicle-cli`.
+
pub mod build {
+
    pub use git2::build::CheckoutBuilder;
+
}
+

+
pub(crate) mod transport {
+
    pub use git2::transport::{
+
        register, Service, SmartSubtransport, SmartSubtransportStream, Transport,
+
    };
+
}
+

+
/// An extension trait for [`git2::Error`] to more conveniently handle
+
/// errors with the code [`git2::ErrorCode::NotFound`].
+
pub trait ErrorExt {
+
    /// Returns `true` if the error associated with this error is [`git2::ErrorCode::NotFound`].
+
    fn is_not_found(&self) -> bool;
+
}
+

+
impl ErrorExt for git2::Error {
+
    fn is_not_found(&self) -> bool {
+
        self.code() == git2::ErrorCode::NotFound
+
    }
+
}
modified crates/radicle/src/identity/doc.rs
@@ -1,7 +1,5 @@
pub mod update;

-
mod id;
-

use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::num::{NonZeroU32, NonZeroUsize};
@@ -10,9 +8,9 @@ use std::path::Path;
use std::str::FromStr;
use std::sync::LazyLock;

+
use crate::git::Oid;
use nonempty::NonEmpty;
use radicle_cob::type_name::{TypeName, TypeNameParse};
-
use radicle_git_ext::Oid;
use serde::{de, Deserialize, Serialize};
use thiserror::Error;

@@ -22,13 +20,14 @@ use crate::crypto;
use crate::crypto::Signature;
use crate::git;
use crate::git::canonical::rules;
+
use crate::git::raw::ErrorExt as _;
use crate::identity::{project::Project, Did};
use crate::node::device::Device;
use crate::storage;
use crate::storage::{ReadRepository, RepositoryError};

pub use crypto::PublicKey;
-
pub use id::*;
+
pub use radicle_core::repo::*;

use super::crefs::{self, RawCanonicalRefs};
use super::CanonicalRefs;
@@ -41,7 +40,7 @@ pub const MAX_STRING_LENGTH: usize = 255;
pub const MAX_DELEGATES: usize = 255;
/// The current, most recent version of the identity document.
// SAFETY: identity version should never be 0, so we can use `unsafe` here
-
pub const IDENTITY_VERSION: Version = Version(unsafe { NonZeroU32::new_unchecked(1) });
+
pub const IDENTITY_VERSION: Version = Version(NonZeroU32::new(1).unwrap());

#[derive(Error, Debug)]
pub enum DocError {
@@ -52,9 +51,7 @@ pub enum DocError {
    #[error(transparent)]
    Threshold(#[from] ThresholdError),
    #[error("git: {0}")]
-
    GitExt(#[from] git::Error),
-
    #[error("git: {0}")]
-
    Git(#[from] git2::Error),
+
    Git(#[from] git::raw::Error),
    #[error("missing identity document")]
    Missing,
}
@@ -71,9 +68,7 @@ impl DocError {
    /// Whether this error is caused by the document not being found.
    pub fn is_not_found(&self) -> bool {
        match self {
-
            Self::GitExt(git::Error::NotFound(_)) => true,
-
            Self::GitExt(git::Error::Git(e)) if git::is_not_found_err(e) => true,
-
            Self::Git(err) if git::is_not_found_err(err) => true,
+
            Self::Git(e) => e.is_not_found(),
            _ => false,
        }
    }
@@ -107,7 +102,7 @@ impl Version {
    pub fn new(n: u32) -> Result<Version, VersionError> {
        match NonZeroU32::new(n) {
            None => Err(VersionError::ZeroVersion),
-
            Some(n) if n > IDENTITY_VERSION.into() => Err(VersionError::UnkownVersion(n)),
+
            Some(n) if n > IDENTITY_VERSION.into() => Err(VersionError::UnknownVersion(n)),
            Some(n) => Ok(Version(n)),
        }
    }
@@ -149,7 +144,7 @@ pub enum VersionError {
    #[error("the version 0 is not supported")]
    ZeroVersion,
    #[error("unknown identity document version {0}, only version {IDENTITY_VERSION} is supported")]
-
    UnkownVersion(NonZeroU32),
+
    UnknownVersion(NonZeroU32),
}

impl VersionError {
@@ -158,14 +153,14 @@ impl VersionError {
    /// This will give a user more information on how to upgrade to a newer
    /// version of an identity document, if there is one.
    pub fn verbose(&self) -> String {
-
        const UNKOWN_VERSION_ERROR: &str = r#"
+
        const UNKNOWN_VERSION_ERROR: &str = r#"
Perhaps a new version of the identity document is released which is not supported by the current client.
See https://radicle.xyz for the latest versions of Radicle.
The CLI command `rad id migrate` will help to migrate to an up-to-date versions."#;

        match self {
            err @ Self::ZeroVersion => err.to_string(),
-
            err @ Self::UnkownVersion(_) => format!("{err}{UNKOWN_VERSION_ERROR}"),
+
            err @ Self::UnknownVersion(_) => format!("{err}{UNKNOWN_VERSION_ERROR}"),
        }
    }
}
@@ -687,7 +682,7 @@ impl Doc {
    }

    /// Construct a [`Doc`] contained in the provided Git blob.
-
    pub fn from_blob(blob: &git2::Blob) -> Result<Self, DocError> {
+
    pub fn from_blob(blob: &git::raw::Blob) -> Result<Self, DocError> {
        RawDoc::from_json(blob.content())?.verified()
    }

@@ -810,7 +805,7 @@ impl Doc {
        if !self.is_delegate(&key.into()) {
            return Err(*key);
        }
-
        if key.verify(blob.as_bytes(), signature).is_err() {
+
        if key.verify(AsRef::<[u8]>::as_ref(&blob), signature).is_err() {
            return Err(*key);
        }
        Ok(())
@@ -830,7 +825,7 @@ impl Doc {
    pub(crate) fn blob_at<R: ReadRepository>(
        commit: Oid,
        repo: &R,
-
    ) -> Result<git2::Blob, DocError> {
+
    ) -> Result<git::raw::Blob<'_>, DocError> {
        let path = Path::new("embeds").join(*PATH);
        repo.blob_at(commit, path.as_path()).map_err(DocError::from)
    }
@@ -843,7 +838,7 @@ impl Doc {
            serde_json::Serializer::with_formatter(&mut buf, CanonicalFormatter::new());

        self.serialize(&mut serializer)?;
-
        let oid = git2::Oid::hash_object(git2::ObjectType::Blob, &buf)?;
+
        let oid = git::raw::Oid::hash_object(git::raw::ObjectType::Blob, &buf)?;

        Ok((oid.into(), buf))
    }
@@ -855,7 +850,7 @@ impl Doc {
        G: crypto::signature::Signer<crypto::Signature>,
    {
        let (oid, bytes) = self.encode()?;
-
        let sig = signer.sign(oid.as_bytes());
+
        let sig = signer.sign(oid.as_ref());

        Ok((oid, bytes, sig))
    }
@@ -1041,7 +1036,7 @@ mod test {
            serde_json::from_str::<Version>(&v)
                .expect_err("should fail to deserialize")
                .to_string(),
-
            VersionError::UnkownVersion(NonZeroU32::MAX).to_string(),
+
            VersionError::UnknownVersion(NonZeroU32::MAX).to_string(),
        )
    }

@@ -1132,7 +1127,7 @@ mod test {
            &repo,
            "heartwood".try_into().unwrap(),
            "Radicle Heartwood Protocol & Stack",
-
            git::refname!("master"),
+
            git::fmt::refname!("master"),
            Visibility::default(),
            &delegate,
            &storage,
@@ -1157,10 +1152,13 @@ mod test {
        let remote = arbitrary::gen::<RemoteId>(1);
        let proj = arbitrary::gen::<RepoId>(1);
        let repo = storage.create(proj).unwrap();
-
        let oid = git2::Oid::from_str("2d52a53ce5e4f141148a5f770cfd3ead2d6a45b8").unwrap();
+
        let oid = git::raw::Oid::from_str("2d52a53ce5e4f141148a5f770cfd3ead2d6a45b8").unwrap();

        let err = repo.identity_head_of(&remote).unwrap_err();
-
        matches!(err, git::ext::Error::NotFound(_));
+
        {
+
            use crate::git::raw::ErrorExt as _;
+
            assert!(err.is_not_found());
+
        }

        let err = Doc::load_at(oid.into(), &repo).unwrap_err();
        assert!(err.is_not_found());
@@ -1179,7 +1177,7 @@ mod test {
            &working,
            "heartwood".try_into().unwrap(),
            "Radicle Heartwood Protocol & Stack",
-
            git::refname!("master"),
+
            git::fmt::refname!("master"),
            Visibility::default(),
            &delegate,
            &storage,
deleted crates/radicle/src/identity/doc/id.rs
@@ -1,158 +0,0 @@
-
use std::ops::Deref;
-
use std::{ffi::OsString, fmt, str::FromStr};
-

-
use git_ext::ref_format::{Component, RefString};
-
use thiserror::Error;
-

-
use crate::git;
-
use crate::serde_ext;
-

-
/// Radicle identifier prefix.
-
pub const RAD_PREFIX: &str = "rad:";
-

-
#[derive(Error, Debug)]
-
pub enum IdError {
-
    #[error("invalid git object id: {0}")]
-
    InvalidOid(#[from] git2::Error),
-
    #[error(transparent)]
-
    Multibase(#[from] multibase::Error),
-
}
-

-
/// A repository identifier.
-
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
-
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
-
pub struct RepoId(
-
    #[cfg_attr(feature = "schemars", schemars(
-
        with = "String",
-
        description = "A repository identifier. Starts with \"rad:\", followed by a multibase Base58 encoded Git object identifier.",
-
        regex(pattern = r"rad:z[1-9a-km-zA-HJ-NP-Z]+"),
-
        length(min = 5),
-
        example = &"rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
-
    ))]
-
    git::Oid,
-
);
-

-
impl fmt::Display for RepoId {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        f.write_str(self.urn().as_str())
-
    }
-
}
-

-
impl fmt::Debug for RepoId {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        write!(f, "RepoId({self})")
-
    }
-
}
-

-
impl RepoId {
-
    /// Format the identifier as a human-readable URN.
-
    ///
-
    /// Eg. `rad:z3XncAdkZjeK9mQS5Sdc4qhw98BUX`.
-
    ///
-
    pub fn urn(&self) -> String {
-
        format!("{RAD_PREFIX}{}", self.canonical())
-
    }
-

-
    /// Parse an identifier from the human-readable URN format.
-
    /// Accepts strings without the radicle prefix as well,
-
    /// for convenience.
-
    pub fn from_urn(s: &str) -> Result<Self, IdError> {
-
        let s = s.strip_prefix(RAD_PREFIX).unwrap_or(s);
-
        let id = Self::from_canonical(s)?;
-

-
        Ok(id)
-
    }
-

-
    /// Format the identifier as a multibase string.
-
    ///
-
    /// Eg. `z3XncAdkZjeK9mQS5Sdc4qhw98BUX`.
-
    ///
-
    pub fn canonical(&self) -> String {
-
        multibase::encode(multibase::Base::Base58Btc, self.0.as_bytes())
-
    }
-

-
    pub fn from_canonical(input: &str) -> Result<Self, IdError> {
-
        let (_, bytes) = multibase::decode(input)?;
-
        let array: git::Oid = bytes.as_slice().try_into()?;
-

-
        Ok(Self(array))
-
    }
-
}
-

-
impl FromStr for RepoId {
-
    type Err = IdError;
-

-
    fn from_str(s: &str) -> Result<Self, Self::Err> {
-
        Self::from_urn(s)
-
    }
-
}
-

-
impl TryFrom<OsString> for RepoId {
-
    type Error = IdError;
-

-
    fn try_from(value: OsString) -> Result<Self, Self::Error> {
-
        let string = value.to_string_lossy();
-
        Self::from_canonical(&string)
-
    }
-
}
-

-
impl From<git::Oid> for RepoId {
-
    fn from(oid: git::Oid) -> Self {
-
        Self(oid)
-
    }
-
}
-

-
impl From<git2::Oid> for RepoId {
-
    fn from(oid: git2::Oid) -> Self {
-
        Self(oid.into())
-
    }
-
}
-

-
impl Deref for RepoId {
-
    type Target = git::Oid;
-

-
    fn deref(&self) -> &Self::Target {
-
        &self.0
-
    }
-
}
-

-
impl serde::Serialize for RepoId {
-
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-
    where
-
        S: serde::Serializer,
-
    {
-
        serde_ext::string::serialize(&self.urn(), serializer)
-
    }
-
}
-

-
impl<'de> serde::Deserialize<'de> for RepoId {
-
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-
    where
-
        D: serde::Deserializer<'de>,
-
    {
-
        serde_ext::string::deserialize(deserializer)
-
    }
-
}
-

-
impl From<&RepoId> for Component<'_> {
-
    fn from(id: &RepoId) -> Self {
-
        let refstr =
-
            RefString::try_from(id.0.to_string()).expect("repository id's are valid ref strings");
-
        Component::from_refstr(refstr).expect("repository id's are valid refname components")
-
    }
-
}
-

-
#[cfg(test)]
-
#[allow(clippy::unwrap_used)]
-
mod test {
-
    use super::*;
-
    use qcheck_macros::quickcheck;
-

-
    #[quickcheck]
-
    fn prop_from_str(input: RepoId) {
-
        let encoded = input.to_string();
-
        let decoded = RepoId::from_str(&encoded).unwrap();
-

-
        assert_eq!(input, decoded);
-
    }
-
}
modified crates/radicle/src/identity/doc/update.rs
@@ -139,40 +139,52 @@ where
    }
}

+
/// [`Payload`]: super::Payload
+
/// A change (update or insertion) to particular `key` within a [`Payload`]
+
/// in a document.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct PayloadUpsert {
+
    /// [`Payload`]: super::Payload
+
    /// The identifier for the document [`Payload`].
+
    pub id: PayloadId,
+
    /// [`Payload`]: super::Payload
+
    /// The key within the [`Payload`] that is being updated.
+
    pub key: String,
+
    /// [`Payload`]: super::Payload
+
    /// The value to update within the [`Payload`].
+
    pub value: json::Value,
+
}
+

// TODO(finto): I think this API would likely be much nicer if we use [JSON Patch][patch] and [JSON Merge Patch][merge]
//
// [patch]: https://datatracker.ietf.org/doc/html/rfc6902
// [merge]: https://datatracker.ietf.org/doc/html/rfc7396
-
/// Change the payload of the document, using the set of triples:
-
///
-
///   - [`PayloadId`]: the identifier for the document [`Payload`]
-
///   - [`String`]: the key within the [`Payload`] that is being updated
-
///   - [`json::Value`]: the value to update the [`Payload`]
+
/// [`Payload`]: super::Payload
+
/// Change (update or insert) a key in a [`Payload`] of the document,
+
/// using the provided `updates`.
///
/// # Errors
///
/// This fails if one of the [`PayloadId`]s does not point to a JSON object as
/// its value.
-
///
-
/// [`Payload`]: super::Payload
pub fn payload(
    mut raw: RawDoc,
-
    payload: Vec<(PayloadId, String, json::Value)>,
+
    upserts: impl IntoIterator<Item = PayloadUpsert>,
) -> Result<RawDoc, error::PayloadError> {
-
    for (id, key, val) in payload {
+
    for PayloadUpsert { id, key, value } in upserts {
        if let Some(ref mut payload) = raw.payload.get_mut(&id) {
            if let Some(obj) = payload.as_object_mut() {
-
                if val.is_null() {
+
                if value.is_null() {
                    obj.remove(&key);
                } else {
-
                    obj.insert(key, val);
+
                    obj.insert(key, value);
                }
            } else {
                return Err(error::PayloadError::ExpectedObject { id });
            }
        } else {
            raw.payload
-
                .insert(id, serde_json::json!({ key: val }).into());
+
                .insert(id, serde_json::json!({ key: value }).into());
        }
    }
    Ok(raw)
@@ -208,7 +220,8 @@ pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> {
        .map(|rcrefs| rcrefs.and_then(|c| project.map(|p| (c, p))))
    {
        Ok(Some((crefs, project))) => {
-
            let default = git::Qualified::from(git::lit::refs_heads(project.default_branch()));
+
            let default =
+
                git::fmt::Qualified::from(git::fmt::lit::refs_heads(project.default_branch()));
            let matches = crefs
                .raw_rules()
                .matches(&default)
@@ -276,21 +289,23 @@ mod test {
        test::arbitrary,
    };

+
    use super::PayloadUpsert;
+

    #[test]
    fn test_can_update_crefs() {
        let raw = arbitrary::gen::<RawDoc>(1);
        let raw = super::payload(
            raw,
-
            vec![(
-
                PayloadId::canonical_refs(),
-
                "rules".to_string(),
-
                json!({
+
            [PayloadUpsert {
+
                id: PayloadId::canonical_refs(),
+
                key: "rules".to_string(),
+
                value: json!({
                    "refs/tags/*": {
                        "threshold": 1,
                        "allow": "delegates"
                    }
                }),
-
            )],
+
            }],
        )
        .unwrap();
        let verified = super::verify(raw);
@@ -300,15 +315,15 @@ mod test {
    #[test]
    fn test_cannot_include_default_branch_rule() {
        let raw = arbitrary::gen::<RawDoc>(1);
-
        let branch = git::Qualified::from(git::lit::refs_heads(
+
        let branch = git::fmt::Qualified::from(git::fmt::lit::refs_heads(
            raw.project().unwrap().default_branch(),
        ));
        let raw = super::payload(
            raw,
-
            vec![(
-
                PayloadId::canonical_refs(),
-
                "rules".to_string(),
-
                json!({
+
            [PayloadUpsert {
+
                id: PayloadId::canonical_refs(),
+
                key: "rules".to_string(),
+
                value: json!({
                    "refs/tags/*": {
                        "threshold": 1,
                        "allow": "delegates"
@@ -318,7 +333,7 @@ mod test {
                        "allow": "delegates",
                    }
                }),
-
            )],
+
            }],
        )
        .unwrap();
        assert!(
@@ -333,21 +348,21 @@ mod test {
    #[test]
    fn test_default_branch_rule_exists_after_verification() {
        let raw = arbitrary::gen::<RawDoc>(1);
-
        let branch = git::Qualified::from(git::lit::refs_heads(
+
        let branch = git::fmt::Qualified::from(git::fmt::lit::refs_heads(
            raw.project().unwrap().default_branch(),
        ));
        let raw = super::payload(
            raw,
-
            vec![(
-
                PayloadId::canonical_refs(),
-
                "rules".to_string(),
-
                json!({
+
            [PayloadUpsert {
+
                id: PayloadId::canonical_refs(),
+
                key: "rules".to_string(),
+
                value: json!({
                    "refs/tags/*": {
                        "threshold": 1,
                        "allow": "delegates"
                    }
                }),
-
            )],
+
            }],
        )
        .unwrap();
        let verified = super::verify(raw).unwrap();
modified crates/radicle/src/identity/doc/update/error.rs
@@ -1,7 +1,7 @@
use thiserror::Error;

use crate::git;
-
use crate::git::RefString;
+
use crate::git::fmt::RefString;
use crate::identity::{doc::PayloadId, Did, DocError};

#[derive(Debug, Error)]
@@ -31,7 +31,7 @@ pub enum DocVerification {
    #[error("incompatible payloads: The rule(s) xyz.radicle.crefs.rules.{matches:?} matches the value of xyz.radicle.project.defaultBranch ('{default}'). Possible resolutions: Change the name of the default branch or remove the rule(s).")]
    DisallowDefault {
        matches: Vec<String>,
-
        default: git::Qualified<'static>,
+
        default: git::fmt::Qualified<'static>,
    },
}

modified crates/radicle/src/identity/project.rs
@@ -7,9 +7,9 @@ use serde::{
use thiserror::Error;

use crate::crypto;
+
use crate::git::BranchName;
use crate::identity::doc;
use crate::identity::doc::Payload;
-
use crate::storage::BranchName;

pub use crypto::PublicKey;

@@ -172,7 +172,7 @@ impl<'de> Deserialize<'de> for Project {
                })
            }
        }
-
        const FIELDS: &[&str] = &["name", "descrption", "defaultBranch"];
+
        const FIELDS: &[&str] = &["name", "description", "defaultBranch"];
        deserializer.deserialize_struct("Project", FIELDS, ProjectVisitor)
    }
}
modified crates/radicle/src/lib.rs
@@ -7,7 +7,8 @@ pub extern crate radicle_crypto as crypto;

#[macro_use]
extern crate amplify;
-
extern crate radicle_git_ext as git_ext;
+

+
extern crate radicle_localtime as localtime;

mod canonical;

@@ -42,10 +43,9 @@ pub mod prelude {
    use super::*;

    pub use crypto::{PublicKey, Verified};
+
    pub use git::BranchName;
    pub use identity::{project::Project, Did, Doc, RawDoc, RepoId};
    pub use node::{Alias, NodeId, Timestamp};
    pub use profile::Profile;
-
    pub use storage::{
-
        BranchName, ReadRepository, ReadStorage, SignRepository, WriteRepository, WriteStorage,
-
    };
+
    pub use storage::{ReadRepository, ReadStorage, SignRepository, WriteRepository, WriteStorage};
}
modified crates/radicle/src/node.rs
@@ -3,6 +3,7 @@
mod features;

pub mod address;
+
pub mod command;
pub mod config;
pub mod db;
pub mod device;
@@ -16,20 +17,23 @@ pub mod sync;
pub mod timestamp;

use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
+
use std::fmt::Display;
use std::io::{BufRead, BufReader};
use std::marker::PhantomData;
+
use std::net::IpAddr;
+
use std::net::Ipv6Addr;
use std::ops::{ControlFlow, Deref};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{fmt, io, net, thread, time};

#[cfg(unix)]
-
use std::os::unix::net::UnixStream as Stream;
+
use std::os::unix::net::UnixStream;
#[cfg(windows)]
-
use winpipe::WinStream as Stream;
+
use uds_windows::UnixStream;

use amplify::WrapperMut;
-
use cyphernet::addr::NetAddr;
+
use cyphernet::addr::{AddrParseError, NetAddr};
use localtime::{LocalDuration, LocalTime};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@@ -43,11 +47,13 @@ use crate::storage::refs::RefsAt;
use crate::storage::RefUpdate;

pub use address::KnownAddress;
+
pub use command::{Command, CommandResult, ConnectOptions, Success, DEFAULT_TIMEOUT};
pub use config::Config;
-
pub use cyphernet::addr::{HostName, PeerAddr};
+
pub use cyphernet::addr::{HostName, PeerAddr, PeerAddrParseError};
pub use db::Database;
pub use events::{Event, Events};
pub use features::Features;
+
pub use radicle_core::NodeId;
pub use seed::SyncedAt;
pub use timestamp::Timestamp;

@@ -55,8 +61,6 @@ pub use timestamp::Timestamp;
pub const PROTOCOL_VERSION: u8 = 1;
/// Default radicle protocol port.
pub const DEFAULT_PORT: u16 = 8776;
-
/// Default timeout when waiting for the node to respond with data.
-
pub const DEFAULT_TIMEOUT: time::Duration = time::Duration::from_secs(30);
/// Default timeout when waiting for an event to be received on the
/// [`Handle::subscribe`] channel.
pub const DEFAULT_SUBSCRIBE_TIMEOUT: time::Duration = time::Duration::from_secs(5);
@@ -102,17 +106,10 @@ pub enum State {
    #[serde(rename_all = "camelCase")]
    Connected {
        /// Connected since this time.
-
        #[serde(with = "crate::serde_ext::localtime::time")]
-
        #[cfg_attr(
-
            feature = "schemars",
-
            schemars(with = "crate::schemars_ext::localtime::LocalDurationInSeconds")
-
        )]
        since: LocalTime,
        /// Ping state.
        #[serde(skip)]
        ping: PingState,
-
        /// Ongoing fetches.
-
        fetching: HashSet<RepoId>,
        /// Measured latencies for this peer.
        #[serde(skip)]
        latencies: VecDeque<LocalDuration>,
@@ -124,18 +121,8 @@ pub enum State {
    #[serde(rename_all = "camelCase")]
    Disconnected {
        /// Since when has this peer been disconnected.
-
        #[serde(with = "crate::serde_ext::localtime::time")]
-
        #[cfg_attr(
-
            feature = "schemars",
-
            schemars(with = "crate::schemars_ext::localtime::LocalDurationInSeconds")
-
        )]
        since: LocalTime,
        /// When to retry the connection.
-
        #[serde(with = "crate::serde_ext::localtime::time")]
-
        #[cfg_attr(
-
            feature = "schemars",
-
            schemars(with = "crate::schemars_ext::localtime::LocalDurationInSeconds")
-
        )]
        retry_at: LocalTime,
    },
}
@@ -436,100 +423,9 @@ impl TryFrom<&sqlite::Value> for Alias {
    }
}

-
/// Options passed to the "connect" node command.
-
#[derive(Debug, Clone, Serialize, Deserialize)]
-
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
-
pub struct ConnectOptions {
-
    /// Establish a persistent connection.
-
    pub persistent: bool,
-
    /// How long to wait for the connection to be established.
-
    pub timeout: time::Duration,
-
}
-

-
impl Default for ConnectOptions {
-
    fn default() -> Self {
-
        Self {
-
            persistent: false,
-
            timeout: DEFAULT_TIMEOUT,
-
        }
-
    }
-
}
-

-
/// Result of a command, on the node control socket.
-
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-
#[serde(untagged)]
-
pub enum CommandResult<T> {
-
    /// Response on node socket indicating that a command was carried out successfully.
-
    Okay(T),
-
    /// Response on node socket indicating that an error occured.
-
    Error {
-
        /// The reason for the error.
-
        #[serde(rename = "error")]
-
        reason: String,
-
    },
-
}
-

-
impl<T, E> From<Result<T, E>> for CommandResult<T>
-
where
-
    E: std::error::Error,
-
{
-
    fn from(result: Result<T, E>) -> Self {
-
        match result {
-
            Ok(t) => Self::Okay(t),
-
            Err(e) => Self::Error {
-
                reason: e.to_string(),
-
            },
-
        }
-
    }
-
}
-

-
impl From<Event> for CommandResult<Event> {
-
    fn from(event: Event) -> Self {
-
        Self::Okay(event)
-
    }
-
}
-

-
/// A success response.
-
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
-
pub struct Success {
-
    /// Whether something was updated.
-
    #[serde(default, skip_serializing_if = "crate::serde_ext::is_default")]
-
    updated: bool,
-
}
-

-
impl CommandResult<Success> {
-
    /// Create an "updated" response.
-
    pub fn updated(updated: bool) -> Self {
-
        Self::Okay(Success { updated })
-
    }
-

-
    /// Create an "ok" response.
-
    pub fn ok() -> Self {
-
        Self::Okay(Success { updated: false })
-
    }
-
}
-

-
impl CommandResult<()> {
-
    /// Create an error result.
-
    pub fn error(err: impl std::error::Error) -> Self {
-
        Self::Error {
-
            reason: err.to_string(),
-
        }
-
    }
-
}
-

-
impl<T: Serialize> CommandResult<T> {
-
    /// Write this command result to a stream, including a terminating LF character.
-
    pub fn to_writer(&self, mut w: impl io::Write) -> io::Result<()> {
-
        json::to_writer(&mut w, self).map_err(|_| io::ErrorKind::InvalidInput)?;
-
        w.write_all(b"\n")
-
    }
-
}
-

/// Peer public protocol address.
#[derive(Clone, Eq, PartialEq, Debug, Hash, From, Wrapper, WrapperMut, Serialize, Deserialize)]
-
#[wrapper(Deref, Display, FromStr)]
+
#[wrapper(Deref)]
#[wrapper_mut(DerefMut)]
#[cfg_attr(
    feature = "schemars",
@@ -556,8 +452,15 @@ pub struct Address(
impl Address {
    /// Check whether this address is from the local network.
    pub fn is_local(&self) -> bool {
-
        match self.0.host {
-
            HostName::Ip(ip) => address::is_local(&ip),
+
        match &self.0.host {
+
            HostName::Ip(ip) => address::is_local(ip),
+
            HostName::Dns(name) => {
+
                let name = name.strip_suffix(".").unwrap_or(name);
+

+
                // RFC 2606, Section 2
+
                // <https://datatracker.ietf.org/doc/html/rfc2606#section-2>
+
                name.ends_with(".localhost") || name == "localhost"
+
            }
            _ => false,
        }
    }
@@ -566,6 +469,7 @@ impl Address {
    pub fn is_routable(&self) -> bool {
        match self.0.host {
            HostName::Ip(ip) => address::is_routable(&ip),
+
            HostName::Dns(_) => !self.is_local(),
            _ => true,
        }
    }
@@ -575,10 +479,76 @@ impl Address {
        &self.0.host
    }

+
    /// Returns `true` if the [`HostName`] is a Tor onion address.
+
    pub fn is_onion(&self) -> bool {
+
        match self.0.host {
+
            HostName::Tor(_) => true,
+
            _ => false,
+
        }
+
    }
+

    /// Return the port number of the [`Address`].
    pub fn port(&self) -> u16 {
        self.0.port
    }
+

+
    pub fn display_compact(&self) -> impl Display {
+
        let host = match self.host() {
+
            HostName::Ip(IpAddr::V4(ip)) => ip.to_string(),
+
            HostName::Ip(IpAddr::V6(ip)) => format!("[{ip}]"),
+
            HostName::Dns(dns) => dns.clone(),
+
            HostName::Tor(onion) => {
+
                let onion = onion.to_string();
+
                let start = onion.chars().take(8).collect::<String>();
+
                let end = onion
+
                    .chars()
+
                    .skip(onion.len() - 8 - ".onion".len())
+
                    .collect::<String>();
+
                format!("{start}…{end}")
+
            }
+
            _ => unreachable!(),
+
        };
+

+
        let port = self.port().to_string();
+

+
        format!("{host}:{port}")
+
    }
+
}
+

+
impl Display for Address {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        match self.host() {
+
            HostName::Ip(IpAddr::V6(ip)) => {
+
                write!(f, "[{ip}]:{}", self.port())
+
            }
+
            _ => self.0.fmt(f),
+
        }
+
    }
+
}
+

+
impl FromStr for Address {
+
    type Err = AddrParseError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        let (host, port) = s.rsplit_once(':').ok_or(AddrParseError::PortAbsent)?;
+

+
        let host = if let Some(host) = host
+
            .strip_prefix('[')
+
            .and_then(|host| host.strip_suffix(']'))
+
        {
+
            HostName::Ip(host.parse::<Ipv6Addr>()?.into())
+
        } else {
+
            // Require IPv6 addresses to always be enclosed in `[` and `]`.
+
            host.parse().and_then(|host| match host {
+
                HostName::Ip(IpAddr::V6(_)) => Err(AddrParseError::UnknownAddressFormat),
+
                host => Ok(host),
+
            })?
+
        };
+

+
        let port = port.parse().map_err(|_| AddrParseError::InvalidPort)?;
+

+
        Ok(Self(NetAddr::new(host, port)))
+
    }
}

impl cyphernet::addr::Host for Address {
@@ -608,126 +578,6 @@ impl From<Address> for HostName {
    }
}

-
/// Command name.
-
#[derive(Debug, Clone, Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase", tag = "command")]
-
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
-
pub enum Command {
-
    /// Announce repository references for given repository to peers.
-
    #[serde(rename_all = "camelCase")]
-
    AnnounceRefs { rid: RepoId },
-

-
    /// Announce local repositories to peers.
-
    #[serde(rename_all = "camelCase")]
-
    AnnounceInventory,
-

-
    /// Update node's inventory.
-
    AddInventory { rid: RepoId },
-

-
    /// Get the current node condiguration.
-
    Config,
-

-
    /// Get the node's listen addresses.
-
    ListenAddrs,
-

-
    /// Connect to node with the given address.
-
    #[serde(rename_all = "camelCase")]
-
    Connect {
-
        addr: config::ConnectAddress,
-
        opts: ConnectOptions,
-
    },
-

-
    /// Disconnect from a node.
-
    #[serde(rename_all = "camelCase")]
-
    Disconnect {
-
        #[cfg_attr(
-
            feature = "schemars",
-
            schemars(with = "crate::schemars_ext::crypto::PublicKey")
-
        )]
-
        nid: NodeId,
-
    },
-

-
    /// Lookup seeds for the given repository in the routing table.
-
    #[serde(rename_all = "camelCase")]
-
    Seeds { rid: RepoId },
-

-
    /// Get the current peer sessions.
-
    Sessions,
-

-
    /// Get a specific peer session.
-
    Session {
-
        #[cfg_attr(
-
            feature = "schemars",
-
            schemars(with = "crate::schemars_ext::crypto::PublicKey")
-
        )]
-
        nid: NodeId,
-
    },
-

-
    /// Fetch the given repository from the network.
-
    #[serde(rename_all = "camelCase")]
-
    Fetch {
-
        rid: RepoId,
-
        #[cfg_attr(
-
            feature = "schemars",
-
            schemars(with = "crate::schemars_ext::crypto::PublicKey")
-
        )]
-
        nid: NodeId,
-
        timeout: time::Duration,
-
    },
-

-
    /// Seed the given repository.
-
    #[serde(rename_all = "camelCase")]
-
    Seed { rid: RepoId, scope: policy::Scope },
-

-
    /// Unseed the given repository.
-
    #[serde(rename_all = "camelCase")]
-
    Unseed { rid: RepoId },
-

-
    /// Follow the given node.
-
    #[serde(rename_all = "camelCase")]
-
    Follow {
-
        #[cfg_attr(
-
            feature = "schemars",
-
            schemars(with = "crate::schemars_ext::crypto::PublicKey")
-
        )]
-
        nid: NodeId,
-
        alias: Option<Alias>,
-
    },
-

-
    /// Unfollow the given node.
-
    #[serde(rename_all = "camelCase")]
-
    Unfollow {
-
        #[cfg_attr(
-
            feature = "schemars",
-
            schemars(with = "crate::schemars_ext::crypto::PublicKey")
-
        )]
-
        nid: NodeId,
-
    },
-

-
    /// Get the node's status.
-
    Status,
-

-
    /// Get node debug information.
-
    Debug,
-

-
    /// Get the node's NID.
-
    NodeId,
-

-
    /// Shutdown the node.
-
    Shutdown,
-

-
    /// Subscribe to events.
-
    Subscribe,
-
}
-

-
impl Command {
-
    /// Write this command to a stream, including a terminating LF character.
-
    pub fn to_writer(&self, mut w: impl io::Write) -> io::Result<()> {
-
        json::to_writer(&mut w, self).map_err(|_| io::ErrorKind::InvalidInput)?;
-
        w.write_all(b"\n")
-
    }
-
}
-

/// Connection link direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -751,14 +601,19 @@ impl Link {
    }
}

+
impl std::fmt::Display for Link {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            Link::Outbound => write!(f, "outbound"),
+
            Link::Inbound => write!(f, "inbound"),
+
        }
+
    }
+
}
+

/// An established network connection with a peer.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Session {
-
    #[cfg_attr(
-
        feature = "schemars",
-
        schemars(with = "crate::schemars_ext::crypto::PublicKey")
-
    )]
    pub nid: NodeId,
    pub link: Link,
    pub addr: Address,
@@ -778,10 +633,6 @@ impl Session {
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Seed {
    /// The Node ID.
-
    #[cfg_attr(
-
        feature = "schemars",
-
        schemars(with = "crate::schemars_ext::crypto::PublicKey")
-
    )]
    pub nid: NodeId,
    /// Known addresses for this seed.
    pub addrs: Vec<KnownAddress>,
@@ -896,16 +747,12 @@ impl From<Vec<Seed>> for Seeds {
    }
}

-
#[derive(Clone, Debug, Serialize, Deserialize)]
+
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum FetchResult {
    Success {
        updated: Vec<RefUpdate>,
-
        #[cfg_attr(
-
            feature = "schemars",
-
            schemars(with = "HashSet<crate::schemars_ext::crypto::PublicKey>")
-
        )]
        namespaces: HashSet<NodeId>,
        clone: bool,
    },
@@ -931,7 +778,7 @@ impl FetchResult {
        }
    }

-
    pub fn find_updated(&self, name: &git::RefStr) -> Option<RefUpdate> {
+
    pub fn find_updated(&self, name: &git::fmt::RefStr) -> Option<RefUpdate> {
        let updated = match self {
            Self::Success { updated, .. } => Some(updated),
            _ => None,
@@ -1094,8 +941,21 @@ pub trait Handle: Clone + Sync + Send {
    ) -> Result<ConnectResult, Self::Error>;
    /// Disconnect from a peer.
    fn disconnect(&mut self, node: NodeId) -> Result<(), Self::Error>;
-
    /// Lookup the seeds of a given repository in the routing table.
-
    fn seeds(&mut self, id: RepoId) -> Result<Seeds, Self::Error>;
+

+
    /// Look up the seeds of a given repository in the routing table.
+
    #[deprecated(note = "use `seeds_for` instead")]
+
    fn seeds(&mut self, id: RepoId) -> Result<Seeds, Self::Error> {
+
        self.seeds_for(id, [self.nid()?])
+
    }
+

+
    /// Look up the seeds of a given repository in the routing table
+
    /// and report sync status for `namespaces`.
+
    fn seeds_for(
+
        &mut self,
+
        id: RepoId,
+
        namespaces: impl IntoIterator<Item = PublicKey>,
+
    ) -> Result<Seeds, Self::Error>;
+

    /// Fetch a repository from the network.
    fn fetch(
        &mut self,
@@ -1108,12 +968,28 @@ pub trait Handle: Clone + Sync + Send {
    fn seed(&mut self, id: RepoId, scope: policy::Scope) -> Result<bool, Self::Error>;
    /// Start following the given peer.
    fn follow(&mut self, id: NodeId, alias: Option<Alias>) -> Result<bool, Self::Error>;
+
    /// Set the following policy to block for the given peer.
+
    fn block(&mut self, id: NodeId) -> Result<bool, Self::Error>;
    /// Un-seed the given repo and delete it from storage.
    fn unseed(&mut self, id: RepoId) -> Result<bool, Self::Error>;
    /// Unfollow the given peer.
    fn unfollow(&mut self, id: NodeId) -> Result<bool, Self::Error>;
-
    /// Notify the service that a project has been updated, and announce local refs.
-
    fn announce_refs(&mut self, id: RepoId) -> Result<RefsAt, Self::Error>;
+

+
    /// Notify the service that a repository has been updated, and references
+
    /// should be announced over the network.
+
    #[deprecated(note = "use `announce_refs_for` instead")]
+
    fn announce_refs(&mut self, id: RepoId) -> Result<RefsAt, Self::Error> {
+
        self.announce_refs_for(id, [self.nid()?])
+
    }
+

+
    /// Notify the service that a repository has been updated, and references
+
    /// for the given `namespaces` should be announced over the network.
+
    fn announce_refs_for(
+
        &mut self,
+
        id: RepoId,
+
        namespaces: impl IntoIterator<Item = PublicKey>,
+
    ) -> Result<RefsAt, Self::Error>;
+

    /// Announce local inventory.
    fn announce_inventory(&mut self) -> Result<(), Self::Error>;
    /// Notify the service that our inventory was updated with the given repository.
@@ -1135,7 +1011,7 @@ pub trait Handle: Clone + Sync + Send {
/// The iterator blocks for a `timeout` duration, returning [`Error::TimedOut`]
/// if the duration is reached.
pub struct LineIter<T> {
-
    stream: BufReader<Stream>,
+
    stream: BufReader<UnixStream>,
    timeout: time::Duration,
    witness: PhantomData<T>,
}
@@ -1176,9 +1052,6 @@ impl<T: DeserializeOwned> Iterator for LineIter<T> {
    }
}

-
/// Public node & device identifier.
-
pub type NodeId = PublicKey;
-

/// Node controller.
#[derive(Debug, Clone)]
pub struct Node {
@@ -1199,7 +1072,7 @@ impl Node {
        cmd: Command,
        timeout: time::Duration,
    ) -> Result<LineIter<T>, Error> {
-
        let mut stream = Stream::connect(&self.socket)
+
        let mut stream = UnixStream::connect(&self.socket)
            .map_err(|e| Error::Connect(self.socket.clone(), e.kind()))?;
        cmd.to_writer(&mut stream)?;
        Ok(LineIter {
@@ -1215,12 +1088,13 @@ impl Node {
    pub fn announce(
        &mut self,
        rid: RepoId,
+
        namespaces: impl IntoIterator<Item = PublicKey>,
        timeout: time::Duration,
        mut announcer: sync::Announcer,
        mut report: impl FnMut(&NodeId, sync::announce::Progress),
    ) -> Result<sync::AnnouncerResult, Error> {
        let mut events = self.subscribe(timeout)?;
-
        let refs = self.announce_refs(rid)?;
+
        let refs = self.announce_refs_for(rid, namespaces)?;

        let started = time::Instant::now();

@@ -1333,9 +1207,19 @@ impl Handle for Node {
        Ok(())
    }

-
    fn seeds(&mut self, rid: RepoId) -> Result<Seeds, Error> {
+
    fn seeds_for(
+
        &mut self,
+
        rid: RepoId,
+
        namespaces: impl IntoIterator<Item = PublicKey>,
+
    ) -> Result<Seeds, Error> {
        let seeds = self
-
            .call::<Seeds>(Command::Seeds { rid }, DEFAULT_TIMEOUT)?
+
            .call::<Seeds>(
+
                Command::SeedsFor {
+
                    rid,
+
                    namespaces: HashSet::from_iter(namespaces),
+
                },
+
                DEFAULT_TIMEOUT,
+
            )?
            .next()
            .ok_or(Error::EmptyResponse)??;

@@ -1370,6 +1254,13 @@ impl Handle for Node {
        Ok(response.updated)
    }

+
    fn block(&mut self, nid: NodeId) -> Result<bool, Error> {
+
        let mut lines = self.call::<Success>(Command::Block { nid }, DEFAULT_TIMEOUT)?;
+
        let response = lines.next().ok_or(Error::EmptyResponse)??;
+

+
        Ok(response.updated)
+
    }
+

    fn seed(&mut self, rid: RepoId, scope: policy::Scope) -> Result<bool, Error> {
        let mut lines = self.call::<Success>(Command::Seed { rid, scope }, DEFAULT_TIMEOUT)?;
        let response = lines.next().ok_or(Error::EmptyResponse)??;
@@ -1391,9 +1282,19 @@ impl Handle for Node {
        Ok(response.updated)
    }

-
    fn announce_refs(&mut self, rid: RepoId) -> Result<RefsAt, Error> {
+
    fn announce_refs_for(
+
        &mut self,
+
        rid: RepoId,
+
        namespaces: impl IntoIterator<Item = PublicKey>,
+
    ) -> Result<RefsAt, Error> {
        let refs: RefsAt = self
-
            .call(Command::AnnounceRefs { rid }, DEFAULT_TIMEOUT)?
+
            .call(
+
                Command::AnnounceRefsFor {
+
                    rid,
+
                    namespaces: HashSet::from_iter(namespaces),
+
                },
+
                DEFAULT_TIMEOUT,
+
            )?
            .next()
            .ok_or(Error::EmptyResponse)??;

@@ -1609,6 +1510,22 @@ mod test {
    }

    #[test]
+
    fn test_address() {
+
        assert!(Address::from_str("127.0.0.1:8776").is_ok());
+
        assert!(Address::from_str("[::1]:8776").is_ok());
+
        assert!(Address::from_str("[::ffff:127.0.0.1]:8776").is_ok());
+
        assert!(Address::from_str("localhost:8776").is_ok());
+

+
        assert!(Address::from_str("").is_err());
+
        assert!(Address::from_str(":").is_err());
+
        assert!(Address::from_str("127.0.0.1").is_err());
+
        assert!(Address::from_str("127.0.0.1:xyz").is_err());
+
        assert!(Address::from_str("[invalid]:8776").is_err());
+
        assert!(Address::from_str("[127.0.0.1]:8776").is_err());
+
        assert!(Address::from_str("::1:8776").is_err());
+
    }
+

+
    #[test]
    fn test_command_result() {
        #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
        struct Test {
@@ -1641,7 +1558,6 @@ mod test {
            &serde_json::to_string(&CommandResult::Okay(State::Connected {
                since: LocalTime::now(),
                ping: Default::default(),
-
                fetching: Default::default(),
                latencies: VecDeque::default(),
                stable: false,
            }))
modified crates/radicle/src/node/address.rs
@@ -122,11 +122,11 @@ impl<K: hash::Hash + Eq, V> DerefMut for AddressBook<K, V> {
pub struct Node {
    /// Protocol version.
    pub version: u8,
-
    /// Advertized alias.
+
    /// Advertised alias.
    pub alias: Alias,
-
    /// Advertized features.
+
    /// Advertised features.
    pub features: node::Features,
-
    /// Advertized addresses
+
    /// Advertised addresses
    pub addrs: Vec<KnownAddress>,
    /// Proof-of-work included in node announcement.
    pub pow: u32,
@@ -150,18 +150,8 @@ pub struct KnownAddress {
    /// Address of the peer who sent us this address.
    pub source: Source,
    /// Last time this address was used to successfully connect to a peer.
-
    #[serde(with = "crate::serde_ext::localtime::option::time")]
-
    #[cfg_attr(
-
        feature = "schemars",
-
        schemars(with = "Option<crate::schemars_ext::localtime::LocalDurationInSeconds>")
-
    )]
    pub last_success: Option<LocalTime>,
    /// Last time this address was tried.
-
    #[serde(with = "crate::serde_ext::localtime::option::time")]
-
    #[cfg_attr(
-
        feature = "schemars",
-
        schemars(with = "Option<crate::schemars_ext::localtime::LocalDurationInSeconds>")
-
    )]
    pub last_attempt: Option<LocalTime>,
    /// Whether this address has been banned.
    pub banned: bool,
@@ -259,33 +249,51 @@ pub fn is_local(addr: &net::IpAddr) -> bool {
        net::IpAddr::V4(addr) => {
            addr.is_private() || addr.is_loopback() || addr.is_link_local() || addr.is_unspecified()
        }
-
        net::IpAddr::V6(_) => false,
+
        net::IpAddr::V6(addr) => {
+
            addr.is_loopback() || addr.is_unicast_link_local() || addr.is_unspecified()
+
        }
    }
}

/// Check whether an IPv4 address is globally routable.
///
-
/// This code is adapted from the Rust standard library's `net::Ipv4Addr::is_global`. It can be
-
/// replaced once that function is stabilized.
+
/// This implementation lacks many exceptions, and should be improved once
+
/// corresponding functions in [`std::net::Ipv4Addr`] are stabilized.
+
///
+
/// See
+
///  - <https://github.com/rust-lang/rust/issues/27709>
+
///  - <https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml>
fn ipv4_is_routable(addr: &net::Ipv4Addr) -> bool {
-
    // Check if this address is 192.0.0.9 or 192.0.0.10. These addresses are the only two
-
    // globally routable addresses in the 192.0.0.0/24 range.
-
    if u32::from(*addr) == 0xc0000009 || u32::from(*addr) == 0xc000000a {
+
    // https://datatracker.ietf.org/doc/html/rfc7723#section-4.1
+
    if *addr == net::Ipv4Addr::new(192, 0, 0, 9) {
+
        return true;
+
    }
+

+
    // https://datatracker.ietf.org/doc/html/rfc8155#section-8.1
+
    if *addr == net::Ipv4Addr::new(192, 0, 0, 10) {
        return true;
    }
+

+
    // https://datatracker.ietf.org/doc/html/rfc791#section-3.2
+
    if addr.octets()[0] == 0 {
+
        return false;
+
    }
+

    !addr.is_private()
        && !addr.is_loopback()
        && !addr.is_link_local()
        && !addr.is_broadcast()
        && !addr.is_documentation()
-
        // Make sure the address is not in 0.0.0.0/8.
-
        && addr.octets()[0] != 0
}

/// Check whether an IPv6 address is globally routable.
///
-
/// For now, this always returns `true`, as IPv6 addresses
-
/// are not fully supported.
-
fn ipv6_is_routable(_addr: &net::Ipv6Addr) -> bool {
-
    true
+
/// This implementation lacks many exceptions, and should be improved once
+
/// corresponding functions in [`std::net::Ipv6Addr`] are stabilized.
+
///
+
/// See
+
///  - <https://github.com/rust-lang/rust/issues/27709>
+
///  - <https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml>
+
fn ipv6_is_routable(addr: &net::Ipv6Addr) -> bool {
+
    !addr.is_loopback() && !addr.is_unicast_link_local() && !addr.is_unspecified()
}
modified crates/radicle/src/node/address/store.rs
@@ -183,7 +183,7 @@ impl Store for Database {

        for row in stmt.into_iter() {
            let row = row?;
-
            let _typ = row.try_read::<AddressType, _>("type")?;
+
            let _type = row.try_read::<AddressType, _>("type")?;
            let addr = row.try_read::<Address, _>("value")?;
            let source = row.try_read::<Source, _>("source")?;
            let last_attempt = row
@@ -300,7 +300,7 @@ impl Store for Database {

        while let Some(Ok(row)) = stmt.next() {
            let node = row.try_read::<NodeId, _>("node")?;
-
            let _typ = row.try_read::<AddressType, _>("type")?;
+
            let _type = row.try_read::<AddressType, _>("type")?;
            let addr = row.try_read::<Address, _>("value")?;
            let source = row.try_read::<Source, _>("source")?;
            let last_success = row.try_read::<Option<i64>, _>("last_success")?;
added crates/radicle/src/node/command.rs
@@ -0,0 +1,306 @@
+
//! Commands sent to the node via the control socket, and auxiliary types, as
+
//! well as their results (responses on the socket).
+

+
// There are derives on an enum with a deprecated variant
+
// in this module, see [`Command::AnnounceRefs`] and also
+
// <https://github.com/rust-lang/rust/issues/92313>.
+
#![allow(deprecated)]
+

+
use std::collections::HashSet;
+
use std::io;
+
use std::time;
+

+
use serde::{Deserialize, Serialize};
+
use serde_json as json;
+

+
use crate::crypto::PublicKey;
+
use crate::identity::RepoId;
+

+
use super::events::Event;
+
use super::NodeId;
+

+
/// Default timeout when waiting for the node to respond with data.
+
pub const DEFAULT_TIMEOUT: time::Duration = time::Duration::from_secs(30);
+

+
/// Command name.
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase", tag = "command")]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
pub enum Command {
+
    /// Announce repository references for given repository to peers.
+
    #[serde(rename_all = "camelCase")]
+
    #[deprecated(note = "use `AnnounceRefsFor` instead")]
+
    AnnounceRefs { rid: RepoId },
+

+
    /// Announce repository references for given repository
+
    /// and namespaces to peers.
+
    #[serde(rename_all = "camelCase")]
+
    AnnounceRefsFor {
+
        /// The ID of the repository for which references should be announced.
+
        rid: RepoId,
+

+
        /// The namespaces for which references should be announced.
+
        namespaces: HashSet<PublicKey>,
+
    },
+

+
    /// Announce local repositories to peers.
+
    #[serde(rename_all = "camelCase")]
+
    AnnounceInventory,
+

+
    /// Update node's inventory.
+
    AddInventory { rid: RepoId },
+

+
    /// Get the current node configuration.
+
    Config,
+

+
    /// Get the node's listen addresses.
+
    ListenAddrs,
+

+
    /// Connect to node with the given address.
+
    #[serde(rename_all = "camelCase")]
+
    Connect {
+
        addr: super::config::ConnectAddress,
+
        opts: ConnectOptions,
+
    },
+

+
    /// Disconnect from a node.
+
    #[serde(rename_all = "camelCase")]
+
    Disconnect { nid: NodeId },
+

+
    /// Look up seeds for the given repository in the routing table.
+
    #[serde(rename_all = "camelCase")]
+
    #[deprecated(note = "use `SeedsFor` instead")]
+
    Seeds { rid: RepoId },
+

+
    /// Look up seeds for the given repository in the routing table and
+
    /// report sync status for the given namespaces.
+
    #[serde(rename_all = "camelCase")]
+
    SeedsFor {
+
        /// The ID of the repository for which seeds should be looked up
+
        /// in the routing table.
+
        rid: RepoId,
+

+
        /// The namespaces for which references should be announced.
+
        namespaces: HashSet<PublicKey>,
+
    },
+

+
    /// Get the current peer sessions.
+
    Sessions,
+

+
    /// Get a specific peer session.
+
    Session { nid: NodeId },
+

+
    /// Fetch the given repository from the network.
+
    #[serde(rename_all = "camelCase")]
+
    Fetch {
+
        rid: RepoId,
+
        nid: NodeId,
+
        timeout: time::Duration,
+
    },
+

+
    /// Seed the given repository.
+
    #[serde(rename_all = "camelCase")]
+
    Seed {
+
        rid: RepoId,
+
        scope: super::policy::Scope,
+
    },
+

+
    /// Unseed the given repository.
+
    #[serde(rename_all = "camelCase")]
+
    Unseed { rid: RepoId },
+

+
    /// Follow the given node.
+
    #[serde(rename_all = "camelCase")]
+
    Follow {
+
        nid: NodeId,
+
        alias: Option<super::Alias>,
+
    },
+

+
    /// Unfollow the given node.
+
    #[serde(rename_all = "camelCase")]
+
    Unfollow { nid: NodeId },
+

+
    /// Block the given node.
+
    #[serde(rename_all = "camelCase")]
+
    Block { nid: NodeId },
+

+
    /// Get the node's status.
+
    Status,
+

+
    /// Get node debug information.
+
    Debug,
+

+
    /// Get the node's NID.
+
    NodeId,
+

+
    /// Shutdown the node.
+
    Shutdown,
+

+
    /// Subscribe to events.
+
    Subscribe,
+
}
+

+
impl Command {
+
    /// Write this command to a stream, including a terminating LF character.
+
    pub fn to_writer(&self, mut w: impl io::Write) -> io::Result<()> {
+
        json::to_writer(&mut w, self).map_err(|_| io::ErrorKind::InvalidInput)?;
+
        w.write_all(b"\n")
+
    }
+
}
+

+
/// Options passed to the "connect" node command.
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
pub struct ConnectOptions {
+
    /// Establish a persistent connection.
+
    pub persistent: bool,
+
    /// How long to wait for the connection to be established.
+
    pub timeout: time::Duration,
+
}
+

+
impl Default for ConnectOptions {
+
    fn default() -> Self {
+
        Self {
+
            persistent: false,
+
            timeout: DEFAULT_TIMEOUT,
+
        }
+
    }
+
}
+

+
/// Result of a command, on the node control socket.
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(untagged)]
+
pub enum CommandResult<T> {
+
    /// Response on node socket indicating that a command was carried out successfully.
+
    Okay(T),
+
    /// Response on node socket indicating that an error occurred.
+
    Error {
+
        /// The reason for the error.
+
        #[serde(rename = "error")]
+
        reason: String,
+
    },
+
}
+

+
impl<T, E> From<Result<T, E>> for CommandResult<T>
+
where
+
    E: std::error::Error,
+
{
+
    fn from(result: Result<T, E>) -> Self {
+
        match result {
+
            Ok(t) => Self::Okay(t),
+
            Err(e) => Self::Error {
+
                reason: e.to_string(),
+
            },
+
        }
+
    }
+
}
+

+
impl From<Event> for CommandResult<Event> {
+
    fn from(event: Event) -> Self {
+
        Self::Okay(event)
+
    }
+
}
+

+
/// A success response.
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
pub struct Success {
+
    /// Whether something was updated.
+
    #[serde(default, skip_serializing_if = "crate::serde_ext::is_default")]
+
    pub(super) updated: bool,
+
}
+

+
impl CommandResult<Success> {
+
    /// Create an "updated" response.
+
    pub fn updated(updated: bool) -> Self {
+
        Self::Okay(Success { updated })
+
    }
+

+
    /// Create an "ok" response.
+
    pub fn ok() -> Self {
+
        Self::Okay(Success { updated: false })
+
    }
+
}
+

+
impl CommandResult<()> {
+
    /// Create an error result.
+
    pub fn error(err: impl std::error::Error) -> Self {
+
        Self::Error {
+
            reason: err.to_string(),
+
        }
+
    }
+
}
+

+
impl<T: Serialize> CommandResult<T> {
+
    /// Write this command result to a stream, including a terminating LF character.
+
    pub fn to_writer(&self, mut w: impl io::Write) -> io::Result<()> {
+
        json::to_writer(&mut w, self).map_err(|_| io::ErrorKind::InvalidInput)?;
+
        w.write_all(b"\n")
+
    }
+
}
+

+
#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
+
mod test {
+
    use super::*;
+
    use std::collections::VecDeque;
+

+
    use localtime::LocalTime;
+

+
    use crate::assert_matches;
+
    use crate::node::{Seeds, State};
+

+
    #[test]
+
    fn command_result() {
+
        #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
+
        struct Test {
+
            value: u32,
+
        }
+

+
        assert_eq!(json::to_string(&CommandResult::Okay(true)).unwrap(), "true");
+
        assert_eq!(
+
            json::to_string(&CommandResult::Okay(Test { value: 42 })).unwrap(),
+
            "{\"value\":42}"
+
        );
+
        assert_eq!(
+
            json::from_str::<CommandResult<Test>>("{\"value\":42}").unwrap(),
+
            CommandResult::Okay(Test { value: 42 })
+
        );
+
        assert_eq!(json::to_string(&CommandResult::ok()).unwrap(), "{}");
+
        assert_eq!(
+
            json::to_string(&CommandResult::updated(true)).unwrap(),
+
            "{\"updated\":true}"
+
        );
+
        assert_eq!(
+
            json::to_string(&CommandResult::error(io::Error::from(
+
                io::ErrorKind::NotFound
+
            )))
+
            .unwrap(),
+
            "{\"error\":\"entity not found\"}"
+
        );
+

+
        json::from_str::<CommandResult<State>>(
+
            &serde_json::to_string(&CommandResult::Okay(State::Connected {
+
                since: LocalTime::now(),
+
                ping: Default::default(),
+
                latencies: VecDeque::default(),
+
                stable: false,
+
            }))
+
            .unwrap(),
+
        )
+
        .unwrap();
+

+
        assert_matches!(
+
            json::from_str::<CommandResult<State>>(
+
                r#"{"connected":{"since":1699636852107,"fetching":[]}}"#
+
            ),
+
            Ok(CommandResult::Okay(_))
+
        );
+
        assert_matches!(
+
            json::from_str::<CommandResult<Seeds>>(
+
                r#"[{"nid":"z6MksmpU5b1dS7oaqF2bHXhQi1DWy2hB7Mh9CuN7y1DN6QSz","addrs":[{"addr":"seed.radicle.example.com:8776","source":"peer","lastSuccess":1699983994234,"lastAttempt":1699983994000,"banned":false}],"state":{"connected":{"since":1699983994}}}]"#
+
            ),
+
            Ok(CommandResult::Okay(_))
+
        );
+
    }
+
}
modified crates/radicle/src/node/config.rs
@@ -9,19 +9,17 @@ use serde::{Deserialize, Serialize};
use serde_json as json;

use crate::node;
-
use crate::node::policy::{Scope, SeedingPolicy};
+
use crate::node::policy::SeedingPolicy;
use crate::node::{Address, Alias, NodeId};

+
use super::policy;
+

/// Peer-to-peer protocol version.
pub type ProtocolVersion = u8;

/// Configured public seeds.
pub mod seeds {
-
    use std::{
-
        net::{Ipv4Addr, Ipv6Addr},
-
        str::FromStr,
-
        sync::LazyLock,
-
    };
+
    use std::{str::FromStr, sync::LazyLock};

    use cyphernet::addr::{tor::OnionAddrV3, HostName, NetAddr};

@@ -42,8 +40,6 @@ pub mod seeds {
            NodeId::from_str("z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7").unwrap(),
            vec![
                HostName::Dns("iris.radicle.xyz".to_owned()),
-
                Ipv6Addr::new(0x2a01, 0x4f9, 0xc010, 0xdfaa, 0, 0, 0, 1).into(),
-
                Ipv4Addr::new(95, 217, 156, 6).into(),
                #[allow(clippy::unwrap_used)] // Value is manually verified.
                OnionAddrV3::from_str(
                    "irisradizskwweumpydlj4oammoshkxxjur3ztcmo7cou5emc6s5lfid.onion",
@@ -61,8 +57,6 @@ pub mod seeds {
            NodeId::from_str("z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo").unwrap(),
            vec![
                HostName::Dns("rosa.radicle.xyz".to_owned()),
-
                Ipv6Addr::new(0x2a01, 0x4ff, 0xf0, 0xabd3, 0, 0, 0, 1).into(),
-
                Ipv4Addr::new(5, 161, 85, 124).into(),
                #[allow(clippy::unwrap_used)] // Value is manually verified.
                OnionAddrV3::from_str(
                    "rosarad5bxgdlgjnzzjygnsxrwxmoaj4vn7xinlstwglxvyt64jlnhyd.onion",
@@ -139,7 +133,7 @@ pub struct Limits {
    /// Maximum number of open files.
    pub max_open_files: LimitMaxOpenFiles,

-
    /// Rate limitter settings.
+
    /// Rate limiter settings.
    pub rate: RateLimits,

    /// Connection limits.
@@ -251,8 +245,8 @@ pub struct ConnectionLimits {
    pub outbound: LimitConnectionsOutbound,
}

-
/// Rate limts for a single connection.
-
#[derive(Debug, Clone, Serialize, Deserialize, Display)]
+
/// Rate limits for a single connection.
+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Display)]
#[display("RateLimit(fill_rate={fill_rate}, capacity={capacity})")]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
@@ -372,13 +366,14 @@ pub enum AddressConfig {

/// Default seeding policy. Applies when no repository policies for the given repo are found.
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase", tag = "default")]
+
#[serde(tag = "default", rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum DefaultSeedingPolicy {
    /// Allow seeding.
    Allow {
        /// Seeding scope.
-
        #[serde(default)]
+
        #[serde(skip_serializing_if = "Scope::is_implicit")]
+
        #[cfg_attr(feature = "schemars", schemars(flatten))]
        scope: Scope,
    },
    /// Block seeding.
@@ -386,6 +381,53 @@ pub enum DefaultSeedingPolicy {
    Block,
}

+
/// [`Scope`] provides a schema for [`policy::Scope`], where the inner scope is
+
/// optional. It is introduced to allow ease migration to a future
+
/// version of [`DefaultSeedingPolicy::Allow`], where no or different defaults
+
/// apply to [`DefaultSeedingPolicy::Allow::scope`].
+
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
#[serde(transparent)]
+
pub struct Scope(Option<policy::Scope>);
+

+
impl Scope {
+
    /// Construct the implicit scope, where the default value,
+
    /// [`policy::Scope::All`], is chosen for the final scope value.
+
    pub fn implicit() -> Self {
+
        Self(None)
+
    }
+

+
    /// Construct the explicit scope, where the given [`policy::Scope`] is used.
+
    pub fn explicit(scope: policy::Scope) -> Self {
+
        Self(Some(scope))
+
    }
+

+
    /// Resolve this [`Scope`] to its [`policy::Scope`] value.
+
    ///
+
    /// If the scope is implicit, then [`policy::Scope::All`] is returned.
+
    pub fn into_inner(self) -> policy::Scope {
+
        self.0.unwrap_or(policy::Scope::All)
+
    }
+

+
    /// Returns `true` when the scope is implicit, i.e. no [`policy::Scope`] was
+
    /// given.
+
    pub fn is_implicit(&self) -> bool {
+
        self.0.is_none()
+
    }
+

+
    /// Construct the explicit [`Scope`] where the inner scope is
+
    /// [`policy::Scope::All`].
+
    fn all() -> Self {
+
        Self::explicit(policy::Scope::All)
+
    }
+

+
    /// Construct the explicit [`Scope`] where the inner scope is
+
    /// [`policy::Scope::Followed`].
+
    fn followed() -> Self {
+
        Self::explicit(policy::Scope::Followed)
+
    }
+
}
+

impl DefaultSeedingPolicy {
    /// Is this an "allow" policy.
    pub fn is_allow(&self) -> bool {
@@ -394,7 +436,16 @@ impl DefaultSeedingPolicy {

    /// Seed everything from anyone.
    pub fn permissive() -> Self {
-
        Self::Allow { scope: Scope::All }
+
        Self::Allow {
+
            scope: Scope::all(),
+
        }
+
    }
+

+
    /// Seed only delegate changes.
+
    pub fn followed() -> Self {
+
        Self::Allow {
+
            scope: Scope::followed(),
+
        }
    }
}

@@ -402,7 +453,9 @@ impl From<DefaultSeedingPolicy> for SeedingPolicy {
    fn from(policy: DefaultSeedingPolicy) -> Self {
        match policy {
            DefaultSeedingPolicy::Block => Self::Block,
-
            DefaultSeedingPolicy::Allow { scope } => Self::Allow { scope },
+
            DefaultSeedingPolicy::Allow { scope } => SeedingPolicy::Allow {
+
                scope: scope.into_inner(),
+
            },
        }
    }
}
@@ -459,6 +512,14 @@ pub struct Config {
    /// Extra fields that aren't supported.
    #[serde(flatten, skip_serializing)]
    pub extra: json::Map<String, json::Value>,
+
    /// Path to a file containing an Ed25519 secret key, in OpenSSH format, i.e.
+
    /// with the `-----BEGIN OPENSSH PRIVATE KEY-----` header. The corresponding
+
    /// public key will be used as the Node ID.
+
    ///
+
    /// A decryption password cannot be configured, but passed at runtime via
+
    /// the environment variable `RAD_PASSPHRASE`.
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    pub secret: Option<std::path::PathBuf>,
}

impl Config {
@@ -485,6 +546,7 @@ impl Config {
            log: LogLevel::default(),
            seeding_policy: DefaultSeedingPolicy::default(),
            extra: json::Map::default(),
+
            secret: None,
        }
    }

@@ -547,14 +609,7 @@ impl From<LogLevel> for log::Level {
#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(transparent)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
-
pub struct LimitRoutingMaxAge(
-
    #[serde(with = "crate::serde_ext::localtime::duration")]
-
    #[cfg_attr(
-
        feature = "schemars",
-
        schemars(with = "crate::schemars_ext::localtime::LocalDuration")
-
    )]
-
    localtime::LocalDuration,
-
);
+
pub struct LimitRoutingMaxAge(localtime::LocalDuration);

impl Default for LimitRoutingMaxAge {
    fn default() -> Self {
@@ -577,14 +632,7 @@ impl From<LocalDuration> for LimitRoutingMaxAge {
#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(transparent)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
-
pub struct LimitGossipMaxAge(
-
    #[serde(with = "crate::serde_ext::localtime::duration")]
-
    #[cfg_attr(
-
        feature = "schemars",
-
        schemars(with = "crate::schemars_ext::localtime::LocalDuration")
-
    )]
-
    localtime::LocalDuration,
-
);
+
pub struct LimitGossipMaxAge(localtime::LocalDuration);

impl Default for LimitGossipMaxAge {
    fn default() -> Self {
@@ -598,6 +646,18 @@ impl From<LimitGossipMaxAge> for LocalDuration {
    }
}

+
/// Create a new type (`$name`) around a given type (`$type`), with a provided
+
/// default (`$default`).
+
///
+
/// The macro will attempt to derive any extra `$derive`s passed.
+
///
+
/// Note that the macro will provide the following traits automatically:
+
///   - `Clone`
+
///   - `Debug`
+
///   - `Display`
+
///   - `Serialize`
+
///   - `Deserialize`
+
///   - `From<$name> for $type`, i.e. can convert back into the original type
macro_rules! wrapper {
    ($name:ident, $type:ty, $default:expr $(, $derive:ty)*) => {
        #[derive(Clone, Debug, Deserialize, Display, Serialize, From $(, $derive)*)]
@@ -630,7 +690,8 @@ wrapper!(
    RateLimit {
        fill_rate: 5.0,
        capacity: 1024,
-
    }
+
    },
+
    Copy
);
wrapper!(LimitMaxOpenFiles, usize, 4096, Copy);
wrapper!(
@@ -639,12 +700,17 @@ wrapper!(
    RateLimit {
        fill_rate: 10.0,
        capacity: 2048,
-
    }
+
    },
+
    Copy
);

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
+
    use super::{DefaultSeedingPolicy, Scope};
+
    use crate::node::policy;
+
    use serde_json::json;
+

    #[test]
    fn partial() {
        use super::Config;
@@ -682,4 +748,85 @@ mod test {
        );
        assert_eq!(config.limits.connection.outbound.0, 1337);
    }
+

+
    #[test]
+
    fn deserialize_migrating_scope() {
+
        let seeding_policy: DefaultSeedingPolicy = serde_json::from_value(json!({
+
            "default": "allow"
+
        }))
+
        .unwrap();
+

+
        assert_eq!(
+
            seeding_policy,
+
            DefaultSeedingPolicy::Allow { scope: Scope(None) }
+
        );
+

+
        let seeding_policy: DefaultSeedingPolicy = serde_json::from_value(json!({
+
            "default": "allow",
+
            "scope": null
+
        }))
+
        .unwrap();
+

+
        assert_eq!(
+
            seeding_policy,
+
            DefaultSeedingPolicy::Allow { scope: Scope(None) }
+
        );
+

+
        let seeding_policy: DefaultSeedingPolicy = serde_json::from_value(json!({
+
            "default": "allow",
+
            "scope": "all"
+
        }))
+
        .unwrap();
+

+
        assert_eq!(
+
            seeding_policy,
+
            DefaultSeedingPolicy::Allow {
+
                scope: Scope(Some(policy::Scope::All))
+
            }
+
        );
+

+
        let seeding_policy: DefaultSeedingPolicy = serde_json::from_value(json!({
+
            "default": "allow",
+
            "scope": "followed"
+
        }))
+
        .unwrap();
+

+
        assert_eq!(
+
            seeding_policy,
+
            DefaultSeedingPolicy::Allow {
+
                scope: Scope(Some(policy::Scope::Followed))
+
            }
+
        )
+
    }
+

+
    #[test]
+
    fn serialize_migrating_scope() {
+
        assert_eq!(
+
            json!({
+
                "default": "allow"
+
            }),
+
            serde_json::to_value(DefaultSeedingPolicy::Allow { scope: Scope(None) }).unwrap()
+
        );
+

+
        assert_eq!(
+
            json!({
+
                "default": "allow",
+
                "scope": "all"
+
            }),
+
            serde_json::to_value(DefaultSeedingPolicy::Allow {
+
                scope: Scope(Some(policy::Scope::All))
+
            })
+
            .unwrap()
+
        );
+
        assert_eq!(
+
            json!({
+
                "default": "allow",
+
                "scope": "followed"
+
            }),
+
            serde_json::to_value(DefaultSeedingPolicy::Allow {
+
                scope: Scope(Some(policy::Scope::Followed))
+
            })
+
            .unwrap()
+
        );
+
    }
}
modified crates/radicle/src/node/db.rs
@@ -1,7 +1,7 @@
//! # Note on database migrations
//!
//! The `user_version` field in the database SQLite header is used to keep track of the database
-
//! version. It starts with `0`, which means no tables exist yet, and is incremented everytime a
+
//! version. It starts with `0`, which means no tables exist yet, and is incremented every time a
//! migration is applied. In turn, migrations are named after their version numbers, so the first
//! migration is `1.sql`, the second one is `2.sql` and so on.
//!
modified crates/radicle/src/node/db/schema.sql
@@ -15,7 +15,7 @@ create table if not exists "nodes" (
  --
) strict;

-
-- Node addresses. These are adresses advertized by a node.
+
-- Node addresses. These are addresses advertised by a node.
create table if not exists "addresses" (
  -- Node ID.
  "node"               text      not null references "nodes" ("id") on delete cascade,
modified crates/radicle/src/node/events.rs
@@ -9,7 +9,8 @@ use std::time;

use crossbeam_channel as chan;

-
use crate::git::{Oid, Qualified};
+
use crate::git::fmt::Qualified;
+
use crate::git::Oid;
use crate::node;
use crate::prelude::*;
use crate::storage::{refs, RefUpdate};
@@ -227,15 +228,13 @@ impl<T: Clone> Emitter<T> {
    /// Emit a batch of events to subscribers and drop those who can't receive
    /// them.
    /// N.b. subscribers are also dropped if their channel is full.
-
    pub fn emit_all(&self, events: Vec<T>) {
+
    pub fn emit_all(&self, events: impl IntoIterator<Item = T>) {
        // SAFETY: We deliberately propagate panics from other threads holding the lock.
        #[allow(clippy::unwrap_used)]
-
        self.subscribers.lock().unwrap().retain(|s| {
-
            events
-
                .clone()
-
                .into_iter()
-
                .all(|event| s.try_send(event).is_ok())
-
        });
+
        let mut subscribers = self.subscribers.lock().unwrap();
+
        for event in events {
+
            subscribers.retain(|s| s.try_send(event.clone()).is_ok());
+
        }
    }

    /// Subscribe to events stream.
modified crates/radicle/src/node/features.rs
@@ -1,8 +1,8 @@
-
//! Node features advertized on the network.
+
//! Node features advertised on the network.
use serde::{Deserialize, Serialize};
use std::{fmt, ops};

-
/// Advertized node features. Signals what services the node supports.
+
/// Advertised node features. Signals what services the node supports.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Features(u64);
modified crates/radicle/src/node/notifications.rs
@@ -7,7 +7,7 @@ use thiserror::Error;

use crate::cob;
use crate::cob::TypedId;
-
use crate::git::{BranchName, Qualified};
+
use crate::git::{fmt::Qualified, BranchName};
use crate::prelude::RepoId;
use crate::storage::{RefUpdate, RemoteId};

@@ -73,7 +73,7 @@ pub enum NotificationKindError {
    TypedId(#[from] cob::ParseIdentifierError),
    /// Invalid Git ref format.
    #[error("invalid ref format: {0}")]
-
    RefFormat(#[from] radicle_git_ext::ref_format::Error),
+
    RefFormat(#[from] crate::git::fmt::Error),
}

impl TryFrom<Qualified<'_>> for NotificationKind {
modified crates/radicle/src/node/notifications/store.rs
@@ -10,7 +10,9 @@ use sqlite as sql;
use thiserror::Error;

use crate::git;
-
use crate::git::{Oid, RefError, RefString};
+
use crate::git::fmt::RefString;
+
use crate::git::Oid;
+
use crate::git::RefError;
use crate::prelude::RepoId;
use crate::sql::transaction;
use crate::storage::RefUpdate;
@@ -40,7 +42,7 @@ pub enum Error {
    RefName(#[from] RefError),
    /// Invalid Git ref format.
    #[error("invalid ref format: {0}")]
-
    RefFormat(#[from] git_ext::ref_format::Error),
+
    RefFormat(#[from] crate::git::fmt::Error),
    /// Invalid notification kind.
    #[error("invalid notification kind: {0}")]
    NotificationKind(#[from] NotificationKindError),
@@ -414,10 +416,10 @@ mod parse {
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
-
    use radicle_git_ext::ref_format::{qualified, refname};
+
    use crate::git::fmt::{qualified, refname};
+
    use crate::{cob, node::NodeId, test::arbitrary};

    use super::*;
-
    use crate::{cob, node::NodeId, test::arbitrary};

    #[test]
    fn test_clear() {
modified crates/radicle/src/node/policy/store.rs
@@ -119,14 +119,15 @@ impl Store<Write> {
    /// Follow a node.
    pub fn follow(&mut self, id: &NodeId, alias: Option<&Alias>) -> Result<bool, Error> {
        let mut stmt = self.db.prepare(
-
            "INSERT INTO `following` (id, alias)
-
             VALUES (?1, ?2)
-
             ON CONFLICT DO UPDATE
-
             SET alias = ?2 WHERE alias != ?2",
+
            "INSERT INTO `following` (id, alias, policy)
+
             VALUES (?1, ?2, ?3)
+
             ON CONFLICT (id) DO UPDATE
+
             SET alias = ?2, policy = ?3 WHERE alias != ?2 OR policy != ?3",
        )?;

        stmt.bind((1, id))?;
        stmt.bind((2, alias.map_or("", |alias| alias.as_str())))?;
+
        stmt.bind((3, Policy::Allow))?;
        stmt.next()?;

        Ok(self.db.change_count() > 0)
@@ -248,6 +249,18 @@ impl<T> Store<T> {
        ))
    }

+
    /// Returns `true` if there is a follow policy for the given node, and that
+
    /// policy is [`Policy::Block`].
+
    pub fn is_blocked(&self, id: &NodeId) -> Result<bool, Error> {
+
        Ok(matches!(
+
            self.follow_policy(id)?,
+
            Some(FollowPolicy {
+
                policy: Policy::Block,
+
                ..
+
            })
+
        ))
+
    }
+

    /// Get a node's follow policy.
    pub fn follow_policy(&self, id: &NodeId) -> Result<Option<FollowPolicy>, Error> {
        let mut stmt = self
modified crates/radicle/src/node/refs/store.rs
@@ -6,7 +6,8 @@ use localtime::LocalTime;
use sqlite as sql;
use thiserror::Error;

-
use crate::git::{Oid, Qualified};
+
use crate::git::fmt::Qualified;
+
use crate::git::Oid;
use crate::node::Database;
use crate::node::NodeId;
use crate::prelude::RepoId;
@@ -176,7 +177,7 @@ impl Store for Database {
#[allow(clippy::unwrap_used)]
mod test {
    use super::*;
-
    use crate::git::qualified;
+
    use crate::git::fmt::qualified;
    use crate::test::arbitrary;
    use localtime::{LocalDuration, LocalTime};

modified crates/radicle/src/node/routing.rs
@@ -21,7 +21,7 @@ pub enum InsertResult {
    SeedAdded,
}

-
/// An error occuring in peer-to-peer networking code.
+
/// An error occurring in peer-to-peer networking code.
#[derive(Error, Debug)]
pub enum Error {
    /// An Internal error.
@@ -222,10 +222,8 @@ impl Store for Database {
        ignore: &NodeId,
    ) -> Result<usize, Error> {
        let limit: i64 = limit
-
            .unwrap_or(i64::MAX as usize)
-
            .try_into()
-
            .map_err(|_| Error::UnitOverflow)?;
-

+
            .and_then(|limit| i64::try_from(limit).ok())
+
            .unwrap_or(i64::MAX);
        let mut stmt = self.db.prepare(
            "DELETE FROM routing
             WHERE node <> ?1 AND rowid IN
modified crates/radicle/src/node/seed.rs
@@ -3,7 +3,6 @@ pub use store::{Error, Store};

use localtime::LocalTime;

-
use crate::git;
use crate::node::KnownAddress;
use crate::prelude::NodeId;
use crate::storage::{refs::RefsAt, ReadRepository, RemoteId};
@@ -14,20 +13,17 @@ use crate::storage::{refs::RefsAt, ReadRepository, RemoteId};
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SyncedAt {
    /// Head of `rad/sigrefs`.
-
    #[cfg_attr(feature = "schemars", schemars(with = "crate::schemars_ext::git::Oid"))]
-
    pub oid: git_ext::Oid,
+
    pub oid: crate::git::Oid,
    /// When these refs were synced.
-
    #[serde(with = "crate::serde_ext::localtime::time")]
-
    #[cfg_attr(
-
        feature = "schemars",
-
        schemars(with = "crate::schemars_ext::localtime::LocalDurationInSeconds")
-
    )]
    pub timestamp: LocalTime,
}

impl SyncedAt {
    /// Load a new [`SyncedAt`] for the given remote.
-
    pub fn load<S: ReadRepository>(repo: &S, remote: RemoteId) -> Result<Self, git::ext::Error> {
+
    pub fn load<S: ReadRepository>(
+
        repo: &S,
+
        remote: RemoteId,
+
    ) -> Result<Self, crate::git::raw::Error> {
        let refs = RefsAt::new(repo, remote)?;
        let oid = refs.at;

@@ -35,7 +31,10 @@ impl SyncedAt {
    }

    /// Create a new [`SyncedAt`] given an OID, by looking up the timestamp in the repo.
-
    pub fn new<S: ReadRepository>(oid: git::ext::Oid, repo: &S) -> Result<Self, git::ext::Error> {
+
    pub fn new<S: ReadRepository>(
+
        oid: crate::git::Oid,
+
        repo: &S,
+
    ) -> Result<Self, crate::git::raw::Error> {
        let timestamp = repo.commit(oid)?.time();
        let timestamp = LocalTime::from_secs(timestamp.seconds() as u64);

modified crates/radicle/src/node/sync.rs
@@ -11,6 +11,8 @@ use crate::prelude::Doc;

use super::NodeId;

+
pub const DEFAULT_REPLICATION_FACTOR: usize = 3;
+

/// A set of nodes that form a private network for fetching from.
///
/// This could be the set of allowed nodes for a private repository, using
@@ -72,7 +74,7 @@ pub struct ReplicationRange {

impl Default for ReplicationFactor {
    fn default() -> Self {
-
        Self::must_reach(3)
+
        Self::must_reach(DEFAULT_REPLICATION_FACTOR)
    }
}

modified crates/radicle/src/profile.rs
@@ -66,12 +66,12 @@ pub mod env {
    // to generate deterministic COB IDs.
    pub const GIT_COMMITTER_DATE: &str = "GIT_COMMITTER_DATE";

-
    /// Commit timestamp to use. Can be overriden by [`RAD_COMMIT_TIME`].
+
    /// Commit timestamp to use. Can be overridden by [`RAD_COMMIT_TIME`].
    pub fn commit_time() -> localtime::LocalTime {
        time(RAD_COMMIT_TIME).unwrap_or_else(local_time)
    }

-
    /// Local time. Can be overriden by [`RAD_LOCAL_TIME`].
+
    /// Local time. Can be overridden by [`RAD_LOCAL_TIME`].
    pub fn local_time() -> localtime::LocalTime {
        time(RAD_LOCAL_TIME).unwrap_or_else(localtime::LocalTime::now)
    }
@@ -88,7 +88,11 @@ pub mod env {

    /// Get the configured pager program from the environment.
    pub fn pager() -> Option<String> {
-
        if let Ok(cfg) = git2::Config::open_default() {
+
        // On Windows, custom pagers configured via Git are not supported,
+
        // because of the complexity surrounding how the pager command is
+
        // parsed and executed. See also <https://stackoverflow.com/a/773973/1835188>.
+
        #[cfg(not(windows))]
+
        if let Ok(cfg) = crate::git::raw::Config::open_default() {
            if let Ok(pager) = cfg.get_string("core.pager") {
                return Some(pager);
            }
@@ -172,14 +176,8 @@ pub enum Error {
    Routing(#[from] node::routing::Error),
    #[error(transparent)]
    Keystore(#[from] keystore::Error),
-
    #[error(transparent)]
-
    MemorySigner(#[from] keystore::MemorySignerError),
    #[error("no radicle profile found at path '{0}'")]
    NotFound(PathBuf),
-
    #[error("error connecting to ssh-agent: {0}")]
-
    Agent(#[from] crate::crypto::ssh::agent::Error),
-
    #[error("radicle key `{0}` is not registered; run `rad auth` to register it with ssh-agent")]
-
    KeyNotRegistered(PublicKey),
    #[error(transparent)]
    PolicyStore(#[from] node::policy::store::Error),
    #[error(transparent)]
@@ -194,6 +192,37 @@ pub enum Error {
    Storage(#[from] storage::Error),
}

+
#[derive(Debug, Error)]
+
pub enum SignerError {
+
    #[error(transparent)]
+
    MemorySigner(#[from] keystore::MemorySignerError),
+

+
    #[error(transparent)]
+
    Agent(#[from] crate::crypto::ssh::agent::Error),
+

+
    #[error("radicle key `{0}` is not registered; run `rad auth` to register it with ssh-agent")]
+
    KeyNotRegistered(PublicKey),
+

+
    #[error(transparent)]
+
    Keystore(#[from] keystore::Error),
+

+
    #[error("error connecting to ssh-agent: {source}")]
+
    AgentConnection {
+
        source: crate::crypto::ssh::agent::Error,
+
    },
+
}
+

+
impl SignerError {
+
    /// Some signer errors are potentially recoverable by prompting the user
+
    /// for a password.
+
    pub fn prompt_for_passphrase(&self) -> bool {
+
        matches!(
+
            self,
+
            Self::AgentConnection { .. } | Self::KeyNotRegistered(_)
+
        )
+
    }
+
}
+

#[derive(Debug, Clone)]
pub struct Profile {
    pub home: Home,
@@ -296,7 +325,7 @@ impl Profile {
        Did::from(self.public_key)
    }

-
    pub fn signer(&self) -> Result<BoxedDevice, Error> {
+
    pub fn signer(&self) -> Result<BoxedDevice, SignerError> {
        if !self.keystore.is_encrypted()? {
            let signer = keystore::MemorySigner::load(&self.keystore, None)?;
            return Ok(Device::from(signer).boxed());
@@ -307,16 +336,12 @@ impl Profile {
            return Ok(Device::from(signer).boxed());
        }

-
        match Agent::connect() {
-
            Ok(agent) => {
-
                let signer = agent.signer(self.public_key);
-
                if signer.is_ready()? {
-
                    Ok(Device::from(signer).boxed())
-
                } else {
-
                    Err(Error::KeyNotRegistered(self.public_key))
-
                }
-
            }
-
            Err(err) => Err(err.into()),
+
        let agent = Agent::connect().map_err(|source| SignerError::AgentConnection { source })?;
+
        let signer = agent.signer(self.public_key);
+
        if signer.is_ready()? {
+
            Ok(Device::from(signer).boxed())
+
        } else {
+
            Err(SignerError::KeyNotRegistered(self.public_key))
        }
    }

@@ -578,32 +603,11 @@ impl Home {
    }

    pub fn socket(&self) -> PathBuf {
-
        use env::RAD_SOCKET;
-

-
        #[cfg(unix)]
        const DEFAULT_SOCKET_NAME: &str = "control.sock";

-
        #[cfg(windows)]
-
        const DEFAULT_SOCKET_NAME: &str = r#"\\.\pipe\radicle-node"#;
-

-
        match env::var_os(RAD_SOCKET).map(PathBuf::from) {
-
            None => {
-
                #[cfg(unix)]
-
                return self.node().join(DEFAULT_SOCKET_NAME);
-

-
                #[cfg(windows)]
-
                return PathBuf::from(DEFAULT_SOCKET_NAME);
-
            }
-
            Some(path) => {
-
                #[cfg(windows)]
-
                {
-
                    const PIPE_PREFIX: &str = r#"\\.\pipe\"#;
-
                    assert!(path.starts_with(PIPE_PREFIX), "The value of the environment variable {RAD_SOCKET} ('{}') must start with {PIPE_PREFIX}. This restriction might be relaxed in the future.", path.display());
-
                }
-

-
                path
-
            }
-
        }
+
        env::var_os(env::RAD_SOCKET)
+
            .map(PathBuf::from)
+
            .unwrap_or_else(|| self.node().join(DEFAULT_SOCKET_NAME))
    }

    /// Return a read-write handle to the notifications database.
@@ -758,15 +762,16 @@ mod test {
    use super::*;

    // Checks that if we have:
-
    // '/run/user/1000/.tmpqfK6ih/../.tmpqfK6ih/Radicle/Home'
+
    // '/run/user/1000/.tmpqfK6ih/../.tmpqfK6ih/Home/Radicle'
    //
    // that it gets normalized to:
-
    // '/run/user/1000/.tmpqfK6ih/Radicle/Home'
+
    // '/run/user/1000/.tmpqfK6ih/Home/Radicle'
    #[test]
    fn canonicalize_home() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("Home").join("Radicle");
        fs::create_dir_all(path.clone()).unwrap();
+
        let path = dunce::canonicalize(path).unwrap();

        let last = tmp.path().components().next_back().unwrap();
        let home = Home::new(
modified crates/radicle/src/profile/config.rs
@@ -192,7 +192,9 @@ impl Config {
            ) {
                log::warn!(target: "radicle", "Overwriting `seedingPolicy` configuration");
                cfg.node.seeding_policy = match policy {
-
                    Policy::Allow => DefaultSeedingPolicy::Allow { scope },
+
                    Policy::Allow => DefaultSeedingPolicy::Allow {
+
                        scope: node::config::Scope::explicit(scope),
+
                    },
                    Policy::Block => DefaultSeedingPolicy::Block,
                }
            }
modified crates/radicle/src/rad.rs
@@ -1,6 +1,6 @@
#![allow(clippy::let_unit_value)]
use std::io;
-
use std::path::{Path, PathBuf};
+
use std::path::Path;
use std::str::FromStr;
use std::sync::LazyLock;

@@ -9,6 +9,7 @@ use thiserror::Error;
use crate::cob::ObjectId;
use crate::crypto::Verified;
use crate::git;
+
use crate::git::BranchName;
use crate::identity::doc;
use crate::identity::doc::{DocError, RepoId, Visibility};
use crate::identity::project::{Project, ProjectName};
@@ -17,25 +18,21 @@ use crate::storage::git::transport;
use crate::storage::git::Repository;
use crate::storage::refs::SignedRefs;
use crate::storage::RepositoryError;
-
use crate::storage::{BranchName, ReadRepository as _, RemoteId, SignRepository as _};
+
use crate::storage::{ReadRepository as _, RemoteId, SignRepository as _};
use crate::storage::{WriteRepository, WriteStorage};
use crate::{identity, storage};

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

#[derive(Error, Debug)]
pub enum InitError {
-
    #[error(
-
        "the Git repository found at {path:?} is a bare repository, expected a working directory"
-
    )]
-
    BareRepository { path: PathBuf },
    #[error("doc: {0}")]
    Doc(#[from] DocError),
    #[error("repository: {0}")]
@@ -43,7 +40,7 @@ pub enum InitError {
    #[error("project payload: {0}")]
    ProjectPayload(String),
    #[error("git: {0}")]
-
    Git(#[from] git2::Error),
+
    Git(#[from] git::raw::Error),
    #[error("i/o: {0}")]
    Io(#[from] io::Error),
    #[error("storage: {0}")]
@@ -52,7 +49,7 @@ pub enum InitError {

/// Initialize a new radicle project from a git repository.
pub fn init<G, S>(
-
    repo: &git2::Repository,
+
    repo: &git::raw::Repository,
    name: ProjectName,
    description: &str,
    default_branch: BranchName,
@@ -100,7 +97,7 @@ where
}

fn init_configure<G>(
-
    repo: &git2::Repository,
+
    repo: &git::raw::Repository,
    stored: &Repository,
    default_branch: &BranchName,
    url: &git::Url,
@@ -114,27 +111,26 @@ where

    git::configure_repository(repo)?;
    git::configure_remote(repo, &REMOTE_NAME, url, &url.clone().with_namespace(*pk))?;
-
    let branch = git::Qualified::from(git::fmt::lit::refs_heads(default_branch));
-
    // Pushes to default branch to the namespace of the `signer`
-
    let pushspec = git::Refspec {
-
        src: branch.clone(),
-
        dst: branch.with_namespace(git::Component::from(pk)),
-
        force: false,
-
    };
-
    git::run::<_, _, &str, &str>(
-
        repo.workdir().ok_or(InitError::BareRepository {
-
            path: repo.path().to_path_buf(),
-
        })?,
-
        [
-
            "push",
-
            &format!("{}", dunce::canonicalize(stored.path())?.display()),
-
            &pushspec.to_string(),
-
        ],
-
        [],
-
    )?;
+
    let branch = git::fmt::Qualified::from(git::fmt::lit::refs_heads(default_branch));
+

+
    {
+
        // Push branch to storage.
+
        let stored = dunce::canonicalize(stored.path())?.display().to_string();
+

+
        // Pushes to default branch to the namespace of the `signer`.
+
        let pushspec = git::fmt::refspec::Refspec {
+
            src: branch.clone(),
+
            dst: branch.with_namespace(git::fmt::Component::from(pk)),
+
            force: false,
+
        }
+
        .to_string();
+

+
        git::run(Some(repo.path()), ["push".to_string(), stored, pushspec])?;
+
    }
+

    // N.b. we need to create the remote branch for the default branch
-
    let rad_remote =
-
        git::Qualified::from(git::lit::refs_remotes(&*REMOTE_COMPONENT)).join(default_branch);
+
    let rad_remote = git::fmt::Qualified::from(git::fmt::lit::refs_remotes(&*REMOTE_COMPONENT))
+
        .join(default_branch);
    let oid = repo.refname_to_id(branch.as_str())?;
    repo.reference(
        rad_remote.as_str(),
@@ -157,7 +153,7 @@ where
#[derive(Error, Debug)]
pub enum ForkError {
    #[error("git: {0}")]
-
    Git(#[from] git2::Error),
+
    Git(#[from] git::raw::Error),
    #[error("storage: {0}")]
    Storage(#[from] storage::Error),
    #[error("payload: {0}")]
@@ -220,7 +216,7 @@ where

    raw.reference(
        &canonical_branch.with_namespace(me.into()),
-
        *canonical_head,
+
        canonical_head.into(),
        true,
        &format!("creating default branch for {me}"),
    )?;
@@ -230,15 +226,18 @@ where
}

#[derive(Error, Debug)]
+
#[non_exhaustive]
pub enum CheckoutError {
-
    #[error(
-
        "the Git repository found at {path:?} is a bare repository, expected a working directory"
-
    )]
-
    BareRepository { path: PathBuf },
-
    #[error("failed to fetch to working copy")]
-
    Fetch(#[source] std::io::Error),
+
    #[error("failed to fetch to working copy: {0}")]
+
    FetchIo(#[source] std::io::Error),
+
    #[error("internal fetch failed with exit status {status}, stderr and stdout follow:\n{stderr}\n{stdout}")]
+
    FetchGit {
+
        status: std::process::ExitStatus,
+
        stderr: String,
+
        stdout: String,
+
    },
    #[error("git: {0}")]
-
    Git(#[from] git2::Error),
+
    Git(#[from] git::raw::Error),
    #[error("payload: {0}")]
    Payload(#[from] doc::PayloadError),
    #[error("repository `{0}` was not found in storage")]
@@ -254,18 +253,20 @@ pub fn checkout<P: AsRef<Path>, S: storage::ReadStorage>(
    remote: &RemoteId,
    path: P,
    storage: &S,
-
) -> Result<git2::Repository, CheckoutError> {
+
    bare: bool,
+
) -> Result<git::raw::Repository, CheckoutError> {
    // TODO: Decide on whether we can use `clone_local`
    // TODO: Look into sharing object databases.
    let doc = storage.get(proj)?.ok_or(CheckoutError::NotFound(proj))?;
    let project = doc.project()?;

-
    let mut opts = git2::RepositoryInitOptions::new();
+
    let mut opts = git::raw::RepositoryInitOptions::new();
    opts.no_reinit(true)
        .external_template(false)
-
        .description(project.description());
+
        .description(project.description())
+
        .bare(bare);

-
    let repo = git2::Repository::init_opts(path.as_ref(), &opts)?;
+
    let repo = git::raw::Repository::init_opts(path.as_ref(), &opts)?;
    let url = git::Url::from(proj);

    // Configure repository for radicle.
@@ -277,33 +278,35 @@ pub fn checkout<P: AsRef<Path>, S: storage::ReadStorage>(
        &url,
        &url.clone().with_namespace(*remote),
    )?;
-
    let fetchspec = git::Refspec {
-
        src: git::refspec::pattern!("refs/heads/*"),
-
        dst: git::Qualified::from(git::lit::refs_remotes(&*REMOTE_NAME))
-
            .to_pattern(git::refspec::STAR)
-
            .into_patternstring(),
-
        force: false,
-
    };
-
    let stored = storage.repository(proj)?;
-
    let workdir = repo.workdir().ok_or(CheckoutError::BareRepository {
-
        path: repo.path().to_path_buf(),
-
    })?;

-
    git::run::<_, _, &str, &str>(
-
        workdir,
-
        [
-
            "fetch",
-
            &format!(
-
                "{}",
-
                dunce::canonicalize(stored.path())
-
                    .map_err(CheckoutError::Fetch)?
-
                    .display()
-
            ),
-
            &fetchspec.to_string(),
-
        ],
-
        [],
-
    )
-
    .map_err(CheckoutError::Fetch)?;
+
    {
+
        // Fetch remote head to working copy.
+

+
        let fetchspec = git::fmt::refspec::Refspec {
+
            src: git::fmt::pattern!("refs/heads/*"),
+
            dst: git::fmt::Qualified::from(git::fmt::lit::refs_remotes(&*REMOTE_NAME))
+
                .to_pattern(git::fmt::refspec::STAR)
+
                .into_patternstring(),
+
            force: false,
+
        }
+
        .to_string();
+

+
        let stored = dunce::canonicalize(storage.repository(proj)?.path())
+
            .map_err(CheckoutError::FetchIo)?
+
            .display()
+
            .to_string();
+

+
        let output = git::run(Some(repo.path()), ["fetch", &stored, &fetchspec])
+
            .map_err(CheckoutError::FetchIo)?;
+

+
        if !output.status.success() {
+
            return Err(CheckoutError::FetchGit {
+
                status: output.status,
+
                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
+
                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
+
            });
+
        }
+
    }

    {
        // Setup default branch.
@@ -319,7 +322,9 @@ pub fn checkout<P: AsRef<Path>, S: storage::ReadStorage>(
            .expect("checkout: default branch name is valid UTF-8");

        repo.set_head(branch_ref)?;
-
        repo.checkout_head(None)?;
+
        if !bare {
+
            repo.checkout_head(None)?;
+
        }

        // Setup remote tracking for default branch.
        git::set_upstream(&repo, &*REMOTE_NAME, project.default_branch(), branch_ref)?;
@@ -331,7 +336,7 @@ pub fn checkout<P: AsRef<Path>, S: storage::ReadStorage>(
#[derive(Error, Debug)]
pub enum RemoteError {
    #[error("git: {0}")]
-
    Git(#[from] git2::Error),
+
    Git(#[from] git::raw::Error),
    #[error("invalid remote url: {0}")]
    Url(#[from] transport::local::UrlError),
    #[error("invalid utf-8 string")]
@@ -343,9 +348,9 @@ pub enum RemoteError {
}

/// Get the radicle ("rad") remote of a repository, and return the associated project id.
-
pub fn remote(repo: &git2::Repository) -> Result<(git2::Remote<'_>, RepoId), RemoteError> {
+
pub fn remote(repo: &git::raw::Repository) -> Result<(git::raw::Remote<'_>, RepoId), RemoteError> {
    let remote = repo.find_remote(&REMOTE_NAME).map_err(|e| {
-
        if e.code() == git2::ErrorCode::NotFound {
+
        if e.code() == git::raw::ErrorCode::NotFound {
            RemoteError::NotFound(REMOTE_NAME.to_string())
        } else {
            RemoteError::from(e)
@@ -358,9 +363,9 @@ pub fn remote(repo: &git2::Repository) -> Result<(git2::Remote<'_>, RepoId), Rem
}

/// Delete the radicle ("rad") remote of a repository.
-
pub fn remove_remote(repo: &git2::Repository) -> Result<(), RemoteError> {
+
pub fn remove_remote(repo: &git::raw::Repository) -> Result<(), RemoteError> {
    repo.remote_delete(&REMOTE_NAME).map_err(|e| {
-
        if e.code() == git2::ErrorCode::NotFound {
+
        if e.code() == git::raw::ErrorCode::NotFound {
            RemoteError::NotFound(REMOTE_NAME.to_string())
        } else {
            RemoteError::from(e)
@@ -369,9 +374,21 @@ pub fn remove_remote(repo: &git2::Repository) -> Result<(), RemoteError> {
    Ok(())
}

+
#[derive(Error, Debug)]
+
pub enum CwdError {
+
    #[error(transparent)]
+
    Remote(#[from] RemoteError),
+

+
    #[error("Detection failed (git: '{git}', jj: '{jj}')")]
+
    Detection {
+
        git: git::raw::Error,
+
        jj: JujutsuGitRootError,
+
    },
+
}
+

/// Get the RID of the repository in current working directory
///
-
/// It will atempt to search parent directories if `path` did not find
+
/// It will attempt to search parent directories if `path` did not find
/// a git repository.
///
/// # Safety
@@ -379,47 +396,76 @@ pub fn remove_remote(repo: &git2::Repository) -> Result<(), RemoteError> {
/// This function should only perform read operations since we do not
/// want to modify the wrong repository in the case that it found a
/// Git repository that is not a Radicle repository.
-
pub fn cwd() -> Result<(git2::Repository, RepoId), RemoteError> {
-
    let repo = repo()?;
-
    let (_, id) = remote(&repo)?;
+
pub fn cwd() -> Result<(git::raw::Repository, RepoId), CwdError> {
+
    let repo =
+
        repo().or_else(|git| repo_jj_git_root().map_err(|jj| CwdError::Detection { git, jj }))?;

+
    let (_, id) = remote(&repo)?;
    Ok((repo, id))
}

/// Get the repository of project in specified directory
-
pub fn at(path: impl AsRef<Path>) -> Result<(git2::Repository, RepoId), RemoteError> {
-
    let repo = git2::Repository::open(path)?;
+
pub fn at(path: impl AsRef<Path>) -> Result<(git::raw::Repository, RepoId), RemoteError> {
+
    let repo = git::raw::Repository::open(path)?;
    let (_, id) = remote(&repo)?;

    Ok((repo, id))
}

/// Get the current Git repository.
-
pub fn repo() -> Result<git2::Repository, git2::Error> {
-
    let mut flags = git2::RepositoryOpenFlags::empty();
+
pub fn repo() -> Result<git::raw::Repository, git::raw::Error> {
+
    let mut flags = git::raw::RepositoryOpenFlags::empty();
    // Allow to search upwards.
-
    flags.set(git2::RepositoryOpenFlags::NO_SEARCH, false);
+
    flags.set(git::raw::RepositoryOpenFlags::NO_SEARCH, false);
    // Allow to use `GIT_DIR` env.
-
    flags.set(git2::RepositoryOpenFlags::FROM_ENV, true);
+
    flags.set(git::raw::RepositoryOpenFlags::FROM_ENV, true);

    let ceilings: &[&str] = &[];
-
    let repo = git2::Repository::open_ext(Path::new("."), flags, ceilings)?;
+
    let repo = git::raw::Repository::open_ext(Path::new("."), flags, ceilings)?;

    Ok(repo)
}

+
#[derive(Error, Debug)]
+
pub enum JujutsuGitRootError {
+
    #[error("git: {0}")]
+
    Git(#[from] git::raw::Error),
+

+
    #[error("i/o: {0}")]
+
    Io(#[from] io::Error),
+

+
    #[error("exited with status {status}")]
+
    CommandFailure { status: std::process::ExitStatus },
+
}
+

+
/// Get the Git repo underlying the current Jujutsu repository.
+
pub fn repo_jj_git_root() -> Result<git::raw::Repository, JujutsuGitRootError> {
+
    let output = std::process::Command::new("jj")
+
        .args(["git", "root"])
+
        .output()?;
+

+
    if !output.status.success() {
+
        return Err(JujutsuGitRootError::CommandFailure {
+
            status: output.status,
+
        });
+
    }
+

+
    let path = std::path::PathBuf::from(String::from_utf8_lossy(&output.stdout).to_string().trim());
+
    Ok(git::raw::Repository::open(path)?)
+
}
+

/// Setup patch upstream branch such that `git push` updates the patch.
pub fn setup_patch_upstream<'a>(
    patch: &ObjectId,
-
    patch_head: git::Oid,
-
    working: &'a git::raw::Repository,
-
    remote: &git::RefString,
+
    patch_head: crate::git::Oid,
+
    working: &'a crate::git::raw::Repository,
+
    remote: &git::fmt::RefString,
    force: bool,
-
) -> Result<Option<git::raw::Branch<'a>>, git::ext::Error> {
+
) -> Result<Option<crate::git::raw::Branch<'a>>, crate::git::raw::Error> {
    let head = working.head()?;

    // Don't do anything in case we're not on the patch branch.
-
    if head.peel_to_commit()?.id() != *patch_head {
+
    if patch_head != head.peel_to_commit()?.id() {
        return Ok(None);
    }
    let Ok(r) = head.resolve() else {
@@ -431,18 +477,18 @@ pub fn setup_patch_upstream<'a>(
        return Ok(None);
    }

-
    let branch = git::raw::Branch::wrap(r);
+
    let branch = crate::git::raw::Branch::wrap(r);

    // Only set the upstream if it's missing or `force` is `true`
    if branch.upstream().is_ok() && !force {
        return Ok(None);
    }

-
    let name: Option<git::RefString> = branch.name()?.and_then(|b| b.try_into().ok());
+
    let name: Option<git::fmt::RefString> = branch.name()?.and_then(|b| b.try_into().ok());
    let remote_branch = git::refs::workdir::patch_upstream(patch);
    let remote_branch = working.reference(
        &remote_branch,
-
        *patch_head,
+
        patch_head.into(),
        true,
        "Create remote tracking branch for patch",
    )?;
@@ -453,7 +499,7 @@ pub fn setup_patch_upstream<'a>(
            git::set_upstream(working, remote, name.as_str(), git::refs::patch(patch))?;
        }
    }
-
    Ok(Some(git::raw::Branch::wrap(remote_branch)))
+
    Ok(Some(crate::git::raw::Branch::wrap(remote_branch)))
}

#[cfg(test)]
@@ -463,12 +509,12 @@ mod tests {

    use pretty_assertions::assert_eq;

-
    use crate::git::{name::component, qualified};
    use crate::identity::Did;
    use crate::storage::git::transport;
    use crate::storage::git::Storage;
    use crate::storage::{ReadStorage, RemoteRepository as _};
    use crate::test::fixtures;
+
    use git::fmt::{component, qualified};

    use super::*;

@@ -486,7 +532,7 @@ mod tests {
            &repo,
            "acme".try_into().unwrap(),
            "Acme's repo",
-
            git::refname!("master"),
+
            git::fmt::refname!("master"),
            Visibility::default(),
            &signer,
            &storage,
@@ -508,19 +554,19 @@ mod tests {

        // Test canonical refs.
        assert_eq!(refs.head(component!("master")).unwrap(), head);
-
        assert_eq!(project_repo.raw().refname_to_id("HEAD").unwrap(), *head);
+
        assert_eq!(head, project_repo.raw().refname_to_id("HEAD").unwrap());
        assert_eq!(
+
            head,
            project_repo
                .raw()
                .refname_to_id("refs/heads/master")
                .unwrap(),
-
            *head
        );

        assert_eq!(remotes[&public_key].refs, refs);
        assert_eq!(project.name(), "acme");
        assert_eq!(project.description(), "Acme's repo");
-
        assert_eq!(project.default_branch(), &git::refname!("master"));
+
        assert_eq!(project.default_branch(), &git::fmt::refname!("master"));
        assert_eq!(doc.delegates().first(), &Did::from(public_key));
    }

@@ -541,7 +587,7 @@ mod tests {
            &original,
            "acme".try_into().unwrap(),
            "Acme's repo",
-
            git::refname!("master"),
+
            git::fmt::refname!("master"),
            Visibility::default(),
            &alice,
            &storage,
@@ -550,7 +596,7 @@ mod tests {

        // Bob forks it and creates a checkout.
        fork(id, &bob, &storage).unwrap();
-
        checkout(id, bob_id, tempdir.path().join("copy"), &storage).unwrap();
+
        checkout(id, bob_id, tempdir.path().join("copy"), &storage, false).unwrap();

        let bob_remote = storage.repository(id).unwrap().remote(bob_id).unwrap();

@@ -577,7 +623,7 @@ mod tests {
            &original,
            "acme".try_into().unwrap(),
            "Acme's repo",
-
            git::refname!("master"),
+
            git::fmt::refname!("master"),
            Visibility::default(),
            &signer,
            &storage,
@@ -585,7 +631,7 @@ mod tests {
        .unwrap();
        git::set_upstream(&original, "rad", "master", "refs/heads/master").unwrap();

-
        let copy = checkout(id, remote_id, tempdir.path().join("copy"), &storage).unwrap();
+
        let copy = checkout(id, remote_id, tempdir.path().join("copy"), &storage, false).unwrap();

        assert_eq!(
            copy.head().unwrap().target(),
modified crates/radicle/src/schemars_ext.rs
@@ -4,29 +4,6 @@

use schemars::JsonSchema;

-
pub mod crypto {
-
    use super::*;
-
    /// See [`crate::node::NodeId`]
-
    /// See [`crate::storage::RemoteId`]
-
    /// See [`::crypto::PublicKey`]
-
    ///
-
    /// An Ed25519 public key in multibase encoding.
-
    ///
-
    /// `MULTIBASE(base58-btc, MULTICODEC(public-key-type, raw-public-key-bytes))`
-
    #[derive(JsonSchema)]
-
    #[schemars(
-
    title = "NodeId",
-
    description = "An Ed25519 public key in multibase encoding.",
-
    extend("examples" = [
-
        "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7",
-
        "z6MkvUJtYD9dHDJfpevWRT98mzDDpdAtmUjwyDSkyqksUr7C",
-
        "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
        "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
-
    ]),
-
)]
-
    pub struct PublicKey(String);
-
}
-

pub(crate) mod log {
    use super::*;

@@ -69,44 +46,10 @@ pub(crate) mod bytesize {
    );
}

-
pub(crate) mod localtime {
-
    use super::*;
-

-
    /// See [`::localtime::LocalDuration`]
-
    #[derive(JsonSchema)]
-
    #[schemars(
-
        remote = "localtime::LocalDuration",
-
        description = "A time duration measured locally in milliseconds."
-
    )]
-
    pub(crate) struct LocalDuration(u64);
-

-
    /// See [`crate::serde_ext::localtime::time`]
-
    #[derive(JsonSchema)]
-
    #[schemars(
-
        remote = "localtime::LocalDuration",
-
        description = "A time duration measured locally in seconds."
-
    )]
-
    pub(crate) struct LocalDurationInSeconds(u64);
-
}
-

pub(crate) mod git {
-
    use super::*;
-

-
    /// See [`crate::git::Oid`]
-
    /// See [`::git_ext::Oid`]
-
    /// See [`::git2::Oid`]
-
    ///
-
    /// A Git Object Identifier in hexadecimal encoding.
-
    #[derive(JsonSchema)]
-
    #[schemars(
-
        remote = "git2::Oid",
-
        description = "A Git Object Identifier (SHA-1 or SHA-256 hash) in hexadecimal encoding."
-
    )]
-
    pub(crate) struct Oid(
-
        #[schemars(regex(pattern = r"^([0-9a-fA-F]{64}|[0-9a-fA-F]{40})$"))] String,
-
    );
-

-
    /// See [`crate::git::RefString`]
-
    #[derive(JsonSchema)]
-
    pub(crate) struct RefString(String);
+
    pub(crate) mod fmt {
+
        /// See [`crate::git::fmt::RefString`]
+
        #[derive(schemars::JsonSchema)]
+
        pub(crate) struct RefString(String);
+
    }
}
modified crates/radicle/src/serde_ext.rs
@@ -31,80 +31,6 @@ pub mod string {
    }
}

-
/// Unlike the default `serde` instances from `localtime`, this encodes and decodes using seconds
-
/// instead of milliseconds.
-
pub mod localtime {
-
    pub mod time {
-
        use localtime::LocalTime;
-
        use serde::{Deserialize, Deserializer, Serializer};
-

-
        pub fn serialize<S>(value: &LocalTime, serializer: S) -> Result<S::Ok, S::Error>
-
        where
-
            S: Serializer,
-
        {
-
            serializer.serialize_u64(value.as_secs())
-
        }
-

-
        pub fn deserialize<'de, D>(deserializer: D) -> Result<LocalTime, D::Error>
-
        where
-
            D: Deserializer<'de>,
-
        {
-
            let seconds = u64::deserialize(deserializer)?;
-

-
            Ok(LocalTime::from_secs(seconds))
-
        }
-
    }
-

-
    pub mod option {
-
        pub mod time {
-
            use localtime::LocalTime;
-
            use serde::{Deserialize, Deserializer, Serializer};
-

-
            pub fn serialize<S>(value: &Option<LocalTime>, serializer: S) -> Result<S::Ok, S::Error>
-
            where
-
                S: Serializer,
-
            {
-
                match value {
-
                    Some(time) => serializer.serialize_some(&time.as_secs()),
-
                    None => serializer.serialize_none(),
-
                }
-
            }
-

-
            pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<LocalTime>, D::Error>
-
            where
-
                D: Deserializer<'de>,
-
            {
-
                let option = Option::<u64>::deserialize(deserializer)?;
-
                match option {
-
                    Some(seconds) => Ok(Some(LocalTime::from_secs(seconds))),
-
                    None => Ok(None),
-
                }
-
            }
-
        }
-
    }
-

-
    pub mod duration {
-
        use localtime::LocalDuration;
-
        use serde::{Deserialize, Deserializer, Serializer};
-

-
        pub fn serialize<S>(value: &LocalDuration, serializer: S) -> Result<S::Ok, S::Error>
-
        where
-
            S: Serializer,
-
        {
-
            serializer.serialize_u64(value.as_secs())
-
        }
-

-
        pub fn deserialize<'de, D>(deserializer: D) -> Result<LocalDuration, D::Error>
-
        where
-
            D: Deserializer<'de>,
-
        {
-
            let seconds = u64::deserialize(deserializer)?;
-

-
            Ok(LocalDuration::from_secs(seconds))
-
        }
-
    }
-
}
-

/// Return true if the given value is the default for that type.
pub fn is_default<T: Default + PartialEq>(t: &T) -> bool {
    t == &T::default()
@@ -119,53 +45,3 @@ where
    let v: serde_json::Value = serde::Deserialize::deserialize(deserializer)?;
    Ok(T::deserialize(v).unwrap_or_default())
}
-

-
#[cfg(test)]
-
#[allow(clippy::unwrap_used)]
-
mod test {
-
    use super::*;
-

-
    use ::localtime::LocalTime;
-

-
    #[test]
-
    fn test_localtime() {
-
        #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)]
-
        struct Test {
-
            time: LocalTime,
-
        }
-
        let value = Test {
-
            time: LocalTime::from_millis(1699636852107),
-
        };
-

-
        assert_eq!(
-
            serde_json::from_str::<Test>(r#"{"time":1699636852107}"#).unwrap(),
-
            value
-
        );
-
        assert_eq!(
-
            serde_json::from_str::<Test>(serde_json::to_string(&value).unwrap().as_str()).unwrap(),
-
            value
-
        );
-
    }
-

-
    #[test]
-
    // Tests serialization into seconds instead of milliseconds.
-
    fn test_localtime_ext() {
-
        #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)]
-
        struct Test {
-
            #[serde(with = "localtime::time")]
-
            time: LocalTime,
-
        }
-
        let value = Test {
-
            time: LocalTime::from_secs(1699636852107),
-
        };
-

-
        assert_eq!(
-
            serde_json::from_str::<Test>(r#"{"time":1699636852107}"#).unwrap(),
-
            value
-
        );
-
        assert_eq!(
-
            serde_json::from_str::<Test>(serde_json::to_string(&value).unwrap().as_str()).unwrap(),
-
            value
-
        );
-
    }
-
}
modified crates/radicle/src/sql.rs
@@ -4,7 +4,6 @@ use std::str::FromStr;
use sqlite as sql;
use sqlite::Value;

-
use crate::identity::RepoId;
use crate::node;
use crate::node::{Address, UserAgent};

@@ -28,29 +27,6 @@ pub fn transaction<T, E: From<sql::Error>>(
    }
}

-
impl TryFrom<&Value> for RepoId {
-
    type Error = sql::Error;
-

-
    fn try_from(value: &Value) -> Result<Self, Self::Error> {
-
        match value {
-
            Value::String(id) => RepoId::from_urn(id).map_err(|e| sql::Error {
-
                code: None,
-
                message: Some(e.to_string()),
-
            }),
-
            _ => Err(sql::Error {
-
                code: None,
-
                message: Some(format!("sql: invalid type `{:?}` for id", value.kind())),
-
            }),
-
        }
-
    }
-
}
-

-
impl sqlite::BindableWithIndex for &RepoId {
-
    fn bind<I: sql::ParameterIndex>(self, stmt: &mut sql::Statement<'_>, i: I) -> sql::Result<()> {
-
        self.urn().as_str().bind(stmt, i)
-
    }
-
}
-

impl sql::BindableWithIndex for node::Features {
    fn bind<I: sql::ParameterIndex>(self, stmt: &mut sql::Statement<'_>, i: I) -> sql::Result<()> {
        (*self.deref() as i64).bind(stmt, i)
modified crates/radicle/src/storage.rs
@@ -10,14 +10,16 @@ use nonempty::NonEmpty;
use serde::{Deserialize, Serialize};
use thiserror::Error;

+
pub use crate::git::Oid;
use crypto::{PublicKey, Unverified, Verified};
pub use git::{Validation, Validations};
-
pub use radicle_git_ext::Oid;

use crate::cob;
use crate::collections::RandomMap;
-
use crate::git::{canonical, ext as git_ext};
-
use crate::git::{refspec::Refspec, PatternString, Qualified, RefError, RefStr, RefString};
+
use crate::git::canonical;
+
use crate::git::fmt::{refspec::PatternString, refspec::Refspec, Qualified, RefStr, RefString};
+
use crate::git::raw::ErrorExt as _;
+
use crate::git::RefError;
use crate::identity::{doc, Did, PayloadError};
use crate::identity::{Doc, DocAt, DocError};
use crate::identity::{Identity, RepoId};
@@ -26,10 +28,8 @@ use crate::node::SyncedAt;
use crate::storage::git::NAMESPACES_GLOB;
use crate::storage::refs::Refs;

-
use self::git::UserInfo;
use self::refs::{RefsAt, SignedRefs};
-

-
pub type BranchName = git::RefString;
+
use crate::git::UserInfo;

/// Basic repository information.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -68,7 +68,9 @@ impl Namespaces {
            Namespaces::Followed(pks) => pks
                .iter()
                .map(|pk| {
-
                    let ns = pk.to_namespace().with_pattern(git::refspec::STAR);
+
                    let ns = pk
+
                        .to_namespace()
+
                        .with_pattern(crate::git::fmt::refspec::STAR);
                    Refspec {
                        src: ns.clone(),
                        dst: ns,
@@ -113,9 +115,7 @@ pub enum RepositoryError {
    #[error(transparent)]
    Payload(#[from] PayloadError),
    #[error(transparent)]
-
    Git(#[from] git::raw::Error),
-
    #[error(transparent)]
-
    GitExt(#[from] git_ext::Error),
+
    Git(#[from] crate::git::raw::Error),
    #[error(transparent)]
    Quorum(#[from] canonical::error::QuorumError),
    #[error(transparent)]
@@ -133,9 +133,8 @@ pub enum RepositoryError {
impl RepositoryError {
    pub fn is_not_found(&self) -> bool {
        match self {
-
            Self::Storage(e) if e.is_not_found() => true,
-
            Self::Git(e) if git_ext::is_not_found_err(e) => true,
-
            Self::GitExt(git_ext::Error::NotFound(_)) => true,
+
            Self::Storage(e) => e.is_not_found(),
+
            Self::Git(e) => e.is_not_found(),
            _ => false,
        }
    }
@@ -153,9 +152,7 @@ pub enum Error {
    #[error(transparent)]
    Refs(#[from] refs::Error),
    #[error("git: {0}")]
-
    Git(#[from] git2::Error),
-
    #[error("git: {0}")]
-
    Ext(#[from] git::ext::Error),
+
    Git(#[from] crate::git::raw::Error),
    #[error("invalid repository identifier {0:?}")]
    InvalidId(std::ffi::OsString),
    #[error("i/o: {0}")]
@@ -167,7 +164,7 @@ impl Error {
    pub fn is_not_found(&self) -> bool {
        match self {
            Self::Io(e) if e.kind() == io::ErrorKind::NotFound => true,
-
            Self::Git(e) if git::ext::is_not_found_err(e) => true,
+
            Self::Git(e) if e.is_not_found() => true,
            Self::Doc(e) if e.is_not_found() => true,
            _ => false,
        }
@@ -179,7 +176,7 @@ impl Error {
#[allow(clippy::large_enum_variant)]
pub enum FetchError {
    #[error("git: {0}")]
-
    Git(#[from] git2::Error),
+
    Git(#[from] crate::git::raw::Error),
    #[error("i/o: {0}")]
    Io(#[from] io::Error),
    #[error(transparent)]
@@ -204,36 +201,31 @@ pub enum RefUpdate {
    Updated {
        #[cfg_attr(
            feature = "schemars",
-
            schemars(with = "crate::schemars_ext::git::RefString")
+
            schemars(with = "crate::schemars_ext::git::fmt::RefString")
        )]
        name: RefString,
-
        #[cfg_attr(feature = "schemars", schemars(with = "crate::schemars_ext::git::Oid"))]
        old: Oid,
-
        #[cfg_attr(feature = "schemars", schemars(with = "crate::schemars_ext::git::Oid"))]
        new: Oid,
    },
    Created {
        #[cfg_attr(
            feature = "schemars",
-
            schemars(with = "crate::schemars_ext::git::RefString")
+
            schemars(with = "crate::schemars_ext::git::fmt::RefString")
        )]
        name: RefString,
-
        #[cfg_attr(feature = "schemars", schemars(with = "crate::schemars_ext::git::Oid"))]
        oid: Oid,
    },
    Deleted {
        #[cfg_attr(
            feature = "schemars",
-
            schemars(with = "crate::schemars_ext::git::RefString")
+
            schemars(with = "crate::schemars_ext::git::fmt::RefString")
        )]
        name: RefString,
-
        #[cfg_attr(feature = "schemars", schemars(with = "crate::schemars_ext::git::Oid"))]
        oid: Oid,
    },
    Skipped {
        #[cfg_attr(feature = "schemars", schemars(with = "String"))]
        name: RefString,
-
        #[cfg_attr(feature = "schemars", schemars(with = "crate::schemars_ext::git::Oid"))]
        oid: Oid,
    },
}
@@ -503,16 +495,20 @@ pub trait ReadRepository: Sized + ValidateRepository {
    fn id(&self) -> RepoId;

    /// Returns `true` if there are no references in the repository.
-
    fn is_empty(&self) -> Result<bool, git2::Error>;
+
    fn is_empty(&self) -> Result<bool, crate::git::raw::Error>;

    /// The [`Path`] to the git repository.
    fn path(&self) -> &Path;

    /// Get a blob in this repository at the given commit and path.
-
    fn blob_at<P: AsRef<Path>>(&self, commit: Oid, path: P) -> Result<git2::Blob, git_ext::Error>;
+
    fn blob_at<P: AsRef<Path>>(
+
        &self,
+
        commit: Oid,
+
        path: P,
+
    ) -> Result<crate::git::raw::Blob<'_>, crate::git::raw::Error>;

    /// Get a blob in this repository, given its id.
-
    fn blob(&self, oid: Oid) -> Result<git2::Blob, git_ext::Error>;
+
    fn blob(&self, oid: Oid) -> Result<crate::git::raw::Blob<'_>, crate::git::raw::Error>;

    /// Get the head of this repository.
    ///
@@ -520,14 +516,14 @@ pub trait ReadRepository: Sized + ValidateRepository {
    /// head using [`ReadRepository::canonical_head`].
    ///
    /// Returns the [`Oid`] as well as the qualified reference name.
-
    fn head(&self) -> Result<(Qualified, Oid), RepositoryError>;
+
    fn head(&self) -> Result<(Qualified<'_>, Oid), RepositoryError>;

    /// Compute the canonical head of this repository.
    ///
    /// Ignores any existing `HEAD` reference.
    ///
    /// Returns the [`Oid`] as well as the qualified reference name.
-
    fn canonical_head(&self) -> Result<(Qualified, Oid), RepositoryError>;
+
    fn canonical_head(&self) -> Result<(Qualified<'_>, Oid), RepositoryError>;

    /// Get the head of the `rad/id` reference in this repository.
    ///
@@ -536,12 +532,12 @@ pub trait ReadRepository: Sized + ValidateRepository {
    fn identity_head(&self) -> Result<Oid, RepositoryError>;

    /// Get the identity head of a specific remote.
-
    fn identity_head_of(&self, remote: &RemoteId) -> Result<Oid, git::ext::Error>;
+
    fn identity_head_of(&self, remote: &RemoteId) -> Result<Oid, crate::git::raw::Error>;

    /// Get the root commit of the canonical identity branch.
    fn identity_root(&self) -> Result<Oid, RepositoryError>;

-
    /// Get the root commit of the identity branch of a sepcific remote.
+
    /// Get the root commit of the identity branch of a specific remote.
    fn identity_root_of(&self, remote: &RemoteId) -> Result<Oid, RepositoryError>;

    /// Load the identity history.
@@ -572,28 +568,28 @@ pub trait ReadRepository: Sized + ValidateRepository {
        &self,
        remote: &RemoteId,
        reference: &Qualified,
-
    ) -> Result<git2::Reference, git_ext::Error>;
+
    ) -> Result<crate::git::raw::Reference<'_>, crate::git::raw::Error>;

-
    /// Get the [`git2::Commit`] found using its `oid`.
+
    /// Get the [`crate::git::raw::Commit`] found using its `oid`.
    ///
    /// Returns `Err` if the commit did not exist.
-
    fn commit(&self, oid: Oid) -> Result<git2::Commit, git::ext::Error>;
+
    fn commit(&self, oid: Oid) -> Result<crate::git::raw::Commit<'_>, crate::git::raw::Error>;

    /// Perform a revision walk of a commit history starting from the given head.
-
    fn revwalk(&self, head: Oid) -> Result<git2::Revwalk, git2::Error>;
+
    fn revwalk(&self, head: Oid) -> Result<crate::git::raw::Revwalk<'_>, crate::git::raw::Error>;

    /// Check if the underlying ODB contains the given `oid`.
-
    fn contains(&self, oid: Oid) -> Result<bool, git2::Error>;
+
    fn contains(&self, oid: Oid) -> Result<bool, crate::git::raw::Error>;

    /// Check whether the given commit is an ancestor of another commit.
-
    fn is_ancestor_of(&self, ancestor: Oid, head: Oid) -> Result<bool, git::ext::Error>;
+
    fn is_ancestor_of(&self, ancestor: Oid, head: Oid) -> Result<bool, crate::git::raw::Error>;

    /// Get the object id of a reference under the given remote.
    fn reference_oid(
        &self,
        remote: &RemoteId,
        reference: &Qualified,
-
    ) -> Result<Oid, git::raw::Error>;
+
    ) -> Result<Oid, crate::git::raw::Error>;

    /// Get all references of the given remote.
    fn references_of(&self, remote: &RemoteId) -> Result<Refs, Error>;
@@ -602,11 +598,11 @@ pub trait ReadRepository: Sized + ValidateRepository {
    /// Skips references with names that are not parseable into [`Qualified`].
    ///
    /// This function always peels reference to the commit. For tags, this means the [`Oid`] of the
-
    /// commit pointed to by the tag is returned, and not the [`Oid`] of the tag itsself.
+
    /// commit pointed to by the tag is returned, and not the [`Oid`] of the tag itself.
    fn references_glob(
        &self,
-
        pattern: &git::PatternStr,
-
    ) -> Result<Vec<(Qualified, Oid)>, git::ext::Error>;
+
        pattern: &crate::git::fmt::refspec::PatternStr,
+
    ) -> Result<Vec<(Qualified<'_>, Oid)>, crate::git::raw::Error>;

    /// Get repository delegates.
    fn delegates(&self) -> Result<NonEmpty<Did>, RepositoryError> {
@@ -627,7 +623,7 @@ pub trait ReadRepository: Sized + ValidateRepository {
    fn identity_doc_at(&self, head: Oid) -> Result<DocAt, DocError>;

    /// Get the merge base of two commits.
-
    fn merge_base(&self, left: &Oid, right: &Oid) -> Result<Oid, git::ext::Error>;
+
    fn merge_base(&self, left: &Oid, right: &Oid) -> Result<Oid, crate::git::raw::Error>;
}

/// Access the remotes of a repository.
@@ -692,7 +688,7 @@ pub trait WriteRepository: ReadRepository + SignRepository {
    /// Set the user info of the Git repository.
    fn set_user(&self, info: &UserInfo) -> Result<(), Error>;
    /// Get the underlying git repository.
-
    fn raw(&self) -> &git2::Repository;
+
    fn raw(&self) -> &crate::git::raw::Repository;
}

/// Allows signing refs.
modified crates/radicle/src/storage/git.rs
@@ -2,6 +2,9 @@
pub mod cob;
pub mod transport;

+
pub mod temp;
+
pub use temp::TempRepository;
+

use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
@@ -9,9 +12,9 @@ use std::sync::LazyLock;
use std::{fs, io};

use crypto::Verified;
-
use tempfile::TempDir;

use crate::git::canonical::Quorum;
+
use crate::git::raw::ErrorExt as _;
use crate::identity::crefs::GetCanonicalRefs as _;
use crate::identity::doc::DocError;
use crate::identity::{CanonicalRefs, Doc, DocAt, RepoId};
@@ -24,24 +27,26 @@ use crate::storage::{
    ReadRepository, ReadStorage, Remote, Remotes, RepositoryInfo, SetHead, SignRepository,
    WriteRepository, WriteStorage,
};
-
use crate::{git, node};
+
use crate::{git, git::Oid, node};

-
pub use crate::git::{
-
    ext, raw, refname, refspec, Oid, PatternStr, Qualified, RefError, RefString, UserInfo,
+
use crate::git::fmt::{
+
    refname, refspec, refspec::PatternStr, refspec::PatternString, Qualified, RefString,
};
+
use crate::git::RefError;
+
use crate::git::UserInfo;
pub use crate::storage::{Error, RepositoryError};

use super::refs::RefsAt;
use super::{RemoteId, RemoteRepository, ValidateRepository};

-
pub static NAMESPACES_GLOB: LazyLock<git::refspec::PatternString> =
-
    LazyLock::new(|| git::refspec::pattern!("refs/namespaces/*"));
+
pub static NAMESPACES_GLOB: LazyLock<PatternString> =
+
    LazyLock::new(|| git::fmt::pattern!("refs/namespaces/*"));
pub static SIGREFS_GLOB: LazyLock<refspec::PatternString> =
-
    LazyLock::new(|| git::refspec::pattern!("refs/namespaces/*/rad/sigrefs"));
-
pub static CANONICAL_IDENTITY: LazyLock<git::Qualified> = LazyLock::new(|| {
-
    git::Qualified::from_components(
-
        git::name::component!("rad"),
-
        git::name::component!("id"),
+
    LazyLock::new(|| git::fmt::pattern!("refs/namespaces/*/rad/sigrefs"));
+
pub static CANONICAL_IDENTITY: LazyLock<git::fmt::Qualified> = LazyLock::new(|| {
+
    git::fmt::Qualified::from_components(
+
        git::fmt::component!("rad"),
+
        git::fmt::component!("id"),
        None,
    )
});
@@ -49,15 +54,15 @@ pub static CANONICAL_IDENTITY: LazyLock<git::Qualified> = LazyLock::new(|| {
/// A parsed Git reference.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ref {
-
    pub oid: git::Oid,
+
    pub oid: crate::git::Oid,
    pub name: RefString,
    pub namespace: Option<RemoteId>,
}

-
impl TryFrom<git2::Reference<'_>> for Ref {
+
impl TryFrom<git::raw::Reference<'_>> for Ref {
    type Error = RefError;

-
    fn try_from(r: git2::Reference) -> Result<Self, Self::Error> {
+
    fn try_from(r: git::raw::Reference) -> Result<Self, Self::Error> {
        let name = r.name().ok_or(RefError::InvalidName)?;
        let (namespace, name) = match git::parse_ref_namespaced::<RemoteId>(name) {
            Ok((namespace, refname)) => (Some(namespace), refname.to_ref_string()),
@@ -121,12 +126,22 @@ impl ReadStorage for Storage {
            if path.file_name().to_string_lossy().starts_with('.') {
                continue;
            }
-
            // Skip lock files.
-
            if let Some(ext) = path.path().extension() {
-
                if ext == "lock" {
+

+
            if let Some(ext) = path.path().extension().and_then(|s| s.to_str()) {
+
                if ext == TempRepository::EXT {
+
                    // Skip temporary repositories
+
                    log::debug!(target: "storage", "Skipping temporary repository at '{}'", path.path().display());
                    continue;
+
                } else if "lock" == ext {
+
                    // In previous versions, the extension ".lock" was used for temporary repositories.
+
                    // This is to handle those names in a backward-compatible way.
+
                    log::debug!(target: "storage", "Skipping locked repository at '{}'", path.path().display());
+
                    continue;
+
                } else {
+
                    log::warn!(target: "storage", "Found path '{}' with unexpected extension '{ext}'", path.path().display());
                }
            }
+

            let rid = RepoId::try_from(path.file_name())
                .map_err(|_| Error::InvalidId(path.file_name()))?;

@@ -213,34 +228,32 @@ impl Storage {

    /// Create a [`Repository`] in a temporary directory.
    ///
-
    /// N.b. it is important to keep the [`TempDir`] in scope while
-
    /// using the [`Repository`]. If it is dropped, any action on the
-
    /// `Repository` will fail.
-
    pub fn lock_repository(&self, rid: RepoId) -> Result<(Repository, TempDir), RepositoryError> {
+
    /// This is used to prevent other processes accessing it during
+
    /// initialization. Usually, callers will want to move the repository
+
    /// to its destination after initialization in the temporary location.
+
    pub fn temporary_repository(&self, rid: RepoId) -> Result<TempRepository, RepositoryError> {
        if self.contains(&rid)? {
            return Err(Error::Io(io::Error::new(
                io::ErrorKind::AlreadyExists,
-
                format!("refusing to create '{rid}.lock'"),
+
                format!("refusing to create temporary repository for {rid}"),
            ))
            .into());
        }
-
        let tmp = tempfile::Builder::new()
-
            .prefix(&rid.canonical())
-
            .suffix(".lock")
-
            .tempdir_in(self.path())
-
            .map_err(Error::from)?;
-
        Ok((Repository::create(tmp.path(), rid, &self.info)?, tmp))
+
        TempRepository::new(self.path(), rid, &self.info)
    }

    pub fn path(&self) -> &Path {
        self.path.as_path()
    }

-
    pub fn repositories_by_id<'a>(
+
    pub fn repositories_by_id<'a, I>(
        &self,
-
        mut rids: impl Iterator<Item = &'a RepoId>,
-
    ) -> Result<Vec<RepositoryInfo>, RepositoryError> {
-
        rids.try_fold(Vec::new(), |mut infos, rid| {
+
        rids: I,
+
    ) -> impl Iterator<Item = Result<RepositoryInfo, RepositoryError>> + use<'_, 'a, I>
+
    where
+
        I: Iterator<Item = &'a RepoId>,
+
    {
+
        rids.map(|rid| {
            let repo = self.repository(*rid)?;
            let (_, head) = repo.head()?;
            let refs = refs::SignedRefsAt::load(self.info.key, &repo)?;
@@ -248,15 +261,14 @@ impl Storage {
                .as_ref()
                .map(|r| SyncedAt::new(r.at, &repo))
                .transpose()?;
-
            let info = RepositoryInfo {
+

+
            Ok(RepositoryInfo {
                rid: *rid,
                head,
                doc: repo.identity_doc()?.into(),
                refs,
                synced_at,
-
            };
-
            infos.push(info);
-
            Ok(infos)
+
            })
        })
    }

@@ -282,7 +294,13 @@ pub struct Repository {
    /// The repository identifier (RID).
    pub id: RepoId,
    /// The backing Git repository.
-
    pub backend: git2::Repository,
+
    pub backend: git::raw::Repository,
+
}
+

+
impl AsRef<Repository> for Repository {
+
    fn as_ref(&self) -> &Repository {
+
        self
+
    }
}

impl git::canonical::effects::Ancestry for Repository {
@@ -377,12 +395,12 @@ pub enum Validation {
impl Repository {
    /// Open an existing repository.
    pub fn open<P: AsRef<Path>>(path: P, id: RepoId) -> Result<Self, RepositoryError> {
-
        let backend = git2::Repository::open_ext(
+
        let backend = git::raw::Repository::open_ext(
            path.as_ref(),
-
            git2::RepositoryOpenFlags::empty()
-
                | git2::RepositoryOpenFlags::BARE
-
                | git2::RepositoryOpenFlags::NO_DOTGIT
-
                | git2::RepositoryOpenFlags::NO_SEARCH,
+
            git::raw::RepositoryOpenFlags::empty()
+
                | git::raw::RepositoryOpenFlags::BARE
+
                | git::raw::RepositoryOpenFlags::NO_DOTGIT
+
                | git::raw::RepositoryOpenFlags::NO_SEARCH,
            &[] as &[&std::ffi::OsStr],
        )?;

@@ -391,13 +409,29 @@ impl Repository {

    /// Create a new repository.
    pub fn create<P: AsRef<Path>>(path: P, id: RepoId, info: &UserInfo) -> Result<Self, Error> {
-
        let backend = git2::Repository::init_opts(
+
        let backend = git::raw::Repository::init_opts(
            &path,
-
            git2::RepositoryInitOptions::new()
+
            git::raw::RepositoryInitOptions::new()
                .bare(true)
                .no_reinit(true)
                .external_template(false),
        )?;
+

+
        {
+
            // Even though `external_template(false)` is called above,
+
            // libgit2 places stub files in the repository:
+
            // https://github.com/libgit2/libgit2/blob/ca225744b992bf2bf24e9a2eb357ddef78179667/src/libgit2/repo_template.h#L50-L54
+
            // This is helpful for a "normal" repository, directly interacted
+
            // with by a human, but not necessary for our use case.
+
            // Attempt to remove these files, but ignore any errors.
+
            // An alternative solution would be to define our own template,
+
            // but distributing that template is way more complex than
+
            // deleting a handful of files.
+
            let _ = fs::remove_dir_all(path.as_ref().join("hooks"));
+
            let _ = fs::remove_dir_all(path.as_ref().join("info"));
+
            let _ = fs::remove_file(path.as_ref().join("description"));
+
        }
+

        let mut config = backend.config()?;

        config.set_str("user.name", &info.name())?;
@@ -442,9 +476,9 @@ impl Repository {
                continue;
            }

-
            let glob = git::refname!("refs/namespaces")
-
                .join(git::Component::from(&id))
-
                .with_pattern(git::refspec::STAR);
+
            let glob = git::fmt::refname!("refs/namespaces")
+
                .join(git::fmt::Component::from(&id))
+
                .with_pattern(git::fmt::refspec::STAR);
            let refs = match self.references_glob(&glob) {
                Ok(refs) => refs,
                Err(e) => {
@@ -472,7 +506,7 @@ impl Repository {
        doc: &Doc,
        storage: &S,
        signer: &Device<G>,
-
    ) -> Result<(Self, git::Oid), RepositoryError>
+
    ) -> Result<(Self, crate::git::Oid), RepositoryError>
    where
        G: crypto::signature::Signer<crypto::Signature>,
        S: WriteStorage,
@@ -482,7 +516,7 @@ impl Repository {
        let repo = Self::create(paths::repository(storage, &id), id, storage.info())?;
        let oid = repo.backend.blob(&doc_bytes)?; // Store document blob in repository.

-
        debug_assert_eq!(oid, *doc_oid);
+
        debug_assert_eq!(doc_oid, oid);

        let commit = doc.init(&repo, signer)?;

@@ -503,7 +537,7 @@ impl Repository {
    /// Iterate over all references.
    pub fn references(
        &self,
-
    ) -> Result<impl Iterator<Item = Result<Ref, refs::Error>> + '_, git2::Error> {
+
    ) -> Result<impl Iterator<Item = Result<Ref, refs::Error>> + '_, git::raw::Error> {
        let refs = self
            .backend
            .references()?
@@ -536,7 +570,7 @@ impl Repository {

    pub fn remote_ids(
        &self,
-
    ) -> Result<impl Iterator<Item = Result<RemoteId, refs::Error>> + '_, git2::Error> {
+
    ) -> Result<impl Iterator<Item = Result<RemoteId, refs::Error>> + '_, git::raw::Error> {
        let iter = self.backend.references_glob(SIGREFS_GLOB.as_str())?.map(
            |reference| -> Result<RemoteId, refs::Error> {
                let r = reference?;
@@ -553,7 +587,7 @@ impl Repository {
        &self,
    ) -> Result<
        impl Iterator<Item = Result<(RemoteId, Remote<Verified>), refs::Error>> + '_,
-
        git2::Error,
+
        git::raw::Error,
    > {
        let remotes =
            self.backend
@@ -649,7 +683,7 @@ impl ReadRepository for Repository {
        self.id
    }

-
    fn is_empty(&self) -> Result<bool, git2::Error> {
+
    fn is_empty(&self) -> Result<bool, git::raw::Error> {
        Ok(self.remotes()?.next().is_none())
    }

@@ -657,67 +691,81 @@ impl ReadRepository for Repository {
        self.backend.path()
    }

-
    fn blob_at<P: AsRef<Path>>(&self, commit: Oid, path: P) -> Result<git2::Blob, git::Error> {
-
        let commit = self.backend.find_commit(*commit)?;
+
    fn blob_at<P: AsRef<Path>>(
+
        &self,
+
        commit_id: Oid,
+
        path: P,
+
    ) -> Result<git::raw::Blob<'_>, git::raw::Error> {
+
        let commit = self.backend.find_commit(git::raw::Oid::from(commit_id))?;
        let tree = commit.tree()?;
        let entry = tree.get_path(path.as_ref())?;
        let obj = entry.to_object(&self.backend)?;
-
        let blob = obj.into_blob().map_err(|_| {
-
            git::Error::NotFound(git::NotFound::NoSuchBlob(
-
                path.as_ref().display().to_string(),
-
            ))
-
        })?;
+
        let blob = obj.into_blob().map_err(|_|
+
            crate::git::raw::Error::new(
+
                crate::git::raw::ErrorCode::NotFound,
+
                crate::git::raw::ErrorClass::None,
+
                format!("Path '{}' in tree of commit {commit_id} was expected to be a blob, but is not.", path.as_ref().display()),
+
            )
+
        )?;

        Ok(blob)
    }

-
    fn blob(&self, oid: Oid) -> Result<git2::Blob, git::Error> {
-
        self.backend.find_blob(oid.into()).map_err(git::Error::from)
+
    fn blob(&self, oid: Oid) -> Result<git::raw::Blob<'_>, git::raw::Error> {
+
        self.backend.find_blob(oid.into())
    }

    fn reference(
        &self,
        remote: &RemoteId,
-
        name: &git::Qualified,
-
    ) -> Result<git2::Reference, git::Error> {
+
        name: &git::fmt::Qualified,
+
    ) -> Result<git::raw::Reference<'_>, git::raw::Error> {
        let name = name.with_namespace(remote.into());
-
        self.backend.find_reference(&name).map_err(git::Error::from)
+
        self.backend.find_reference(&name)
    }

    fn reference_oid(
        &self,
        remote: &RemoteId,
-
        reference: &git::Qualified,
-
    ) -> Result<Oid, git::raw::Error> {
+
        reference: &git::fmt::Qualified,
+
    ) -> Result<Oid, crate::git::raw::Error> {
        let name = reference.with_namespace(remote.into());
        let oid = self.backend.refname_to_id(&name)?;

        Ok(oid.into())
    }

-
    fn commit(&self, oid: Oid) -> Result<git2::Commit, git::Error> {
-
        self.backend
-
            .find_commit(oid.into())
-
            .map_err(git::Error::from)
+
    fn commit(&self, oid: Oid) -> Result<git::raw::Commit<'_>, git::raw::Error> {
+
        self.backend.find_commit(oid.into())
    }

-
    fn revwalk(&self, head: Oid) -> Result<git2::Revwalk, git2::Error> {
+
    fn revwalk(&self, head: Oid) -> Result<git::raw::Revwalk<'_>, git::raw::Error> {
        let mut revwalk = self.backend.revwalk()?;
        revwalk.push(head.into())?;

        Ok(revwalk)
    }

-
    fn contains(&self, oid: Oid) -> Result<bool, raw::Error> {
+
    fn contains(&self, oid: Oid) -> Result<bool, crate::git::raw::Error> {
        self.backend.odb().map(|odb| odb.exists(oid.into()))
    }

-
    fn is_ancestor_of(&self, ancestor: Oid, head: Oid) -> Result<bool, git::Error> {
+
    fn is_ancestor_of(&self, ancestor: Oid, head: Oid) -> Result<bool, crate::git::raw::Error> {
        self.backend
            .graph_descendant_of(head.into(), ancestor.into())
-
            .map_err(git::Error::from)
    }

+
    /// The published references of the given `remote`.
+
    ///
+
    /// Note that this includes all references, including `refs/rad/sigrefs`.
+
    /// This reference must be removed before signing the payload.
+
    ///
+
    /// # Skipped References
+
    ///
+
    /// References created by [`staging::patch`], i.e. references that begin
+
    /// with `refs/tmp/heads`, are skipped.
+
    ///
+
    /// [`staging::patch`]: crate::git::refs::storage::staging::patch
    fn references_of(&self, remote: &RemoteId) -> Result<Refs, Error> {
        let entries = self
            .backend
@@ -729,18 +777,13 @@ impl ReadRepository for Repository {
            let name = e.name().ok_or(Error::InvalidRef)?;
            let (_, refname) = git::parse_ref::<RemoteId>(name)?;
            let oid = e.resolve()?.target().ok_or(Error::InvalidRef)?;
-
            let (_, category, _, _) = refname.non_empty_components();
-

-
            if [
-
                git::name::HEADS,
-
                git::name::TAGS,
-
                git::name::NOTES,
-
                &git::name::component!("rad"),
-
                &git::name::component!("cobs"),
-
            ]
-
            .contains(&category.as_ref())
-
            {
-
                refs.insert(refname.into(), oid.into());
+
            let (_, category, subcategory, _) = refname.non_empty_components();
+

+
            match (category.as_str(), subcategory.as_str()) {
+
                ("tmp", "heads") => continue,
+
                _ => {
+
                    refs.insert(refname.into(), oid.into());
+
                }
            }
        }
        Ok(refs.into())
@@ -749,7 +792,7 @@ impl ReadRepository for Repository {
    fn references_glob(
        &self,
        pattern: &PatternStr,
-
    ) -> Result<Vec<(Qualified, Oid)>, git::ext::Error> {
+
    ) -> Result<Vec<(Qualified<'_>, Oid)>, crate::git::raw::Error> {
        let mut refs = Vec::new();

        for r in self.backend.references_glob(pattern)? {
@@ -761,8 +804,8 @@ impl ReadRepository for Repository {

            if let Some(name) = r
                .name()
-
                .and_then(|n| git::RefStr::try_from_str(n).ok())
-
                .and_then(git::Qualified::from_refstr)
+
                .and_then(|n| git::fmt::RefStr::try_from_str(n).ok())
+
                .and_then(git::fmt::Qualified::from_refstr)
            {
                refs.push((name.to_owned(), oid.into()));
            }
@@ -774,7 +817,7 @@ impl ReadRepository for Repository {
        Doc::load_at(head, self)
    }

-
    fn head(&self) -> Result<(Qualified, Oid), RepositoryError> {
+
    fn head(&self) -> Result<(Qualified<'_>, Oid), RepositoryError> {
        // If `HEAD` is already set locally, just return that.
        if let Ok(head) = self.backend.head() {
            if let Ok((name, oid)) = git::refs::qualified_from(&head) {
@@ -784,7 +827,7 @@ impl ReadRepository for Repository {
        self.canonical_head()
    }

-
    fn canonical_head(&self) -> Result<(Qualified, Oid), RepositoryError> {
+
    fn canonical_head(&self) -> Result<(Qualified<'_>, Oid), RepositoryError> {
        let doc = self.identity_doc()?;
        let refname = git::refs::branch(doc.project()?.default_branch());
        let crefs = match doc.canonical_refs()? {
@@ -814,14 +857,13 @@ impl ReadRepository for Repository {

        match result {
            Ok(oid) => Ok(oid),
-
            Err(err) if git::ext::is_not_found_err(&err) => self.canonical_identity_head(),
+
            Err(err) if err.is_not_found() => self.canonical_identity_head(),
            Err(err) => Err(err.into()),
        }
    }

-
    fn identity_head_of(&self, remote: &RemoteId) -> Result<Oid, git::ext::Error> {
+
    fn identity_head_of(&self, remote: &RemoteId) -> Result<Oid, crate::git::raw::Error> {
        self.reference_oid(remote, &git::refs::storage::IDENTITY_BRANCH)
-
            .map_err(git::ext::Error::from)
    }

    fn identity_root(&self) -> Result<Oid, RepositoryError> {
@@ -860,7 +902,7 @@ impl ReadRepository for Repository {
            let blob = Doc::blob_at(root, self)?;

            // We've got an identity that goes back to the correct root.
-
            if blob.id() == **self.id {
+
            if *self.id == blob.id() {
                let identity = Identity::get(&root.into(), self)?;

                return Ok(identity.head());
@@ -869,11 +911,10 @@ impl ReadRepository for Repository {
        Err(DocError::Missing.into())
    }

-
    fn merge_base(&self, left: &Oid, right: &Oid) -> Result<Oid, git::ext::Error> {
+
    fn merge_base(&self, left: &Oid, right: &Oid) -> Result<Oid, crate::git::raw::Error> {
        self.backend
-
            .merge_base(**left, **right)
+
            .merge_base(left.into(), right.into())
            .map(Oid::from)
-
            .map_err(git::ext::Error::from)
    }
}

@@ -893,7 +934,7 @@ impl WriteRepository for Repository {
        }
        log::debug!(target: "storage", "Setting ref: {} -> {}", &branch_ref, new);
        self.raw()
-
            .reference(&branch_ref, *new, true, "set-local-branch (radicle)")?;
+
            .reference(&branch_ref, new.into(), true, "set-local-branch (radicle)")?;

        log::debug!(target: "storage", "Setting ref: {head_ref} -> {branch_ref}");
        self.raw()
@@ -906,7 +947,7 @@ impl WriteRepository for Repository {
        log::debug!(target: "storage", "Setting ref: {} -> {}", *CANONICAL_IDENTITY, commit);
        self.raw().reference(
            CANONICAL_IDENTITY.as_str(),
-
            *commit,
+
            commit.into(),
            true,
            "set-local-branch (radicle)",
        )?;
@@ -921,7 +962,7 @@ impl WriteRepository for Repository {
        let refname = git::refs::storage::id_root(remote);

        self.raw()
-
            .reference(refname.as_str(), *root, true, "set-id-root (radicle)")?;
+
            .reference(refname.as_str(), root.into(), true, "set-id-root (radicle)")?;

        Ok(())
    }
@@ -933,7 +974,7 @@ impl WriteRepository for Repository {
        Ok(())
    }

-
    fn raw(&self) -> &git2::Repository {
+
    fn raw(&self) -> &git::raw::Repository {
        &self.backend
    }
}
@@ -983,7 +1024,7 @@ pub mod trailers {

    pub fn parse_signatures(msg: &str) -> Result<HashMap<PublicKey, Signature>, Error> {
        let trailers =
-
            git2::message_trailers_strs(msg).map_err(|_| Error::SignatureTrailerFormat)?;
+
            git::raw::message_trailers_strs(msg).map_err(|_| Error::SignatureTrailerFormat)?;
        let mut signatures = HashMap::with_capacity(trailers.len());

        for (key, val) in trailers.iter() {
@@ -1094,13 +1135,13 @@ mod tests {
            fixtures::project(tmp.path().join("project"), &storage, &signer).unwrap();
        let stored = storage.repository(rid).unwrap();
        let sig =
-
            git2::Signature::now(&alice.to_string(), "anonymous@radicle.example.com").unwrap();
+
            git::raw::Signature::now(&alice.to_string(), "anonymous@radicle.example.com").unwrap();
        let head = working.head().unwrap().peel_to_commit().unwrap();

        git::commit(
            &working,
            &head,
-
            &git::RefString::try_from(format!("refs/remotes/{alice}/heads/master")).unwrap(),
+
            &git::fmt::RefString::try_from(format!("refs/remotes/{alice}/heads/master")).unwrap(),
            "Second commit",
            &sig,
            &head.tree().unwrap(),
modified crates/radicle/src/storage/git/cob.rs
@@ -12,6 +12,7 @@ use storage::SignRepository;
use storage::ValidateRepository;

use crate::git;
+
use crate::git::fmt::*;
use crate::git::*;
use crate::identity;
use crate::identity::doc::DocError;
@@ -33,9 +34,7 @@ pub enum ObjectsError {
    #[error(transparent)]
    Convert(#[from] cob::object::storage::convert::Error),
    #[error(transparent)]
-
    Git(#[from] git2::Error),
-
    #[error(transparent)]
-
    GitExt(#[from] git_ext::Error),
+
    Git(#[from] git::raw::Error),
}

#[derive(Error, Debug)]
@@ -43,7 +42,7 @@ pub enum TypesError {
    #[error(transparent)]
    Convert(#[from] cob::object::storage::convert::Error),
    #[error(transparent)]
-
    Git(#[from] git2::Error),
+
    Git(#[from] git::raw::Error),
    #[error(transparent)]
    ParseKey(#[from] crypto::Error),
    #[error(transparent)]
@@ -55,12 +54,12 @@ pub enum TypesError {
impl cob::Store for Repository {}

impl change::Storage for Repository {
-
    type StoreError = <git2::Repository as change::Storage>::StoreError;
-
    type LoadError = <git2::Repository as change::Storage>::LoadError;
+
    type StoreError = <git::raw::Repository as change::Storage>::StoreError;
+
    type LoadError = <git::raw::Repository as change::Storage>::LoadError;

-
    type ObjectId = <git2::Repository as change::Storage>::ObjectId;
-
    type Parent = <git2::Repository as change::Storage>::Parent;
-
    type Signatures = <git2::Repository as change::Storage>::Signatures;
+
    type ObjectId = <git::raw::Repository as change::Storage>::ObjectId;
+
    type Parent = <git::raw::Repository as change::Storage>::Parent;
+
    type Signatures = <git::raw::Repository as change::Storage>::Signatures;

    fn store<Signer>(
        &self,
@@ -91,8 +90,8 @@ impl change::Storage for Repository {
impl cob::object::Storage for Repository {
    type ObjectsError = ObjectsError;
    type TypesError = TypesError;
-
    type UpdateError = git2::Error;
-
    type RemoveError = git2::Error;
+
    type UpdateError = git::raw::Error;
+
    type RemoveError = git::raw::Error;

    type Namespace = NodeId;

@@ -200,12 +199,12 @@ impl<'a, R> DraftStore<'a, R> {
impl<R: storage::WriteRepository> cob::Store for DraftStore<'_, R> {}

impl<R: storage::WriteRepository> change::Storage for DraftStore<'_, R> {
-
    type StoreError = <git2::Repository as change::Storage>::StoreError;
-
    type LoadError = <git2::Repository as change::Storage>::LoadError;
+
    type StoreError = <git::raw::Repository as change::Storage>::StoreError;
+
    type LoadError = <git::raw::Repository as change::Storage>::LoadError;

-
    type ObjectId = <git2::Repository as change::Storage>::ObjectId;
-
    type Parent = <git2::Repository as change::Storage>::Parent;
-
    type Signatures = <git2::Repository as change::Storage>::Signatures;
+
    type ObjectId = <git::raw::Repository as change::Storage>::ObjectId;
+
    type Parent = <git::raw::Repository as change::Storage>::Parent;
+
    type Signatures = <git::raw::Repository as change::Storage>::Signatures;

    fn store<Signer>(
        &self,
@@ -274,15 +273,15 @@ impl<R: storage::ReadRepository> ReadRepository for DraftStore<'_, R> {
        self.repo.id()
    }

-
    fn is_empty(&self) -> Result<bool, git2::Error> {
+
    fn is_empty(&self) -> Result<bool, git::raw::Error> {
        self.repo.is_empty()
    }

-
    fn head(&self) -> Result<(Qualified, Oid), RepositoryError> {
+
    fn head(&self) -> Result<(Qualified<'_>, Oid), RepositoryError> {
        self.repo.head()
    }

-
    fn canonical_head(&self) -> Result<(Qualified, Oid), RepositoryError> {
+
    fn canonical_head(&self) -> Result<(Qualified<'_>, Oid), RepositoryError> {
        self.repo.canonical_head()
    }

@@ -290,47 +289,47 @@ impl<R: storage::ReadRepository> ReadRepository for DraftStore<'_, R> {
        self.repo.path()
    }

-
    fn commit(&self, oid: Oid) -> Result<git2::Commit, git_ext::Error> {
+
    fn commit(&self, oid: Oid) -> Result<git::raw::Commit<'_>, git::raw::Error> {
        self.repo.commit(oid)
    }

-
    fn revwalk(&self, head: Oid) -> Result<git2::Revwalk, git2::Error> {
+
    fn revwalk(&self, head: Oid) -> Result<git::raw::Revwalk<'_>, git::raw::Error> {
        self.repo.revwalk(head)
    }

-
    fn contains(&self, oid: Oid) -> Result<bool, raw::Error> {
+
    fn contains(&self, oid: Oid) -> Result<bool, crate::git::raw::Error> {
        self.repo.contains(oid)
    }

-
    fn is_ancestor_of(&self, ancestor: Oid, head: Oid) -> Result<bool, git_ext::Error> {
+
    fn is_ancestor_of(&self, ancestor: Oid, head: Oid) -> Result<bool, crate::git::raw::Error> {
        self.repo.is_ancestor_of(ancestor, head)
    }

    fn blob_at<P: AsRef<Path>>(
        &self,
-
        oid: git_ext::Oid,
+
        oid: Oid,
        path: P,
-
    ) -> Result<git2::Blob, git_ext::Error> {
+
    ) -> Result<git::raw::Blob<'_>, git::raw::Error> {
        self.repo.blob_at(oid, path)
    }

-
    fn blob(&self, oid: git_ext::Oid) -> Result<raw::Blob, ext::Error> {
+
    fn blob(&self, oid: Oid) -> Result<crate::git::raw::Blob<'_>, crate::git::raw::Error> {
        self.repo.blob(oid)
    }

    fn reference(
        &self,
        remote: &RemoteId,
-
        reference: &git::Qualified,
-
    ) -> Result<git2::Reference, git_ext::Error> {
+
        reference: &git::fmt::Qualified,
+
    ) -> Result<git::raw::Reference<'_>, git::raw::Error> {
        self.repo.reference(remote, reference)
    }

    fn reference_oid(
        &self,
        remote: &RemoteId,
-
        reference: &git::Qualified,
-
    ) -> Result<git_ext::Oid, git::raw::Error> {
+
        reference: &git::fmt::Qualified,
+
    ) -> Result<Oid, crate::git::raw::Error> {
        self.repo.reference_oid(remote, reference)
    }

@@ -340,8 +339,8 @@ impl<R: storage::ReadRepository> ReadRepository for DraftStore<'_, R> {

    fn references_glob(
        &self,
-
        pattern: &git::PatternStr,
-
    ) -> Result<Vec<(fmt::Qualified, Oid)>, git::ext::Error> {
+
        pattern: &git::fmt::refspec::PatternStr,
+
    ) -> Result<Vec<(fmt::Qualified<'_>, Oid)>, crate::git::raw::Error> {
        self.repo.references_glob(pattern)
    }

@@ -357,7 +356,7 @@ impl<R: storage::ReadRepository> ReadRepository for DraftStore<'_, R> {
        self.repo.identity_head()
    }

-
    fn identity_head_of(&self, remote: &RemoteId) -> Result<Oid, super::ext::Error> {
+
    fn identity_head_of(&self, remote: &RemoteId) -> Result<Oid, crate::git::raw::Error> {
        self.repo.identity_head_of(remote)
    }

@@ -373,16 +372,16 @@ impl<R: storage::ReadRepository> ReadRepository for DraftStore<'_, R> {
        self.repo.canonical_identity_head()
    }

-
    fn merge_base(&self, left: &Oid, right: &Oid) -> Result<Oid, git::ext::Error> {
+
    fn merge_base(&self, left: &Oid, right: &Oid) -> Result<Oid, crate::git::raw::Error> {
        self.repo.merge_base(left, right)
    }
}

impl<R: storage::WriteRepository> cob::object::Storage for DraftStore<'_, R> {
    type ObjectsError = ObjectsError;
-
    type TypesError = git::ext::Error;
-
    type UpdateError = git2::Error;
-
    type RemoveError = git2::Error;
+
    type TypesError = git::raw::Error;
+
    type UpdateError = git::raw::Error;
+
    type RemoveError = git::raw::Error;

    type Namespace = NodeId;

added crates/radicle/src/storage/git/temp.rs
@@ -0,0 +1,88 @@
+
use std::io;
+
use std::path::{Path, PathBuf};
+

+
use crate::prelude::RepoId;
+

+
use super::{Repository, RepositoryError, UserInfo};
+

+
/// A [`Repository`] that is created for temporary operations, such as cloning.
+
///
+
/// When the `TempRepository` is no longer needed, then call one of destructors:
+
///
+
///   - [`TempRepository::cleanup`]: remove the repository directory
+
///   - [`TempRepository::mv`]: move the repository directory to a final
+
///     destination and remove the old directory
+
///
+
/// [`TempRepository`] implements [`AsRef`] so that the [`Repository`] can be
+
/// used in places where a [`Repository`] is needed.
+
pub struct TempRepository {
+
    repo: Repository,
+
    path: PathBuf,
+
}
+

+
impl TempRepository {
+
    /// Extension used for the directory
+
    pub(crate) const EXT: &str = "tmp";
+

+
    const RANDOMNESS_LENGTH: usize = 6;
+

+
    pub(super) fn new<P>(root: P, rid: RepoId, info: &UserInfo) -> Result<Self, RepositoryError>
+
    where
+
        P: AsRef<Path>,
+
    {
+
        let random: String = std::iter::repeat_with(fastrand::alphanumeric)
+
            .take(Self::RANDOMNESS_LENGTH)
+
            .collect();
+
        let path = root
+
            .as_ref()
+
            .join(format!("{}.{random}", rid.canonical()))
+
            .with_extension(Self::EXT);
+
        let repo = Repository::create(&path, rid, info)?;
+
        Ok(Self { repo, path })
+
    }
+

+
    /// Clean up the temporary directory of the repository.
+
    ///
+
    /// Note that the repository is dropped first to ensure that there are no
+
    /// handles to the repository, before removing the directory.
+
    pub fn cleanup(self) {
+
        let path = self.path.clone();
+
        drop(self.repo);
+
        Self::remove(&path)
+
    }
+

+
    /// Move the temporary directory of the repository to the new path.
+
    ///
+
    /// If `to` already exists, then the temporary directory is removed, and the
+
    /// repository is not moved.
+
    ///
+
    /// Note that the repository is dropped first to ensure that there are no
+
    /// handles to the repository, before removing the directory.
+
    pub fn mv<P>(self, to: P) -> io::Result<()>
+
    where
+
        P: AsRef<Path>,
+
    {
+
        let to = to.as_ref();
+
        let rid = self.repo.id;
+
        let path = self.path.clone();
+
        drop(self.repo);
+
        if to.exists() {
+
            log::warn!(target: "radicle", "Refusing to move from temporary directory '{}' because destination {rid} already exists. Removing the temporary directory.", self.path.display());
+
            Self::remove(&path);
+
        }
+
        std::fs::rename(path, to)
+
    }
+

+
    fn remove(path: &PathBuf) {
+
        if let Err(err) = std::fs::remove_dir_all(path) {
+
            let path = path.display();
+
            log::error!(target: "worker", "Failed to remove temporary directory '{path}': {err}");
+
        }
+
    }
+
}
+

+
impl AsRef<Repository> for TempRepository {
+
    fn as_ref(&self) -> &Repository {
+
        &self.repo
+
    }
+
}
modified crates/radicle/src/storage/git/transport/local.rs
@@ -7,6 +7,7 @@ use std::process;
use std::str::FromStr;
use std::sync::Once;

+
use crate::git;
use crate::storage;
use crate::storage::git::Storage;

@@ -25,20 +26,19 @@ struct Local {
    child: RefCell<Option<process::Child>>,
}

-
impl git2::transport::SmartSubtransport for Local {
+
impl crate::git::raw::transport::SmartSubtransport for Local {
    fn action(
        &self,
        url: &str,
-
        service: git2::transport::Service,
-
    ) -> Result<Box<dyn git2::transport::SmartSubtransportStream>, git2::Error> {
-
        let url = Url::from_str(url).map_err(|e| git2::Error::from_str(e.to_string().as_str()))?;
+
        service: git::raw::transport::Service,
+
    ) -> Result<Box<dyn git::raw::transport::SmartSubtransportStream>, git::raw::Error> {
+
        let url =
+
            Url::from_str(url).map_err(|e| git::raw::Error::from_str(e.to_string().as_str()))?;
        let service: &str = match service {
-
            git2::transport::Service::UploadPack | git2::transport::Service::UploadPackLs => {
-
                "upload-pack"
-
            }
-
            git2::transport::Service::ReceivePack | git2::transport::Service::ReceivePackLs => {
-
                "receive-pack"
-
            }
+
            git::raw::transport::Service::UploadPack
+
            | git::raw::transport::Service::UploadPackLs => "upload-pack",
+
            git::raw::transport::Service::ReceivePack
+
            | git::raw::transport::Service::ReceivePackLs => "receive-pack",
        };
        let git_dir = THREAD_STORAGE
            .with(|t| {
@@ -46,7 +46,9 @@ impl git2::transport::SmartSubtransport for Local {
                    .as_ref()
                    .map(|s| storage::git::paths::repository(&s, &url.repo))
            })
-
            .ok_or_else(|| git2::Error::from_str("local transport storage was not registered"))?;
+
            .ok_or_else(|| {
+
                git::raw::Error::from_str("local transport storage was not registered")
+
            })?;

        let mut cmd = process::Command::new("git");

@@ -61,7 +63,7 @@ impl git2::transport::SmartSubtransport for Local {
            .stdout(process::Stdio::piped())
            .stderr(process::Stdio::inherit())
            .spawn()
-
            .map_err(|e| git2::Error::from_str(e.to_string().as_str()))?;
+
            .map_err(|e| git::raw::Error::from_str(e.to_string().as_str()))?;

        let stdin = child.stdin.take().expect("taking stdin is safe");
        let stdout = child.stdout.take().expect("taking stdout is safe");
@@ -71,19 +73,19 @@ impl git2::transport::SmartSubtransport for Local {
        Ok(Box::new(ChildStream { stdout, stdin }))
    }

-
    fn close(&self) -> Result<(), git2::Error> {
+
    fn close(&self) -> Result<(), git::raw::Error> {
        if let Some(mut child) = self.child.take() {
            let result = child
                .wait()
-
                .map_err(|e| git2::Error::from_str(e.to_string().as_str()))?;
+
                .map_err(|e| git::raw::Error::from_str(e.to_string().as_str()))?;

            if !result.success() {
                return if let Some(code) = result.code() {
-
                    Err(git2::Error::from_str(
+
                    Err(git::raw::Error::from_str(
                        format!("transport: child process exited with error code {code}").as_str(),
                    ))
                } else {
-
                    Err(git2::Error::from_str(
+
                    Err(git::raw::Error::from_str(
                        "transport: child process exited with unknown error",
                    ))
                };
@@ -103,8 +105,8 @@ pub fn register(storage: Storage) {
    });

    REGISTER.call_once(|| unsafe {
-
        git2::transport::register(Url::SCHEME, move |remote| {
-
            git2::transport::Transport::smart(remote, false, Local::default())
+
        git::raw::transport::register(Url::SCHEME, move |remote| {
+
            git::raw::transport::Transport::smart(remote, false, Local::default())
        })
        .expect("local transport registration");
    });
modified crates/radicle/src/storage/git/transport/local/url.rs
@@ -31,7 +31,7 @@ pub enum UrlError {
/// A git local transport URL.
///
/// * Used to content-address a repository, eg. when sharing projects.
-
/// * Used as a remore url in a git working copy.
+
/// * Used as a remote url in a git working copy.
///
/// `rad://<repo>[/<namespace>]`
///
modified crates/radicle/src/storage/git/transport/remote/mock.rs
@@ -8,6 +8,7 @@ use std::thread::ThreadId;
use std::{process, thread};

use super::Url;
+
use crate::git;
use crate::storage::git::transport::ChildStream;
use crate::storage::RemoteId;

@@ -19,19 +20,21 @@ static NODES: LazyLock<Mutex<HashMap<(ThreadId, RemoteId), PathBuf>>> =
#[derive(Default)]
struct MockTransport;

-
impl git2::transport::SmartSubtransport for MockTransport {
+
impl git::raw::transport::SmartSubtransport for MockTransport {
    fn action(
        &self,
        url: &str,
-
        service: git2::transport::Service,
-
    ) -> Result<Box<dyn git2::transport::SmartSubtransportStream>, git2::Error> {
-
        let url = Url::from_str(url).map_err(|e| git2::Error::from_str(e.to_string().as_str()))?;
+
        service: git::raw::transport::Service,
+
    ) -> Result<Box<dyn git::raw::transport::SmartSubtransportStream>, git::raw::Error> {
+
        let url =
+
            Url::from_str(url).map_err(|e| git::raw::Error::from_str(e.to_string().as_str()))?;
        let id = thread::current().id();
        let nodes = NODES.lock().expect("lock cannot be poisoned");
        let storage = if let Some(storage) = nodes.get(&(id, url.node)) {
            match service {
-
                git2::transport::Service::ReceivePack | git2::transport::Service::ReceivePackLs => {
-
                    return Err(git2::Error::from_str(
+
                git::raw::transport::Service::ReceivePack
+
                | git::raw::transport::Service::ReceivePackLs => {
+
                    return Err(git::raw::Error::from_str(
                        "git-receive-pack is not supported with the mock transport",
                    ));
                }
@@ -39,7 +42,7 @@ impl git2::transport::SmartSubtransport for MockTransport {
            }
            storage
        } else {
-
            return Err(git2::Error::from_str(&format!(
+
            return Err(git::raw::Error::from_str(&format!(
                "node {} was not registered with the mock transport",
                url.node
            )));
@@ -76,7 +79,7 @@ impl git2::transport::SmartSubtransport for MockTransport {
        Ok(Box::new(ChildStream { stdout, stdin }))
    }

-
    fn close(&self) -> Result<(), git2::Error> {
+
    fn close(&self) -> Result<(), git::raw::Error> {
        Ok(())
    }
}
@@ -86,8 +89,8 @@ pub fn register(node: &RemoteId, path: &Path) {
    static REGISTER: Once = Once::new();

    REGISTER.call_once(|| unsafe {
-
        git2::transport::register(Url::SCHEME, move |remote| {
-
            git2::transport::Transport::smart(remote, false, MockTransport)
+
        git::raw::transport::register(Url::SCHEME, move |remote| {
+
            git::raw::transport::Transport::smart(remote, false, MockTransport)
        })
        .expect("transport registration is successful");
    });
modified crates/radicle/src/storage/refs.rs
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::git;
-
use crate::git::ext as git_ext;
+
use crate::git::raw::ErrorExt as _;
use crate::git::Oid;
use crate::node::device::Device;
use crate::profile::env;
@@ -47,7 +47,7 @@ pub enum Error {
    #[error("invalid reference")]
    InvalidRef,
    #[error("missing identity root reference '{0}'")]
-
    MissingIdentityRoot(git::RefString),
+
    MissingIdentityRoot(git::fmt::RefString),
    #[error("missing identity object '{0}'")]
    MissingIdentity(Oid),
    #[error("mismatched identity: local {local}, remote {remote}")]
@@ -55,28 +55,24 @@ pub enum Error {
    #[error("invalid reference: {0}")]
    Ref(#[from] git::RefError),
    #[error(transparent)]
-
    Git(#[from] git2::Error),
-
    #[error(transparent)]
-
    GitExt(#[from] git_ext::Error),
+
    Git(#[from] git::raw::Error),
}

impl Error {
    /// Whether this error is caused by a reference not being found.
    pub fn is_not_found(&self) -> bool {
        match self {
-
            Self::GitExt(git::Error::NotFound(_)) => true,
-
            Self::GitExt(git::Error::Git(e)) if git::is_not_found_err(e) => true,
-
            Self::Git(e) if git::is_not_found_err(e) => true,
+
            Self::Git(e) => e.is_not_found(),
            _ => false,
        }
    }
}

-
// TODO(finto): we should turn `git::RefString` to `git::Qualified`,
+
// TODO(finto): we should turn `git::fmt::RefString` to `git::fmt::Qualified`,
// since all these refs SHOULD be `Qualified`.
/// The published state of a local repository.
#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize)]
-
pub struct Refs(BTreeMap<git::RefString, Oid>);
+
pub struct Refs(BTreeMap<git::fmt::RefString, Oid>);

impl Refs {
    /// Verify the given signature on these refs, and return [`SignedRefs`] on success.
@@ -102,13 +98,13 @@ impl Refs {
    }

    /// Get a particular ref.
-
    pub fn get(&self, name: &git::Qualified) -> Option<Oid> {
+
    pub fn get(&self, name: &git::fmt::Qualified) -> Option<Oid> {
        self.0.get(name.to_ref_string().as_refstr()).copied()
    }

    /// Get a particular head ref.
-
    pub fn head(&self, name: impl AsRef<git::RefStr>) -> Option<Oid> {
-
        let branch = git::refname!("refs/heads").join(name);
+
    pub fn head(&self, name: impl AsRef<git::fmt::RefStr>) -> Option<Oid> {
+
        let branch = git::fmt::refname!("refs/heads").join(name);
        self.0.get(&branch).copied()
    }

@@ -123,8 +119,8 @@ impl Refs {
                .split_once(' ')
                .ok_or(canonical::Error::InvalidFormat)?;

-
            let name = git::RefString::try_from(name)?;
-
            let oid = Oid::from_str(oid)?;
+
            let name = git::fmt::RefString::try_from(name)?;
+
            let oid = Oid::from_str(oid).map_err(|_| canonical::Error::InvalidFormat)?;

            if oid.is_zero() {
                continue;
@@ -148,15 +144,15 @@ impl Refs {
}

impl IntoIterator for Refs {
-
    type Item = (git::RefString, Oid);
-
    type IntoIter = std::collections::btree_map::IntoIter<git::RefString, Oid>;
+
    type Item = (git::fmt::RefString, Oid);
+
    type IntoIter = std::collections::btree_map::IntoIter<git::fmt::RefString, Oid>;

    fn into_iter(self) -> Self::IntoIter {
        self.0.into_iter()
    }
}

-
impl From<Refs> for BTreeMap<git::RefString, Oid> {
+
impl From<Refs> for BTreeMap<git::fmt::RefString, Oid> {
    fn from(refs: Refs) -> Self {
        refs.0
    }
@@ -168,14 +164,14 @@ impl<V> From<SignedRefs<V>> for Refs {
    }
}

-
impl From<BTreeMap<git::RefString, Oid>> for Refs {
-
    fn from(refs: BTreeMap<git::RefString, Oid>) -> Self {
+
impl From<BTreeMap<git::fmt::RefString, Oid>> for Refs {
+
    fn from(refs: BTreeMap<git::fmt::RefString, Oid>) -> Self {
        Self(refs)
    }
}

impl Deref for Refs {
-
    type Target = BTreeMap<git::RefString, Oid>;
+
    type Target = BTreeMap<git::fmt::RefString, Oid>;

    fn deref(&self) -> &Self::Target {
        &self.0
@@ -297,7 +293,7 @@ impl SignedRefs<Verified> {
            Some(SignedRefsAt { sigrefs, at }) if sigrefs.signature == self.signature => {
                return Ok(Updated::Unchanged { oid: at });
            }
-
            Some(SignedRefsAt { at, .. }) => Some(raw.find_commit(*at)?),
+
            Some(SignedRefsAt { at, .. }) => Some(raw.find_commit(at.into())?),
            None => None,
        };

@@ -322,8 +318,8 @@ impl SignedRefs<Verified> {
                    env::GIT_COMMITTER_DATE
                );
            };
-
            let time = git2::Time::new(timestamp, 0);
-
            git2::Signature::new("radicle", remote.to_string().as_str(), &time)?
+
            let time = git::raw::Time::new(timestamp, 0);
+
            git::raw::Signature::new("radicle", remote.to_string().as_str(), &time)?
        } else {
            raw.signature()?
        };
@@ -334,13 +330,13 @@ impl SignedRefs<Verified> {
            &author,
            "Update signed refs\n",
            &tree,
-
            &parent.iter().collect::<Vec<&git2::Commit>>(),
+
            &parent.iter().collect::<Vec<&git::raw::Commit>>(),
        );

        match commit {
            Ok(oid) => Ok(Updated::Updated { oid: oid.into() }),
            Err(e) => match (e.class(), e.code()) {
-
                (git2::ErrorClass::Object, git2::ErrorCode::Modified) => {
+
                (git::raw::ErrorClass::Object, git::raw::ErrorCode::Modified) => {
                    log::warn!("Concurrent modification of refs: {e:?}");

                    Err(Error::Git(e))
@@ -375,23 +371,21 @@ impl<V> Deref for SignedRefs<V> {
///
/// `RefsAt` can also be used for communicating announcements of updates
/// references to other nodes.
-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RefsAt {
    /// The remote namespace of the `rad/sigrefs`.
-
    #[cfg_attr(
-
        feature = "schemars",
-
        schemars(with = "crate::schemars_ext::crypto::PublicKey")
-
    )]
    pub remote: RemoteId,
    /// The commit SHA that `rad/sigrefs` points to.
-
    #[cfg_attr(feature = "schemars", schemars(with = "crate::schemars_ext::git::Oid"))]
    pub at: Oid,
}

impl RefsAt {
-
    pub fn new<S: ReadRepository>(repo: &S, remote: RemoteId) -> Result<Self, git::raw::Error> {
+
    pub fn new<S: ReadRepository>(
+
        repo: &S,
+
        remote: RemoteId,
+
    ) -> Result<Self, crate::git::raw::Error> {
        let at = repo.reference_oid(&remote, &storage::refs::SIGREFS_BRANCH)?;
        Ok(RefsAt { remote, at })
    }
@@ -400,11 +394,17 @@ impl RefsAt {
        SignedRefsAt::load_at(self.at, self.remote, repo)
    }

-
    pub fn path(&self) -> &git::Qualified {
+
    pub fn path(&self) -> &git::fmt::Qualified<'_> {
        &SIGREFS_BRANCH
    }
}

+
impl std::fmt::Display for RefsAt {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        write!(f, "{} @ {}", self.remote, self.at)
+
    }
+
}
+

/// Verified [`SignedRefs`] that keeps track of their content address
/// [`Oid`].
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -424,7 +424,7 @@ impl SignedRefsAt {
    {
        let at = match RefsAt::new(repo, remote) {
            Ok(RefsAt { at, .. }) => at,
-
            Err(e) if git::is_not_found_err(&e) => return Ok(None),
+
            Err(e) if e.is_not_found() => return Ok(None),
            Err(e) => return Err(e.into()),
        };
        Self::load_at(at, remote, repo).map(Some)
@@ -440,7 +440,7 @@ impl SignedRefsAt {
        })
    }

-
    pub fn iter(&self) -> impl Iterator<Item = (&git::RefString, &Oid)> {
+
    pub fn iter(&self) -> impl Iterator<Item = (&git::fmt::RefString, &Oid)> {
        self.sigrefs.refs.iter()
    }
}
@@ -465,7 +465,7 @@ pub mod canonical {
        #[error(transparent)]
        Io(#[from] io::Error),
        #[error(transparent)]
-
        Git(#[from] git2::Error),
+
        Git(#[from] git::raw::Error),
    }
}

@@ -509,7 +509,7 @@ mod tests {
            &paris_repo,
            "paris".try_into().unwrap(),
            "Paris repository",
-
            git::refname!("master"),
+
            git::fmt::refname!("master"),
            Default::default(),
            &alice,
            storage,
@@ -522,7 +522,7 @@ mod tests {
            &london_repo,
            "london".try_into().unwrap(),
            "London repository",
-
            git::refname!("master"),
+
            git::fmt::refname!("master"),
            Default::default(),
            &alice,
            storage,
@@ -562,7 +562,7 @@ mod tests {
        }

        // Now Bob checks out a copy of the `paris` repository and pushes a commit to the
-
        // default branch (master). We store the OID of that commti in `bob_head`, as this
+
        // default branch (master). We store the OID of that commit in `bob_head`, as this
        // is the commit we will try to get the `london` repo to point to.
        let (bob_paris_sigrefs, bob_head) = {
            let bob_working = rad::checkout(
@@ -570,15 +570,16 @@ mod tests {
                bob.public_key(),
                tmp.path().join("working"),
                &storage,
+
                false,
            )
            .unwrap();

            let paris_head = bob_working.find_commit(paris_head).unwrap();
-
            let bob_sig = git2::Signature::now("bob", "bob@example.com").unwrap();
+
            let bob_sig = git::raw::Signature::now("bob", "bob@example.com").unwrap();
            let bob_head = git::empty_commit(
                &bob_working,
                &paris_head,
-
                git::refname!("refs/heads/master").as_refstr(),
+
                git::fmt::refname!("refs/heads/master").as_refstr(),
                "Bob's commit",
                &bob_sig,
            )
@@ -595,9 +596,9 @@ mod tests {

            assert_eq!(
                sigrefs
-
                    .get(&git_ext::ref_format::qualified!("refs/heads/master"))
+
                    .get(&crate::git::fmt::qualified!("refs/heads/master"))
                    .unwrap(),
-
                bob_head.id().into()
+
                bob_head.id()
            );
            (sigrefs, bob_head.id())
        };
@@ -609,10 +610,10 @@ mod tests {
                .unwrap();
            assert_ne!(
                alice_paris_sigrefs
-
                    .get(&git_ext::ref_format::qualified!("refs/heads/master"))
+
                    .get(&crate::git::fmt::qualified!("refs/heads/master"))
                    .unwrap(),
                bob_paris_sigrefs
-
                    .get(&git_ext::ref_format::qualified!("refs/heads/master"))
+
                    .get(&crate::git::fmt::qualified!("refs/heads/master"))
                    .unwrap()
            );
        }
@@ -644,7 +645,8 @@ mod tests {
        london
            .raw()
            .reference(
-
                git::refs::storage::branch_of(bob.public_key(), &git::refname!("master")).as_str(),
+
                git::refs::storage::branch_of(bob.public_key(), &git::fmt::refname!("master"))
+
                    .as_str(),
                bob_head,
                false,
                "",
modified crates/radicle/src/test.rs
@@ -6,6 +6,7 @@ pub mod storage;

use super::storage::{Namespaces, RefUpdate};

+
use crate::git;
use crate::prelude::NodeId;
use crate::storage::WriteRepository;

@@ -22,18 +23,18 @@ pub fn fetch<W: WriteRepository>(
        Namespaces::Followed(followed) => followed.into_iter().next(),
    };
    let mut updates = Vec::new();
-
    let mut callbacks = git2::RemoteCallbacks::new();
-
    let mut opts = git2::FetchOptions::default();
+
    let mut callbacks = git::raw::RemoteCallbacks::new();
+
    let mut opts = git::raw::FetchOptions::default();
    let refspec = if let Some(namespace) = namespace {
-
        opts.prune(git2::FetchPrune::On);
+
        opts.prune(git::raw::FetchPrune::On);
        format!("refs/namespaces/{namespace}/refs/*:refs/namespaces/{namespace}/refs/*")
    } else {
-
        opts.prune(git2::FetchPrune::Off);
+
        opts.prune(git::raw::FetchPrune::Off);
        "refs/namespaces/*:refs/namespaces/*".to_owned()
    };

    callbacks.update_tips(|name, old, new| {
-
        if let Ok(name) = crate::git::RefString::try_from(name) {
+
        if let Ok(name) = git::fmt::RefString::try_from(name) {
            if name.to_namespaced().is_some() {
                updates.push(RefUpdate::from(name, old, new));
                // Returning `true` ensures the process is not aborted.
@@ -76,13 +77,8 @@ pub mod setup {
    use crate::crypto::test::signer::MockSigner;
    use crate::node::device::Device;
    use crate::storage::git::transport::remote;
-
    use crate::{
-
        git,
-
        profile::Home,
-
        rad::REMOTE_NAME,
-
        test::{fixtures, storage::git::Repository},
-
        Storage,
-
    };
+
    use crate::storage::git::Repository;
+
    use crate::{git, profile::Home, rad::REMOTE_NAME, test::fixtures, Storage};
    use crate::{prelude::*, rad};

    /// A node.
@@ -187,7 +183,8 @@ pub mod setup {
            &self,
            blobs: impl IntoIterator<Item = (S, T)>,
        ) -> BranchWith {
-
            let refname = git::Qualified::from(git::lit::refs_heads(git::refname!("master")));
+
            let refname =
+
                git::fmt::Qualified::from(git::fmt::lit::refs_heads(git::fmt::refname!("master")));
            let base = self.checkout.refname_to_id(refname.as_str()).unwrap();
            let parent = self.checkout.find_commit(base).unwrap();
            let oid = commit(&self.checkout, &refname, blobs, &[&parent]);
@@ -295,22 +292,22 @@ pub mod setup {
    }

    pub fn commit<S: AsRef<Path>, T: AsRef<[u8]>>(
-
        repo: &git2::Repository,
-
        refname: &git::Qualified,
+
        repo: &git::raw::Repository,
+
        refname: &git::fmt::Qualified,
        blobs: impl IntoIterator<Item = (S, T)>,
-
        parents: &[&git2::Commit<'_>],
-
    ) -> git::Oid {
+
        parents: &[&git::raw::Commit<'_>],
+
    ) -> crate::git::Oid {
        let tree = {
            let mut tb = repo.treebuilder(None).unwrap();
            for (name, blob) in blobs.into_iter() {
                let oid = repo.blob(blob.as_ref()).unwrap();
-
                tb.insert(name.as_ref(), oid, git2::FileMode::Blob.into())
+
                tb.insert(name.as_ref(), oid, git::raw::FileMode::Blob.into())
                    .unwrap();
            }
            tb.write().unwrap()
        };
        let tree = repo.find_tree(tree).unwrap();
-
        let author = git2::Signature::now("anonymous", "anonymous@example.com").unwrap();
+
        let author = git::raw::Signature::now("anonymous", "anonymous@example.com").unwrap();

        repo.commit(
            Some(refname.as_str()),
modified crates/radicle/src/test/arbitrary.rs
@@ -27,14 +27,14 @@ use crate::{cob, git};

pub fn oid() -> storage::Oid {
    let oid_bytes: [u8; 20] = gen(1);
-
    storage::Oid::try_from(oid_bytes.as_slice()).unwrap()
+
    storage::Oid::from_sha1(oid_bytes)
}

pub fn entry_id() -> cob::EntryId {
    self::oid()
}

-
pub fn refstring(len: usize) -> git::RefString {
+
pub fn refstring(len: usize) -> git::fmt::RefString {
    let mut buf = Vec::<u8>::new();
    for _ in 0..len {
        buf.push(fastrand::u8(0x61..0x7a));
@@ -135,7 +135,7 @@ impl Arbitrary for Project {
        let description = iter::repeat_with(|| rng.alphanumeric())
            .take(length * 2)
            .collect();
-
        let default_branch: git::RefString = iter::repeat_with(|| rng.alphanumeric())
+
        let default_branch: git::fmt::RefString = iter::repeat_with(|| rng.alphanumeric())
            .take(length)
            .collect::<String>()
            .try_into()
@@ -207,7 +207,7 @@ impl Arbitrary for SignedRefs<Unverified> {

impl Arbitrary for Refs {
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
-
        let mut refs: BTreeMap<git::RefString, storage::Oid> = BTreeMap::new();
+
        let mut refs: BTreeMap<git::fmt::RefString, storage::Oid> = BTreeMap::new();
        let mut bytes: [u8; 20] = [0; 20];
        let names = &[
            "heads/master",
@@ -225,8 +225,8 @@ impl Arbitrary for Refs {
                for byte in &mut bytes {
                    *byte = u8::arbitrary(g);
                }
-
                let oid = storage::Oid::try_from(&bytes[..]).unwrap();
-
                let name = git::RefString::try_from(*name).unwrap();
+
                let oid = storage::Oid::from_sha1(bytes);
+
                let name = git::fmt::RefString::try_from(*name).unwrap();

                refs.insert(name, oid);
            }
@@ -270,15 +270,6 @@ impl Arbitrary for storage::Remote<crypto::Unverified> {
    }
}

-
impl Arbitrary for RepoId {
-
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
-
        let bytes = <[u8; 20]>::arbitrary(g);
-
        let oid = git::Oid::try_from(bytes.as_slice()).unwrap();
-

-
        RepoId::from(oid)
-
    }
-
}
-

impl Arbitrary for AddressType {
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
        let t = *g.choose(&[1, 2, 3, 4]).unwrap() as u8;
modified crates/radicle/src/test/fixtures.rs
@@ -57,7 +57,7 @@ where
            &repo,
            name.try_into().unwrap(),
            desc,
-
            git::refname!("master"),
+
            git::fmt::refname!("master"),
            Visibility::default(),
            signer,
            &storage,
@@ -72,7 +72,15 @@ pub fn project<P, G>(
    path: P,
    storage: &Storage,
    signer: &Device<G>,
-
) -> Result<(RepoId, SignedRefs<Verified>, git2::Repository, git2::Oid), rad::InitError>
+
) -> Result<
+
    (
+
        RepoId,
+
        SignedRefs<Verified>,
+
        git::raw::Repository,
+
        git::raw::Oid,
+
    ),
+
    rad::InitError,
+
>
where
    P: AsRef<Path>,
    G: crypto::signature::Signer<crypto::Signature>,
@@ -84,7 +92,7 @@ where
        &working,
        "acme".try_into().unwrap(),
        "Acme's repository",
-
        git::refname!("master"),
+
        git::fmt::refname!("master"),
        Visibility::default(),
        signer,
        storage,
@@ -94,27 +102,50 @@ where
}

/// Creates a regular repository at the given path with a couple of commits.
-
pub fn repository<P: AsRef<Path>>(path: P) -> (git2::Repository, git2::Oid) {
-
    let repo = git2::Repository::init_opts(
+
pub fn repository<P: AsRef<Path>>(path: P) -> (git::raw::Repository, git::raw::Oid) {
+
    let (repo, oid) = repository_with(
        path,
-
        git2::RepositoryInitOptions::new().external_template(false),
+
        git::raw::RepositoryInitOptions::new().external_template(false),
+
    );
+
    repo.checkout_head(None).unwrap();
+
    (repo, oid)
+
}
+

+
pub fn bare_repository<P: AsRef<Path>>(path: P) -> (git::raw::Repository, git::raw::Oid) {
+
    repository_with(
+
        path,
+
        git::raw::RepositoryInitOptions::new()
+
            .external_template(false)
+
            .bare(true),
    )
-
    .unwrap();
+
}
+

+
fn repository_with<P: AsRef<Path>>(
+
    path: P,
+
    opts: &mut git::raw::RepositoryInitOptions,
+
) -> (git::raw::Repository, git::raw::Oid) {
+
    let repo = git::raw::Repository::init_opts(path, opts).unwrap();

    {
        let mut config = repo.config().unwrap();
        config.set_str("user.name", USER_NAME).unwrap();
        config.set_str("user.email", USER_EMAIL).unwrap();
    }
-
    let sig =
-
        git2::Signature::new(USER_NAME, USER_EMAIL, &git2::Time::new(RADICLE_EPOCH, 0)).unwrap();
+

+
    let sig = git::raw::Signature::new(
+
        USER_NAME,
+
        USER_EMAIL,
+
        &git::raw::Time::new(RADICLE_EPOCH, 0),
+
    )
+
    .unwrap();
+

    let head = git::initial_commit(&repo, &sig).unwrap();
    let tree = git::write_tree(Path::new("README"), "Hello World!\n".as_bytes(), &repo).unwrap();
    let oid = {
        let commit = git::commit(
            &repo,
            &head,
-
            git::refname!("refs/heads/master").as_refstr(),
+
            git::fmt::refname!("refs/heads/master").as_refstr(),
            "Second commit",
            &sig,
            &tree,
@@ -124,7 +155,6 @@ pub fn repository<P: AsRef<Path>>(path: P) -> (git2::Repository, git2::Oid) {
        commit.id()
    };
    repo.set_head("refs/heads/master").unwrap();
-
    repo.checkout_head(None).unwrap();

    drop(tree);
    drop(head);
@@ -133,10 +163,14 @@ pub fn repository<P: AsRef<Path>>(path: P) -> (git2::Repository, git2::Oid) {
}

/// Create an empty commit on the current branch.
-
pub fn commit(msg: &str, parents: &[git2::Oid], repo: &git2::Repository) -> git::Oid {
+
pub fn commit(msg: &str, parents: &[git::raw::Oid], repo: &git::raw::Repository) -> git::Oid {
    let head = repo.head().unwrap();
-
    let sig =
-
        git2::Signature::new(USER_NAME, USER_EMAIL, &git2::Time::new(RADICLE_EPOCH, 0)).unwrap();
+
    let sig = git::raw::Signature::new(
+
        USER_NAME,
+
        USER_EMAIL,
+
        &git::raw::Time::new(RADICLE_EPOCH, 0),
+
    )
+
    .unwrap();
    let tree = head.peel_to_commit().unwrap().tree().unwrap();
    let parents = parents
        .iter()
@@ -150,19 +184,28 @@ pub fn commit(msg: &str, parents: &[git2::Oid], repo: &git2::Repository) -> git:
}

/// Create an (annotated) tag of the given commit.
-
pub fn tag(name: &str, message: &str, commit: git2::Oid, repo: &git2::Repository) -> git::Oid {
+
pub fn tag(
+
    name: &str,
+
    message: &str,
+
    commit: git::raw::Oid,
+
    repo: &git::raw::Repository,
+
) -> git::Oid {
    let target = repo
-
        .find_object(commit, Some(git2::ObjectType::Commit))
+
        .find_object(commit, Some(git::raw::ObjectType::Commit))
        .unwrap();
-
    let tagger =
-
        git2::Signature::new(USER_NAME, USER_EMAIL, &git2::Time::new(RADICLE_EPOCH, 0)).unwrap();
+
    let tagger = git::raw::Signature::new(
+
        USER_NAME,
+
        USER_EMAIL,
+
        &git::raw::Time::new(RADICLE_EPOCH, 0),
+
    )
+
    .unwrap();
    repo.tag(name, &target, &tagger, message, false)
        .unwrap()
        .into()
}

/// Populate a repository with commits, branches and blobs.
-
pub fn populate(repo: &git2::Repository, scale: usize) -> Vec<git::Qualified> {
+
pub fn populate(repo: &git::raw::Repository, scale: usize) -> Vec<git::fmt::Qualified<'_>> {
    assert!(
        scale <= 8,
        "Scale parameter must be less than or equal to 8"
@@ -180,9 +223,9 @@ pub fn populate(repo: &git2::Repository, scale: usize) -> Vec<git::Qualified> {
            .take(7)
            .collect::<String>()
            .to_lowercase();
-
        let name =
-
            git::refname!("feature").join(git::RefString::try_from(random.as_str()).unwrap());
-
        let signature = git2::Signature::now("Radicle", "radicle@radicle.xyz").unwrap();
+
        let name = git::fmt::refname!("feature")
+
            .join(git::fmt::RefString::try_from(random.as_str()).unwrap());
+
        let signature = git::raw::Signature::now("Radicle", "radicle@radicle.xyz").unwrap();

        rng.fill(&mut buffer);

@@ -203,7 +246,7 @@ pub fn populate(repo: &git2::Repository, scale: usize) -> Vec<git::Qualified> {
        )
        .unwrap();

-
        refs.push(git::Qualified::from_refstr(refstr).unwrap());
+
        refs.push(git::fmt::Qualified::from_refstr(refstr).unwrap());
    }
    refs
}
@@ -225,20 +268,20 @@ pub mod gen {
    }

    /// Creates a regular repository at the given path with a couple of commits.
-
    pub fn repository<P: AsRef<Path>>(path: P) -> (git2::Repository, git2::Oid) {
-
        let repo = git2::Repository::init_opts(
+
    pub fn repository<P: AsRef<Path>>(path: P) -> (git::raw::Repository, git::raw::Oid) {
+
        let repo = git::raw::Repository::init_opts(
            path,
-
            git2::RepositoryInitOptions::new().external_template(false),
+
            git::raw::RepositoryInitOptions::new().external_template(false),
        )
        .unwrap();
-
        let sig = git2::Signature::now(string(6).as_str(), email().as_str()).unwrap();
+
        let sig = git::raw::Signature::now(string(6).as_str(), email().as_str()).unwrap();
        let head = git::initial_commit(&repo, &sig).unwrap();
        let tree =
            git::write_tree(Path::new("README"), "Hello World!\n".as_bytes(), &repo).unwrap();
        let oid = git::commit(
            &repo,
            &head,
-
            git::refname!("refs/heads/master").as_refstr(),
+
            git::fmt::refname!("refs/heads/master").as_refstr(),
            string(16).as_str(),
            &sig,
            &tree,
modified crates/radicle/src/test/storage.rs
@@ -4,7 +4,8 @@ use std::io;
use std::path::{Path, PathBuf};
use std::str::FromStr;

-
use git_ext::ref_format as fmt;
+
pub use crate::git;
+
use crate::git::fmt;

use crate::crypto::Verified;
use crate::identity::doc::{Doc, DocAt, DocError, RawDoc, RepoId};
@@ -18,7 +19,7 @@ use super::{arbitrary, fixtures};
#[derive(Clone, Debug)]
pub struct MockStorage {
    pub path: PathBuf,
-
    pub info: git::UserInfo,
+
    pub info: crate::git::UserInfo,

    /// All refs keyed by RID.
    /// Each value is a map of refs keyed by node Id (public key).
@@ -67,7 +68,7 @@ impl MockStorage {
impl ReadStorage for MockStorage {
    type Repository = MockRepository;

-
    fn info(&self) -> &git::UserInfo {
+
    fn info(&self) -> &crate::git::UserInfo {
        &self.info
    }

@@ -199,15 +200,15 @@ impl ReadRepository for MockRepository {
        self.id
    }

-
    fn is_empty(&self) -> Result<bool, git2::Error> {
+
    fn is_empty(&self) -> Result<bool, git::raw::Error> {
        Ok(self.remotes.is_empty())
    }

-
    fn head(&self) -> Result<(fmt::Qualified, Oid), RepositoryError> {
+
    fn head(&self) -> Result<(fmt::Qualified<'_>, Oid), RepositoryError> {
        Ok((fmt::qualified!("refs/heads/master"), arbitrary::oid()))
    }

-
    fn canonical_head(&self) -> Result<(fmt::Qualified, Oid), RepositoryError> {
+
    fn canonical_head(&self) -> Result<(fmt::Qualified<'_>, Oid), RepositoryError> {
        todo!()
    }

@@ -215,56 +216,58 @@ impl ReadRepository for MockRepository {
        todo!()
    }

-
    fn commit(&self, oid: Oid) -> Result<git2::Commit, git_ext::Error> {
-
        Err(git_ext::Error::NotFound(git_ext::NotFound::NoSuchObject(
-
            *oid,
-
        )))
+
    fn commit(&self, oid: Oid) -> Result<git::raw::Commit<'_>, git::raw::Error> {
+
        Err(git::raw::Error::new(
+
            git::raw::ErrorCode::NotFound,
+
            git::raw::ErrorClass::None,
+
            format!("commit {oid} not found"),
+
        ))
    }

-
    fn revwalk(&self, _head: Oid) -> Result<git2::Revwalk, git2::Error> {
+
    fn revwalk(&self, _head: Oid) -> Result<git::raw::Revwalk<'_>, git::raw::Error> {
        todo!()
    }

-
    fn contains(&self, oid: Oid) -> Result<bool, git2::Error> {
+
    fn contains(&self, oid: Oid) -> Result<bool, git::raw::Error> {
        Ok(self
            .remotes
            .values()
            .any(|sigrefs| sigrefs.at == oid || sigrefs.refs.values().any(|oid_| *oid_ == oid)))
    }

-
    fn is_ancestor_of(&self, _ancestor: Oid, _head: Oid) -> Result<bool, git_ext::Error> {
+
    fn is_ancestor_of(&self, _ancestor: Oid, _head: Oid) -> Result<bool, crate::git::raw::Error> {
        Ok(true)
    }

-
    fn blob(&self, _oid: Oid) -> Result<git2::Blob, git_ext::Error> {
+
    fn blob(&self, _oid: Oid) -> Result<git::raw::Blob<'_>, git::raw::Error> {
        todo!()
    }

    fn blob_at<P: AsRef<std::path::Path>>(
        &self,
-
        _oid: git_ext::Oid,
+
        _oid: Oid,
        _path: P,
-
    ) -> Result<git2::Blob, git_ext::Error> {
+
    ) -> Result<git::raw::Blob<'_>, git::raw::Error> {
        todo!()
    }

    fn reference(
        &self,
        _remote: &RemoteId,
-
        _reference: &git::Qualified,
-
    ) -> Result<git2::Reference, git_ext::Error> {
+
        _reference: &git::fmt::Qualified,
+
    ) -> Result<git::raw::Reference<'_>, git::raw::Error> {
        todo!()
    }

    fn reference_oid(
        &self,
        remote: &RemoteId,
-
        reference: &git::Qualified,
-
    ) -> Result<git_ext::Oid, git::raw::Error> {
+
        reference: &crate::git::fmt::Qualified,
+
    ) -> Result<Oid, crate::git::raw::Error> {
        let not_found = || {
-
            git::raw::Error::new(
-
                git::raw::ErrorCode::NotFound,
-
                git::raw::ErrorClass::Reference,
+
            crate::git::raw::Error::new(
+
                crate::git::raw::ErrorCode::NotFound,
+
                crate::git::raw::ErrorClass::Reference,
                format!("could not find {reference} for {remote}"),
            )
        };
@@ -283,8 +286,8 @@ impl ReadRepository for MockRepository {

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

@@ -300,7 +303,7 @@ impl ReadRepository for MockRepository {
        self.canonical_identity_head()
    }

-
    fn identity_head_of(&self, _remote: &RemoteId) -> Result<Oid, git::ext::Error> {
+
    fn identity_head_of(&self, _remote: &RemoteId) -> Result<Oid, crate::git::raw::Error> {
        Ok(self.doc.commit)
    }

@@ -316,13 +319,13 @@ impl ReadRepository for MockRepository {
        Ok(self.doc.commit)
    }

-
    fn merge_base(&self, _left: &Oid, _right: &Oid) -> Result<Oid, git::ext::Error> {
+
    fn merge_base(&self, _left: &Oid, _right: &Oid) -> Result<Oid, crate::git::raw::Error> {
        todo!()
    }
}

impl WriteRepository for MockRepository {
-
    fn raw(&self) -> &git2::Repository {
+
    fn raw(&self) -> &git::raw::Repository {
        todo!()
    }

@@ -342,7 +345,7 @@ impl WriteRepository for MockRepository {
        todo!()
    }

-
    fn set_user(&self, _info: &git::UserInfo) -> Result<(), Error> {
+
    fn set_user(&self, _info: &crate::git::UserInfo) -> Result<(), Error> {
        todo!()
    }
}
modified deny.toml
@@ -51,20 +51,14 @@ allow = [
    "Unicode-DFS-2016",
    "Unicode-3.0",
    "BSD-3-Clause",
-
    "MPL-2.0"
+
    "MPL-2.0",
+
    "Zlib",
]
# The confidence threshold for detecting a license from license text.
confidence-threshold = 0.8
# Allow 1 or more licenses on a per-crate basis, so that particular licenses
# aren't accepted for every possible crate as with the normal allow list
-
exceptions = [
-
    { allow = ["GPL-3.0"], name = "radicle-surf", version = "*" },
-
    { allow = ["GPL-3.0"], name = "radicle-std-ext", version = "*" },
-
    { allow = ["GPL-3.0"], name = "radicle-git-ext", version = "*" },
-
    { allow = ["GPL-3.0"], name = "git-ref-format-core", version = "*" },
-
    { allow = ["GPL-3.0"], name = "git-ref-format-macro", version = "*" },
-
    { allow = ["GPL-3.0"], name = "git-ref-format", version = "*" },
-
]
+
exceptions = []

[licenses.private]
# If true, ignores workspace crates that aren't published, or are only
modified flake.lock
@@ -3,11 +3,11 @@
    "advisory-db": {
      "flake": false,
      "locked": {
-
        "lastModified": 1739520703,
-
        "narHash": "sha256-UqR1f9gThWNBCBobWet7T46vTSxkB6dVAdeqNBoF8mc=",
+
        "lastModified": 1771365779,
+
        "narHash": "sha256-KaMQHARfo0nykDbE2pNW8GGCm5k8kIiqZ0IQI0RRYWE=",
        "owner": "rustsec",
        "repo": "advisory-db",
-
        "rev": "ddccfe8aced779f7b54d27bbe7e122ecb1dda33a",
+
        "rev": "532daba438136696af8764d4d69ea83ed8694da9",
        "type": "github"
      },
      "original": {
@@ -18,11 +18,11 @@
    },
    "crane": {
      "locked": {
-
        "lastModified": 1739936662,
-
        "narHash": "sha256-x4syUjNUuRblR07nDPeLDP7DpphaBVbUaSoeZkFbGSk=",
+
        "lastModified": 1771121070,
+
        "narHash": "sha256-aIlv7FRXF9q70DNJPI237dEDAznSKaXmL5lfK/Id/bI=",
        "owner": "ipetkov",
        "repo": "crane",
-
        "rev": "19de14aaeb869287647d9461cbd389187d8ecdb7",
+
        "rev": "a2812c19f1ed2e5ed5ce2ef7109798b575c180e1",
        "type": "github"
      },
      "original": {
@@ -34,15 +34,15 @@
    "flake-compat": {
      "flake": false,
      "locked": {
-
        "lastModified": 1696426674,
-
        "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
-
        "owner": "edolstra",
+
        "lastModified": 1767039857,
+
        "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
+
        "owner": "NixOS",
        "repo": "flake-compat",
-
        "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
+
        "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
        "type": "github"
      },
      "original": {
-
        "owner": "edolstra",
+
        "owner": "NixOS",
        "repo": "flake-compat",
        "type": "github"
      }
@@ -74,11 +74,11 @@
        ]
      },
      "locked": {
-
        "lastModified": 1742649964,
-
        "narHash": "sha256-DwOTp7nvfi8mRfuL1escHDXabVXFGT1VlPD1JHrtrco=",
+
        "lastModified": 1770726378,
+
        "narHash": "sha256-kck+vIbGOaM/dHea7aTBxdFYpeUl/jHOy5W3eyRvVx8=",
        "owner": "cachix",
        "repo": "git-hooks.nix",
-
        "rev": "dcf5072734cb576d2b0c59b2ac44f5050b5eac82",
+
        "rev": "5eaaedde414f6eb1aea8b8525c466dc37bba95ae",
        "type": "github"
      },
      "original": {
@@ -110,32 +110,32 @@
    },
    "nixpkgs-stable": {
      "locked": {
-
        "lastModified": 1754919767,
-
        "narHash": "sha256-bc9tjR2ymbmbtYlnOcksjI7tQtDDEEJFGm41t0msXsg=",
+
        "lastModified": 1771371298,
+
        "narHash": "sha256-AudCpsjP7MfL1mAusLjyyiOz+SF+JujxWzoSAhjk+Og=",
        "owner": "NixOS",
        "repo": "nixpkgs",
-
        "rev": "8c0c41355297485b39d6f6a6d722c8cdfe0257df",
+
        "rev": "bf117123cd658283c5b5c587e59091168323ad61",
        "type": "github"
      },
      "original": {
        "owner": "NixOS",
-
        "ref": "release-25.05",
+
        "ref": "release-25.11",
        "repo": "nixpkgs",
        "type": "github"
      }
    },
    "nixpkgs-unstable": {
      "locked": {
-
        "lastModified": 1754919767,
-
        "narHash": "sha256-bc9tjR2ymbmbtYlnOcksjI7tQtDDEEJFGm41t0msXsg=",
+
        "lastModified": 1771371298,
+
        "narHash": "sha256-AudCpsjP7MfL1mAusLjyyiOz+SF+JujxWzoSAhjk+Og=",
        "owner": "NixOS",
        "repo": "nixpkgs",
-
        "rev": "8c0c41355297485b39d6f6a6d722c8cdfe0257df",
+
        "rev": "bf117123cd658283c5b5c587e59091168323ad61",
        "type": "github"
      },
      "original": {
        "owner": "NixOS",
-
        "ref": "release-25.05",
+
        "ref": "release-25.11",
        "repo": "nixpkgs",
        "type": "github"
      }
@@ -161,11 +161,11 @@
        ]
      },
      "locked": {
-
        "lastModified": 1753325142,
-
        "narHash": "sha256-7A8epLZ/LW9tek4OJY4IHesH7BgfBKr3aEm9JjUwqQo=",
+
        "lastModified": 1771384185,
+
        "narHash": "sha256-KvmjUeA7uODwzbcQoN/B8DCZIbhT/Q/uErF1BBMcYnw=",
        "owner": "oxalica",
        "repo": "rust-overlay",
-
        "rev": "cf608fb54d8854f31d7f7c499e2d2c928af48036",
+
        "rev": "23dd7fa91602a68bd04847ac41bc10af1e6e2fd2",
        "type": "github"
      },
      "original": {
modified flake.nix
@@ -2,8 +2,8 @@
  description = "Radicle";

  inputs = {
-
    nixpkgs-unstable.url = "github:NixOS/nixpkgs/release-25.05";
-
    nixpkgs-stable.url = "github:NixOS/nixpkgs/release-25.05";
+
    nixpkgs-unstable.url = "github:NixOS/nixpkgs/release-25.11";
+
    nixpkgs-stable.url = "github:NixOS/nixpkgs/release-25.11";
    nixpkgs.follows = "nixpkgs-stable";

    crane.url = "github:ipetkov/crane";
@@ -28,6 +28,8 @@

  nixConfig = {
    keepOutputs = true;
+
    extra-substituters = ["https://attic.radicle.xyz/radicle"];
+
    extra-trusted-public-keys = ["radicle:TruHbueGHPm9iYSq7Gq6wJApJOqddWH+CEo+fsZnf4g="];
  };

  outputs = {
@@ -60,6 +62,14 @@
        commonArgs = mkCommonArgs craneLib;
      };

+
      rustupDevShell = rec {
+
        toolchain = rustup.toolchain.override (prev: {
+
          extensions = prev.extensions ++ ["rust-analyzer"];
+
        });
+
        craneLib = (crane.mkLib pkgs).overrideToolchain toolchain;
+
        commonArgs = mkCommonArgs craneLib;
+
      };
+

      srcFilters = path: type:
        builtins.any (suffix: lib.hasSuffix suffix path) [
          ".sql" # schemas
@@ -67,6 +77,7 @@
          ".md" # testing
          ".adoc" # man pages
          ".json" # testing samples
+
          ".txt" # might be included with `include_str!`
          "rad-cob-multiset" # testing external COBs
        ]
        ||
@@ -102,6 +113,7 @@
          ]);
          nativeCheckInputs = with pkgs; [
            jq
+
            jujutsu
          ];

          env =
@@ -170,25 +182,45 @@
        // {
          pre-commit-check = let
            grep = rec {
-
              words = ["radicle.xyz" "radicle.zulipchat.com"];
-
              after = map id words;
+
              generators = [
+
                {
+
                  word = "radicle.xyz";
+
                  files = "\\.rs$";
+
                  excludes = [];
+
                }
+
                {
+
                  word = "radicle.zulipchat.com";
+
                  files = "\\.rs$";
+
                  excludes = [];
+
                }
+
                {
+
                  word = "git2::";
+
                  files = "^crates/radicle/.*\\.rs$";
+
                  excludes = ["crates/radicle/src/git/raw.rs"];
+
                }
+
              ];
+
              after = map id generators;
              prefix = "grep-";
-
              id = word: prefix + word;
-
              hooks = builtins.listToAttrs (map (word: {
+
              id = {word, ...}: prefix + word;
+
              hooks = builtins.listToAttrs (map (generator: {
                  # "," is problematic, as this is used to split
                  # lists of hook names, when skipping, see:
                  # https://pre-commit.com/#temporarily-disabling-hooks
-
                  name = assert !lib.hasInfix "," word; id word;
-
                  value = hook word;
+
                  name = assert !lib.hasInfix "," generator.word; id generator;
+
                  value = hook generator;
                })
-
                words);
-
              hook = word: {
+
                generators);
+
              hook = {
+
                word,
+
                files,
+
                excludes,
+
              }: {
+
                inherit files excludes;
                enable = true;
                entry = builtins.toString (pkgs.writeShellScript
                  "grep-${word}"
                  "! ${lib.getExe pkgs.ripgrep} --context=3 --fixed-strings '${word}' $@");
-
                name = "Avoid '${word}' in Rust code";
-
                files = "\\.rs$";
+
                name = "Avoid '${word}' in '${files}'";
                pass_filenames = true;
              };
            };
@@ -196,9 +228,25 @@
            inputs.git-hooks.lib.${system}.run {
              src = ./.;
              settings.rust.check.cargoDeps = pkgs.rustPlatform.importCargoLock {lockFile = ./Cargo.lock;};
+
              default_stages = [
+
                "pre-commit"
+
                "pre-push"
+
              ];
              hooks =
                {
                  alejandra.enable = true;
+
                  typos = {
+
                    enable = true;
+
                    settings = {
+
                      verbose = true;
+
                      write = true;
+
                    };
+
                  };
+
                  codespell = {
+
                    enable = true;
+
                    entry = "${lib.getExe pkgs.codespell} -w";
+
                    types = ["text"];
+
                  };
                  rustfmt = {
                    enable = true;
                    fail_fast = true;
@@ -303,7 +351,7 @@
          };
        };

-
      devShells.default = rustup.craneLib.devShell {
+
      devShells.default = rustupDevShell.craneLib.devShell {
        inherit (self.checks.${system}.pre-commit-check) shellHook;
        buildInputs = self.checks.${system}.pre-commit-check.enabledPackages;

@@ -315,11 +363,10 @@
          cargo-nextest
          cargo-semver-checks
          ripgrep
-
          rust-analyzer
          sqlite
        ];

-
        env.RUST_SRC_PATH = "${rustup.toolchain}/lib/rustlib/src/rust/library";
+
        env.RUST_SRC_PATH = "${rustupDevShell.toolchain}/lib/rustlib/src/rust/library";
      };
    });
}
modified rad-patch.1.adoc
@@ -152,10 +152,10 @@ Any other Revision ID will comment on the revision specified.

*--message*, *-m <string>*::
Comment message. If omitted, Radicle will prompt for a comment string via
-
*$EDITOR*. Multiple messages will be concatinated with a blank line in between.
+
*$EDITOR*. Multiple messages will be concatenated with a blank line in between.

*--reply-to <comment-id>*::
-
Optional comment to reply to. If ommitted, the comment is a top-level comment
+
Optional comment to reply to. If omitted, the comment is a top-level comment
on the given revision.

== Opening a patch
@@ -217,6 +217,11 @@ The full list of options follows:
*patch.draft*::
  Open the patch as a _draft_. Turned off by default.

+
*patch.branch[=<name>]*::
+
  Create a branch when opening a patch. Turned off by default.
+
  If a custom *name* is provided, this name is used. Otherwise
+
  the branch name defaults to *patches/<patch id>*.
+

*patch.message*=_<message>_::
  To prevent the editor from opening, you can specify the patch message via this
  option. Multiple *patch.message* options are concatenated with a blank line
modified rad.1.adoc
@@ -199,7 +199,7 @@ description and default branch by running:
*~/.radicle*::

The _Radicle Home_. This is the default location for Radicle configuration
-
files, keys, and repository storage. Can be controlled by settting the
+
files, keys, and repository storage. Can be controlled by setting the
**RAD_HOME** environment variable.

*~/.radicle/config.json*::
modified rust-toolchain.toml
@@ -1,4 +1,4 @@
[toolchain]
-
channel = "1.88"
+
channel = "1.90"
profile = "default"
components = [ "rust-src" ]
modified systemd/system/radicle-node.service
@@ -30,12 +30,6 @@ MemoryDenyWriteExecute=true
# `rad auth`.
#Environment=RAD_PASSPHRASE=snickerdoodle

-
# Basic hardening options. For more, please refer to `systemd-analyze security`.
-
PrivateTmp=true
-
ProtectSystem=strict
-
NoNewPrivileges=true
-
MemoryDenyWriteExecute=true
-

User=seed
Group=seed

added windows/.gitignore
@@ -0,0 +1,2 @@
+
bin
+
obj
added windows/Package.wxs
@@ -0,0 +1,62 @@
+
<?xml version="1.0" encoding="utf-8" ?>
+

+
<?ifndef env.CARGO_PROFILE ?>
+
  <?define CARGO_PROFILE = "debug" ?>
+
<?else?>
+
  <?define CARGO_PROFILE = $(env.CARGO_PROFILE) ?>
+
<?endif?>
+

+
<?ifndef env.CARGO_TARGET_DIR ?>
+
  <?define CARGO_TARGET_DIR = "target" ?>
+
<?else?>
+
  <?define CARGO_TARGET_DIR = $(env.CARGO_TARGET_DIR) ?>
+
<?endif?>
+

+
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
+
  <Package
+
    Id="xyz.radicle"
+
    Name="Radicle"
+
    Manufacturer="Radicle Team"
+
    Version="$(env.RADICLE_VERSION)"
+
    UpgradeCode="24fb0aa5-a2a7-4c6f-8283-4f2c8aaf5dd7"
+
    Scope="perUser"
+
    InstallerVersion="500"
+
    Compressed="yes"
+
  >
+
    <MediaTemplate EmbedCab="yes" />
+
    <MajorUpgrade DowngradeErrorMessage="A newer version of Radicle is already installed." />
+
    <Property Id="ARPURLINFOABOUT" Value="https://www.radicle.xyz/" />
+
    <StandardDirectory Id="LocalAppDataFolder">
+
      <Directory Id="INSTALLFOLDER" Name="Radicle">  
+
        <Component Id="InstallFolderComponent" Guid="*">
+
          <RemoveFolder On="uninstall" />
+
          <RegistryKey Root="HKCU" Key="Software\Radicle\Components">
+
            <RegistryValue Name="InstallFolder" Type="integer" Value="0" KeyPath="yes" />
+
          </RegistryKey>
+
        </Component>
+
        <Directory Id="BinFolder" Name="bin">
+
          <Component Id="BinFolderComponent" Guid="9d24e7f1-4c00-4cb4-9644-14ed3e0e7ebf">
+
            <RemoveFolder Id="RemoveDirectoryOnUninstall" On="uninstall" />
+
            <File Source="..\$(CARGO_TARGET_DIR)\$(CARGO_PROFILE)\rad.exe" />
+
            <File Source="..\$(CARGO_TARGET_DIR)\$(CARGO_PROFILE)\radicle-node.exe" />
+
            <File Source="..\$(CARGO_TARGET_DIR)\$(CARGO_PROFILE)\git-remote-rad.exe" />
+
            <RegistryKey Root="HKCU" Key="Software\Radicle\Components">
+
              <RegistryValue Name="BinFolder" Type="integer" Value="0" KeyPath="yes" />
+
            </RegistryKey>
+
          </Component>
+
          <Component Id="BinFolderInPathComponent" Guid="c37b7983-5f94-4c07-a1bd-40fe31b9e468">
+
            <Environment Id="envPath" Name="PATH" Value="[BinFolder]" Action="set" System="no" Part="last" Permanent="no" />
+
            <RegistryKey Root="HKCU" Key="Software\Radicle\Components">
+
              <RegistryValue Name="BinFolderInPath" Type="integer" Value="0" KeyPath="yes" />
+
            </RegistryKey>
+
          </Component>
+
        </Directory>
+
      </Directory>
+
    </StandardDirectory>
+
    <Feature Id="Main" Title="Radicle" Level="1">
+
      <ComponentRef Id="InstallFolderComponent" />
+
      <ComponentRef Id="BinFolderComponent" />
+
      <ComponentRef Id="BinFolderInPathComponent" />
+
    </Feature>
+
  </Package>
+
</Wix>
added windows/README.md
@@ -0,0 +1,17 @@
+
# Radicle for Windows
+

+
## Prerequisites
+

+
### Code-Signing
+

+
For `signtool.exe`, install [Windows SDK].
+

+
### WiX Toolset
+

+
Requires [.NET] SDK in version 6.0 or higher.
+
See [WiX Toolset Tutorial].
+

+

+
[Windows SDK]: https://learn.microsoft.com/windows/apps/windows-sdk/downloads
+
[WiX Toolset Tutorial]: https://docs.firegiant.com/wix/tutorial/
+
[.NET]: https://dotnet.microsoft.com/download/dotnet/

\ No newline at end of file
added windows/Radicle.wixproj
@@ -0,0 +1,5 @@
+
<Project Sdk="WixToolset.Sdk/6.0.0">
+
  <PropertyGroup>
+
    <SuppressIces>ICE91</SuppressIces>
+
  </PropertyGroup>
+
</Project>

\ No newline at end of file
added windows/version.ps1
@@ -0,0 +1,20 @@
+
$prefix = "releases"
+

+
$describe = git describe --match "$prefix/*.*.*" --exclude "$prefix/*.*.*-*.*" --candidates=1 2>$null
+
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($describe)) {
+
  Write-Error -Category ObjectNotFound "tag not found"
+
  exit 1
+
}
+

+
if (-not ($describe.Trim() -match "^$prefix/(\d+)\.(\d+)\.(\d+)(?:-(\d+)-g[0-9a-f]+)?$")) {
+
  Write-Error -Category ParserError "tag not recognized"
+
  exit 1
+
}
+

+
$major = [int]$matches[1]
+
$minor = [int]$matches[2]
+
$patch = [int]$matches[3]
+

+
$commits = if ([string]::IsNullOrEmpty($matches[4])) { 0 } else { [int]$matches[4] }
+

+
Write-Output "$major.$minor.$patch.$commits"