Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
cli/sync: Rewrite Announcement in Two Phases
Archived lorenz opened 11 months ago

The code for announcing changes is rewritten. It is now separated into two phases, such that first, announcements are made to preferred seeds, and only then, to other seeds, to meet desired replication target.

The primary reason is that in order for an announcement process to be considered successful, there are two conditions, and both have to be met:

  1. All preferred seeds must be in sync.
  2. Replication target must be met.

Up to now, the two conditions were correctly recognized, but it was hard to follow for the user, since they were both tracked in a pass.

Along the way I improved the messages quite a bit. It is now possible to obtain progress information, such as how many seeds are already in sync, as the process of announcement unfolds.

I took care to use the terminology “announce”, “sync” and “seed” precisely.

This is motivated by

issue/022227e32a410d39118c35f7909d3512e980bc29

but since my understanding of the situation improved after working with the code, I deviated from my original (much too verbose), proposal.

What I originally wanted is more or less what one gets with --debug.

22 files changed +372 -140 f30760d6 d9969fe2
modified radicle-cli/examples/git/git-push-amend.md
@@ -21,7 +21,7 @@ $ git commit --amend -m "Neue Änderungen" --allow-empty -q
``` ~alice (stderr)
$ git push rad master -f
✓ Canonical head updated to 9170c8795d3a78f0381a0ffafb20ea69fb0f5b6b
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + fb25886...9170c87 master -> master (forced update)
```
modified radicle-cli/examples/git/git-push-converge.md
@@ -92,7 +92,7 @@ $ git push rad -f
warn: could not determine canonical tip for `refs/heads/master`
warn: no commit found with at least 3 vote(s) (threshold not met)
warn: it is recommended to find a commit to agree upon
-
✓ Synced with 2 node(s)
+
✓ Announced to 2 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   d09e634..0f9bd80  master -> master
```
@@ -116,7 +116,7 @@ become the canonical `master`.
``` ~bob (stderr)
$ git push rad
✓ Canonical head updated to 3a75f66dd0020c9a0355cc6ec21f15de989e2001
-
✓ Synced with 2 node(s)
+
✓ Announced to 2 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
   2a37862..0f9bd80  master -> master
```
@@ -137,7 +137,7 @@ HEAD is now at 0f9bd80 Merge remote-tracking branch 'eve/master'
``` ~eve (stderr)
$ git push rad
✓ Canonical head updated to 0f9bd8035c04b3f73f5408e73e8454879b20800b
-
✓ Synced with 2 node(s)
+
✓ Announced to 2 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z
   3a75f66..0f9bd80  master -> master
```
modified radicle-cli/examples/git/git-push-rollback.md
@@ -35,7 +35,7 @@ Fast-forward
``` ~alice (stderr)
$ git push rad
✓ Canonical head updated to 319a7dc3b195368ded4b099f8c90bbb80addccd3
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..319a7dc  master -> master
```
@@ -54,7 +54,7 @@ push and the new canonical head becomes the previous commit again:
``` ~alice (stderr)
$ git push rad -f
✓ Canonical head updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + 319a7dc...f2de534 master -> master (forced update)
```
modified radicle-cli/examples/git/git-push.md
@@ -59,7 +59,7 @@ f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/master

```
$ rad sync --announce
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
```

Note that it is forbidden to delete the default/canonical branch:
modified radicle-cli/examples/git/git-tag.md
@@ -12,7 +12,7 @@ $ git tag v1.0 -a -m "Release v1.0"

``` ~alice (stderr)
$ git push rad v1.0
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new tag]         v1.0 -> v1.0
```
@@ -63,7 +63,7 @@ Updated tag 'v1.0' (was be18ed6)

``` ~alice (stderr)
$ git push rad v1.0 -f
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + be18ed6...9dbdebc v1.0 -> v1.0 (forced update)
```
modified radicle-cli/examples/rad-id-collaboration.md
@@ -53,14 +53,14 @@ $ rad sync
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MkuPZ…xEuaPUp@[..]..
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MkvVv…Z1Ct4tD@[..]..
✓ Fetched repository from 2 seed(s)
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
$ rad id update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --title "Add Bob" --description "" --threshold 2 --delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --no-confirm -q
069e7d58faa9a7473d27f5510d676af33282796f
$ rad sync
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MkuPZ…xEuaPUp@[..]..
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MkvVv…Z1Ct4tD@[..]..
✓ Fetched repository from 2 seed(s)
-
✓ Synced with 3 node(s)
+
✓ Announced to 3 seed(s) to meet replication target.
```

