# 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 #[derive(Debug, Clone, 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(crate) 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>, { ... } // 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 { ... } } ``` ### 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 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. ### 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`), then call `announce_and_push(cx, client, signer, announcement, on_step)`. - `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.