From 64747526c9f6ab43f9dac461d056bb42992573b4 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 20 Feb 2026 20:09:09 +0000 Subject: extract grasp/maintainership helpers to lib and auto-accept on push move apply_grasp_infrastructure, latest_event_repo_ref to lib/repo_ref.rs and wait_for_grasp_servers + grasp_servers_from_user_or_fallback to a new lib/accept_maintainership.rs so both binaries can share them. add accept_maintainership_with_defaults which publishes the co-maintainer's own Kind:30617 announcement with defaults (user grasp servers, shared metadata from existing events) then waits for grasp server provisioning and updates nostr.repo config and origin remote. replace the push error block with a call to accept_maintainership_with_defaults so pushing now silently accepts co-maintainership instead of failing. --- src/bin/git_remote_nostr/main.rs | 2 +- src/bin/git_remote_nostr/push.rs | 25 ++- src/bin/ngit/sub_commands/init.rs | 412 +------------------------------------- 3 files changed, 17 insertions(+), 422 deletions(-) (limited to 'src/bin') diff --git a/src/bin/git_remote_nostr/main.rs b/src/bin/git_remote_nostr/main.rs index f670b7b..e0821e9 100644 --- a/src/bin/git_remote_nostr/main.rs +++ b/src/bin/git_remote_nostr/main.rs @@ -203,7 +203,7 @@ async fn main() -> Result<()> { &repo_ref, &stdin, refspec, - &client, + &mut client, list_outputs.clone(), title_description, ) diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs index e1c94f8..b64cdd9 100644 --- a/src/bin/git_remote_nostr/push.rs +++ b/src/bin/git_remote_nostr/push.rs @@ -14,6 +14,7 @@ use git_events::{ }; use git2::{Oid, Repository}; use ngit::{ + accept_maintainership::accept_maintainership_with_defaults, client::{self, get_event_from_cache_by_id}, git::{self, nostr_url::NostrUrlDecoded}, git_events::{ @@ -50,7 +51,7 @@ pub async fn run_push( repo_ref: &RepoRef, stdin: &Stdin, initial_refspec: &str, - client: &Client, + client: &mut Client, list_outputs: Option, bool)>>, title_description: Option<(String, String)>, ) -> Result<()> { @@ -127,7 +128,7 @@ pub async fn run_push( repo_ref, &git_state_refspecs, &proposal_refspecs, - client, + client, // &mut Client existing_state, &term, title_description.as_ref(), @@ -182,7 +183,7 @@ async fn create_and_publish_events_and_proposals( repo_ref: &RepoRef, git_server_refspecs: &Vec, proposal_refspecs: &Vec, - client: &Client, + client: &mut Client, existing_state: HashMap, term: &Term, title_description: Option<&(String, String)>, @@ -216,16 +217,14 @@ async fn create_and_publish_events_and_proposals( .clone() .is_some_and(|ms| ms.contains(&user_ref.public_key)) { - for refspec in git_server_refspecs { - let (_, to) = refspec_to_from_to(refspec).unwrap(); - eprintln!( - "error {to} you have been offered co-maintainership of '{}'. to accept, run `ngit init` which will publish your own repository announcement. use `ngit init -d` to accept with defaults and no interactive prompts.", - repo_ref.name, - ); - } - if proposal_refspecs.is_empty() { - return Ok((vec![], true)); - } + // Auto-accept co-maintainership: publish the user's own announcement + // with defaults before proceeding with the push. The announcement is + // required (not just for consent, but to prevent scammers from + // attributing a person's state events to a fake project with the same + // identifier). See docs/design/co-maintainer-announcement-rationale.md. + accept_maintainership_with_defaults(git_repo, repo_ref, &user_ref, client, &signer) + .await + .context("failed to auto-accept co-maintainership")?; } let mut events = vec![]; diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs index 39c8e8e..d24a41e 100644 --- a/src/bin/ngit/sub_commands/init.rs +++ b/src/bin/ngit/sub_commands/init.rs @@ -3,19 +3,14 @@ use std::{ env, process::{Command, Stdio}, str::FromStr, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, AtomicU64, Ordering}, - }, - time::Duration, + sync::Arc, }; use anyhow::{Context, Result, bail}; use console::{Style, Term}; -use futures::future::join_all; use git2::Oid; -use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; use ngit::{ + accept_maintainership::{grasp_servers_from_user_or_fallback, wait_for_grasp_servers}, cli_interactor::{ PromptChoiceParms, PromptConfirmParms, cli_error, multi_select_with_custom_value, show_multi_input_prompt_success, @@ -25,8 +20,8 @@ use ngit::{ git::nostr_url::{CloneUrl, NostrUrlDecoded}, list::list_from_remote, repo_ref::{ - detect_existing_grasp_servers, extract_npub, extract_pks, - format_grasp_server_url_as_relay_url, is_grasp_server_clone_url, + apply_grasp_infrastructure, detect_existing_grasp_servers, extract_npub, extract_pks, + format_grasp_server_url_as_relay_url, is_grasp_server_clone_url, latest_event_repo_ref, normalize_grasp_server_url, save_repo_config_to_yaml, }, repo_state::RepoState, @@ -129,16 +124,6 @@ fn my_event_repo_ref(repo_ref: &RepoRef, my_pubkey: &PublicKey) -> Option Option { - repo_ref - .events - .values() - .max_by_key(|e| e.created_at) - .and_then(|e| RepoRef::try_from((e.clone(), None)).ok()) -} - /// Check if a grasp-format clone URL belongs to the given public key. fn is_my_grasp_clone_url(url: &str, my_pubkey: &PublicKey) -> bool { if !is_grasp_server_clone_url(url) { @@ -261,60 +246,6 @@ fn resolve_hashtags(args_hashtag: &[String], state: &InitState) -> Result, - relays: &mut Vec, - public_key: &PublicKey, - identifier: &str, -) -> Result<()> { - for (grasp_relay_insert_idx, grasp_server) in grasp_servers.iter().enumerate() { - // Always add grasp-derived clone URL - let clone_url = format_grasp_server_url_as_clone_url(grasp_server, public_key, identifier)?; - - let grasp_server_clone_root = if clone_url.contains("https://") { - format!("https://{grasp_server}") - } else { - grasp_server.to_string() - }; - - let matching_positions: Vec = git_servers - .iter() - .enumerate() - .filter_map(|(idx, url)| { - if url.contains(&grasp_server_clone_root) { - Some(idx) - } else { - None - } - }) - .collect(); - - if matching_positions.is_empty() { - git_servers.push(clone_url); - } else { - git_servers[matching_positions[0]] = clone_url; - for &position in matching_positions.iter().skip(1).rev() { - git_servers.remove(position); - } - } - - // Prepend grasp-derived relay in order (for relay hint) so that the - // first grasp server in the list ends up at relays[0]. - let relay_url = format_grasp_server_url_as_relay_url(grasp_server)?; - if !relays.contains(&relay_url) { - relays.insert(grasp_relay_insert_idx, relay_url); - } - } - Ok(()) -} - /// Resolve which grasp servers to use. Handles flag overrides, detection from /// existing URLs, user grasp list / system fallbacks, and interactive /// prompting. @@ -392,26 +323,6 @@ fn resolve_grasp_servers( Ok(selected) } -fn grasp_servers_from_user_or_fallback( - user_ref: &ngit::login::user::UserRef, - client: &Client, -) -> Vec { - if user_ref.grasp_list.urls.is_empty() { - client - .get_grasp_default_set() - .iter() - .map(std::string::ToString::to_string) - .collect() - } else { - user_ref - .grasp_list - .urls - .iter() - .map(std::string::ToString::to_string) - .collect() - } -} - // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- @@ -1626,24 +1537,6 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { .await } -fn format_grasp_server_url_as_clone_url( - url: &str, - public_key: &PublicKey, - identifier: &str, -) -> Result { - let grasp_server_url = normalize_grasp_server_url(url)?; - if grasp_server_url.contains("http://") { - return Ok(format!( - "{grasp_server_url}/{}/{identifier}.git", - public_key.to_bech32()? - )); - } - Ok(format!( - "https://{grasp_server_url}/{}/{identifier}.git", - public_key.to_bech32()? - )) -} - fn parse_relay_url(s: &str) -> Result { // Attempt to parse the original string match RelayUrl::parse(s) { @@ -1733,300 +1626,3 @@ fn run_ngit_sync() -> Result<()> { bail!("ngit sync process exited with an error: {exit_status}"); } } - -fn check_git_server_ready(git_repo_path: &std::path::Path, git_server_url: &str) -> bool { - let Ok(git_repo) = git2::Repository::open(git_repo_path) else { - return false; - }; - let Ok(mut remote) = git_repo.remote_anonymous(git_server_url) else { - return false; - }; - match remote.connect(git2::Direction::Fetch) { - Ok(()) => { - let _ = remote.disconnect(); - true - } - Err(_) => false, - } -} - -/// Holds the final style+message for a bar that completed before the detail -/// view was revealed. -struct DeferredServerFinish { - bar: ProgressBar, - style: ProgressStyle, - message: String, -} - -struct ServerRevealState { - revealed: AtomicBool, - deferred: Mutex>, -} - -struct PollContext { - timeout_secs: u64, - total: u64, - ready_count: Arc, - spinner_pb: ProgressBar, - reveal_state: Arc, -} - -fn create_server_bars(clone_urls: &[String], detail_multi: &MultiProgress) -> Vec { - let waiting_style = ProgressStyle::with_template(" {spinner} {msg}") - .unwrap() - .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"); - clone_urls - .iter() - .map(|url| { - let name = url - .trim_start_matches("https://") - .trim_start_matches("http://") - .to_string(); - detail_multi.add( - ProgressBar::new_spinner() - .with_style(waiting_style.clone()) - .with_message( - console::style(format!("{name} - waiting")) - .for_stderr() - .dim() - .to_string(), - ), - ) - }) - .collect() -} - -fn spawn_expand_timer( - expand_delay_ms: u64, - spinner_pb: ProgressBar, - detail_multi: MultiProgress, - heading_bar: ProgressBar, - reveal_state: Arc, - server_bars: Vec, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(expand_delay_ms)).await; - spinner_pb.finish_and_clear(); - detail_multi.set_draw_target(ProgressDrawTarget::stderr()); - heading_bar.finish_with_message("waiting for servers to create bare git repo..."); - let mut deferred = reveal_state.deferred.lock().unwrap(); - reveal_state.revealed.store(true, Ordering::Release); - for df in deferred.drain(..) { - df.bar.set_style(df.style); - df.bar.finish_with_message(df.message); - } - for bar in &server_bars { - if !bar.is_finished() { - bar.enable_steady_tick(Duration::from_millis(100)); - } - } - }) -} - -fn finalize_spinner(all_ready: bool, spinner_pb: &ProgressBar, final_ready: u64, total: u64) { - if all_ready { - spinner_pb.finish_and_clear(); - } else { - spinner_pb.set_style(ProgressStyle::with_template("{msg}").unwrap()); - spinner_pb.finish_with_message(format!( - "timed out waiting for servers to create bare git repo ({final_ready}/{total} - complete), proceeding anyway" - )); - } -} - -fn finish_server_bar( - bar: &ProgressBar, - style: ProgressStyle, - message: String, - reveal_state: &Arc, -) { - let mut deferred = reveal_state.deferred.lock().unwrap(); - if reveal_state.revealed.load(Ordering::Acquire) { - drop(deferred); - bar.set_style(style); - bar.finish_with_message(message); - } else { - // Style is set now so the timer can drain it correctly; finish is - // deferred until the detail view becomes visible. - bar.set_style(style.clone()); - deferred.push(DeferredServerFinish { - bar: bar.clone(), - style, - message, - }); - } -} - -async fn poll_single_server( - url: String, - git_repo_path: std::path::PathBuf, - bar: ProgressBar, - ctx: Arc, -) -> bool { - let poll_interval = Duration::from_millis(500); - let deadline = tokio::time::Instant::now() + Duration::from_secs(ctx.timeout_secs); - let mut ready = false; - loop { - let is_ready = tokio::task::spawn_blocking({ - let url = url.clone(); - let path = git_repo_path.clone(); - move || check_git_server_ready(&path, &url) - }) - .await - .unwrap_or(false); - - if is_ready { - ready = true; - break; - } - - if tokio::time::Instant::now() >= deadline { - break; - } - - tokio::time::sleep(poll_interval).await; - } - - let count = if ready { - ctx.ready_count.fetch_add(1, Ordering::Relaxed) + 1 - } else { - ctx.ready_count.load(Ordering::Relaxed) - }; - - ctx.spinner_pb.set_message(format!( - "waiting for servers to create bare git repo... ({count}/{total} - complete)", - total = ctx.total - )); - - let name = url - .trim_start_matches("https://") - .trim_start_matches("http://") - .to_string(); - if ready { - let style = ProgressStyle::with_template(&format!( - " {} {{msg}}", - console::style("✔").for_stderr().green() - )) - .unwrap(); - let msg = console::style(format!("{name} - ready")) - .for_stderr() - .green() - .to_string(); - finish_server_bar(&bar, style, msg, &ctx.reveal_state); - } else { - let style = ProgressStyle::with_template(&format!( - " {} {{msg}}", - console::style("✘").for_stderr().red() - )) - .unwrap(); - let msg = console::style(format!("{name} - timeout")) - .for_stderr() - .red() - .to_string(); - finish_server_bar(&bar, style, msg, &ctx.reveal_state); - } - - ready -} - -/// Poll grasp servers in parallel until all are ready or timeout is reached. -/// -/// Shows a concise spinner with `x/y - complete` progress. After 5s without -/// all servers responding, expands to show per-server status bars (including -/// any that already finished). Times out after 15s (2s in tests) and proceeds -/// anyway rather than failing. -async fn wait_for_grasp_servers( - git_repo: &Repo, - grasp_servers: &[String], - public_key: &PublicKey, - identifier: &str, -) -> Result<()> { - let clone_urls: Vec = grasp_servers - .iter() - .filter_map(|gs| format_grasp_server_url_as_clone_url(gs, public_key, identifier).ok()) - .collect(); - - if clone_urls.is_empty() { - return Ok(()); - } - - let is_test = std::env::var("NGITTEST").is_ok(); - let timeout_secs: u64 = if is_test { 2 } else { 15 }; - let expand_delay_ms: u64 = if is_test { 500 } else { 5000 }; - let total = clone_urls.len() as u64; - - // Spinner shown immediately with x/y count - let spinner_multi = MultiProgress::new(); - let spinner_pb = spinner_multi.add( - ProgressBar::new_spinner() - .with_style( - ProgressStyle::with_template("{spinner} {msg}") - .unwrap() - .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"), - ) - .with_message(format!( - "waiting for servers to create bare git repo... (0/{total} - complete)" - )), - ); - spinner_pb.enable_steady_tick(Duration::from_millis(100)); - - // Detail MultiProgress starts hidden; revealed after expand_delay_ms. - // A heading bar is pre-added at position 0 so it holds its slot before - // any per-server bars are added. - let detail_multi = MultiProgress::with_draw_target(ProgressDrawTarget::hidden()); - let heading_bar = detail_multi - .add(ProgressBar::new(0).with_style(ProgressStyle::with_template("{msg}").unwrap())); - - let ready_count = Arc::new(AtomicU64::new(0)); - let reveal_state = Arc::new(ServerRevealState { - revealed: AtomicBool::new(false), - deferred: Mutex::new(Vec::new()), - }); - - let server_bars = create_server_bars(&clone_urls, &detail_multi); - - let timer_handle = spawn_expand_timer( - expand_delay_ms, - spinner_pb.clone(), - detail_multi.clone(), - heading_bar, - reveal_state.clone(), - server_bars.clone(), - ); - - // Poll each server in parallel - let git_repo_path = git_repo.get_path()?.to_path_buf(); - let poll_ctx = Arc::new(PollContext { - timeout_secs, - total, - ready_count: ready_count.clone(), - spinner_pb: spinner_pb.clone(), - reveal_state: reveal_state.clone(), - }); - let futures: Vec<_> = clone_urls - .iter() - .enumerate() - .map(|(i, url)| { - poll_single_server( - url.clone(), - git_repo_path.clone(), - server_bars[i].clone(), - poll_ctx.clone(), - ) - }) - .collect(); - - let results = join_all(futures).await; - let final_ready = ready_count.load(Ordering::Relaxed); - - timer_handle.abort(); - - if reveal_state.revealed.load(Ordering::Acquire) { - let _ = detail_multi.clear(); - } - - let all_ready = results.iter().all(|&r| r); - finalize_spinner(all_ready, &spinner_pb, final_ready, total); - - Ok(()) -} -- cgit v1.2.3