Bob can confirm that he was made a delegate by fetching the update:
@@ -70,7 +70,7 @@ $ rad sync
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MkuPZ…xEuaPUp@[..]..
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MkvVv…Z1Ct4tD@[..]..
✓ Fetched repository from 2 seed(s)
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
$ rad inspect --delegates
did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (alice)
did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (bob)
@@ -110,7 +110,7 @@ $ rad sync
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MkvVv…Z1Ct4tD@[..]..
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MkuPZ…xEuaPUp@[..]..
✓ Fetched repository from 2 seed(s)
-
✓ Synced with 3 node(s)
+
✓ Announced to 3 seed(s) to meet replication target.
```

Notice how there was no need to follow Eve right away in this case?
@@ -160,7 +160,7 @@ $ rad sync --timeout 3
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MkvVv…Z1Ct4tD@[..]..
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MkuPZ…xEuaPUp@[..]..
✓ Fetched repository from 2 seed(s)
-
✓ Synced with 3 node(s)
+
✓ Announced to 3 seed(s) to meet replication target.
$ rad inspect rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --sigrefs
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 1f716870f890be0c13fdd0af9f527af849fec792
z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk c40018821dc1b41cad75e91e0c9d00827e815324
@@ -176,7 +176,7 @@ $ rad sync
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MkvVv…Z1Ct4tD@[..]..
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MkuPZ…xEuaPUp@[..]..
✓ Fetched repository from 2 seed(s)
-
✓ Synced with 3 node(s)
+
✓ Announced to 3 seed(s) to meet replication target.
$ rad sync status
╭─────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ●   Node                            Address                        Status   Tip       Timestamp │
modified radicle-cli/examples/rad-id-threshold-soft-fork.md
@@ -13,7 +13,7 @@ $ rad issue open --title "Add Bob as a delegate" --description "We agreed to add
│ We agreed to add me as a delegate, so I am creating an issue │
│ to track that work                                           │
╰──────────────────────────────────────────────────────────────╯
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
```

and if we inspect Alice's references, then we will see the following:
modified radicle-cli/examples/rad-id-threshold.md
@@ -71,7 +71,7 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE

``` ~alice
$ rad sync -a
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
```

``` ~alice
modified radicle-cli/examples/rad-inbox.md
@@ -7,7 +7,7 @@ Your inbox is empty.
``` ~bob
$ cd heartwood
$ rad issue open --title "No license file" --description "..." -q
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
$ git commit -m "Change copyright" --allow-empty -q
$ git push rad HEAD:bob/copy
$ cd ..
@@ -113,7 +113,7 @@ Now let's do an identity update.
$ rad id update --title "Modify description" --description "Use website" --payload xyz.radicle.project description '"https://radicle.xyz"' -q
[..]
$ rad sync -a
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
```

``` ~bob
modified radicle-cli/examples/rad-init-private-clone.md
@@ -18,7 +18,7 @@ the refs.
$ rad id update --title "Allow Bob" --description "" --allow did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk -q
...
$ rad sync --announce --timeout 3
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
```

Bob can now fetch the private repo without specifying a seed, because he knows
modified radicle-cli/examples/rad-patch-checkout-force.md
@@ -14,7 +14,7 @@ $ git commit -v -m "Define power requirements"
``` ~alice (stderr)
$ git push rad -o patch.message="Define power requirements" -o patch.message="See details." HEAD:refs/patches
✓ Patch aa45913e757cacd46972733bddee5472c78fa32a opened
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -50,7 +50,7 @@ To compare against your previous revision aa45913, run:

   git range-diff f2de534[..] 3e674d1[..] 27857ec[..]

