9.5 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_graspsis a free function with 9 arguments;refs/headare just threaded through. Both call sites wrap it in anotherbackground_spawnwith ~8 lines of cloning (backend.rspush_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 thepushingflag and the post-hocpartial_warning. RepoStatelives insigned_core, but its only consumers are insigned_state.signed_git::RepoRefStateis 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,RepoStaterenamed toSyncState): the data (refs,head, pushpath,mode), builders (from_repo,with_mode), eventbuild/parse, and the whole push flow as methods onimpl SyncState.signed_state/src/push.rs: keepsGraspServer,PushOutcomeand the grasp URL helpers. No flow logic.Backend: thin wiring, emitsBackendEvent::PushProgressper step.RepoStore: owns the displayed step list (it already subscribes toBackend).RepoDetailView: renders the checklist.
Step 1 — Move and rename
Mechanical, no behavior change.
- Move
crates/signed_core/src/state.rstocrates/signed_state/src/state.rs, renameRepoStatetoSyncState. Tests move along. signed_core/src/lib.rs: droppub mod state;and the re-export.signed_state/src/lib.rs:mod state;andpub use state::SyncState;.- Update importers in
signed_state:push.rs,repo.rs,sync_status.rs. Nothing else usessigned_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:
/// 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<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 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>,
{ ... }
// 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<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,modecome from&self. Thepush: fn(&Path, &str, &str, &str) -> Result<(), Error>callback becomes themodefield, resolved inside toRepo::push_main/Repo::push_all; the wildcard-refspec mirror semantics ofpush_allare preserved. States built bySyncState::parse(remote events) have no local repo, sopathisOption<PathBuf>andpush_to_graspserrors once up front when it isNone.repo_id,owner,serverscollapse into&Announcement— itsid,ownerandrelaysare exactly those values, and both call sites already hold one.push_repo_fromcurrently 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), sopush_to_graspstakes a signedEventand needs no signer orSgeneric — it only ever sends events. The signer lives entirely with the callers:announce_and_pushtakes the pre-signed announcement and state events, so a failing call site can retract the announcement by id. clientstays as a reference per the constraints;SyncStateremains plain data, soSyncState::parsekeeps working for remote states that have no client or path.on_steptakes&mut AsyncAppso it can update an entity directly. That is what lets the whole flow run in the caller's foregroundcx.spawntask 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_stepreturnsResultso 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-spawnSyncState::from_repo(&path), signstate.state_event(&announcement.id)with the signer, then callpush_to_grasps(cx, &client, event, &announcement, on_step)in the same foreground task,on_stepemitting the event viathis.update. The nestedbackground_spawnand its clone block disappear. Keep therefs.is_empty()early return at the call site.announce_repository_and_pushis deleted frombackend.rs; its callers build aSyncStatefirst (SyncState::from_repofor publish; repo creation builds the single-ref state literally withGitPushMode::Main), sign the announcement and state events, then callannounce_and_push(client, announcement, state_event, cx, on_step). When it fails, the call site retracts viaSyncState::retract_announcement— retraction is the very end of the flow, not inside the method.push.rskeepsGraspServer,PushOutcomeand the URL helpers.
Step 4 — UI checklist
-
RepoStore: addpush_steps: Vec<PushStepStatus>; clear it inrun_pushstart/end; handleBackendEvent::PushProgressin the existingsubscribe_backend(match onaddr, replace byPushStepequality,cx.notify()— no refresh). -
RepoDetailView: whilepushing, 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 theRepoAddrup 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_stateandcargo test -p signed_stateafter 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_warningstill appears.