From 5d4a421d1c47567e91870b23b9d98c6896a68c21 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 13 Oct 2026 16:09:25 +0700 Subject: [PATCH 1/7] add plan --- docs/TODO.md | 1 + docs/push-refactor.md | 164 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 docs/push-refactor.md diff --git a/docs/TODO.md b/docs/TODO.md index fd9183b..57f8b40 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -28,6 +28,7 @@ ## Push +- [ ] Refactor the push pipeline onto `SyncState` with step-by-step progress, see [push-refactor.md](./push-refactor.md). - [ ] Add a fast-forward check and a force option (nak refuses non-FF without `--force`), today it relies entirely on grasp CAS. - [ ] Allow NIP-34 maintainers to push, not just the owner. diff --git a/docs/push-refactor.md b/docs/push-refactor.md new file mode 100644 index 0000000..5c6c31a --- /dev/null +++ b/docs/push-refactor.md @@ -0,0 +1,164 @@ +# Push refactor plan + +Refactor the grasp push pipeline into `SyncState` methods with step-by-step +progress the UI can render while a push is in flight. + +## Motivation + +- `push_staged_to_grasps` takes 9 parameters (needs `#[allow(clippy::too_many_arguments)]`) + and mixes three concerns per iteration: signing a Nostr event, relay + connection/staging, and git transport. +- The flow is orchestrated in two places with different error handling: + `Backend::push_repo_from` and `announce_repository_and_push` in + `signed_state/src/backend.rs`. +- The state event is rebuilt and re-signed per server inside the loop, forcing + `PushOutcome::keep_newest` to exist. +- Feedback is binary: `RepoStore::pushing` plus a final `Result`. The loop runs + inside an opaque `background_spawn`, so the UI cannot show where a push is. + +Moving `signed_core/src/state.rs` into `signed_state` is safe: `RepoState` is +only used inside `signed_state` (`push.rs`, `repo.rs`, `sync_status.rs`), and +`signed_state` already depends on `nostr` directly. `Kind::RepoState` references +are the Nostr kind enum and are unaffected. + +## Target shape + +```rust +// signed_state/src/sync_state.rs +pub struct SyncState { + pub refs: Vec<(String, String)>, + pub head: Option, +} + +pub enum PushStep { + ReadLocalState, + Announce, + StageStateEvent(RelayUrl), + PushGitObjects(RelayUrl), + PublishStateEvent, +} + +pub enum StepStatus { + Running, + Done, + Failed(String), +} + +pub struct PushProgress { + pub steps: Vec<(PushStep, StepStatus)>, +} +``` + +`PushProgress` is a GPUI `Entity` the caller creates and passes in; views +observe it and re-render as steps update. Label mapping for the UI: + +- `ReadLocalState` -> "Read local refs" +- `Announce` -> "Announce repository" +- `StageStateEvent(relay)` -> "Publish state event to {relay}" +- `PushGitObjects(relay)` -> "Push to grasp {relay}" +- `PublishStateEvent` -> "Publish state event on relays" + +```rust +impl SyncState { + pub fn parse(event: &Event) -> Self; + pub fn build(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder; + pub fn from_repo(repo: &Repo) -> Result; + pub async fn push_to_grasps( + &self, + plan: PushPlan, + progress: Entity, + cx: &mut AsyncApp, + ) -> PushOutcome; + async fn stage_on_grasp(...) -> Result<(), String>; // connect + purgatory staging + async fn publish(...); // final broadcast +} +``` + +The key enabler: the per-server orchestration loop moves from +`background_spawn` into the foreground `cx.spawn` task. Each blocking piece +(`ref_state`, git push) stays on background threads; network awaits are fine on +the foreground executor. Each step can then update `PushProgress` directly. + +Flow: + +``` +UI action + -> Backend push method + -> ReadLocalState (background git) + -> Announce (sign + broadcast, create/publish flows only) + -> for each grasp server: + StageStateEvent(relay) (sign once, send to relay) + PushGitObjects(relay) (background git) + -> PublishStateEvent (broadcast) + -> every step updates Entity -> views render the checklist +``` + +## Steps + +### 1. Move and rename (mechanical) + +- `git mv crates/signed_core/src/state.rs crates/signed_state/src/sync_state.rs`, + rename `RepoState` -> `SyncState`. +- `signed_core/src/lib.rs`: drop `pub mod state;` and the re-export. No + deprecated alias; there are no users outside `signed_state`. +- `signed_state/src/lib.rs`: add `mod sync_state;` and + `pub use sync_state::SyncState;`. +- Update the three import sites: `push.rs`, `repo.rs`, `sync_status.rs`. +- Tests in the file move along unchanged. +- Validate: `cargo check -p signed_core -p signed_state && cargo test -p signed_state`. + +### 2. Consolidate the push core onto `SyncState` (behavior-preserving) + +- Introduce `PushPlan { client, signer, repo_id, owner, servers, path, kind }` + to kill the 9-arg signature. Replace the `push` closure with + `enum PushKind { Main, All }` - the only two variants used across all call + sites (`push_main` for create, `push_all` otherwise). +- Sign the state event once before the server loop instead of per iteration + (BIP-340 signing is deterministic; `created_at` is fixed at build time). + Delete `PushOutcome::keep_newest`. A signing failure now aborts the push up + front instead of "retrying" per server; key errors are not transient. +- Split the loop body into named methods: `SyncState::stage_on_grasp` (connect + + staging match block) and the git-push arm. Move the final state-event + broadcast (duplicated in both call sites today) into `SyncState::publish`. +- Fold `GraspServer`, `PushOutcome`, and the `grasp_*` URL helpers into + `sync_state.rs` and delete `push.rs`. +- `announce_repository_and_push` shrinks to: connect -> sign/broadcast + announcement -> `state.push_to_grasps(plan, ...)` -> retraction on failure. +- Validate: full `cargo test` for the signed crates; outcomes must be + identical (same events, same `PushOutcome`). + +### 3. Add step-by-step progress + +- Add `PushStep`, `StepStatus`, `PushProgress` to `sync_state.rs`. Steps update + via `progress.update(cx, |progress, cx| { ...; cx.notify(); })` from the + foreground orchestration task; per-server failures carry their reason inline + (`Failed("relay did not confirm the event")`). +- `Backend::push_repository` / `push_checkout` / `create_repository` / + `publish_local_repo` accept `Entity` from the caller. +- Keep the `pushing_repos` guard and `PushOutcome` returns as-is; + `PushProgress` is additive. +- Validate: unit test in `sync_state.rs` driving `push_to_grasps` with a mock + signer against a fake server list, asserting the emitted step sequence. + +### 4. Surface it in the UI + +- `RepoStore` holds `push_progress: Option>` for the + running push. `RepoDetailView` renders a checklist while `pushing`: spinner + on `Running`, check on `Done`, cross plus reason on `Failed`. The existing + loading state on header/push buttons stays. +- Dialogs: extend `DialogProgress` with `steps: Option>` + so the create/publish dialogs show the same checklist. +- Validate: manual run of the push and create-repository flows; the + partial-failure path must still show `render_push_warning_banner` after the + checklist. + +Each step lands as a compiling, testable commit: 1 and 2 are mechanical +refactors, 3 is additive API, 4 is the visible payoff. + +## Open decisions + +- `PushKind` enum vs. keeping the push closure: the enum matches current call + sites; keep the closure if other push modes (e.g. PR namespace pushes) are + planned. +- `PushProgress` as an `Entity` passed in (recommended) vs. a + `BackendEvent::PushProgress` broadcast that `RepoStore` accumulates. -- 2.54.0 From 74e1226b93a79bd9a6b588584085e5486710603d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 13 Oct 2026 17:40:53 +0700 Subject: [PATCH 2/7] update plan --- docs/push-refactor.md | 251 +++++++++++++++++++++--------------------- 1 file changed, 126 insertions(+), 125 deletions(-) diff --git a/docs/push-refactor.md b/docs/push-refactor.md index 5c6c31a..f1073fb 100644 --- a/docs/push-refactor.md +++ b/docs/push-refactor.md @@ -1,164 +1,165 @@ -# Push refactor plan +# Push refactor -Refactor the grasp push pipeline into `SyncState` methods with step-by-step -progress the UI can render while a push is in flight. +Move the push pipeline onto `SyncState` with step-by-step progress, so the UI can render a live checklist instead of a single spinner. -## Motivation +## Problems -- `push_staged_to_grasps` takes 9 parameters (needs `#[allow(clippy::too_many_arguments)]`) - and mixes three concerns per iteration: signing a Nostr event, relay - connection/staging, and git transport. -- The flow is orchestrated in two places with different error handling: - `Backend::push_repo_from` and `announce_repository_and_push` in - `signed_state/src/backend.rs`. -- The state event is rebuilt and re-signed per server inside the loop, forcing - `PushOutcome::keep_newest` to exist. -- Feedback is binary: `RepoStore::pushing` plus a final `Result`. The loop runs - inside an opaque `background_spawn`, so the UI cannot show where a push is. +- `push_staged_to_grasps` is a free function with 9 arguments; `refs`/`head` are just threaded through. Both call sites wrap it in another `background_spawn` with ~8 lines of cloning (`backend.rs` `push_repo_from`, `announce_repository_and_push`). +- The state event is re-signed per relay inside the loop. +- There is no progress surface: the whole flow is one opaque `Task>`. The UI only has the `pushing` flag and the post-hoc `partial_warning`. +- `RepoState` lives in `signed_core`, but its only consumers are in `signed_state`. `signed_git::RepoRefState` is structurally identical (`refs` + `head`). -Moving `signed_core/src/state.rs` into `signed_state` is safe: `RepoState` is -only used inside `signed_state` (`push.rs`, `repo.rs`, `sync_status.rs`), and -`signed_state` already depends on `nostr` directly. `Kind::RepoState` references -are the Nostr kind enum and are unaffected. +## Constraints + +- Keep it simple; no edge-case handling, no over-engineering. +- No new struct wrapping the Nostr client or signer; reference them. +- No new global entity. ## Target shape +- `signed_state/src/state.rs` (moved from core, `RepoState` renamed to `SyncState`): the data (`refs`, `head`, push `path`, `mode`), builders (`from_repo`, `with_mode`), event `build`/`parse`, and the whole push flow as methods on `impl SyncState`. +- `signed_state/src/push.rs`: keeps `GraspServer`, `PushOutcome` and the grasp URL helpers. No flow logic. +- `Backend`: thin wiring, emits `BackendEvent::PushProgress` per step. +- `RepoStore`: owns the displayed step list (it already subscribes to `Backend`). +- `RepoDetailView`: renders the checklist. + +## Step 1 — Move and rename + +Mechanical, no behavior change. + +- Move `crates/signed_core/src/state.rs` to `crates/signed_state/src/state.rs`, rename `RepoState` to `SyncState`. Tests move along. +- `signed_core/src/lib.rs`: drop `pub mod state;` and the re-export. +- `signed_state/src/lib.rs`: `mod state;` and `pub use state::SyncState;`. +- Update importers in `signed_state`: `push.rs`, `repo.rs`, `sync_status.rs`. Nothing else uses `signed_core::RepoState`. `Kind::RepoState` (the nostr kind) is untouched. + +## Step 2 — Push flow under `impl SyncState` + +Replace `push_staged_to_grasps` with methods on the type that owns the refs: + ```rust -// signed_state/src/sync_state.rs +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum GitPushMode { + /// Mirror all branches and tags. + #[default] + All, + /// Push `refs/heads/main` only (repo creation). + Main, +} + pub struct SyncState { pub refs: Vec<(String, String)>, pub head: Option, + /// Local repository to push from; `None` for states parsed from events. + pub path: Option, + pub mode: GitPushMode, } +#[derive(Debug, Clone, PartialEq, Eq)] pub enum PushStep { - ReadLocalState, - Announce, - StageStateEvent(RelayUrl), - PushGitObjects(RelayUrl), - PublishStateEvent, + AnnounceRepository, + StageStateEvent(RelayUrl), // publish state event to one grasp server + PushObjects(RelayUrl), // git push to one grasp server + BroadcastStateEvent, // fan the state event out to relays } -pub enum StepStatus { - Running, - Done, - Failed(String), -} - -pub struct PushProgress { - pub steps: Vec<(PushStep, StepStatus)>, +#[derive(Debug, Clone)] +pub enum PushStepStatus { + Running(PushStep), + Done(PushStep), + Failed(PushStep, String), } ``` -`PushProgress` is a GPUI `Entity` the caller creates and passes in; views -observe it and re-render as steps update. Label mapping for the UI: - -- `ReadLocalState` -> "Read local refs" -- `Announce` -> "Announce repository" -- `StageStateEvent(relay)` -> "Publish state event to {relay}" -- `PushGitObjects(relay)` -> "Push to grasp {relay}" -- `PublishStateEvent` -> "Publish state event on relays" - ```rust impl SyncState { - pub fn parse(event: &Event) -> Self; - pub fn build(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder; - pub fn from_repo(repo: &Repo) -> Result; - pub async fn push_to_grasps( + /// Reads the refs and head of a local repository, ready to push. + pub fn from_repo(path: &Path) -> Result { + let repo = Repo::open(path)?; + let state = repo.ref_state()?; + Ok(Self { + refs: state.refs, + head: state.head, + path: Some(path.to_path_buf()), + mode: GitPushMode::default(), + }) + } + + pub fn with_mode(mut self, mode: GitPushMode) -> Self { + self.mode = mode; + self + } + + /// State event for these refs; the caller signs it. + pub fn state_event(&self, repo_id: &str) -> EventBuilder { + Self::build(repo_id, &self.refs, self.head.as_deref()) + } + + // Per server: connect + send the pre-signed state event, then git push + // via cx.background_spawn (blocking gix work). Reports every transition + // through on_step. Ends with the relay fan-out broadcast. Errors once + // up front when self.path is None (state not built from a local repo). + pub(crate) async fn push_to_grasps