-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   3e674d1..27857ec  flux-capacitor-power -> patches/aa45913e757cacd46972733bddee5472c78fa32a
```
modified radicle-cli/examples/rad-patch-delete.md
@@ -11,7 +11,7 @@ $ git commit -m "Introduce license"
``` ~alice (stderr)
$ git push rad -o patch.draft -o patch.message="Define LICENSE for project" HEAD:refs/patches
✓ Patch 6c61ef1716ad8a5c11e04dd7a3fec51e01fba70b drafted
-
✓ Synced with 2 node(s)
+
✓ Announced to 2 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -26,7 +26,7 @@ $ rad patch comment 6c61ef1 -m "I think we should use MIT"
│ bob (you) now 833db19     │
│ I think we should use MIT │
╰───────────────────────────╯
-
✓ Synced with 2 node(s)
+
✓ Announced to 2 seed(s) to meet replication target.
```

``` ~alice
@@ -53,7 +53,7 @@ $ rad patch comment 6c61ef1 --reply-to 833db19 -m "Thanks, I'll add it!"
│ alice (you) now 1803a38 │
│ Thanks, I'll add it!    │
╰─────────────────────────╯
-
✓ Synced with 2 node(s)
+
✓ Announced to 2 seed(s) to meet replication target.
```

``` ~alice
@@ -73,7 +73,7 @@ To compare against your previous revision 6c61ef1, run:

   git range-diff f2de534[..] 717c900[..] 1cc8cd9[..]

-
✓ Synced with 2 node(s)
+
✓ Announced to 2 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   717c900..1cc8cd9  prepare-license -> patches/6c61ef1716ad8a5c11e04dd7a3fec51e01fba70b
```
@@ -81,7 +81,7 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE
``` ~bob
$ rad patch review 6c61ef1 --accept -m "LGTM!"
✓ Patch 6c61ef1 accepted
-
✓ Synced with 2 node(s)
+
✓ Announced to 2 seed(s) to meet replication target.
$ rad patch show 6c61ef1 -v
╭─────────────────────────────────────────────────────────────────────╮
│ Title    Define LICENSE for project                                 │
@@ -103,7 +103,7 @@ $ rad patch show 6c61ef1 -v

``` ~bob
$ rad patch delete 6c61ef1
-
✓ Synced with 2 node(s)
+
✓ Announced to 2 seed(s) to meet replication target.
```

``` ~alice
@@ -131,7 +131,7 @@ Alice should no longer have the patch:

``` ~alice
$ rad patch delete 6c61ef1
-
✓ Synced with 2 node(s)
+
✓ Announced to 2 seed(s) to meet replication target.
```

``` ~seed (fails)
modified radicle-cli/examples/rad-patch-open-explore.md
@@ -5,7 +5,8 @@ $ git checkout -b changes -q
$ git commit --allow-empty -q -m "Changes"
$ git push rad HEAD:refs/patches
✓ Patch acab0ec777a97d013f30be5d5d1aec32562ecb02 opened
-
✓ Synced with 1 node(s)
+
✓ Announced to preferred 1 seed(s).
+
! No other seeds to announce to.

  https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/patches/acab0ec777a97d013f30be5d5d1aec32562ecb02

@@ -23,7 +24,8 @@ To compare against your previous revision acab0ec, run:

   git range-diff f2de534[..] e12525d[..] b2b6432[..]

-
✓ Synced with 1 node(s)
+
✓ Announced to preferred 1 seed(s).
+
! No other seeds to announce to.

  https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/patches/acab0ec777a97d013f30be5d5d1aec32562ecb02

@@ -39,7 +41,8 @@ $ git merge changes -q
$ git push rad master
✓ Patch acab0ec777a97d013f30be5d5d1aec32562ecb02 merged
✓ Canonical head updated to b2b6432af93f8fe188e32d400263021b602cfec8
-
✓ Synced with 1 node(s)
+
✓ Announced to preferred 1 seed(s).
+
! No other seeds to announce to.

  https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/tree/b2b6432af93f8fe188e32d400263021b602cfec8

modified radicle-cli/examples/rad-patch-pull-update.md
@@ -42,7 +42,7 @@ our fork:
``` ~bob (stderr)
$ cd heartwood
$ git push rad master
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
To rad://zhbMU4DUXrzB8xT6qAJh6yZ7bFMK/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
 * [new branch]      master -> master
```
@@ -54,7 +54,7 @@ $ git checkout -b bob/feature -q
$ git commit --allow-empty -m "Bob's commit #1" -q
$ git push rad -o sync -o patch.message="Bob's patch" HEAD:refs/patches
✓ Patch 55b9721ed7f6bfec38f43729e9b6631c5dc812fb opened
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
To rad://zhbMU4DUXrzB8xT6qAJh6yZ7bFMK/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
 * [new reference]   HEAD -> refs/patches
