diff options
Diffstat (limited to 'src/lib/accept_maintainership.rs')
| -rw-r--r-- | src/lib/accept_maintainership.rs | 529 |
1 files changed, 529 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 | } | ||