( &self, - plan: PushPlan, - progress: Entity, cx: &mut AsyncApp, - ) -> PushOutcome; - async fn stage_on_grasp(...) -> Result<(), String>; // connect + purgatory staging - async fn publish(...); // final broadcast + client: &Client, + event: Event, + announcement: &Announcement, + on_step: &mut P, + ) -> Result + where + P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>, + { ... } + + // announce_repository_and_push moved as-is: sign + broadcast the + // announcement (AnnounceRepository step), parse it into an + // `Announcement`, sign the state event, delegate to push_to_grasps, + // retract on total failure. This one keeps the signer. + pub(crate) async fn announce_and_push(...) -> Result { ... } } ``` -The key enabler: the per-server orchestration loop moves from -`background_spawn` into the foreground `cx.spawn` task. Each blocking piece -(`ref_state`, git push) stays on background threads; network awaits are fine on -the foreground executor. Each step can then update `PushProgress` directly. +### Why these arguments -Flow: +The struct carries the job description — what (`refs`, `head`), from where (`path`), how much (`mode`) — built with `SyncState::from_repo(path)?.with_mode(..)`. The method arguments are then only what varies per call or cannot live on the data: `cx`, `client`, the pre-signed `event`, `announcement`, `on_step`. Down from the 9-arg free function: -``` -UI action - -> Backend push method - -> ReadLocalState (background git) - -> Announce (sign + broadcast, create/publish flows only) - -> for each grasp server: - StageStateEvent(relay) (sign once, send to relay) - PushGitObjects(relay) (background git) - -> PublishStateEvent (broadcast) - -> every step updates Entity -> views render the checklist -``` +- `refs`, `head`, `path`, `mode` come from `&self`. The `push: fn(&Path, &str, &str, &str) -> Result<(), Error>` callback becomes the `mode` field, resolved inside to `Repo::push_main` / `Repo::push_all`; the wildcard-refspec mirror semantics of `push_all` are preserved. States built by `SyncState::parse` (remote events) have no local repo, so `path` is `Option` and `push_to_grasps` errors once up front when it is `None`. +- `repo_id`, `owner`, `servers` collapse into `&Announcement` — its `id`, `owner` and `relays` are exactly those values, and both call sites already hold one. `push_repo_from` currently destructures the announcement into those three fields just to re-pass them. It stays an argument because states parsed from events carry no announcement. +- The state event is signed by the caller via `state_event(..).finalize_async(&signer)`, so `push_to_grasps` takes a signed `Event` and needs no signer or `S` generic — it only ever sends events. The signer lives with the callers (`push_repo_from`, `announce_and_push`, which also signs the announcement and the retraction). +- `client` stays as a reference per the constraints; `SyncState` remains plain data, so `SyncState::parse` keeps working for remote states that have no client or path. +- `on_step` takes `&mut AsyncApp` so it can update an entity directly. That is what lets the whole flow run in the caller's foreground `cx.spawn` task and emit progress without a channel. -## Steps +### Progress mechanics -### 1. Move and rename (mechanical) +- Per-relay git push is backgrounded inside the method (`cx.background_spawn`), so the method itself runs in the caller's foreground task. Foreground nostr awaits are already precedent (`connect_grasp_relays`, `set_signer`). +- `on_step` returns `Result` so a dead entity propagates instead of being swallowed. +- The caller signs the state event up front instead of per relay; a signing failure aborts before any network work. +- The final fan-out broadcast moves into `push_to_grasps`; both existing flows did exactly push, bail if zero accepted, then broadcast. -- `git mv crates/signed_core/src/state.rs crates/signed_state/src/sync_state.rs`, - rename `RepoState` -> `SyncState`. -- `signed_core/src/lib.rs`: drop `pub mod state;` and the re-export. No - deprecated alias; there are no users outside `signed_state`. -- `signed_state/src/lib.rs`: add `mod sync_state;` and - `pub use sync_state::SyncState;`. -- Update the three import sites: `push.rs`, `repo.rs`, `sync_status.rs`. -- Tests in the file move along unchanged. -- Validate: `cargo check -p signed_core -p signed_state && cargo test -p signed_state`. +## Step 3 — Backend wiring -### 2. Consolidate the push core onto `SyncState` (behavior-preserving) +- Add `BackendEvent::PushProgress { addr: RepoAddr, status: PushStepStatus }`. +- `push_repo_from`: background-spawn `SyncState::from_repo(&path)`, sign `state.state_event(&announcement.id)` with the signer, then call `push_to_grasps(cx, &client, event, &announcement, on_step)` in the same foreground task, `on_step` emitting the event via `this.update`. The nested `background_spawn` and its clone block disappear. Keep the `refs.is_empty()` early return at the call site. +- `announce_repository_and_push` is deleted from `backend.rs`; its callers build a `SyncState` first (`SyncState::from_repo` for publish; repo creation builds the single-ref state literally with `GitPushMode::Main`), then call `announce_and_push(cx, client, signer, announcement, on_step)`. +- `push.rs` keeps `GraspServer`, `PushOutcome` and the URL helpers. -- Introduce `PushPlan { client, signer, repo_id, owner, servers, path, kind }` - to kill the 9-arg signature. Replace the `push` closure with - `enum PushKind { Main, All }` - the only two variants used across all call - sites (`push_main` for create, `push_all` otherwise). -- Sign the state event once before the server loop instead of per iteration - (BIP-340 signing is deterministic; `created_at` is fixed at build time). - Delete `PushOutcome::keep_newest`. A signing failure now aborts the push up - front instead of "retrying" per server; key errors are not transient. -- Split the loop body into named methods: `SyncState::stage_on_grasp` (connect - + staging match block) and the git-push arm. Move the final state-event - broadcast (duplicated in both call sites today) into `SyncState::publish`. -- Fold `GraspServer`, `PushOutcome`, and the `grasp_*` URL helpers into - `sync_state.rs` and delete `push.rs`. -- `announce_repository_and_push` shrinks to: connect -> sign/broadcast - announcement -> `state.push_to_grasps(plan, ...)` -> retraction on failure. -- Validate: full `cargo test` for the signed crates; outcomes must be - identical (same events, same `PushOutcome`). +## Step 4 — UI checklist -### 3. Add step-by-step progress +- `RepoStore`: add `push_steps: Vec`; clear it in `run_push` start/end; handle `BackendEvent::PushProgress` in the existing `subscribe_backend` (match on `addr`, replace by `PushStep` equality, `cx.notify()` — no refresh). +- `RepoDetailView`: while `pushing`, render the checklist where the spinner is today (header menu / push banner): -- Add `PushStep`, `StepStatus`, `PushProgress` to `sync_state.rs`. Steps update - via `progress.update(cx, |progress, cx| { ...; cx.notify(); })` from the - foreground orchestration task; per-server failures carry their reason inline - (`Failed("relay did not confirm the event")`). -- `Backend::push_repository` / `push_checkout` / `create_repository` / - `publish_local_repo` accept `Entity` from the caller. -- Keep the `pushing_repos` guard and `PushOutcome` returns as-is; - `PushProgress` is additive. -- Validate: unit test in `sync_state.rs` driving `push_to_grasps` with a mock - signer against a fake server list, asserting the emitted step sequence. + ``` + Announce repository ✓ + Publish state event → wss://relay.damus.io ✓ + Push objects → git.damus.io ⟳ + Push objects → git.ngit.org · + ``` -### 4. Surface it in the UI +- Labels come from `PushStep::label()` so user-facing copy lives in one place. +- The publish dialog (`init_dialog.rs`) can opt in the same way by constructing the `RepoAddr` up front; optional follow-up. -- `RepoStore` holds `push_progress: Option>` for the - running push. `RepoDetailView` renders a checklist while `pushing`: spinner - on `Running`, check on `Done`, cross plus reason on `Failed`. The existing - loading state on header/push buttons stays. -- Dialogs: extend `DialogProgress` with `steps: Option>` - so the create/publish dialogs show the same checklist. -- Validate: manual run of the push and create-repository flows; the - partial-failure path must still show `render_push_warning_banner` after the - checklist. +Each step compiles green independently: Step 2 adds the methods while the old function still exists, Step 3 switches call sites and deletes it. -Each step lands as a compiling, testable commit: 1 and 2 are mechanical -refactors, 3 is additive API, 4 is the visible payoff. +## Validation -## Open decisions - -- `PushKind` enum vs. keeping the push closure: the enum matches current call - sites; keep the closure if other push modes (e.g. PR namespace pushes) are - planned. -- `PushProgress` as an `Entity` passed in (recommended) vs. a - `BackendEvent::PushProgress` broadcast that `RepoStore` accumulates. +- `cargo check -p signed_state` and `cargo test -p signed_state` after Steps 1 and 2 (the state round-trip tests move with the file). +- Manual: push a repo with two grasp servers where one is unreachable, and confirm the checklist shows running/✓/✗ per server and the existing `partial_warning` still appears. -- 2.54.0 From 5b16ba50c2399a47d4922afb1a20b233ea887f51 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 13 Oct 2026 18:01:38 +0700 Subject: [PATCH 3/7] move state types from signed_core into signed_state --- crates/signed_core/src/lib.rs | 2 - crates/signed_state/src/backend.rs | 22 ++++-- crates/signed_state/src/lib.rs | 2 + crates/signed_state/src/push.rs | 7 +- crates/signed_state/src/repo.rs | 8 +-- .../src/state.rs | 70 ++++++++++++++----- crates/signed_state/src/sync_status.rs | 14 ++-- docs/push-refactor.md | 10 ++- 8 files changed, 99 insertions(+), 36 deletions(-) rename crates/{signed_core => signed_state}/src/state.rs (57%) diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index 66e6812..076deed 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -5,7 +5,6 @@ pub mod filters; pub mod git_event; pub mod inbox; pub mod pull_request; -pub mod state; pub mod status; pub mod upstream; @@ -16,6 +15,5 @@ pub use filters::Filters; pub use git_event::GitEvent; pub use inbox::{InboxItem, InboxReadState, ThreadResolver}; pub use pull_request::PullRequest; -pub use state::RepoState; pub use status::RepoStatus; pub use upstream::Upstream; diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index de1a045..ef7ae38 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -21,6 +21,7 @@ use crate::git_store::Mirrors; use crate::inbox::Inbox; use crate::push::{PushOutcome, grasp_base_url, grasp_clone_url, push_staged_to_grasps}; use crate::repos::RepoListStore; +use crate::state::GitRef; pub const USER_KEYRING: &str = "Signed Safe Storage"; pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; @@ -581,7 +582,10 @@ impl Backend { &repo_id, &owner, &servers, - vec![("refs/heads/main".to_owned(), commit)], + vec![GitRef { + name: "refs/heads/main".to_owned(), + commit: commit_sha, + }], Some("main".to_owned()), &destination, cx, @@ -668,6 +672,12 @@ impl Backend { let euc = euc.and_then(|commit| Sha1Hash::from_str(&commit).ok()); let ann = build_announcement(&repo_id, &name, &description, &owner, &servers, euc); + let refs = state + .refs + .iter() + .map(|(name, commit)| GitRef::new(name, commit)) + .collect::, Error>>()?; + let event = announce_repository_and_push( &client, &signer, @@ -675,7 +685,7 @@ impl Backend { &repo_id, &owner, &servers, - state.refs.clone(), + refs, state.head.clone(), &path, cx, @@ -793,7 +803,11 @@ impl Backend { } // Grasp servers authorize a push by the state event they hold in purgatory. - let refs = state.refs.clone(); + let refs = state + .refs + .iter() + .map(|(name, commit)| GitRef::new(name, commit)) + .collect::, Error>>()?; let head = state.head.clone(); let outcome = if refs.is_empty() { @@ -1196,7 +1210,7 @@ async fn announce_repository_and_push( repo_id: &str, owner: &str, servers: &[RelayUrl], - refs: Vec<(String, String)>, + refs: Vec, head: Option, path: &Path, cx: &mut AsyncApp, diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index bcbd7e1..25e1ff0 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -10,6 +10,7 @@ mod push; mod refresh; mod repo; mod repos; +mod state; mod sync_status; use std::path::{Path, PathBuf}; @@ -29,6 +30,7 @@ pub use repo::RepoStore; pub use repos::RepoListStore; pub use signed_git::{Nip34Binding, Nip34Kind}; use signed_nostr::NostrBackend; +pub use state::{GitRef, SyncState}; pub use sync_status::{CheckoutSyncStatus, SyncStatusStore}; pub fn init( diff --git a/crates/signed_state/src/push.rs b/crates/signed_state/src/push.rs index 56f7171..6615ee8 100644 --- a/crates/signed_state/src/push.rs +++ b/crates/signed_state/src/push.rs @@ -3,7 +3,8 @@ use std::path::Path; use anyhow::Error; use nostr::prelude::Url; use nostr_sdk::prelude::*; -use signed_core::RepoState; + +use crate::state::{GitRef, SyncState}; /// `ws://` grasp servers, like ngit, use `http(s)://`. pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option { @@ -133,7 +134,7 @@ pub(crate) async fn push_staged_to_grasps( client: &Client, signer: &S, repo_id: &str, - refs: &[(String, String)], + refs: &[GitRef], head: Option<&str>, path: &Path, owner: &str, @@ -153,7 +154,7 @@ where continue; }; - let event = match RepoState::build(repo_id, refs, head) + let event = match SyncState::build(repo_id, refs, head) .finalize_async(signer) .await { diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index fba0a34..5edcaae 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -8,8 +8,7 @@ use nostr::event::IntoEventBuilder; use nostr_sdk::prelude::*; use settings::{EventFetchingStrategy, SettingsStore}; use signed_core::{ - Announcement, Deletions, Filters, GitEvent, PullRequest, RepoAddr, RepoState, RepoStatus, - filters, + Announcement, Deletions, Filters, GitEvent, PullRequest, RepoAddr, RepoStatus, filters, }; use signed_git::{Nip34Binding, Repo}; @@ -19,6 +18,7 @@ use crate::checkouts::CheckoutsStore; use crate::patch_series::{MAX_PATCH_EVENT_BYTES, PatchSeries}; use crate::push::{PushOutcome, grasp_base_url, grasp06_prs_url, pr_clone_urls}; use crate::repos::RepoListStore; +use crate::state::{GitRef, SyncState}; use crate::sync_status::SyncStatusStore; pub struct RepoStore { @@ -34,7 +34,7 @@ pub struct RepoStore { pub loaded: bool, pub head: Option, // Refs and timestamp of the latest state event, for sync detection. - pub state_refs: Vec<(String, String)>, + pub state_refs: Vec, pub state_at: Option, pub issues: Vec, pub patches: Vec, @@ -354,7 +354,7 @@ impl RepoStore { let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e)); let state = - utils::latest(all_states).map(|event| (RepoState::parse(&event), event.created_at)); + utils::latest(all_states).map(|event| (SyncState::parse(&event), event.created_at)); let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); diff --git a/crates/signed_core/src/state.rs b/crates/signed_state/src/state.rs similarity index 57% rename from crates/signed_core/src/state.rs rename to crates/signed_state/src/state.rs index f7af5a2..c60556b 100644 --- a/crates/signed_core/src/state.rs +++ b/crates/signed_state/src/state.rs @@ -1,17 +1,41 @@ +use std::str::FromStr; + +use anyhow::{Error, anyhow}; +use bitcoin_hashes::sha1::Hash as Sha1Hash; use nostr::prelude::*; +/// A git ref with the commit it points at. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct RepoState { - pub refs: Vec<(String, String)>, +pub struct GitRef { + /// Full ref name, like `refs/heads/main` or `refs/tags/v1.0`. + pub name: String, + pub commit: Sha1Hash, +} + +impl GitRef { + pub fn new(name: impl Into, commit: &str) -> Result { + let commit = Sha1Hash::from_str(commit).map_err(|_| anyhow!("invalid commit: {commit}"))?; + + Ok(Self { + name: name.into(), + commit, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyncState { + pub refs: Vec, pub head: Option, } -impl RepoState { - pub fn build(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder { +impl SyncState { + pub fn build(id: &str, refs: &[GitRef], head: Option<&str>) -> EventBuilder { let mut tags: Vec = vec![Tag::identifier(id.to_owned())]; - for (name, commit) in refs { - tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag")); + for git_ref in refs { + let commit = git_ref.commit.to_string(); + tags.push(Tag::parse([git_ref.name.as_str(), commit.as_str()]).expect("valid ref tag")); } if let Some(head) = head { @@ -31,10 +55,16 @@ impl RepoState { match Nip34Tag::parse(tag.as_slice()) { Ok(Nip34Tag::Head(branch)) => head = Some(branch), Ok(Nip34Tag::RefHead { branch, commit }) => { - refs.push((format!("refs/heads/{branch}"), commit.to_string())); + refs.push(GitRef { + name: format!("refs/heads/{branch}"), + commit, + }); } Ok(Nip34Tag::RefTag { name, commit }) => { - refs.push((format!("refs/tags/{name}"), commit.to_string())); + refs.push(GitRef { + name: format!("refs/tags/{name}"), + commit, + }); } _ => {} } @@ -51,6 +81,10 @@ mod tests { const COMMIT_A: &str = "aa231c4c6a5777dc89b42207b499891a344add5c"; const COMMIT_B: &str = "59429cfc6cb35b0a1ddace73b5a5c5ed57b8f5ca"; + fn git_ref(name: &str, commit: &str) -> GitRef { + GitRef::new(name, commit).expect("valid ref") + } + fn keys() -> Keys { Keys::new( SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") @@ -79,35 +113,35 @@ mod tests { &["refs/tags/v1.0", COMMIT_A], ]); - let state = RepoState::parse(&event); + let state = SyncState::parse(&event); assert_eq!(state.head.as_deref(), Some("main")); assert_eq!( state.refs, vec![ - ("refs/heads/main".to_owned(), COMMIT_A.to_owned()), - ("refs/heads/dev".to_owned(), COMMIT_B.to_owned()), - ("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()), + git_ref("refs/heads/main", COMMIT_A), + git_ref("refs/heads/dev", COMMIT_B), + git_ref("refs/tags/v1.0", COMMIT_A), ] ); } #[test] fn build_round_trips_through_parse() { - let refs = [ - ("refs/heads/main".to_owned(), COMMIT_A.to_owned()), - ("refs/heads/dev".to_owned(), COMMIT_B.to_owned()), - ("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()), + let refs = vec![ + git_ref("refs/heads/main", COMMIT_A), + git_ref("refs/heads/dev", COMMIT_B), + git_ref("refs/tags/v1.0", COMMIT_A), ]; - let event = RepoState::build("my-repo", &refs, Some("main")) + let event = SyncState::build("my-repo", &refs, Some("main")) .finalize(&keys()) .expect("signed event"); assert_eq!(event.kind, Kind::RepoState); assert_eq!(event.tags.identifier().as_deref(), Some("my-repo")); - let state = RepoState::parse(&event); + let state = SyncState::parse(&event); assert_eq!(state.refs, refs); assert_eq!(state.head.as_deref(), Some("main")); } diff --git a/crates/signed_state/src/sync_status.rs b/crates/signed_state/src/sync_status.rs index cfbe4a9..2c26fbb 100644 --- a/crates/signed_state/src/sync_status.rs +++ b/crates/signed_state/src/sync_status.rs @@ -5,13 +5,14 @@ use std::time::Duration; use anyhow::Error; use gpui::{App, AppContext, Context, Entity, Global, Subscription}; use nostr_sdk::prelude::*; -use signed_core::{Deletions, Filters, RepoAddr, RepoState}; +use signed_core::{Deletions, Filters, RepoAddr}; use signed_git::{Repo, RepoSyncStatus}; use crate::backend::{Backend, BackendEvent}; use crate::checkouts::CheckoutsStore; use crate::refresh::{RefreshGate, RefreshRequest}; use crate::repos::RepoListStore; +use crate::state::{GitRef, SyncState}; const SYNC_POLL: Duration = Duration::from_secs(180); const MAX_SYNC_CHECKOUTS: usize = 8; @@ -167,7 +168,7 @@ impl SyncStatusStore { let deletion_events = db.query(Filters::deletions()).await?; let deletions = Deletions::from_events(deletion_events); - let mut states: HashMap> = HashMap::new(); + let mut states: HashMap> = HashMap::new(); for (addr, _) in &targets { let events = db.query(addr.state_filter()).await?; let latest = utils::latest( @@ -178,7 +179,7 @@ impl SyncStatusStore { let Some(event) = latest else { continue; }; - states.insert(addr.clone(), RepoState::parse(&event).refs); + states.insert(addr.clone(), SyncState::parse(&event).refs); } let mut statuses: HashMap> = HashMap::new(); @@ -187,10 +188,15 @@ impl SyncStatusStore { continue; }; + let remote_refs: Vec<(String, String)> = nostr_refs + .iter() + .map(|git_ref| (git_ref.name.clone(), git_ref.commit.to_string())) + .collect(); + let mut list = Vec::new(); for path in paths.iter().take(MAX_SYNC_CHECKOUTS) { let synced = - Repo::try_open(path).and_then(|repo| repo.sync_status(nostr_refs).ok()); + Repo::try_open(path).and_then(|repo| repo.sync_status(&remote_refs).ok()); if let Some(status) = synced { list.push(CheckoutSyncStatus { diff --git a/docs/push-refactor.md b/docs/push-refactor.md index f1073fb..5aeedde 100644 --- a/docs/push-refactor.md +++ b/docs/push-refactor.md @@ -46,8 +46,16 @@ pub enum GitPushMode { Main, } +/// A git ref with the commit it points at. +pub struct GitRef { + /// Full ref name, like `refs/heads/main` or `refs/tags/v1.0`. + pub name: String, + /// Commit id, `bitcoin_hashes::sha1::Hash`, the type NIP-34 tags carry. + pub commit: Sha1Hash, +} + pub struct SyncState { - pub refs: Vec<(String, String)>, + pub refs: Vec, pub head: Option, /// Local repository to push from; `None` for states parsed from events. pub path: Option, -- 2.54.0 From 62f06fc52b83d3463f595ea4dc31d0ce25f797c6 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 13 Oct 2026 19:14:32 +0700 Subject: [PATCH 4/7] Move push flow into SyncState methods with step callbacks --- crates/signed_state/src/lib.rs | 2 +- crates/signed_state/src/push.rs | 20 +-- crates/signed_state/src/state.rs | 278 ++++++++++++++++++++++++++++++- docs/push-refactor.md | 18 +- 4 files changed, 293 insertions(+), 25 deletions(-) diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 25e1ff0..fc47a83 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -30,7 +30,7 @@ pub use repo::RepoStore; pub use repos::RepoListStore; pub use signed_git::{Nip34Binding, Nip34Kind}; use signed_nostr::NostrBackend; -pub use state::{GitRef, SyncState}; +pub use state::{GitPushMode, GitRef, PushStep, PushStepStatus, SyncState}; pub use sync_status::{CheckoutSyncStatus, SyncStatusStore}; pub fn init( diff --git a/crates/signed_state/src/push.rs b/crates/signed_state/src/push.rs index 6615ee8..ca95968 100644 --- a/crates/signed_state/src/push.rs +++ b/crates/signed_state/src/push.rs @@ -51,16 +51,16 @@ pub struct GraspServer { } impl GraspServer { - fn ok(relay: RelayUrl) -> Self { + pub(crate) fn ok(relay: RelayUrl) -> Self { Self { relay, reason: None, } } - fn failed(relay: RelayUrl, reason: impl Into) -> Self { + pub(crate) fn failed(relay: &RelayUrl, reason: impl Into) -> Self { Self { - relay, + relay: relay.to_owned(), reason: Some(reason.into()), } } @@ -150,7 +150,7 @@ where let Some(base) = grasp_base_url(relay) else { outcome .servers - .push(GraspServer::failed(relay.clone(), "no domain")); + .push(GraspServer::failed(relay, "no domain")); continue; }; @@ -161,9 +161,7 @@ where Ok(event) => event, Err(e) => { let reason = format!("could not sign the state event: {e}"); - outcome - .servers - .push(GraspServer::failed(relay.clone(), reason)); + outcome.servers.push(GraspServer::failed(relay, reason)); continue; } }; @@ -183,10 +181,7 @@ where }; if let Err(reason) = staging { - log::warn!("grasp push failed: {relay}: {reason}"); - outcome - .servers - .push(GraspServer::failed(relay.clone(), reason)); + outcome.servers.push(GraspServer::failed(relay, reason)); continue; } @@ -196,10 +191,9 @@ where outcome.servers.push(GraspServer::ok(relay.clone())); } Err(e) => { - log::warn!("grasp push failed: {relay}: {e}"); outcome .servers - .push(GraspServer::failed(relay.clone(), e.to_string())); + .push(GraspServer::failed(relay, e.to_string())); } } } diff --git a/crates/signed_state/src/state.rs b/crates/signed_state/src/state.rs index c60556b..a8102f7 100644 --- a/crates/signed_state/src/state.rs +++ b/crates/signed_state/src/state.rs @@ -1,8 +1,15 @@ +use std::path::{Path, PathBuf}; use std::str::FromStr; -use anyhow::{Error, anyhow}; +use anyhow::{Error, anyhow, bail}; use bitcoin_hashes::sha1::Hash as Sha1Hash; +use gpui::{AppContext, AsyncApp}; use nostr::prelude::*; +use nostr_sdk::prelude::*; +use signed_core::Announcement; +use signed_git::Repo; + +use crate::push::{GraspServer, PushOutcome, grasp_base_url}; /// A git ref with the commit it points at. #[derive(Debug, Clone, PartialEq, Eq)] @@ -23,10 +30,41 @@ impl GitRef { } } +/// How much to push to the grasp servers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GitPushMode { + /// Mirror all branches and tags. + #[default] + All, + /// Push `refs/heads/main` only (repo creation). + Main, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SyncState { pub refs: Vec, pub head: Option, + /// Local repository to push from; `None` for states parsed from events. + pub path: Option, + pub mode: GitPushMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PushStep { + AnnounceRepository, + /// Publish the state event to one grasp server. + StageStateEvent(RelayUrl), + /// Git push to one grasp server. + PushObjects(RelayUrl), + /// Fan the state event out to the relays. + BroadcastStateEvent, +} + +#[derive(Debug, Clone)] +pub enum PushStepStatus { + Running(PushStep), + Done(PushStep), + Failed(PushStep, String), } impl SyncState { @@ -70,7 +108,243 @@ impl SyncState { } } - Self { refs, head } + Self { + refs, + head, + path: None, + mode: GitPushMode::default(), + } + } + + /// Reads the refs and head of a local repository, ready to push. + pub fn from_repo(path: &Path) -> Result { + let repo = Repo::open(path)?; + let state = repo.ref_state()?; + + let refs = state + .refs + .iter() + .map(|(name, commit)| GitRef::new(name, commit)) + .collect::, Error>>()?; + + Ok(Self { + refs, + head: state.head, + path: Some(path.to_path_buf()), + mode: GitPushMode::default(), + }) + } + + pub fn with_mode(mut self, mode: GitPushMode) -> Self { + self.mode = mode; + self + } + + /// State event for these refs; the caller signs it. + pub fn state_event(&self, repo_id: &str) -> EventBuilder { + Self::build(repo_id, &self.refs, self.head.as_deref()) + } + + /// Push the state event and objects to the grasp servers, + /// calling `on_step` for each step. + pub async fn push_to_grasps