```
@@ -87,7 +87,7 @@ To compare against your previous revision 55b9721, run:

   git range-diff f2de534[..] bdcdb30[..] cad2666[..]

-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
To rad://zhbMU4DUXrzB8xT6qAJh6yZ7bFMK/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
   bdcdb30..cad2666  bob/feature -> patches/55b9721ed7f6bfec38f43729e9b6631c5dc812fb
```
modified radicle-cli/examples/rad-push-and-pull-patches.md
@@ -37,7 +37,7 @@ To compare against your previous revision d004b67, run:

   git range-diff f2de534[..] 8d5f1ba[..] c2aaf1c[..]

-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new branch]      patch/d004b67 -> patches/d004b67355456c46de10c0d287e4a791ad1a6945
```
@@ -60,7 +60,7 @@ To compare against your previous revision d004b67, run:

   git range-diff f2de534[..] 8d5f1ba[..] d9f8caf[..]

-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   c2aaf1c..d9f8caf  patch/d004b67 -> patches/d004b67355456c46de10c0d287e4a791ad1a6945
```
modified radicle-cli/examples/rad-sync.md
@@ -26,7 +26,7 @@ wait for nodes to announce that they have fetched those refs.

```
$ rad sync --announce
-
✓ Synced with 2 node(s)
+
✓ Announced to 2 seed(s) to meet replication target.
```

Now, when we run `rad sync status` again, we can see that `bob` and
@@ -48,7 +48,7 @@ be up to date.

```
$ rad sync --announce
-
✓ Nothing to announce, already in sync with 2 node(s) (see `rad sync status`)
+
✓ All preferred seeds are in sync, and the replication target is met. Nothing to announce.
```

We can also use the `--fetch` option to only fetch objects:
@@ -67,7 +67,7 @@ $ rad sync --fetch --announce
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6Mkux1…nVhib7Z@[..]..
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6Mkt67…v4N1tRk@[..]..
✓ Fetched repository from 2 seed(s)
-
✓ Nothing to announce, already in sync with 2 node(s) (see `rad sync status`)
+
✓ All preferred seeds are in sync, and the replication target is met. Nothing to announce.
```

It's also possible to use the `--seed` flag to only sync with a specific node:
@@ -89,11 +89,11 @@ $ rad issue open --title "Test `rad sync --replicas`" --description "Check that
$ rad sync --replicas 1
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6Mkux1…nVhib7Z@[..]..
✓ Fetched repository from 1 seed(s)
-
✓ Synced with 1 node(s)
+
✓ Announced to 2 seed(s) to meet replication target (1 succeeded, 0 failed).
```

