Files
signed/docs/push-refactor.md
T

9.3 KiB

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<Result<PushOutcome>>. 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:

#[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<GitRef>,
    pub head: Option<String>,
    /// Local repository to push from; `None` for states parsed from events.
    pub path: Option<PathBuf>,
    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),
}
impl SyncState {
    /// Reads the refs and head of a local repository, ready to push.
    pub fn from_repo(path: &Path) -> Result<Self> {
        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<P>(
        &self,
        cx: &mut AsyncApp,
        client: &Client,
        event: Event,
        announcement: &Announcement,
        on_step: &mut P,
    ) -> Result<PushOutcome, Error>
    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<S, P>(...) -> Result<Event, Error> { ... }
}

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<PathBuf> 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<PushStepStatus>; 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.