From 0d6ed93e4d143bb066205543af13f0ec6ddbdd58 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 26 Feb 2026 13:31:44 +0000 Subject: feat: publish state event to stale grasp relays before sync push FetchReport now captures the full state event seen on each relay during the nostr fetch (state_per_relay: HashMap>). ngit sync uses this to identify grasp server relays with a missing or outdated state event and publishes the current state event to them before attempting git pushes, preventing rejections. An existing login is loaded silently (no prompt, no profile fetch) to provide a signer for NIP-42 auth if requested. --- src/bin/ngit/sub_commands/sync.rs | 105 +++++++++++++++++++++++++++++++++++--- src/lib/client.rs | 57 +++++++++++++++++++++ 2 files changed, 156 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/bin/ngit/sub_commands/sync.rs b/src/bin/ngit/sub_commands/sync.rs index 99cd2d8..4d7e799 100644 --- a/src/bin/ngit/sub_commands/sync.rs +++ b/src/bin/ngit/sub_commands/sync.rs @@ -6,16 +6,21 @@ use git2::Oid; use ngit::{ client::{ Client, Connect, Params, fetching_with_report, get_repo_ref_from_cache, - get_state_from_cache, + get_state_from_cache, send_events, }, fetch::fetch_from_git_server, git::{Repo, RepoActions, nostr_url::NostrUrlDecoded}, list::{get_ahead_behind, list_from_remotes}, + login::existing::load_existing_login, push::push_to_remote, - repo_ref::{get_repo_coordinates_when_remote_unknown, is_grasp_server_clone_url}, + repo_ref::{ + format_grasp_server_url_as_relay_url, get_repo_coordinates_when_remote_unknown, + is_grasp_server_clone_url, + }, repo_state::RepoState, utils::{get_short_git_server_name, join_with_and}, }; +use nostr_sdk::RelayUrl; #[derive(Debug, clap::Args)] pub struct SubCommandArgs { @@ -58,7 +63,7 @@ pub async fn launch(args: &SubCommandArgs) -> Result<()> { None }; - let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); + let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); let (nostr_remote_name, decoded_nostr_url) = git_repo .get_first_nostr_remote_when_in_ngit_binary() @@ -67,14 +72,84 @@ pub async fn launch(args: &SubCommandArgs) -> Result<()> { let repo_coordinate = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; - let _ = fetching_with_report(git_repo_path, &client, &repo_coordinate).await?; - - // TODO push announcement event, then state event to grasps + let fetch_report = fetching_with_report(git_repo_path, &client, &repo_coordinate).await?; let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinate).await?; let nostr_state = get_state_from_cache(Some(git_repo_path), &repo_ref).await?; + // Publish the current state event to any grasp server relays that are + // missing it or have a stale version. Grasp servers reject git pushes + // unless the state event is already present on their relay, so we must + // do this before attempting any git push. + // + // We use the per-relay state events captured during the fetch rather than + // the local database, because the database only stores the canonical latest + // event and cannot tell us what each individual relay holds. + let grasp_relays_needing_state: Vec = repo_ref + .git_server + .iter() + .filter(|url| is_grasp_server_clone_url(url)) + .filter_map(|url| { + format_grasp_server_url_as_relay_url(url) + .ok() + .and_then(|relay_str| RelayUrl::parse(&relay_str).ok()) + }) + .filter(|relay_url| { + // Include this relay if it was absent from the fetch results, had + // no state event, or had a state event older than the canonical one. + match fetch_report.state_per_relay.get(relay_url) { + // relay wasn't queried, or returned no state event + None | Some(None) => true, + Some(Some(relay_event)) => relay_event.id != nostr_state.event.id, + } + }) + .collect(); + + // relay URL -> whether the state event was successfully published to it. + // Only populated for grasp relays that needed the state event; grasp + // relays that already had the current state event are considered succeeded. + let mut grasp_relay_publish_results: HashMap = HashMap::new(); + + if !grasp_relays_needing_state.is_empty() { + // Attempt to load an existing login silently so the signer is + // available for NIP-42 auth if a relay requests it. We do not + // prompt the user, do not fetch profile updates, and ignore any + // failure — the events are already signed so publishing works + // without a signer. + if let Ok((signer, _, _)) = load_existing_login( + &Some(&git_repo), + &None, + &None, + &None, + Some(&client), + true, // silent + false, // prompt_for_password + false, // fetch_profile_updates + ) + .await + { + client.set_signer(signer).await; + } + // Send only to the specific grasp relays that are missing or have a + // stale state event — no user write relays. + if let Ok(results) = send_events( + &client, + Some(git_repo_path), + vec![nostr_state.event.clone()], + vec![], // no user write relays + grasp_relays_needing_state, + true, + false, + ) + .await + { + for (relay_url, succeeded) in results { + grasp_relay_publish_results.insert(relay_url, succeeded); + } + } + } + let term = console::Term::stderr(); let remote_states = list_from_remotes( @@ -178,6 +253,24 @@ pub async fn launch(args: &SubCommandArgs) -> Result<()> { } } + // Skip grasp servers whose relay did not receive the state event — + // they would reject the git push anyway. + if (*is_grasp_server || is_grasp_server_clone_url(url)) + && !grasp_relay_publish_results.is_empty() + { + if let Ok(relay_url) = format_grasp_server_url_as_relay_url(url) { + if grasp_relay_publish_results + .get(&relay_url) + .is_some_and(|succeeded| !succeeded) + { + term.write_line(&format!( + "WARNING: skipping {remote_name} - state event failed to reach its relay" + ))?; + continue; + } + } + } + if refspecs.is_empty() { if !not_updated.is_empty() || !not_deleted.is_empty() { term.write_line(&format!("{remote_name} in sync excluding"))?; diff --git a/src/lib/client.rs b/src/lib/client.rs index 3f1f22e..8ae9820 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs @@ -840,6 +840,33 @@ impl Connect for Client { let events: Vec = get_events_of(&relay, filters.clone(), pb).await?; // TODO: try reconcile + // Track the best state event seen from this relay so callers can + // determine which relays have a stale or absent state event. + // We must do this before process_fetched_events because the local + // database only stores the canonical latest event; per-relay + // visibility is only available here. + for event in &events { + if event.kind.eq(&STATE_KIND) { + let entry = report + .state_per_relay + .entry(relay_url.clone()) + .or_insert(None); + let is_newer = entry.as_ref().is_none_or(|existing: &nostr::Event| { + event.created_at.gt(&existing.created_at) + || (event.created_at.eq(&existing.created_at) + && event.id.gt(&existing.id)) + }); + if is_newer { + *entry = Some(event.clone()); + } + } + } + // Mark relay as queried even if no state event was returned. + report + .state_per_relay + .entry(relay_url.clone()) + .or_insert(None); + process_fetched_events( events, &request, @@ -2018,6 +2045,30 @@ pub fn consolidate_fetch_reports(reports: Vec>) -> FetchRepo for c in relay_report.profile_updates { report.profile_updates.insert(c); } + // Per-relay state events are independent: each relay entry is kept as-is. + // If a relay appears in multiple per-relay reports (shouldn't happen in + // practice but possible in tests), keep the newer event. + for (relay_url, maybe_event) in relay_report.state_per_relay { + match report.state_per_relay.entry(relay_url) { + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(maybe_event); + } + std::collections::hash_map::Entry::Occupied(mut e) => { + let keep = match (e.get(), &maybe_event) { + (None, Some(_)) => true, + (Some(existing), Some(incoming)) => { + incoming.created_at.gt(&existing.created_at) + || (incoming.created_at.eq(&existing.created_at) + && incoming.id.gt(&existing.id)) + } + _ => false, + }; + if keep { + e.insert(maybe_event); + } + } + } + } } report } @@ -2146,6 +2197,12 @@ pub struct FetchReport { statuses: HashSet, contributor_profiles: HashSet, profile_updates: HashSet, + /// The best (newest) state event seen on each relay during the fetch. + /// `None` as a value means the relay was queried but returned no state + /// event at all. Relays that were never queried are absent from the map. + /// This is the only point at which per-relay state visibility is available; + /// the local database only stores the canonical latest event. + pub state_per_relay: HashMap>, } impl Display for FetchReport { -- cgit v1.2.3