-
Note that we see `✓ Fetched repository from 1 seed(s)` and `✓ Synced
-
with 1 node(s)`. This does not necessarily mean that only `bob` or
+
Note that we see `✓ Fetched repository from 1 seed(s)` and announced to only
+
with 1 seed. This does not necessarily mean that only `bob` or
`eve` were synchronized with, since they both could have received the
announcement of the new changes. However, it does mean that we only
wait for at least 1 of the nodes to have fetched the changes from us.
modified radicle-cli/examples/workflow/4-patching-contributor.md
@@ -91,5 +91,5 @@ And let's leave a quick comment for our team:
```
$ rad patch comment e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46 --message 'I cannot wait to get back to the 90s!' -q
8c66f87afadc7c7c857f8bb92973c25f64e75776
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
```
modified radicle-cli/examples/workflow/5-patching-maintainer.md
@@ -76,7 +76,7 @@ Great, all fixed up, lets accept and merge the code.
```
$ rad patch review e4934b6 --revision 9d62420 --accept
✓ Patch e4934b6 accepted
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
$ git checkout master
Your branch is up to date with 'rad/master'.
$ git merge patch/e4934b6
@@ -92,7 +92,7 @@ Fast-forward
$ git push rad master
✓ Patch e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46 merged at revision 9d62420
✓ Canonical head updated to f567f695d25b4e8fb63b5f5ad2a584529826e908
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..f567f69  master -> master
```
@@ -134,5 +134,5 @@ patch, marking it as solved:
```
$ rad issue state 9037b7a --solved
✓ Issue 9037b7a is now solved
-
✓ Synced with 1 node(s)
+
✓ Announced to 1 seed(s) to meet replication target.
```
modified radicle-cli/src/node.rs
@@ -180,116 +180,320 @@ fn announce_<R: ReadRepository>(
    let rid = repo.id();
    let doc = repo.identity_doc()?;
    let mut settings = settings.with_profile(profile);
-
    let unsynced: Vec<_> = if doc.is_public() {
-
        // All seeds.
-
        let all = node.seeds(rid)?;
-
        if all.is_empty() {
-
            term::info!(&mut reporting.completion; "No seeds found for {rid}.");
-
            return Ok(AnnounceResult::default());
-
        }
-
        // Seeds in sync with us.
-
        let synced = all
+

+
    let seeds = node.seeds(rid)?;
+
    let me = radicle::identity::Did::from(profile.id());
+

+
    // Note that we filter out `me` from the candidate set,
+
    // as we do not count want to sync with outselves and we
+
    // do not count ourselves towards the replication target.
+
    let candidates: BTreeSet<NodeId> = if doc.is_public() {
+
        seeds
            .iter()
-
            .filter(|s| s.is_synced())
-
            .map(|s| s.nid)
-
            .collect::<BTreeSet<_>>();
-
        // Replicas not counting our local replica.
-
        let replicas = synced.iter().filter(|nid| *nid != profile.id()).count();
-
        // Maximum replication factor we can achieve.
-
        let max_replicas = all.iter().filter(|s| &s.nid != profile.id()).count();
-
        // If the seeds we specified in the sync settings are all synced.
-
        let is_seeds_synced = settings.seeds.iter().all(|s| synced.contains(s));
-
        // If we met our desired replica count. Note that this can never exceed the maximum count.
-
        let is_replicas_synced = replicas >= settings.replicas.min(max_replicas);
-

-
        // Nothing to do if we've met our sync state.
-
        if is_seeds_synced && is_replicas_synced {
-
            term::success!(
-
                &mut reporting.completion;
-
                "Nothing to announce, already in sync with {replicas} node(s) (see `rad sync status`)"
-
            );
-
            return Ok(AnnounceResult::default());
-
        }
-
        // Return nodes we can announce to. They don't have to be connected directly.
-
        all.iter()
-
            .filter(|s| !s.is_synced() && &s.nid != profile.id())
-
            .map(|s| s.nid)
+
            .filter_map(|seed| (seed.nid != *me).then_some(seed.nid))
            .collect()
    } else {
        node.sessions()?
            .into_iter()
-
            .filter(|s| s.state.is_connected() && doc.is_visible_to(&s.nid.into()))
-
            .map(|s| s.nid)
+
            .filter_map(|session| {
+
                (session.nid != *me
+
                    && session.state.is_connected()
+
                    && doc.is_visible_to(&session.nid.into()))
+
                .then_some(session.nid)
+
            })
            .collect()
    };

-
    if unsynced.is_empty() {
-
        term::info!(&mut reporting.completion; "No seeds to announce to for {rid}. (see `rad sync status`)");
+
    if candidates.is_empty() {
+
        term::info!(&mut reporting.completion; "No candidate seeds found to announce {rid} to.");
+
        if !doc.is_public() && profile.config.cli.hints {
+
            term::hint_write(&mut reporting.completion, "This is a private repository. It can only be synced with connected nodes to which it is visible.").ok();
+
            if let Some(visible_to) = doc.visible_to() {
+
                term::hint_write(
+
                    &mut reporting.completion,
+
                    "The repository is currently visible to:",
+
                )
+
                .ok();
+
                visible_to.iter().filter(|did| **did != me).for_each(|n| {
+
                    term::hint_write(&mut reporting.completion, format!("  - {}", format::dim(n)))
+
                        .ok();
+
                });
+
            }
+
        }
        return Ok(AnnounceResult::default());
    }
+

+
    let (synced, unsynced) = candidates
+
        .iter()
+
        .filter(|nid| !settings.seeds.contains(nid))
+
        .partition::<BTreeSet<_>, _>(|s| seeds.get(s).is_some_and(|s| s.is_synced()));
+

+
    let (preferred_synced, preferred_unsynced) = settings
+
        .seeds
+
        .iter()
+
        .partition::<BTreeSet<_>, _>(|s| seeds.get(s).is_some_and(|s| s.is_synced()));
+

+
    // Replicas not counting our local replica.
+
    let replicas = synced.len() + preferred_synced.len();
+

+
    // Maximum replication factor we can achieve.
+
    let max_replicas = unsynced.len() + preferred_unsynced.len();
+

    // Cap the replicas to the maximum achievable.
    // Nb. It's impossible to know if a replica follows our node. This means that if we announce
    // only our refs, and the replica doesn't follow us, it won't fetch from us.
-
    settings.replicas = settings.replicas.min(unsynced.len());
-

-
    let mut spinner = term::spinner_to(
-
        format!("Found {} seed(s)..", unsynced.len()),
-
        reporting.completion.clone(),
-
        reporting.progress.clone(),
-
    );
-
    let result = node.announce(
-
        rid,
-
        unsynced,
-
        settings.timeout,
-
        |event, replicas| match event {
-
            node::AnnounceEvent::Announced => ControlFlow::Continue(()),
-
            node::AnnounceEvent::RefsSynced { remote, time } => {
-
                spinner.message(format!(
-
                    "Synced with {} in {}..",
-
                    format::dim(remote),
-
                    format::dim(format!("{time:?}"))
-
                ));
-

-
                // We're done syncing when both of these conditions are met:
-
                //
-
                // 1. We've matched or exceeded our target replica count.
-
                // 2. We've synced with one of the seeds specified manually.
-
                if replicas.len() >= settings.replicas
-
                    && (settings.seeds.is_empty()
-
                        || settings.seeds.iter().any(|s| replicas.contains_key(s)))
-
                {
-
                    ControlFlow::Break(())
+
    settings.replicas = settings.replicas.min(max_replicas);
+

+
    // If we met our desired replica count. Note that this can never exceed the maximum count.
+
    let is_replicas_synced = replicas >= settings.replicas;
+

+
    // If the seeds we specified in the sync settings are all synced.
+
    let is_seeds_synced = preferred_unsynced.is_empty();
+

+
    if is_seeds_synced && is_replicas_synced {
+
        term::success!(
+
            &mut reporting.completion;
+
            "All preferred seeds are {}, and the replication target {}. Nothing to announce.",
+
            format::positive("in sync"),
+
            format::positive("is met"),
+
        );
+
        if profile.config.cli.hints {
+
            term::hint_write(
+
                &mut reporting.completion,
+
                "For further information, run `rad sync status`.",
+
            )
+
            .ok();
+
        }
+
        return Ok(AnnounceResult::default());
+
    }
+

+
    Ok(
+
        (if preferred_unsynced.is_empty() && !preferred_synced.is_empty() {
+
            term::success!(
+
                &mut reporting.completion;
+
                "Preferred seeds are in sync."
+
            );
+
            AnnounceResult::default()
+
        } else if preferred_unsynced.is_empty() && preferred_synced.is_empty() {
+
            AnnounceResult::default()
+
        } else {
+
            // We first sync with preferred seeds.
+
            let preferred_len = preferred_synced.len() + preferred_unsynced.len();
+
            let mut spinner = term::spinner_to(
+
                format!(
+
                    "Announcing to {} preferred seed(s) ({} {}, {} {}) …",
+
                    preferred_len,
+
                    format::secondary(preferred_synced.len()),
+
                    format::positive("succeeded"),
+
                    format::secondary(preferred_unsynced.len()),
+
                    format::negative("pending")
+
                ),
+
                reporting.completion.clone(),
+
                reporting.progress.clone(),
+
            );
+

+
            let (mut preferred_synced, mut preferred_unsynced) =
+
                (preferred_synced, preferred_unsynced);
+
            let result = node.announce(
+
                rid,
+
                preferred_unsynced.clone(),
+
                settings.timeout,
+
                |event, _| match event {
+
                    node::AnnounceEvent::Announced => ControlFlow::Continue(()),
+
                    node::AnnounceEvent::RefsSynced { remote, time } => {
+
                        preferred_unsynced.remove(&remote);
+
                        preferred_synced.insert(remote);
+

+
                        spinner.message(format!(
+
                            "Announcing to {} preferred seed(s) ({} {}, {} {}) … {}",
+
                            preferred_len,
+
                            format::secondary(preferred_synced.len()),
+
                            format::positive("succeeded"),
+
                            format::secondary(preferred_unsynced.len()),
+
                            format::negative("pending"),
+
                            format::italic(format!(
+
                                "[{} {}]",
+
                                format::primary(term::format::node(&remote)),
+
                                format::dim(format!("in {time:?}"))
+
                            ))
+
                        ));
+

+
                        if preferred_unsynced.is_empty() {
+
                            ControlFlow::Break(())
+
                        } else {
+
                            ControlFlow::Continue(())
+
                        }
+
                    }
+
                },
+
            )?;
+

+
            spinner.message(format!(
+
                "{} to preferred {} seed(s){}.",
+
                if preferred_unsynced.is_empty() {
+
                    "Announced"
                } else {
-
                    ControlFlow::Continue(())
+
                    "Failed to announce"
+
                },
+
                preferred_len,
+
                if preferred_synced.len() * preferred_unsynced.len() == 0 {
+
                    "".to_string()
+
                } else {
+
                    format!(
+
                        " ({} {}, {} {})",
+
                        format::secondary(preferred_synced.len()),
+
                        format::positive("succeeded"),
+
                        format::secondary(preferred_unsynced.len()),
+
                        format::negative("failed")
+
                    )
                }
+
            ));
+

+
            if !preferred_unsynced.is_empty() {
+
                spinner.failed();
+
            } else {
+
                spinner.finish();
            }
-
        },
-
    )?;

-
    if result.synced.is_empty() {
-
        spinner.failed();
-
    } else {
-
        spinner.message(format!("Synced with {} node(s)", result.synced.len()));
-
        spinner.finish();
+
            // We expect that the users keeps `settings.seeds` (even if filled with
+
            // preferred seeds from config file) to a reasonable size. Then, it is
+
            // much more important to know syncing with which nodes *failed* then
+
            // for which it *succeeded*. Anyway, the debug flag will give a full
+
            // picture.
+
            if reporting.debug {
+
                for (seed, time) in &result.synced {
+
                    term::success!(
+
                        &mut reporting.completion;
+
                        "{}",
+
                        format::dim(format!(
+
                            "Announced to preferred seed {} in {time:?}.",
+
                            term::format::primary(term::format::node(seed))
+
                        ))
+
                    );
+
                }
+
            }

-
        if reporting.debug {
-
            for (seed, time) in &result.synced {
-
                writeln!(
-
                    &mut reporting.completion,
-
                    "  {}",
-
                    term::format::dim(format!("Synced with {seed} in {time:?}")),
-
                )
-
                .ok();
+
            for seed in &result.timed_out {
+
                term::notice!(&mut reporting.completion; "Preferred seed {} {}.", term::format::primary(term::format::node(seed)), format::negative("timed out"));
            }
-
        }
-
    }
-
    for seed in &result.timed_out {
-
        if settings.seeds.contains(seed) {
-
            term::notice!(&mut reporting.completion; "Seed {seed} timed out..");
-
        }
-
    }
-
    if result.synced.is_empty() {
-
        return Err(SyncError::AllSeedsTimedOut);
-
    }
-
    Ok(result)
+

+
            if !reporting.debug && !preferred_unsynced.is_empty() {
+
                term::notice!(&mut reporting.completion; "For more details, run `rad sync status`.");
+
            }
+

+
            result
+
        }) + if is_replicas_synced {
+
            term::success!(
+
                &mut reporting.completion;
+
                "Found {} replica(s), meeting target of {}.",
+
                format::primary(replicas),
+
                format::primary(settings.replicas)
+
            );
+
            AnnounceResult::default()
+
        } else if unsynced.is_empty() {
+
            term::notice!(
+
                &mut reporting.completion;
+
                "No other seeds to announce to.",
+
            );
+
            AnnounceResult::default()
+
        } else {
+
            // We then attempt to sync with all others.
+
            let unsynced_len = unsynced.len();
+

+
            let mut spinner = term::spinner_to(
+
                format!(
+
                    "Announcing to {} seed(s) to meet replication target ({} {}, {} {}) …",
+
                    unsynced_len,
+
                    format::secondary(replicas),
+
                    format::positive("succeeded"),
+
                    format::secondary(settings.replicas - replicas),
+
                    format::negative("pending")
+
                ),
+
                reporting.completion.clone(),
+
                reporting.progress.clone(),
+
            );
+

+
            let mut replicas = replicas;
+

+
            let result =
+
                node.announce(
+
                    rid,
+
                    unsynced,
+
                    settings.timeout,
+
                    |event, replica_map| match event {
+
                        node::AnnounceEvent::Announced => ControlFlow::Continue(()),
+
                        node::AnnounceEvent::RefsSynced { remote, time } => {
+
                            replicas = replica_map.len();
+
                            spinner.message(format!(
+
                        "Announcing to {} seed(s) to meet replication target ({} {}, {} {}) … {}",
+
                        unsynced_len,
+
                        format::secondary(replicas),
+
                        format::positive("succeeded"),
+
                        format::secondary(settings.replicas - replicas),
+
                        format::negative("pending"),
+
                        format::italic(format!(
+
                            "[{} {}]",
+
                            format::primary(term::format::node(&remote)),
+
                            format::dim(format!("in {time:?}"))
+
                        ))
+
                    ));
+

+
                            if replicas >= settings.replicas {
+
                                ControlFlow::Break(())
+
                            } else {
+
                                ControlFlow::Continue(())
+
                            }
+
                        }
+
                    },
+
                )?;
+

+
            spinner.message(format!(
+
                "{} to {} seed(s) to meet replication target{}.",
+
                if replicas >= settings.replicas {
+
                    "Announced"
+
                } else {
+
                    "Failed to announce"
+
                },
+
                unsynced_len,
+
                if settings.replicas == unsynced_len
+
                    && replicas * (settings.replicas - replicas) == 0
+
                {
+
                    "".to_string()
+
                } else {
+
                    format!(
+
                        " ({} {}, {} {})",
+
                        format::secondary(replicas),
+
                        format::positive("succeeded"),
+
                        format::secondary(settings.replicas - replicas),
+
                        format::negative("failed")
+
                    )
+
                }
+
            ));
+

+
            if replicas >= settings.replicas {
+
                spinner.finish();
+
            } else {
+
                spinner.failed();
+
            }
+

+
            if reporting.debug {
+
                for (seed, time) in &result.synced {
+
                    term::success!(
+
                        &mut reporting.completion;
+
                        "{}",
+
                        format::dim(format!(
+
                            "Announced to seed {} in {time:?}.",
+
                            term::format::primary(term::format::node(seed))
+
                        ))
+
                    );
+
                }
+
                for seed in &result.timed_out {
+
                    term::notice!(&mut reporting.completion; "Seed {} {}.", term::format::primary(term::format::node(seed)), format::negative("timed out"));
+
                }
+
            } else if !&result.timed_out.is_empty() {
+
                term::notice!(&mut reporting.completion; "{} seed(s) {}. For more details, run `rad sync` with the `--debug` flag, or run `rad sync status`.", &result.timed_out.len(), format::negative("timed out"));
+
            }
+

+
            result
+
        },
+
    )
}
modified radicle-term/src/io.rs
@@ -197,6 +197,10 @@ pub fn hint(hint: impl fmt::Display) {
    println!("{ERROR_HINT_PREFIX} {}", format::hint(hint));
}

+
pub fn hint_write(write: &mut impl io::Write, hint: impl fmt::Display) -> io::Result<()> {
+
    writeln!(write, "{ERROR_HINT_PREFIX} {}", format::hint(hint))
+
}
+

pub fn ask<D: fmt::Display>(prompt: D, default: bool) -> bool {
    let prompt = prompt.to_string();

modified radicle/src/identity/doc.rs
@@ -752,6 +752,13 @@ impl Doc {
        }
    }

+
    pub fn visible_to(&self) -> Option<&BTreeSet<Did>> {
+
        match &self.visibility {
+
            Visibility::Public => None,
+
            Visibility::Private { allow } => Some(allow),
+
        }
+
    }
+

    /// Validate `signature` using this document's delegates, against a given
    /// document blob.
    pub fn verify_signature(
modified radicle/src/node.rs
@@ -764,6 +764,10 @@ impl Seeds {
    pub fn with(self, rng: fastrand::Rng) -> Self {
        Self(self.0.with(rng))
    }
+

+
    pub fn get(&self, nid: &NodeId) -> Option<&Seed> {
+
        self.0.get(nid)
+
    }
}

impl From<Seeds> for Vec<Seed> {
@@ -799,6 +803,16 @@ impl AnnounceResult {
    }
}

+
impl std::ops::Add for AnnounceResult {
+
    type Output = Self;
+

+
    fn add(mut self, AnnounceResult { timed_out, synced }: Self) -> Self {
+
        self.timed_out.extend(timed_out);
+
        self.synced.extend(synced);
+
        self
+
    }
+
}
+

/// A sync event, emitted by [`Node::announce`].
#[derive(Debug)]
pub enum AnnounceEvent {