diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-20 20:09:09 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-20 21:21:48 +0000 |
| commit | 64747526c9f6ab43f9dac461d056bb42992573b4 (patch) | |
| tree | c2506828ae7b188e3e4b569cd73202ec37779278 /src/lib | |
| parent | 365dfb9a1e986b68bc2389e2a3cd3da30b0d4636 (diff) | |
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.
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/accept_maintainership.rs | 529 | ||||
| -rw-r--r-- | src/lib/mod.rs | 1 | ||||
| -rw-r--r-- | src/lib/repo_ref.rs | 65 |
3 files changed, 595 insertions, 0 deletions
diff --git a/src/lib/accept_maintainership.rs b/src/lib/accept_maintainership.rs new file mode 100644 index 0000000..173d1a5 --- /dev/null +++ b/src/lib/accept_maintainership.rs | |||
| @@ -0,0 +1,529 @@ | |||
| 1 | //! Auto-accept co-maintainership on push. | ||
| 2 | //! | ||
| 3 | //! When a user has been offered co-maintainership (they appear in another | ||
| 4 | //! maintainer's `maintainers` tag but have never published their own | ||
| 5 | //! Kind:30617 announcement), pushing would normally fail. This module | ||
| 6 | //! provides `accept_maintainership_with_defaults`, called by the push path | ||
| 7 | //! to silently publish the co-maintainer's announcement with sensible | ||
| 8 | //! defaults before continuing the push. | ||
| 9 | //! | ||
| 10 | //! See `docs/design/co-maintainer-announcement-rationale.md` for why the | ||
| 11 | //! announcement is required (scam-protection) even though the fetch/read side | ||
| 12 | //! already trusts state events from all listed maintainers. | ||
| 13 | use std::{ | ||
| 14 | collections::HashMap, | ||
| 15 | sync::{ | ||
| 16 | Arc, Mutex, | ||
| 17 | atomic::{AtomicBool, AtomicU64, Ordering}, | ||
| 18 | }, | ||
| 19 | time::Duration, | ||
| 20 | }; | ||
| 21 | |||
| 22 | use anyhow::{Context, Result}; | ||
| 23 | use futures::future::join_all; | ||
| 24 | use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; | ||
| 25 | use nostr::{ | ||
| 26 | PublicKey, ToBech32, | ||
| 27 | nips::{nip01::Coordinate, nip19::Nip19Coordinate}, | ||
| 28 | }; | ||
| 29 | use nostr_sdk::{Kind, NostrSigner, RelayUrl}; | ||
| 30 | |||
| 31 | #[cfg(not(test))] | ||
| 32 | use crate::client::Client; | ||
| 33 | #[cfg(test)] | ||
| 34 | use crate::client::MockConnect; | ||
| 35 | use crate::{ | ||
| 36 | client::{Connect, send_events}, | ||
| 37 | git::{Repo, RepoActions}, | ||
| 38 | login::user::UserRef, | ||
| 39 | repo_ref::{ | ||
| 40 | RepoRef, apply_grasp_infrastructure, format_grasp_server_url_as_clone_url, | ||
| 41 | latest_event_repo_ref, | ||
| 42 | }, | ||
| 43 | }; | ||
| 44 | |||
| 45 | // --------------------------------------------------------------------------- | ||
| 46 | // Public entry point | ||
| 47 | // --------------------------------------------------------------------------- | ||
| 48 | |||
| 49 | /// Publish the co-maintainer's own Kind:30617 announcement with defaults and | ||
| 50 | /// update the local git config / origin remote to point to it. | ||
| 51 | /// | ||
| 52 | /// This is called automatically from the push path when the pushing user is | ||
| 53 | /// listed as a maintainer but has not yet published their own announcement. | ||
| 54 | /// No interactive prompts are shown — all values come from the existing | ||
| 55 | /// announcement and the user's saved grasp server / relay preferences. | ||
| 56 | pub async fn accept_maintainership_with_defaults( | ||
| 57 | git_repo: &Repo, | ||
| 58 | repo_ref: &RepoRef, | ||
| 59 | user_ref: &UserRef, | ||
| 60 | #[cfg(test)] client: &mut MockConnect, | ||
| 61 | #[cfg(not(test))] client: &mut Client, | ||
| 62 | signer: &Arc<dyn NostrSigner>, | ||
| 63 | ) -> Result<()> { | ||
| 64 | let my_pubkey = &user_ref.public_key; | ||
| 65 | let identifier = &repo_ref.identifier; | ||
| 66 | |||
| 67 | // --- Step 1: resolve infrastructure --- | ||
| 68 | |||
| 69 | let selected_grasp_servers = grasp_servers_from_user_or_fallback(user_ref, client); | ||
| 70 | |||
| 71 | let mut git_servers: Vec<String> = vec![]; | ||
| 72 | let mut relay_strings: Vec<String> = client | ||
| 73 | .get_relay_default_set() | ||
| 74 | .iter() | ||
| 75 | .map(std::string::ToString::to_string) | ||
| 76 | .collect(); | ||
| 77 | |||
| 78 | apply_grasp_infrastructure( | ||
| 79 | &selected_grasp_servers, | ||
| 80 | &mut git_servers, | ||
| 81 | &mut relay_strings, | ||
| 82 | my_pubkey, | ||
| 83 | identifier, | ||
| 84 | )?; | ||
| 85 | |||
| 86 | let relays: Vec<RelayUrl> = relay_strings | ||
| 87 | .iter() | ||
| 88 | .filter_map(|r| RelayUrl::parse(r).ok()) | ||
| 89 | .collect(); | ||
| 90 | |||
| 91 | // --- Step 2: resolve shared metadata from latest existing event --- | ||
| 92 | |||
| 93 | let latest = latest_event_repo_ref(repo_ref); | ||
| 94 | let name = latest | ||
| 95 | .as_ref() | ||
| 96 | .map(|lr| lr.name.clone()) | ||
| 97 | .unwrap_or_else(|| identifier.clone()); | ||
| 98 | let description = latest | ||
| 99 | .as_ref() | ||
| 100 | .map(|lr| lr.description.clone()) | ||
| 101 | .unwrap_or_default(); | ||
| 102 | let web = latest.as_ref().map(|lr| lr.web.clone()).unwrap_or_default(); | ||
| 103 | let hashtags = latest | ||
| 104 | .as_ref() | ||
| 105 | .map(|lr| lr.hashtags.clone()) | ||
| 106 | .unwrap_or_default(); | ||
| 107 | let blossoms = latest | ||
| 108 | .as_ref() | ||
| 109 | .map(|lr| lr.blossoms.clone()) | ||
| 110 | .unwrap_or_default(); | ||
| 111 | let root_commit = latest | ||
| 112 | .as_ref() | ||
| 113 | .map(|lr| lr.root_commit.clone()) | ||
| 114 | .filter(|c| !c.is_empty()) | ||
| 115 | .unwrap_or_else(|| repo_ref.root_commit.clone()); | ||
| 116 | |||
| 117 | // --- Step 3: maintainers = [me, trusted_maintainer] --- | ||
| 118 | |||
| 119 | let mut maintainers = vec![*my_pubkey]; | ||
| 120 | if repo_ref.trusted_maintainer != *my_pubkey { | ||
| 121 | maintainers.push(repo_ref.trusted_maintainer); | ||
| 122 | } | ||
| 123 | |||
| 124 | // --- Step 4: build RepoRef --- | ||
| 125 | |||
| 126 | let my_repo_ref = RepoRef { | ||
| 127 | identifier: identifier.clone(), | ||
| 128 | name: name.clone(), | ||
| 129 | description, | ||
| 130 | root_commit, | ||
| 131 | git_server: git_servers, | ||
| 132 | web, | ||
| 133 | relays: relays.clone(), | ||
| 134 | blossoms, | ||
| 135 | hashtags, | ||
| 136 | trusted_maintainer: *my_pubkey, | ||
| 137 | maintainers_without_annoucnement: None, | ||
| 138 | maintainers, | ||
| 139 | events: HashMap::new(), | ||
| 140 | nostr_git_url: None, | ||
| 141 | }; | ||
| 142 | |||
| 143 | // --- Step 5: sign and publish the announcement --- | ||
| 144 | |||
| 145 | eprintln!( | ||
| 146 | "info: accepting co-maintainership of '{}' with defaults", | ||
| 147 | name | ||
| 148 | ); | ||
| 149 | eprintln!("info: publishing your repository announcement to nostr..."); | ||
| 150 | |||
| 151 | let repo_event = my_repo_ref.to_event(signer).await?; | ||
| 152 | |||
| 153 | client.set_signer(signer.clone()).await; | ||
| 154 | |||
| 155 | send_events( | ||
| 156 | client, | ||
| 157 | Some(git_repo.get_path()?), | ||
| 158 | vec![repo_event], | ||
| 159 | user_ref.relays.write(), | ||
| 160 | relays.clone(), | ||
| 161 | false, // no spinner — we are mid-push | ||
| 162 | true, // silent | ||
| 163 | ) | ||
| 164 | .await | ||
| 165 | .context("failed to publish co-maintainer announcement")?; | ||
| 166 | |||
| 167 | // --- Step 6: wait for grasp server provisioning --- | ||
| 168 | |||
| 169 | if !selected_grasp_servers.is_empty() { | ||
| 170 | wait_for_grasp_servers(git_repo, &selected_grasp_servers, my_pubkey, identifier).await?; | ||
| 171 | } | ||
| 172 | |||
| 173 | // --- Step 7: update nostr.repo git config --- | ||
| 174 | |||
| 175 | git_repo | ||
| 176 | .save_git_config_item( | ||
| 177 | "nostr.repo", | ||
| 178 | &Nip19Coordinate { | ||
| 179 | coordinate: Coordinate { | ||
| 180 | kind: Kind::GitRepoAnnouncement, | ||
| 181 | public_key: *my_pubkey, | ||
| 182 | identifier: identifier.clone(), | ||
| 183 | }, | ||
| 184 | relays: vec![], | ||
| 185 | } | ||
| 186 | .to_bech32()?, | ||
| 187 | false, | ||
| 188 | ) | ||
| 189 | .context("failed to update nostr.repo git config")?; | ||
| 190 | |||
| 191 | // --- Step 8: update origin remote --- | ||
| 192 | |||
| 193 | let nostr_url = my_repo_ref.to_nostr_git_url(&Some(git_repo)).to_string(); | ||
| 194 | if git_repo.git_repo.find_remote("origin").is_ok() { | ||
| 195 | git_repo | ||
| 196 | .git_repo | ||
| 197 | .remote_set_url("origin", &nostr_url) | ||
| 198 | .context("failed to update origin remote")?; | ||
| 199 | } else { | ||
| 200 | git_repo | ||
| 201 | .git_repo | ||
| 202 | .remote("origin", &nostr_url) | ||
| 203 | .context("failed to set origin remote")?; | ||
| 204 | } | ||
| 205 | |||
| 206 | eprintln!("info: co-maintainership accepted. run `ngit init` to customise your announcement."); | ||
| 207 | |||
| 208 | Ok(()) | ||
| 209 | } | ||
| 210 | |||
| 211 | // --------------------------------------------------------------------------- | ||
| 212 | // Grasp server helpers | ||
| 213 | // --------------------------------------------------------------------------- | ||
| 214 | |||
| 215 | /// Return the user's saved grasp servers, falling back to client defaults. | ||
| 216 | pub fn grasp_servers_from_user_or_fallback( | ||
| 217 | user_ref: &UserRef, | ||
| 218 | #[cfg(test)] client: &MockConnect, | ||
| 219 | #[cfg(not(test))] client: &Client, | ||
| 220 | ) -> Vec<String> { | ||
| 221 | if user_ref.grasp_list.urls.is_empty() { | ||
| 222 | client | ||
| 223 | .get_grasp_default_set() | ||
| 224 | .iter() | ||
| 225 | .map(std::string::ToString::to_string) | ||
| 226 | .collect() | ||
| 227 | } else { | ||
| 228 | user_ref | ||
| 229 | .grasp_list | ||
| 230 | .urls | ||
| 231 | .iter() | ||
| 232 | .map(std::string::ToString::to_string) | ||
| 233 | .collect() | ||
| 234 | } | ||
| 235 | } | ||
| 236 | |||
| 237 | // --------------------------------------------------------------------------- | ||
| 238 | // Grasp server provisioning poll | ||
| 239 | // --------------------------------------------------------------------------- | ||
| 240 | |||
| 241 | /// Holds the final style + message for a bar that completed before the detail | ||
| 242 | /// view was revealed. | ||
| 243 | struct DeferredServerFinish { | ||
| 244 | bar: ProgressBar, | ||
| 245 | style: ProgressStyle, | ||
| 246 | message: String, | ||
| 247 | } | ||
| 248 | |||
| 249 | struct ServerRevealState { | ||
| 250 | revealed: AtomicBool, | ||
| 251 | deferred: Mutex<Vec<DeferredServerFinish>>, | ||
| 252 | } | ||
| 253 | |||
| 254 | struct PollContext { | ||
| 255 | timeout_secs: u64, | ||
| 256 | total: u64, | ||
| 257 | ready_count: Arc<AtomicU64>, | ||
| 258 | spinner_pb: ProgressBar, | ||
| 259 | reveal_state: Arc<ServerRevealState>, | ||
| 260 | } | ||
| 261 | |||
| 262 | fn check_git_server_ready(git_repo_path: &std::path::Path, git_server_url: &str) -> bool { | ||
| 263 | let Ok(git_repo) = git2::Repository::open(git_repo_path) else { | ||
| 264 | return false; | ||
| 265 | }; | ||
| 266 | let Ok(mut remote) = git_repo.remote_anonymous(git_server_url) else { | ||
| 267 | return false; | ||
| 268 | }; | ||
| 269 | match remote.connect(git2::Direction::Fetch) { | ||
| 270 | Ok(()) => { | ||
| 271 | let _ = remote.disconnect(); | ||
| 272 | true | ||
| 273 | } | ||
| 274 | Err(_) => false, | ||
| 275 | } | ||
| 276 | } | ||
| 277 | |||
| 278 | fn create_server_bars(clone_urls: &[String], detail_multi: &MultiProgress) -> Vec<ProgressBar> { | ||
| 279 | let waiting_style = ProgressStyle::with_template(" {spinner} {msg}") | ||
| 280 | .unwrap() | ||
| 281 | .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"); | ||
| 282 | clone_urls | ||
| 283 | .iter() | ||
| 284 | .map(|url| { | ||
| 285 | let name = url | ||
| 286 | .trim_start_matches("https://") | ||
| 287 | .trim_start_matches("http://") | ||
| 288 | .to_string(); | ||
| 289 | detail_multi.add( | ||
| 290 | ProgressBar::new_spinner() | ||
| 291 | .with_style(waiting_style.clone()) | ||
| 292 | .with_message( | ||
| 293 | console::style(format!("{name} - waiting")) | ||
| 294 | .for_stderr() | ||
| 295 | .dim() | ||
| 296 | .to_string(), | ||
| 297 | ), | ||
| 298 | ) | ||
| 299 | }) | ||
| 300 | .collect() | ||
| 301 | } | ||
| 302 | |||
| 303 | fn spawn_expand_timer( | ||
| 304 | expand_delay_ms: u64, | ||
| 305 | spinner_pb: ProgressBar, | ||
| 306 | detail_multi: MultiProgress, | ||
| 307 | heading_bar: ProgressBar, | ||
| 308 | reveal_state: Arc<ServerRevealState>, | ||
| 309 | server_bars: Vec<ProgressBar>, | ||
| 310 | ) -> tokio::task::JoinHandle<()> { | ||
| 311 | tokio::spawn(async move { | ||
| 312 | tokio::time::sleep(Duration::from_millis(expand_delay_ms)).await; | ||
| 313 | spinner_pb.finish_and_clear(); | ||
| 314 | detail_multi.set_draw_target(ProgressDrawTarget::stderr()); | ||
| 315 | heading_bar.finish_with_message("waiting for servers to create bare git repo..."); | ||
| 316 | let mut deferred = reveal_state.deferred.lock().unwrap(); | ||
| 317 | reveal_state.revealed.store(true, Ordering::Release); | ||
| 318 | for df in deferred.drain(..) { | ||
| 319 | df.bar.set_style(df.style); | ||
| 320 | df.bar.finish_with_message(df.message); | ||
| 321 | } | ||
| 322 | for bar in &server_bars { | ||
| 323 | if !bar.is_finished() { | ||
| 324 | bar.enable_steady_tick(Duration::from_millis(100)); | ||
| 325 | } | ||
| 326 | } | ||
| 327 | }) | ||
| 328 | } | ||
| 329 | |||
| 330 | fn finalize_spinner(all_ready: bool, spinner_pb: &ProgressBar, final_ready: u64, total: u64) { | ||
| 331 | if all_ready { | ||
| 332 | spinner_pb.finish_and_clear(); | ||
| 333 | } else { | ||
| 334 | spinner_pb.set_style(ProgressStyle::with_template("{msg}").unwrap()); | ||
| 335 | spinner_pb.finish_with_message(format!( | ||
| 336 | "timed out waiting for servers to create bare git repo ({final_ready}/{total} - complete), proceeding anyway" | ||
| 337 | )); | ||
| 338 | } | ||
| 339 | } | ||
| 340 | |||
| 341 | fn finish_server_bar( | ||
| 342 | bar: &ProgressBar, | ||
| 343 | style: ProgressStyle, | ||
| 344 | message: String, | ||
| 345 | reveal_state: &Arc<ServerRevealState>, | ||
| 346 | ) { | ||
| 347 | let mut deferred = reveal_state.deferred.lock().unwrap(); | ||
| 348 | if reveal_state.revealed.load(Ordering::Acquire) { | ||
| 349 | drop(deferred); | ||
| 350 | bar.set_style(style); | ||
| 351 | bar.finish_with_message(message); | ||
| 352 | } else { | ||
| 353 | bar.set_style(style.clone()); | ||
| 354 | deferred.push(DeferredServerFinish { | ||
| 355 | bar: bar.clone(), | ||
| 356 | style, | ||
| 357 | message, | ||
| 358 | }); | ||
| 359 | } | ||
| 360 | } | ||
| 361 | |||
| 362 | async fn poll_single_server( | ||
| 363 | url: String, | ||
| 364 | git_repo_path: std::path::PathBuf, | ||
| 365 | bar: ProgressBar, | ||
| 366 | ctx: Arc<PollContext>, | ||
| 367 | ) -> bool { | ||
| 368 | let poll_interval = Duration::from_millis(500); | ||
| 369 | let deadline = tokio::time::Instant::now() + Duration::from_secs(ctx.timeout_secs); | ||
| 370 | let mut ready = false; | ||
| 371 | loop { | ||
| 372 | let is_ready = tokio::task::spawn_blocking({ | ||
| 373 | let url = url.clone(); | ||
| 374 | let path = git_repo_path.clone(); | ||
| 375 | move || check_git_server_ready(&path, &url) | ||
| 376 | }) | ||
| 377 | .await | ||
| 378 | .unwrap_or(false); | ||
| 379 | |||
| 380 | if is_ready { | ||
| 381 | ready = true; | ||
| 382 | break; | ||
| 383 | } | ||
| 384 | |||
| 385 | if tokio::time::Instant::now() >= deadline { | ||
| 386 | break; | ||
| 387 | } | ||
| 388 | |||
| 389 | tokio::time::sleep(poll_interval).await; | ||
| 390 | } | ||
| 391 | |||
| 392 | let count = if ready { | ||
| 393 | ctx.ready_count.fetch_add(1, Ordering::Relaxed) + 1 | ||
| 394 | } else { | ||
| 395 | ctx.ready_count.load(Ordering::Relaxed) | ||
| 396 | }; | ||
| 397 | |||
| 398 | ctx.spinner_pb.set_message(format!( | ||
| 399 | "waiting for servers to create bare git repo... ({count}/{total} - complete)", | ||
| 400 | total = ctx.total | ||
| 401 | )); | ||
| 402 | |||
| 403 | let name = url | ||
| 404 | .trim_start_matches("https://") | ||
| 405 | .trim_start_matches("http://") | ||
| 406 | .to_string(); | ||
| 407 | if ready { | ||
| 408 | let style = ProgressStyle::with_template(&format!( | ||
| 409 | " {} {{msg}}", | ||
| 410 | console::style("✔").for_stderr().green() | ||
| 411 | )) | ||
| 412 | .unwrap(); | ||
| 413 | let msg = console::style(format!("{name} - ready")) | ||
| 414 | .for_stderr() | ||
| 415 | .green() | ||
| 416 | .to_string(); | ||
| 417 | finish_server_bar(&bar, style, msg, &ctx.reveal_state); | ||
| 418 | } else { | ||
| 419 | let style = ProgressStyle::with_template(&format!( | ||
| 420 | " {} {{msg}}", | ||
| 421 | console::style("✘").for_stderr().red() | ||
| 422 | )) | ||
| 423 | .unwrap(); | ||
| 424 | let msg = console::style(format!("{name} - timeout")) | ||
| 425 | .for_stderr() | ||
| 426 | .red() | ||
| 427 | .to_string(); | ||
| 428 | finish_server_bar(&bar, style, msg, &ctx.reveal_state); | ||
| 429 | } | ||
| 430 | |||
| 431 | ready | ||
| 432 | } | ||
| 433 | |||
| 434 | /// Poll grasp servers in parallel until all are ready or timeout is reached. | ||
| 435 | /// | ||
| 436 | /// Shows a concise spinner with `x/y - complete` progress. After 5 s without | ||
| 437 | /// all servers responding, expands to show per-server status bars (including | ||
| 438 | /// any that already finished). Times out after 15 s (2 s in tests) and | ||
| 439 | /// proceeds rather than failing. | ||
| 440 | pub async fn wait_for_grasp_servers( | ||
| 441 | git_repo: &Repo, | ||
| 442 | grasp_servers: &[String], | ||
| 443 | public_key: &PublicKey, | ||
| 444 | identifier: &str, | ||
| 445 | ) -> Result<()> { | ||
| 446 | let clone_urls: Vec<String> = grasp_servers | ||
| 447 | .iter() | ||
| 448 | .filter_map(|gs| format_grasp_server_url_as_clone_url(gs, public_key, identifier).ok()) | ||
| 449 | .collect(); | ||
| 450 | |||
| 451 | if clone_urls.is_empty() { | ||
| 452 | return Ok(()); | ||
| 453 | } | ||
| 454 | |||
| 455 | let is_test = std::env::var("NGITTEST").is_ok(); | ||
| 456 | let timeout_secs: u64 = if is_test { 2 } else { 15 }; | ||
| 457 | let expand_delay_ms: u64 = if is_test { 500 } else { 5000 }; | ||
| 458 | let total = clone_urls.len() as u64; | ||
| 459 | |||
| 460 | let spinner_multi = MultiProgress::new(); | ||
| 461 | let spinner_pb = spinner_multi.add( | ||
| 462 | ProgressBar::new_spinner() | ||
| 463 | .with_style( | ||
| 464 | ProgressStyle::with_template("{spinner} {msg}") | ||
| 465 | .unwrap() | ||
| 466 | .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"), | ||
| 467 | ) | ||
| 468 | .with_message(format!( | ||
| 469 | "waiting for servers to create bare git repo... (0/{total} - complete)" | ||
| 470 | )), | ||
| 471 | ); | ||
| 472 | spinner_pb.enable_steady_tick(Duration::from_millis(100)); | ||
| 473 | |||
| 474 | let detail_multi = MultiProgress::with_draw_target(ProgressDrawTarget::hidden()); | ||
| 475 | let heading_bar = detail_multi | ||
| 476 | .add(ProgressBar::new(0).with_style(ProgressStyle::with_template("{msg}").unwrap())); | ||
| 477 | |||
| 478 | let ready_count = Arc::new(AtomicU64::new(0)); | ||
| 479 | let reveal_state = Arc::new(ServerRevealState { | ||
| 480 | revealed: AtomicBool::new(false), | ||
| 481 | deferred: Mutex::new(Vec::new()), | ||
| 482 | }); | ||
| 483 | |||
| 484 | let server_bars = create_server_bars(&clone_urls, &detail_multi); | ||
| 485 | |||
| 486 | let timer_handle = spawn_expand_timer( | ||
| 487 | expand_delay_ms, | ||
| 488 | spinner_pb.clone(), | ||
| 489 | detail_multi.clone(), | ||
| 490 | heading_bar, | ||
| 491 | reveal_state.clone(), | ||
| 492 | server_bars.clone(), | ||
| 493 | ); | ||
| 494 | |||
| 495 | let git_repo_path = git_repo.get_path()?.to_path_buf(); | ||
| 496 | let poll_ctx = Arc::new(PollContext { | ||
| 497 | timeout_secs, | ||
| 498 | total, | ||
| 499 | ready_count: ready_count.clone(), | ||
| 500 | spinner_pb: spinner_pb.clone(), | ||
| 501 | reveal_state: reveal_state.clone(), | ||
| 502 | }); | ||
| 503 | let futures: Vec<_> = clone_urls | ||
| 504 | .iter() | ||
| 505 | .enumerate() | ||
| 506 | .map(|(i, url)| { | ||
| 507 | poll_single_server( | ||
| 508 | url.clone(), | ||
| 509 | git_repo_path.clone(), | ||
| 510 | server_bars[i].clone(), | ||
| 511 | poll_ctx.clone(), | ||
| 512 | ) | ||
| 513 | }) | ||
| 514 | .collect(); | ||
| 515 | |||
| 516 | let results = join_all(futures).await; | ||
| 517 | let final_ready = ready_count.load(Ordering::Relaxed); | ||
| 518 | |||
| 519 | timer_handle.abort(); | ||
| 520 | |||
| 521 | if reveal_state.revealed.load(Ordering::Acquire) { | ||
| 522 | let _ = detail_multi.clear(); | ||
| 523 | } | ||
| 524 | |||
| 525 | let all_ready = results.iter().all(|&r| r); | ||
| 526 | finalize_spinner(all_ready, &spinner_pb, final_ready, total); | ||
| 527 | |||
| 528 | Ok(()) | ||
| 529 | } | ||
diff --git a/src/lib/mod.rs b/src/lib/mod.rs index b388b23..1229e8c 100644 --- a/src/lib/mod.rs +++ b/src/lib/mod.rs | |||
| @@ -1,3 +1,4 @@ | |||
| 1 | pub mod accept_maintainership; | ||
| 1 | pub mod cli_interactor; | 2 | pub mod cli_interactor; |
| 2 | pub mod client; | 3 | pub mod client; |
| 3 | pub mod fetch; | 4 | pub mod fetch; |
diff --git a/src/lib/repo_ref.rs b/src/lib/repo_ref.rs index 9573238..c0f9136 100644 --- a/src/lib/repo_ref.rs +++ b/src/lib/repo_ref.rs | |||
| @@ -814,6 +814,71 @@ pub fn format_grasp_server_url_as_clone_url( | |||
| 814 | )) | 814 | )) |
| 815 | } | 815 | } |
| 816 | 816 | ||
| 817 | /// Find the latest announcement event (by `created_at`) across all maintainer | ||
| 818 | /// events and parse it into a `RepoRef` for shared metadata (name, description, | ||
| 819 | /// web, etc.). | ||
| 820 | pub fn latest_event_repo_ref(repo_ref: &RepoRef) -> Option<RepoRef> { | ||
| 821 | repo_ref | ||
| 822 | .events | ||
| 823 | .values() | ||
| 824 | .max_by_key(|e| e.created_at) | ||
| 825 | .and_then(|e| RepoRef::try_from((e.clone(), None)).ok()) | ||
| 826 | } | ||
| 827 | |||
| 828 | /// Derive clone-URLs and relay URLs from selected grasp servers. | ||
| 829 | /// | ||
| 830 | /// For each grasp server, adds or replaces the corresponding clone URL in | ||
| 831 | /// `git_servers` and prepends a relay URL in `relays`. Grasp-derived | ||
| 832 | /// infrastructure always takes priority — the other lists contain *additional* | ||
| 833 | /// infrastructure beyond what grasp servers provide. | ||
| 834 | pub fn apply_grasp_infrastructure( | ||
| 835 | grasp_servers: &[String], | ||
| 836 | git_servers: &mut Vec<String>, | ||
| 837 | relays: &mut Vec<String>, | ||
| 838 | public_key: &PublicKey, | ||
| 839 | identifier: &str, | ||
| 840 | ) -> Result<()> { | ||
| 841 | for (grasp_relay_insert_idx, grasp_server) in grasp_servers.iter().enumerate() { | ||
| 842 | // Always add grasp-derived clone URL | ||
| 843 | let clone_url = format_grasp_server_url_as_clone_url(grasp_server, public_key, identifier)?; | ||
| 844 | |||
| 845 | let grasp_server_clone_root = if clone_url.contains("https://") { | ||
| 846 | format!("https://{grasp_server}") | ||
| 847 | } else { | ||
| 848 | grasp_server.to_string() | ||
| 849 | }; | ||
| 850 | |||
| 851 | let matching_positions: Vec<usize> = git_servers | ||
| 852 | .iter() | ||
| 853 | .enumerate() | ||
| 854 | .filter_map(|(idx, url)| { | ||
| 855 | if url.contains(&grasp_server_clone_root) { | ||
| 856 | Some(idx) | ||
| 857 | } else { | ||
| 858 | None | ||
| 859 | } | ||
| 860 | }) | ||
| 861 | .collect(); | ||
| 862 | |||
| 863 | if matching_positions.is_empty() { | ||
| 864 | git_servers.push(clone_url); | ||
| 865 | } else { | ||
| 866 | git_servers[matching_positions[0]] = clone_url; | ||
| 867 | for &position in matching_positions.iter().skip(1).rev() { | ||
| 868 | git_servers.remove(position); | ||
| 869 | } | ||
| 870 | } | ||
| 871 | |||
| 872 | // Prepend grasp-derived relay in order (for relay hint) so that the | ||
| 873 | // first grasp server in the list ends up at relays[0]. | ||
| 874 | let relay_url = format_grasp_server_url_as_relay_url(grasp_server)?; | ||
| 875 | if !relays.contains(&relay_url) { | ||
| 876 | relays.insert(grasp_relay_insert_idx, relay_url); | ||
| 877 | } | ||
| 878 | } | ||
| 879 | Ok(()) | ||
| 880 | } | ||
| 881 | |||
| 817 | #[cfg(test)] | 882 | #[cfg(test)] |
| 818 | mod tests { | 883 | mod tests { |
| 819 | use test_utils::*; | 884 | use test_utils::*; |