( + &self, + client: &Client, + event: Event, + announcement: &Announcement, + cx: &mut AsyncApp, + on_step: &mut P, + ) -> Result + where + P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>, + { + let path = self + .path + .as_deref() + .ok_or_else(|| anyhow!("push requires a local repository"))?; + + let owner = announcement + .owner + .to_bech32() + .map_err(|error| anyhow!("could not encode the repository owner: {error}"))?; + + let mut outcome = PushOutcome::default(); + + for relay in &announcement.relays { + let step = PushStep::StageStateEvent(relay.clone()); + + // The relay must accept the state event before the git push is allowed. + on_step(PushStepStatus::Running(step.clone()), cx)?; + + // Get base URL for the git push + let base = grasp_base_url(relay).unwrap_or_default(); + + // Ensure the relay is connected before sending the event. + client.add_relay(relay).and_connect().await?; + + let staging = client.send_event(&event).to([relay.clone()]).await; + // Whether the relay accepted the event + let sent_failed = !staging + .as_ref() + .is_ok_and(|output| output.success.contains_key(relay)); + + if sent_failed { + let reason = match &staging { + Ok(output) => output + .failed + .get(relay) + .cloned() + .unwrap_or_else(|| "relay did not confirm the event".to_owned()), + Err(e) => format!("could not send the state event to {relay}: {e}"), + }; + + // Run the callback to report the failure + on_step(PushStepStatus::Failed(step, reason.clone()), cx)?; + + // Record the failure in the outcome and continue to the next relay + outcome.servers.push(GraspServer::failed(relay, reason)); + continue; + } + + // Run the callback to report the success + on_step(PushStepStatus::Done(step), cx)?; + + // Run the git push in the background, reporting progress through the callback + let step = PushStep::PushObjects(relay.clone()); + on_step(PushStepStatus::Running(step.clone()), cx)?; + + let pushed = cx.background_spawn({ + let path = path.to_path_buf(); + let base = base.clone(); + let owner = owner.clone(); + let repo_id = announcement.id.clone(); + let mode = self.mode; + + async move { + let repo = Repo::open(&path)?; + match mode { + GitPushMode::Main => repo.push_main(&base, &owner, &repo_id), + GitPushMode::All => repo.push_all(&base, &owner, &repo_id), + } + } + }); + + match pushed.await { + Ok(()) => { + // Run the callback to report the success + on_step(PushStepStatus::Done(step), cx)?; + + outcome.state_event = Some(event.clone()); + outcome.servers.push(GraspServer::ok(relay.clone())); + } + Err(e) => { + // Run the callback to report the failure + on_step(PushStepStatus::Failed(step, e.to_string()), cx)?; + + outcome + .servers + .push(GraspServer::failed(relay, e.to_string())); + } + } + } + + // Fan the state out to the relays once a git server holds the objects. + if outcome.state_event.is_some() { + let step = PushStep::BroadcastStateEvent; + on_step(PushStepStatus::Running(step.clone()), cx)?; + + if let Err(e) = client.send_event(&event).broadcast().await { + on_step(PushStepStatus::Failed(step, e.to_string()), cx)?; + log::warn!("failed to broadcast repository state: {e}"); + } else { + on_step(PushStepStatus::Done(step), cx)?; + } + } + + Ok(outcome) + } + + /// Announce the repository and push the state and objects to the grasp servers. + /// + /// Both events are signed by the caller, so a failure at the call site can retract the announcement by id. + pub async fn announce_and_push

( + &self, + client: &Client, + announcement: Event, + state_event: Event, + cx: &mut AsyncApp, + on_step: &mut P, + ) -> Result + where + P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>, + { + let step = PushStep::AnnounceRepository; + on_step(PushStepStatus::Running(step.clone()), cx)?; + + let published = Announcement::from_event(&announcement) + .ok_or_else(|| anyhow!("failed to parse announcement"))?; + + // Ensure the relays are connected before sending the announcement event. + for url in &published.relays { + client.add_relay(url).and_connect().await.ok(); + } + + let output = client.send_event(&announcement).broadcast().await?; + + // Handle the case where no relay accepted the announcement event. + if output.success.is_empty() { + let reasons = output + .failed + .values() + .cloned() + .collect::>() + .join(", "); + + // Run the callback to report the failure + let step = PushStepStatus::Failed(step, format!("event not accepted: {reasons}")); + on_step(step, cx)?; + + bail!("event not accepted: {reasons}"); + } + + on_step(PushStepStatus::Done(step), cx)?; + + let outcome = if self.refs.is_empty() { + PushOutcome::default() + } else { + self.push_to_grasps(client, state_event, &published, cx, on_step) + .await? + }; + + if outcome.accepted() == 0 { + bail!( + "Repository was announced, but the push to grasp servers failed: {}", + outcome.failure_summary() + ); + } + + Ok(announcement) + } + + /// Retracts a live announcement; call when `announce_and_push` fails. + pub async fn retract_announcement( + client: &Client, + signer: &S, + id: EventId, + ) -> Result<(), Error> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { + let builder = EventDeletionRequest::new().id(id); + let deletion = builder.finalize_async(signer).await?; + let output = client.send_event(&deletion).broadcast().await?; + + // Without relay acceptance the deletion request stays live. + if output.success.is_empty() { + bail!("deletion event not accepted by any relay"); + } + + Ok(()) } } diff --git a/docs/push-refactor.md b/docs/push-refactor.md index 5aeedde..d2c3b29 100644 --- a/docs/push-refactor.md +++ b/docs/push-refactor.md @@ -37,7 +37,8 @@ Mechanical, no behavior change. Replace `push_staged_to_grasps` with methods on the type that owns the refs: ```rust -#[derive(Debug, Clone, PartialEq, Eq, Default)] +/// How much to push to the grasp servers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum GitPushMode { /// Mirror all branches and tags. #[default] @@ -106,7 +107,7 @@ impl SyncState { // via cx.background_spawn (blocking gix work). Reports every transition // through on_step. Ends with the relay fan-out broadcast. Errors once // up front when self.path is None (state not built from a local repo). - pub(crate) async fn push_to_grasps

( + pub async fn push_to_grasps

( &self, cx: &mut AsyncApp, client: &Client, @@ -118,11 +119,10 @@ impl SyncState { P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>, { ... } - // announce_repository_and_push moved as-is: sign + broadcast the - // announcement (AnnounceRepository step), parse it into an - // `Announcement`, sign the state event, delegate to push_to_grasps, - // retract on total failure. This one keeps the signer. - pub(crate) async fn announce_and_push(...) -> Result { ... } + // Announces the pre-signed announcement (AnnounceRepository step), then + // delegates to push_to_grasps with the pre-signed state event. No signer: + // on failure the call site retracts the announcement by id. + pub async fn announce_and_push

(...) -> Result { ... } } ``` @@ -132,7 +132,7 @@ The struct carries the job description — what (`refs`, `head`), from where (`p - `refs`, `head`, `path`, `mode` come from `&self`. The `push: fn(&Path, &str, &str, &str) -> Result<(), Error>` callback becomes the `mode` field, resolved inside to `Repo::push_main` / `Repo::push_all`; the wildcard-refspec mirror semantics of `push_all` are preserved. States built by `SyncState::parse` (remote events) have no local repo, so `path` is `Option` and `push_to_grasps` errors once up front when it is `None`. - `repo_id`, `owner`, `servers` collapse into `&Announcement` — its `id`, `owner` and `relays` are exactly those values, and both call sites already hold one. `push_repo_from` currently destructures the announcement into those three fields just to re-pass them. It stays an argument because states parsed from events carry no announcement. -- The state event is signed by the caller via `state_event(..).finalize_async(&signer)`, so `push_to_grasps` takes a signed `Event` and needs no signer or `S` generic — it only ever sends events. The signer lives with the callers (`push_repo_from`, `announce_and_push`, which also signs the announcement and the retraction). +- The state event is signed by the caller via `state_event(..).finalize_async(&signer)`, so `push_to_grasps` takes a signed `Event` and needs no signer or `S` generic — it only ever sends events. The signer lives entirely with the callers: `announce_and_push` takes the pre-signed announcement and state events, so a failing call site can retract the announcement by id. - `client` stays as a reference per the constraints; `SyncState` remains plain data, so `SyncState::parse` keeps working for remote states that have no client or path. - `on_step` takes `&mut AsyncApp` so it can update an entity directly. That is what lets the whole flow run in the caller's foreground `cx.spawn` task and emit progress without a channel. @@ -147,7 +147,7 @@ The struct carries the job description — what (`refs`, `head`), from where (`p - Add `BackendEvent::PushProgress { addr: RepoAddr, status: PushStepStatus }`. - `push_repo_from`: background-spawn `SyncState::from_repo(&path)`, sign `state.state_event(&announcement.id)` with the signer, then call `push_to_grasps(cx, &client, event, &announcement, on_step)` in the same foreground task, `on_step` emitting the event via `this.update`. The nested `background_spawn` and its clone block disappear. Keep the `refs.is_empty()` early return at the call site. -- `announce_repository_and_push` is deleted from `backend.rs`; its callers build a `SyncState` first (`SyncState::from_repo` for publish; repo creation builds the single-ref state literally with `GitPushMode::Main`), then call `announce_and_push(cx, client, signer, announcement, on_step)`. +- `announce_repository_and_push` is deleted from `backend.rs`; its callers build a `SyncState` first (`SyncState::from_repo` for publish; repo creation builds the single-ref state literally with `GitPushMode::Main`), sign the announcement and state events, then call `announce_and_push(client, announcement, state_event, cx, on_step)`. When it fails, the call site retracts via `SyncState::retract_announcement` — retraction is the very end of the flow, not inside the method. - `push.rs` keeps `GraspServer`, `PushOutcome` and the URL helpers. ## Step 4 — UI checklist -- 2.54.0 From d5acef65a6e77b97ae57c8656c80a34c8cea4b84 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 14 Oct 2026 08:11:14 +0700 Subject: [PATCH 5/7] Refactor push logic into SyncState methods --- crates/signed_state/src/backend.rs | 269 ++++++++--------------------- crates/signed_state/src/push.rs | 88 ---------- 2 files changed, 76 insertions(+), 281 deletions(-) diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index ef7ae38..3acef17 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1,5 +1,5 @@ use std::collections::HashSet; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::str::FromStr; use std::time::{Duration, Instant}; @@ -19,9 +19,9 @@ use crate::bootstrap::{ }; use crate::git_store::Mirrors; use crate::inbox::Inbox; -use crate::push::{PushOutcome, grasp_base_url, grasp_clone_url, push_staged_to_grasps}; +use crate::push::{PushOutcome, grasp_base_url, grasp_clone_url}; use crate::repos::RepoListStore; -use crate::state::GitRef; +use crate::state::{GitPushMode, GitRef, PushStepStatus, SyncState}; pub const USER_KEYRING: &str = "Signed Safe Storage"; pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; @@ -36,6 +36,10 @@ pub enum BackendEvent { ProfileUpdates(Vec), RepoUpdates(Vec), Synced, + PushProgress { + addr: RepoAddr, + status: PushStepStatus, + }, Error(String), } @@ -575,23 +579,18 @@ impl Backend { Some(commit_sha), ); - let event = announce_repository_and_push( - &client, - &signer, - announcement, - &repo_id, - &owner, - &servers, - vec![GitRef { + let state = SyncState { + refs: vec![GitRef { name: "refs/heads/main".to_owned(), commit: commit_sha, }], - Some("main".to_owned()), - &destination, - cx, - |path, base, owner, repo_id| Repo::open(path)?.push_main(base, owner, repo_id), - ) - .await?; + head: Some("main".to_owned()), + path: Some(destination.clone()), + mode: GitPushMode::Main, + }; + + let event = + announce_and_push_or_retract(&client, &signer, &state, announcement, cx).await?; let announcement = Announcement::from_event(&event) .ok_or_else(|| anyhow!("failed to parse announcement"))?; @@ -650,8 +649,8 @@ impl Backend { let path = path.clone(); async move { let repo = Repo::open(&path)?; - let state = repo.ref_state()?; let euc = repo.root_commit()?; + let state = SyncState::from_repo(&path)?; Ok::<_, Error>((state, euc)) } }) @@ -664,34 +663,14 @@ impl Backend { if let Some(branch) = chosen { let wanted = format!("refs/heads/{branch}"); - if state.refs.iter().any(|(name, _)| name == &wanted) { + if state.refs.iter().any(|git_ref| git_ref.name == wanted) { state.head = Some(branch.to_owned()); } } - let euc = euc.and_then(|commit| Sha1Hash::from_str(&commit).ok()); + let euc = euc.and_then(|commit: String| Sha1Hash::from_str(&commit).ok()); let ann = build_announcement(&repo_id, &name, &description, &owner, &servers, euc); - - let refs = state - .refs - .iter() - .map(|(name, commit)| GitRef::new(name, commit)) - .collect::, Error>>()?; - - let event = announce_repository_and_push( - &client, - &signer, - ann, - &repo_id, - &owner, - &servers, - refs, - state.head.clone(), - &path, - cx, - |path, base, owner, repo_id| Repo::open(path)?.push_all(base, owner, repo_id), - ) - .await?; + let event = announce_and_push_or_retract(&client, &signer, &state, ann, cx).await?; if let Some(base) = servers.first().and_then(grasp_base_url) { let url = format!("{base}/{owner}/{repo_id}.git"); @@ -764,9 +743,6 @@ impl Backend { cx.notify(); }); - let owner = announcement.owner.to_bech32().unwrap(); - let repo_id = announcement.id.clone(); - let relays = announcement.relays.clone(); let client = self.client.clone(); let signer = self.signer.clone(); @@ -784,78 +760,50 @@ impl Backend { let mut state = { let work = cx.background_spawn({ let path = path.clone(); - async move { Repo::open(&path).and_then(|repo| repo.ref_state()) } + async move { SyncState::from_repo(&path) } }); work.await? }; // Keep the announced default branch in `HEAD` when it is among the pushed refs. - let heads: Vec<&str> = state - .refs - .iter() - .filter_map(|(name, _)| name.strip_prefix("refs/heads/")) - .collect(); - if let Some(head) = announced_head - && heads.iter().any(|branch| *branch == head) + && state + .refs + .iter() + .any(|git_ref| git_ref.name.strip_prefix("refs/heads/") == Some(head.as_str())) { state.head = Some(head); } - // Grasp servers authorize a push by the state event they hold in purgatory. - let refs = state - .refs - .iter() - .map(|(name, commit)| GitRef::new(name, commit)) - .collect::, Error>>()?; - let head = state.head.clone(); - - let outcome = if refs.is_empty() { + let outcome = if state.refs.is_empty() { PushOutcome::default() } else { - let push = cx.background_spawn({ - let client = client.clone(); - let signer = signer.clone(); - let path = path.clone(); - let owner = owner.clone(); - let repo_id = repo_id.clone(); - let relays = relays.clone(); - let refs = refs.clone(); - let head = head.clone(); - async move { - push_staged_to_grasps( - &client, - &signer, - &repo_id, - &refs, - head.as_deref(), - &path, - &owner, - &relays, - |path, base, owner, repo_id| { - Repo::open(path)?.push_all(base, owner, repo_id) - }, - ) - .await - } - }); - push.await + // Grasp servers authorize a push by the state event they hold in purgatory. + let builder = state.state_event(&announcement.id); + let event = builder.finalize_async(&signer).await?; + + let on_step = &mut |status: PushStepStatus, cx: &mut AsyncApp| { + this.update(cx, |_, cx| { + cx.emit(BackendEvent::PushProgress { + addr: addr.clone(), + status, + }); + })?; + Ok::<(), Error>(()) + }; + + state + .push_to_grasps(&client, event, &announcement, cx, on_step) + .await? }; - if !refs.is_empty() && outcome.accepted() == 0 { + if !state.refs.is_empty() && outcome.accepted() == 0 { bail!( "could not push the repository to any grasp server: {}", outcome.failure_summary() ); } - // Fan the state out to the relays once a git server holds the objects. - if let Some(state_event) = &outcome.state_event - && let Err(e) = client.send_event(state_event).broadcast().await - { - log::warn!("failed to broadcast repository state: {e}"); - } - Ok(outcome) }) } @@ -1201,113 +1149,48 @@ fn build_announcement( } } -// Announce the repository, then stage the state event and push. -#[allow(clippy::too_many_arguments)] -async fn announce_repository_and_push( +/// Announces a repository and pushes its state to grasp servers, +/// or retracts the announcement if the push fails. +async fn announce_and_push_or_retract( client: &Client, signer: &S, + state: &SyncState, announcement: GitRepositoryAnnouncement, - repo_id: &str, - owner: &str, - servers: &[RelayUrl], - refs: Vec, - head: Option, - path: &Path, cx: &mut AsyncApp, - push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error> + Send + 'static, ) -> Result where - S: AsyncGetPublicKey + AsyncSignEvent + Clone, + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, { - for url in servers { - if let Err(e) = client.add_relay(url).and_connect().await { - log::warn!("failed to connect grasp relay {url}: {e}"); - } - } + let state_event = state + .state_event(&announcement.id) + .finalize_async(signer) + .await?; + let announcement_event = announcement.finalize_async(signer).await?; - let event = announcement.finalize_async(signer).await?; - let output = client.send_event(&event).broadcast().await?; - - if output.success.is_empty() { - let reasons = output - .failed - .values() - .cloned() - .collect::>() - .join(", "); - bail!("event not accepted by any relay: {reasons}"); - } - - // The state event is the push authorization. - let outcome = if refs.is_empty() { - PushOutcome::default() - } else { - let push = { - let client = client.clone(); - let signer = S::clone(signer); - let repo_id = repo_id.to_owned(); - let owner = owner.to_owned(); - let servers = servers.to_vec(); - let head = head.clone(); - let path = path.to_path_buf(); - cx.background_spawn(async move { - push_staged_to_grasps( - &client, - &signer, - &repo_id, - &refs, - head.as_deref(), - &path, - &owner, - &servers, - push, - ) - .await - }) - }; - push.await - }; - - if outcome.accepted() == 0 { - // Retract the announcement so the repository is not left announced without content. - let retracted = async { - let deletion = EventDeletionRequest::new() - .id(event.id) - .finalize_async(signer) - .await?; - let output = client.send_event(&deletion).broadcast().await?; - - // Without relay acceptance the deletion request stays live. - if output.success.is_empty() { - bail!("deletion event not accepted by any relay"); - } - - Ok::<(), Error>(()) - } - .await; - - return Err(match retracted { - Ok(()) => anyhow!( - "The repository was announced, but the push to every grasp server failed: {}. \ - The announcement has been retracted", - outcome.failure_summary() - ), - Err(e) => anyhow!( - "The repository was announced, but the push to every grasp server failed: {}. \ - Retracting the announcement failed: {e}; delete the repository to clean it up", - outcome.failure_summary() - ), - }); - } - - // Fan the state out to the relays once a git server holds the objects. - if let Some(state_event) = &outcome.state_event - && let Err(e) = client.send_event(state_event).broadcast().await + match state + .announce_and_push( + client, + announcement_event.clone(), + state_event, + cx, + &mut |_, _| Ok(()), + ) + .await { - log::warn!("failed to broadcast repository state: {e}"); - } + Ok(event) => Ok(event), + Err(e) => { + let retracted = + SyncState::retract_announcement(client, signer, announcement_event.id).await; + let retraction = match retracted { + Ok(()) => "The announcement has been retracted".to_owned(), + Err(error) => format!( + "Retracting the announcement failed: {error}; delete the repository to clean it up" + ), + }; - Ok(event) + bail!("{e}. {retraction}"); + } + } } enum UpdateEvent { diff --git a/crates/signed_state/src/push.rs b/crates/signed_state/src/push.rs index ca95968..006ff6a 100644 --- a/crates/signed_state/src/push.rs +++ b/crates/signed_state/src/push.rs @@ -1,11 +1,6 @@ -use std::path::Path; - -use anyhow::Error; use nostr::prelude::Url; use nostr_sdk::prelude::*; -use crate::state::{GitRef, SyncState}; - /// `ws://` grasp servers, like ngit, use `http(s)://`. pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option { let parsed = Url::parse(relay.as_str()).ok()?; @@ -116,87 +111,4 @@ impl PushOutcome { self.failure_summary() )) } - - /// All staged events carry the same refs; the newest timestamp wins on the relays. - fn keep_newest(&mut self, event: Event) { - if self - .state_event - .as_ref() - .is_none_or(|current| event.created_at > current.created_at) - { - self.state_event = Some(event); - } - } -} - -#[allow(clippy::too_many_arguments)] -pub(crate) async fn push_staged_to_grasps( - client: &Client, - signer: &S, - repo_id: &str, - refs: &[GitRef], - head: Option<&str>, - path: &Path, - owner: &str, - servers: &[RelayUrl], - push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error>, -) -> PushOutcome -where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, -{ - let mut outcome = PushOutcome::default(); - - for relay in servers { - let Some(base) = grasp_base_url(relay) else { - outcome - .servers - .push(GraspServer::failed(relay, "no domain")); - continue; - }; - - let event = match SyncState::build(repo_id, refs, head) - .finalize_async(signer) - .await - { - Ok(event) => event, - Err(e) => { - let reason = format!("could not sign the state event: {e}"); - outcome.servers.push(GraspServer::failed(relay, reason)); - continue; - } - }; - - // The relay parks the accepted event in purgatory, authorizing the paired git push. - let staging = match client.add_relay(relay).and_connect().await { - Ok(_) => match client.send_event(&event).to([relay.clone()]).await { - Ok(output) if output.success.contains_key(relay) => Ok(()), - Ok(output) => Err(output - .failed - .get(relay) - .cloned() - .unwrap_or_else(|| "relay did not confirm the event".to_owned())), - Err(e) => Err(format!("could not send the state event to {relay}: {e}")), - }, - Err(e) => Err(format!("could not add relay {relay}: {e}")), - }; - - if let Err(reason) = staging { - outcome.servers.push(GraspServer::failed(relay, reason)); - continue; - } - - match push(path, &base, owner, repo_id) { - Ok(()) => { - outcome.keep_newest(event); - outcome.servers.push(GraspServer::ok(relay.clone())); - } - Err(e) => { - outcome - .servers - .push(GraspServer::failed(relay, e.to_string())); - } - } - } - - outcome } -- 2.54.0 From f25aa9fdc001111eebbd175fc83f3069b18f3bf1 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 14 Oct 2026 08:47:07 +0700 Subject: [PATCH 6/7] Track per-step push progress and show it in a banner --- crates/signed_state/src/repo.rs | 28 +++++++++++- crates/signed_state/src/state.rs | 22 ++++++++++ crates/workspace/src/views/repo/mod.rs | 59 +++++++++++++++++++++++++- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 5edcaae..a1b29d6 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -18,7 +18,7 @@ use crate::checkouts::CheckoutsStore; use crate::patch_series::{MAX_PATCH_EVENT_BYTES, PatchSeries}; use crate::push::{PushOutcome, grasp_base_url, grasp06_prs_url, pr_clone_urls}; use crate::repos::RepoListStore; -use crate::state::{GitRef, SyncState}; +use crate::state::{GitRef, PushStepStatus, SyncState}; use crate::sync_status::SyncStatusStore; pub struct RepoStore { @@ -48,6 +48,7 @@ pub struct RepoStore { // The repository is out of sync on the rejected servers until republished. pub last_push_warning: Option, pub pushing: bool, + pub push_steps: Vec, pub cloning: bool, // Avoids re-subscribing and re-fetching on every refresh. repo_relays: HashSet, @@ -101,6 +102,7 @@ impl RepoStore { last_warning: None, last_push_warning: None, pushing: false, + push_steps: Vec::new(), cloning: false, repo_relays: HashSet::new(), root_fetches: HashSet::new(), @@ -131,6 +133,7 @@ impl RepoStore { last_warning: None, last_push_warning: None, pushing: false, + push_steps: Vec::new(), cloning: false, repo_relays: HashSet::new(), root_fetches: HashSet::new(), @@ -169,6 +172,14 @@ impl RepoStore { let backend = Backend::global(cx); cx.subscribe(&backend, |this, _backend, event, cx| { + if let BackendEvent::PushProgress { addr, status } = event { + if this.addr.as_ref() == Some(addr) { + this.apply_push_step(status.clone(), cx); + } + + return; + } + let Some(addr) = this.addr.as_ref() else { return; }; @@ -203,6 +214,19 @@ impl RepoStore { self.addr.as_ref() } + fn apply_push_step(&mut self, status: PushStepStatus, cx: &mut Context) { + match self + .push_steps + .iter_mut() + .find(|entry| entry.step() == status.step()) + { + Some(entry) => *entry = status, + None => self.push_steps.push(status), + } + + cx.notify(); + } + pub fn name(&self) -> SharedString { self.announcement .as_ref() @@ -1186,6 +1210,7 @@ impl RepoStore { cx: &mut Context, ) -> Task> { self.pushing = true; + self.push_steps.clear(); self.last_error = None; self.last_push_warning = None; cx.notify(); @@ -1195,6 +1220,7 @@ impl RepoStore { this.update(cx, |this, cx| { this.pushing = false; + this.push_steps.clear(); match &result { Ok(outcome) => { diff --git a/crates/signed_state/src/state.rs b/crates/signed_state/src/state.rs index a8102f7..901651f 100644 --- a/crates/signed_state/src/state.rs +++ b/crates/signed_state/src/state.rs @@ -67,6 +67,28 @@ pub enum PushStepStatus { Failed(PushStep, String), } +impl PushStep { + pub fn label(&self) -> String { + match self { + Self::AnnounceRepository => "Announce repository".to_owned(), + Self::StageStateEvent(relay) => format!("Publish state event -> {relay}"), + Self::BroadcastStateEvent => "Publish state event to relays".to_owned(), + Self::PushObjects(relay) => format!( + "Push objects -> {}", + grasp_base_url(relay).unwrap_or_else(|| relay.to_string()) + ), + } + } +} + +impl PushStepStatus { + pub fn step(&self) -> &PushStep { + match self { + Self::Running(step) | Self::Done(step) | Self::Failed(step, _) => step, + } + } +} + impl SyncState { pub fn build(id: &str, refs: &[GitRef], head: Option<&str>) -> EventBuilder { let mut tags: Vec = vec![Tag::identifier(id.to_owned())]; diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index c750ed5..463fd87 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -26,7 +26,8 @@ use signed_core::{Announcement, RepoAddr, RepoStatus}; use signed_git::{FileCommit, GitCache, RefSync, Repo}; use signed_state::{ Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors, - Nip34Binding, Nip34Kind, ProfileStore, RepoListStore, RepoStore, SyncStatusStore, + Nip34Binding, Nip34Kind, ProfileStore, PushStepStatus, RepoListStore, RepoStore, + SyncStatusStore, }; use signed_ui::{ Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger, @@ -1747,6 +1748,59 @@ impl RepoDetailView { ) } + pub(super) fn render_push_progress_banner(&self, cx: &Context) -> Option { + let store = self.store.read(cx); + if !store.pushing || store.push_steps.is_empty() { + return None; + } + + let steps = store + .push_steps + .iter() + .map(|status| { + let label = SharedString::from(status.step().label()); + + match status { + PushStepStatus::Running(_) => h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(Spinner::new().small()) + .child(label) + .into_any_element(), + PushStepStatus::Done(_) => h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().foreground) + .child(Icon::new(IconName::CircleCheck).small()) + .child(label) + .into_any_element(), + PushStepStatus::Failed(_, reason) => h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().danger) + .child(Icon::new(IconName::Close).small()) + .child(SharedString::from(format!( + "{}: {reason}", + status.step().label() + ))) + .into_any_element(), + } + }) + .collect::>(); + + Some( + v_flex() + .p_4() + .gap_1() + .w_full() + .bg(cx.theme().muted) + .child(div().text_sm().child("Pushing")) + .children(steps) + .into_any_element(), + ) + } + pub(super) fn render_push_warning_banner(&self, cx: &Context) -> Option { let store = self.store.read(cx); let warning = store.last_push_warning.clone()?; @@ -2189,6 +2243,9 @@ impl Render for RepoDetailView { .id("repo") .size_full() .when_some(banner, |this, banner| this.child(banner)) + .when_some(self.render_push_progress_banner(cx), |this, banner| { + this.child(banner) + }) .when_some(self.render_push_warning_banner(cx), |this, banner| { this.child(banner) }) -- 2.54.0 From dd42d921a9c31b0c374ed25ab7466170b562dd0a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 14 Oct 2026 09:02:30 +0700 Subject: [PATCH 7/7] Show push step progress in publish dialogs --- crates/signed_state/src/backend.rs | 45 +++-- crates/workspace/src/views/dialog_state.rs | 85 ++++++++- .../workspace/src/views/repo/init_dialog.rs | 14 +- .../src/views/sidebar/create_repo_dialog.rs | 14 +- docs/TODO.md | 1 - docs/push-refactor.md | 173 ------------------ 6 files changed, 138 insertions(+), 194 deletions(-) delete mode 100644 docs/push-refactor.md diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 3acef17..62dc33b 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -528,6 +528,7 @@ impl Backend { } let owner = public_key.to_bech32().unwrap(); + let addr = RepoAddr::new(public_key, repo_id.clone()); let servers = grasp_servers.clone(); let client = self.client.clone(); let signer = self.signer.clone(); @@ -542,7 +543,7 @@ impl Backend { folder.join(dir_name) }; - cx.spawn(async move |_this, cx| { + cx.spawn(async move |this, cx| { let work = cx.background_spawn({ let destination = destination.clone(); let name = name.clone(); @@ -589,8 +590,19 @@ impl Backend { mode: GitPushMode::Main, }; + let on_step = &mut |status: PushStepStatus, cx: &mut AsyncApp| { + this.update(cx, |_, cx| { + cx.emit(BackendEvent::PushProgress { + addr: addr.clone(), + status, + }); + })?; + Ok::<(), Error>(()) + }; + let event = - announce_and_push_or_retract(&client, &signer, &state, announcement, cx).await?; + announce_and_push_or_retract(&client, &signer, &state, announcement, cx, on_step) + .await?; let announcement = Announcement::from_event(&event) .ok_or_else(|| anyhow!("failed to parse announcement"))?; @@ -638,12 +650,13 @@ impl Backend { } let owner = public_key.to_bech32().unwrap(); + let addr = RepoAddr::new(public_key, repo_id.clone()); let servers = grasp_servers.clone(); let client = self.client.clone(); let signer = self.signer.clone(); - cx.spawn(async move |_this, cx| { + cx.spawn(async move |this, cx| { let (mut state, euc) = cx .background_spawn({ let path = path.clone(); @@ -670,7 +683,19 @@ impl Backend { let euc = euc.and_then(|commit: String| Sha1Hash::from_str(&commit).ok()); let ann = build_announcement(&repo_id, &name, &description, &owner, &servers, euc); - let event = announce_and_push_or_retract(&client, &signer, &state, ann, cx).await?; + + let on_step = &mut |status: PushStepStatus, cx: &mut AsyncApp| { + this.update(cx, |_, cx| { + cx.emit(BackendEvent::PushProgress { + addr: addr.clone(), + status, + }); + })?; + Ok::<(), Error>(()) + }; + + let event = + announce_and_push_or_retract(&client, &signer, &state, ann, cx, on_step).await?; if let Some(base) = servers.first().and_then(grasp_base_url) { let url = format!("{base}/{owner}/{repo_id}.git"); @@ -1151,15 +1176,17 @@ fn build_announcement( /// Announces a repository and pushes its state to grasp servers, /// or retracts the announcement if the push fails. -async fn announce_and_push_or_retract( +async fn announce_and_push_or_retract( client: &Client, signer: &S, state: &SyncState, announcement: GitRepositoryAnnouncement, cx: &mut AsyncApp, + on_step: &mut P, ) -> Result where S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>, { let state_event = state .state_event(&announcement.id) @@ -1168,13 +1195,7 @@ where let announcement_event = announcement.finalize_async(signer).await?; match state - .announce_and_push( - client, - announcement_event.clone(), - state_event, - cx, - &mut |_, _| Ok(()), - ) + .announce_and_push(client, announcement_event.clone(), state_event, cx, on_step) .await { Ok(event) => Ok(event), diff --git a/crates/workspace/src/views/dialog_state.rs b/crates/workspace/src/views/dialog_state.rs index 2eda596..e7e3aa9 100644 --- a/crates/workspace/src/views/dialog_state.rs +++ b/crates/workspace/src/views/dialog_state.rs @@ -1,12 +1,19 @@ use gpui::prelude::*; -use gpui::{AnyElement, App, SharedString, div}; -use gpui_component::ActiveTheme; +use gpui::{AnyElement, App, Context, SharedString, Subscription, div}; +use gpui_component::spinner::Spinner; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex}; +use signed_core::RepoAddr; +use signed_state::{Backend, BackendEvent, PushStepStatus}; /// Progress of an async dialog action: a busy flag that disables the form and an error shown below it. #[derive(Debug, Default)] pub struct DialogProgress { pub busy: bool, pub error: Option, + // The repository whose push checklist is shown; `None` outside a publish. + push_addr: Option, + pub push_steps: Vec, + _push_subscription: Option, } impl DialogProgress { @@ -16,6 +23,37 @@ impl DialogProgress { self.error = None; } + /// Starts a publish for the repository at `addr` and listens for its push steps. + pub fn begin_push(&mut self, addr: RepoAddr, cx: &mut Context) { + self.begin(); + self.push_addr = Some(addr); + self.push_steps.clear(); + + if self._push_subscription.is_none() { + let backend = Backend::global(cx); + self._push_subscription = Some(cx.subscribe(&backend, |this, _backend, event, cx| { + let BackendEvent::PushProgress { addr, status } = event else { + return; + }; + + if this.push_addr.as_ref() != Some(addr) { + return; + } + + match this + .push_steps + .iter_mut() + .find(|step| step.step() == status.step()) + { + Some(step) => *step = status.clone(), + None => this.push_steps.push(status.clone()), + } + + cx.notify(); + })); + } + } + /// Marks an action as failed, enabling the form and showing `message`. pub fn fail(&mut self, message: impl Into) { self.busy = false; @@ -33,3 +71,46 @@ pub fn error_row(error: &Option, cx: &App) -> Option { .into_any_element() }) } + +/// The push checklist of a running publish, `None` before any step is reported. +pub fn push_steps_row(steps: &[PushStepStatus], cx: &App) -> Option { + if steps.is_empty() { + return None; + } + + let rows = steps + .iter() + .map(|status| { + let label = SharedString::from(status.step().label()); + + match status { + PushStepStatus::Running(_) => h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(Spinner::new().small()) + .child(label) + .into_any_element(), + PushStepStatus::Done(_) => h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().foreground) + .child(Icon::new(IconName::CircleCheck).small()) + .child(label) + .into_any_element(), + PushStepStatus::Failed(_, reason) => h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().danger) + .child(Icon::new(IconName::Close).small()) + .child(SharedString::from(format!( + "{}: {reason}", + status.step().label() + ))) + .into_any_element(), + } + }) + .collect::>(); + + Some(v_flex().gap_1().children(rows).into_any_element()) +} diff --git a/crates/workspace/src/views/repo/init_dialog.rs b/crates/workspace/src/views/repo/init_dialog.rs index 8b87a6a..f566408 100644 --- a/crates/workspace/src/views/repo/init_dialog.rs +++ b/crates/workspace/src/views/repo/init_dialog.rs @@ -12,12 +12,13 @@ use gpui_component::input::{Input, InputState, Textarea}; use gpui_component::select::{Select, SelectState}; use gpui_component::{ActiveTheme, Disableable, WindowExt}; use settings::SettingsStore; +use signed_core::RepoAddr; use signed_git::Repo; use signed_state::Backend; use signed_ui::SelectOption; use super::RepoDetailView; -use crate::views::dialog_state::{DialogProgress, error_row}; +use crate::views::dialog_state::{DialogProgress, error_row, push_steps_row}; use crate::views::sidebar::grasp_servers::{ GraspServersState, grasp_servers_field, load_user_grasp_servers, }; @@ -120,6 +121,7 @@ pub fn open( ) .child(grasp_servers_field(&grasp_state, &relay_input, cx)), ) + .children(push_steps_row(&state.read(cx).push_steps, cx)) .children(error_row(&error, cx)) .child( DialogFooter::new().justify_end().child( @@ -189,9 +191,15 @@ fn init_repository( return; } - state.update(cx, |state, _| state.begin()); - let backend = Backend::global(cx); + let Some(user) = backend.read(cx).current_user() else { + state.update(cx, |state, _| state.fail("Sign in to publish a repository")); + return; + }; + + let addr = RepoAddr::new(user, RepoAddr::identifier_from_name(&name)); + state.update(cx, |state, cx| state.begin_push(addr, cx)); + let task = backend.update(cx, |backend, cx| { backend.publish_local_repo( local_path.clone(), diff --git a/crates/workspace/src/views/sidebar/create_repo_dialog.rs b/crates/workspace/src/views/sidebar/create_repo_dialog.rs index 9cc933e..9eb7fcd 100644 --- a/crates/workspace/src/views/sidebar/create_repo_dialog.rs +++ b/crates/workspace/src/views/sidebar/create_repo_dialog.rs @@ -10,11 +10,12 @@ use gpui_component::form::{field, v_form}; use gpui_component::input::{Input, InputState, Textarea}; use gpui_component::{Disableable, IconName, WindowExt, h_flex}; use settings::SettingsStore; +use signed_core::RepoAddr; use signed_state::{Backend, CheckoutsStore}; use super::super::open_repo_panel; use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers}; -use crate::views::dialog_state::{DialogProgress, error_row}; +use crate::views::dialog_state::{DialogProgress, error_row, push_steps_row}; /// Progress of the create-repository flow, so async results can be rendered. pub type CreateRepoState = DialogProgress; @@ -112,6 +113,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) ) .child(grasp_servers_field(&grasp_state, &relay_input, cx)), ) + .children(push_steps_row(&state.read(cx).push_steps, cx)) .children(error_row(&error, cx)) .child( DialogFooter::new().justify_end().child( @@ -209,9 +211,15 @@ fn create_repository( return; } - state.update(cx, |state, _| state.begin()); - let backend = Backend::global(cx); + let Some(user) = backend.read(cx).current_user() else { + state.update(cx, |state, _| state.fail("Sign in to create a repository")); + return; + }; + + let addr = RepoAddr::new(user, RepoAddr::identifier_from_name(&name)); + state.update(cx, |state, cx| state.begin_push(addr, cx)); + let task = backend.update(cx, |backend, cx| { backend.create_repository(&name, &description, folder, servers, cx) }); diff --git a/docs/TODO.md b/docs/TODO.md index 57f8b40..fd9183b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -28,7 +28,6 @@ ## Push -- [ ] Refactor the push pipeline onto `SyncState` with step-by-step progress, see [push-refactor.md](./push-refactor.md). - [ ] Add a fast-forward check and a force option (nak refuses non-FF without `--force`), today it relies entirely on grasp CAS. - [ ] Allow NIP-34 maintainers to push, not just the owner. diff --git a/docs/push-refactor.md b/docs/push-refactor.md deleted file mode 100644 index d2c3b29..0000000 --- a/docs/push-refactor.md +++ /dev/null @@ -1,173 +0,0 @@ -# Push refactor - -Move the push pipeline onto `SyncState` with step-by-step progress, so the UI can render a live checklist instead of a single spinner. - -## Problems - -- `push_staged_to_grasps` is a free function with 9 arguments; `refs`/`head` are just threaded through. Both call sites wrap it in another `background_spawn` with ~8 lines of cloning (`backend.rs` `push_repo_from`, `announce_repository_and_push`). -- The state event is re-signed per relay inside the loop. -- There is no progress surface: the whole flow is one opaque `Task>`. The UI only has the `pushing` flag and the post-hoc `partial_warning`. -- `RepoState` lives in `signed_core`, but its only consumers are in `signed_state`. `signed_git::RepoRefState` is structurally identical (`refs` + `head`). - -## Constraints - -- Keep it simple; no edge-case handling, no over-engineering. -- No new struct wrapping the Nostr client or signer; reference them. -- No new global entity. - -## Target shape - -- `signed_state/src/state.rs` (moved from core, `RepoState` renamed to `SyncState`): the data (`refs`, `head`, push `path`, `mode`), builders (`from_repo`, `with_mode`), event `build`/`parse`, and the whole push flow as methods on `impl SyncState`. -- `signed_state/src/push.rs`: keeps `GraspServer`, `PushOutcome` and the grasp URL helpers. No flow logic. -- `Backend`: thin wiring, emits `BackendEvent::PushProgress` per step. -- `RepoStore`: owns the displayed step list (it already subscribes to `Backend`). -- `RepoDetailView`: renders the checklist. - -## Step 1 — Move and rename - -Mechanical, no behavior change. - -- Move `crates/signed_core/src/state.rs` to `crates/signed_state/src/state.rs`, rename `RepoState` to `SyncState`. Tests move along. -- `signed_core/src/lib.rs`: drop `pub mod state;` and the re-export. -- `signed_state/src/lib.rs`: `mod state;` and `pub use state::SyncState;`. -- Update importers in `signed_state`: `push.rs`, `repo.rs`, `sync_status.rs`. Nothing else uses `signed_core::RepoState`. `Kind::RepoState` (the nostr kind) is untouched. - -## Step 2 — Push flow under `impl SyncState` - -Replace `push_staged_to_grasps` with methods on the type that owns the refs: - -```rust -/// How much to push to the grasp servers. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum GitPushMode { - /// Mirror all branches and tags. - #[default] - All, - /// Push `refs/heads/main` only (repo creation). - Main, -} - -/// A git ref with the commit it points at. -pub struct GitRef { - /// Full ref name, like `refs/heads/main` or `refs/tags/v1.0`. - pub name: String, - /// Commit id, `bitcoin_hashes::sha1::Hash`, the type NIP-34 tags carry. - pub commit: Sha1Hash, -} - -pub struct SyncState { - pub refs: Vec, - pub head: Option, - /// Local repository to push from; `None` for states parsed from events. - pub path: Option, - pub mode: GitPushMode, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PushStep { - AnnounceRepository, - StageStateEvent(RelayUrl), // publish state event to one grasp server - PushObjects(RelayUrl), // git push to one grasp server - BroadcastStateEvent, // fan the state event out to relays -} - -#[derive(Debug, Clone)] -pub enum PushStepStatus { - Running(PushStep), - Done(PushStep), - Failed(PushStep, String), -} -``` - -```rust -impl SyncState { - /// Reads the refs and head of a local repository, ready to push. - pub fn from_repo(path: &Path) -> Result { - let repo = Repo::open(path)?; - let state = repo.ref_state()?; - Ok(Self { - refs: state.refs, - head: state.head, - path: Some(path.to_path_buf()), - mode: GitPushMode::default(), - }) - } - - pub fn with_mode(mut self, mode: GitPushMode) -> Self { - self.mode = mode; - self - } - - /// State event for these refs; the caller signs it. - pub fn state_event(&self, repo_id: &str) -> EventBuilder { - Self::build(repo_id, &self.refs, self.head.as_deref()) - } - - // Per server: connect + send the pre-signed state event, then git push - // via cx.background_spawn (blocking gix work). Reports every transition - // through on_step. Ends with the relay fan-out broadcast. Errors once - // up front when self.path is None (state not built from a local repo). - pub async fn push_to_grasps

( - &self, - cx: &mut AsyncApp, - client: &Client, - event: Event, - announcement: &Announcement, - on_step: &mut P, - ) -> Result - where - P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>, - { ... } - - // Announces the pre-signed announcement (AnnounceRepository step), then - // delegates to push_to_grasps with the pre-signed state event. No signer: - // on failure the call site retracts the announcement by id. - pub async fn announce_and_push

(...) -> Result { ... } -} -``` - -### Why these arguments - -The struct carries the job description — what (`refs`, `head`), from where (`path`), how much (`mode`) — built with `SyncState::from_repo(path)?.with_mode(..)`. The method arguments are then only what varies per call or cannot live on the data: `cx`, `client`, the pre-signed `event`, `announcement`, `on_step`. Down from the 9-arg free function: - -- `refs`, `head`, `path`, `mode` come from `&self`. The `push: fn(&Path, &str, &str, &str) -> Result<(), Error>` callback becomes the `mode` field, resolved inside to `Repo::push_main` / `Repo::push_all`; the wildcard-refspec mirror semantics of `push_all` are preserved. States built by `SyncState::parse` (remote events) have no local repo, so `path` is `Option` and `push_to_grasps` errors once up front when it is `None`. -- `repo_id`, `owner`, `servers` collapse into `&Announcement` — its `id`, `owner` and `relays` are exactly those values, and both call sites already hold one. `push_repo_from` currently destructures the announcement into those three fields just to re-pass them. It stays an argument because states parsed from events carry no announcement. -- The state event is signed by the caller via `state_event(..).finalize_async(&signer)`, so `push_to_grasps` takes a signed `Event` and needs no signer or `S` generic — it only ever sends events. The signer lives entirely with the callers: `announce_and_push` takes the pre-signed announcement and state events, so a failing call site can retract the announcement by id. -- `client` stays as a reference per the constraints; `SyncState` remains plain data, so `SyncState::parse` keeps working for remote states that have no client or path. -- `on_step` takes `&mut AsyncApp` so it can update an entity directly. That is what lets the whole flow run in the caller's foreground `cx.spawn` task and emit progress without a channel. - -### Progress mechanics - -- Per-relay git push is backgrounded inside the method (`cx.background_spawn`), so the method itself runs in the caller's foreground task. Foreground nostr awaits are already precedent (`connect_grasp_relays`, `set_signer`). -- `on_step` returns `Result` so a dead entity propagates instead of being swallowed. -- The caller signs the state event up front instead of per relay; a signing failure aborts before any network work. -- The final fan-out broadcast moves into `push_to_grasps`; both existing flows did exactly push, bail if zero accepted, then broadcast. - -## Step 3 — Backend wiring - -- Add `BackendEvent::PushProgress { addr: RepoAddr, status: PushStepStatus }`. -- `push_repo_from`: background-spawn `SyncState::from_repo(&path)`, sign `state.state_event(&announcement.id)` with the signer, then call `push_to_grasps(cx, &client, event, &announcement, on_step)` in the same foreground task, `on_step` emitting the event via `this.update`. The nested `background_spawn` and its clone block disappear. Keep the `refs.is_empty()` early return at the call site. -- `announce_repository_and_push` is deleted from `backend.rs`; its callers build a `SyncState` first (`SyncState::from_repo` for publish; repo creation builds the single-ref state literally with `GitPushMode::Main`), sign the announcement and state events, then call `announce_and_push(client, announcement, state_event, cx, on_step)`. When it fails, the call site retracts via `SyncState::retract_announcement` — retraction is the very end of the flow, not inside the method. -- `push.rs` keeps `GraspServer`, `PushOutcome` and the URL helpers. - -## Step 4 — UI checklist - -- `RepoStore`: add `push_steps: Vec`; clear it in `run_push` start/end; handle `BackendEvent::PushProgress` in the existing `subscribe_backend` (match on `addr`, replace by `PushStep` equality, `cx.notify()` — no refresh). -- `RepoDetailView`: while `pushing`, render the checklist where the spinner is today (header menu / push banner): - - ``` - Announce repository ✓ - Publish state event → wss://relay.damus.io ✓ - Push objects → git.damus.io ⟳ - Push objects → git.ngit.org · - ``` - -- Labels come from `PushStep::label()` so user-facing copy lives in one place. -- The publish dialog (`init_dialog.rs`) can opt in the same way by constructing the `RepoAddr` up front; optional follow-up. - -Each step compiles green independently: Step 2 adds the methods while the old function still exists, Step 3 switches call sites and deletes it. - -## Validation - -- `cargo check -p signed_state` and `cargo test -p signed_state` after Steps 1 and 2 (the state round-trip tests move with the file). -- Manual: push a repo with two grasp servers where one is unreachable, and confirm the checklist shows running/✓/✗ per server and the existing `partial_warning` still appears. -- 2.54.0