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 | |
| 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.
| -rw-r--r-- | CHANGELOG.md | 1 | ||||
| -rw-r--r-- | src/bin/git_remote_nostr/main.rs | 2 | ||||
| -rw-r--r-- | src/bin/git_remote_nostr/push.rs | 25 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/init.rs | 412 | ||||
| -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 |
7 files changed, 613 insertions, 422 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 75fe9a8..6d93f43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md | |||
| @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | |||
| 20 | 20 | ||
| 21 | ### Added | 21 | ### Added |
| 22 | 22 | ||
| 23 | - **Auto-accept co-maintainership on push**: when pushing to a repo where you are listed as a maintainer but have not yet published an announcement, ngit now automatically publishes your announcement (using your grasp servers or the trusted maintainer's as fallback) and provisions your grasp server instead of failing | ||
| 23 | - `ngit account login --signer-relay` - specify custom relays for nostrconnect (auto-prefixes with `wss://` if no scheme) | 24 | - `ngit account login --signer-relay` - specify custom relays for nostrconnect (auto-prefixes with `wss://` if no scheme) |
| 24 | - `ngit checkout <id>` - checkout a proposal branch by event-id or nevent | 25 | - `ngit checkout <id>` - checkout a proposal branch by event-id or nevent |
| 25 | - `ngit apply <id>` - apply proposal patches to current branch | 26 | - `ngit apply <id>` - apply proposal patches to current branch |
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<()> { | |||
| 203 | &repo_ref, | 203 | &repo_ref, |
| 204 | &stdin, | 204 | &stdin, |
| 205 | refspec, | 205 | refspec, |
| 206 | &client, | 206 | &mut client, |
| 207 | list_outputs.clone(), | 207 | list_outputs.clone(), |
| 208 | title_description, | 208 | title_description, |
| 209 | ) | 209 | ) |
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::{ | |||
| 14 | }; | 14 | }; |
| 15 | use git2::{Oid, Repository}; | 15 | use git2::{Oid, Repository}; |
| 16 | use ngit::{ | 16 | use ngit::{ |
| 17 | accept_maintainership::accept_maintainership_with_defaults, | ||
| 17 | client::{self, get_event_from_cache_by_id}, | 18 | client::{self, get_event_from_cache_by_id}, |
| 18 | git::{self, nostr_url::NostrUrlDecoded}, | 19 | git::{self, nostr_url::NostrUrlDecoded}, |
| 19 | git_events::{ | 20 | git_events::{ |
| @@ -50,7 +51,7 @@ pub async fn run_push( | |||
| 50 | repo_ref: &RepoRef, | 51 | repo_ref: &RepoRef, |
| 51 | stdin: &Stdin, | 52 | stdin: &Stdin, |
| 52 | initial_refspec: &str, | 53 | initial_refspec: &str, |
| 53 | client: &Client, | 54 | client: &mut Client, |
| 54 | list_outputs: Option<HashMap<String, (HashMap<String, String>, bool)>>, | 55 | list_outputs: Option<HashMap<String, (HashMap<String, String>, bool)>>, |
| 55 | title_description: Option<(String, String)>, | 56 | title_description: Option<(String, String)>, |
| 56 | ) -> Result<()> { | 57 | ) -> Result<()> { |
| @@ -127,7 +128,7 @@ pub async fn run_push( | |||
| 127 | repo_ref, | 128 | repo_ref, |
| 128 | &git_state_refspecs, | 129 | &git_state_refspecs, |
| 129 | &proposal_refspecs, | 130 | &proposal_refspecs, |
| 130 | client, | 131 | client, // &mut Client |
| 131 | existing_state, | 132 | existing_state, |
| 132 | &term, | 133 | &term, |
| 133 | title_description.as_ref(), | 134 | title_description.as_ref(), |
| @@ -182,7 +183,7 @@ async fn create_and_publish_events_and_proposals( | |||
| 182 | repo_ref: &RepoRef, | 183 | repo_ref: &RepoRef, |
| 183 | git_server_refspecs: &Vec<String>, | 184 | git_server_refspecs: &Vec<String>, |
| 184 | proposal_refspecs: &Vec<String>, | 185 | proposal_refspecs: &Vec<String>, |
| 185 | client: &Client, | 186 | client: &mut Client, |
| 186 | existing_state: HashMap<String, String>, | 187 | existing_state: HashMap<String, String>, |
| 187 | term: &Term, | 188 | term: &Term, |
| 188 | title_description: Option<&(String, String)>, | 189 | title_description: Option<&(String, String)>, |
| @@ -216,16 +217,14 @@ async fn create_and_publish_events_and_proposals( | |||
| 216 | .clone() | 217 | .clone() |
| 217 | .is_some_and(|ms| ms.contains(&user_ref.public_key)) | 218 | .is_some_and(|ms| ms.contains(&user_ref.public_key)) |
| 218 | { | 219 | { |
| 219 | for refspec in git_server_refspecs { | 220 | // Auto-accept co-maintainership: publish the user's own announcement |
| 220 | let (_, to) = refspec_to_from_to(refspec).unwrap(); | 221 | // with defaults before proceeding with the push. The announcement is |
| 221 | eprintln!( | 222 | // required (not just for consent, but to prevent scammers from |
| 222 | "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.", | 223 | // attributing a person's state events to a fake project with the same |
| 223 | repo_ref.name, | 224 | // identifier). See docs/design/co-maintainer-announcement-rationale.md. |
| 224 | ); | 225 | accept_maintainership_with_defaults(git_repo, repo_ref, &user_ref, client, &signer) |
| 225 | } | 226 | .await |
| 226 | if proposal_refspecs.is_empty() { | 227 | .context("failed to auto-accept co-maintainership")?; |
| 227 | return Ok((vec![], true)); | ||
| 228 | } | ||
| 229 | } | 228 | } |
| 230 | 229 | ||
| 231 | let mut events = vec![]; | 230 | 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::{ | |||
| 3 | env, | 3 | env, |
| 4 | process::{Command, Stdio}, | 4 | process::{Command, Stdio}, |
| 5 | str::FromStr, | 5 | str::FromStr, |
| 6 | sync::{ | 6 | sync::Arc, |
| 7 | Arc, Mutex, | ||
| 8 | atomic::{AtomicBool, AtomicU64, Ordering}, | ||
| 9 | }, | ||
| 10 | time::Duration, | ||
| 11 | }; | 7 | }; |
| 12 | 8 | ||
| 13 | use anyhow::{Context, Result, bail}; | 9 | use anyhow::{Context, Result, bail}; |
| 14 | use console::{Style, Term}; | 10 | use console::{Style, Term}; |
| 15 | use futures::future::join_all; | ||
| 16 | use git2::Oid; | 11 | use git2::Oid; |
| 17 | use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; | ||
| 18 | use ngit::{ | 12 | use ngit::{ |
| 13 | accept_maintainership::{grasp_servers_from_user_or_fallback, wait_for_grasp_servers}, | ||
| 19 | cli_interactor::{ | 14 | cli_interactor::{ |
| 20 | PromptChoiceParms, PromptConfirmParms, cli_error, multi_select_with_custom_value, | 15 | PromptChoiceParms, PromptConfirmParms, cli_error, multi_select_with_custom_value, |
| 21 | show_multi_input_prompt_success, | 16 | show_multi_input_prompt_success, |
| @@ -25,8 +20,8 @@ use ngit::{ | |||
| 25 | git::nostr_url::{CloneUrl, NostrUrlDecoded}, | 20 | git::nostr_url::{CloneUrl, NostrUrlDecoded}, |
| 26 | list::list_from_remote, | 21 | list::list_from_remote, |
| 27 | repo_ref::{ | 22 | repo_ref::{ |
| 28 | detect_existing_grasp_servers, extract_npub, extract_pks, | 23 | apply_grasp_infrastructure, detect_existing_grasp_servers, extract_npub, extract_pks, |
| 29 | format_grasp_server_url_as_relay_url, is_grasp_server_clone_url, | 24 | format_grasp_server_url_as_relay_url, is_grasp_server_clone_url, latest_event_repo_ref, |
| 30 | normalize_grasp_server_url, save_repo_config_to_yaml, | 25 | normalize_grasp_server_url, save_repo_config_to_yaml, |
| 31 | }, | 26 | }, |
| 32 | repo_state::RepoState, | 27 | repo_state::RepoState, |
| @@ -129,16 +124,6 @@ fn my_event_repo_ref(repo_ref: &RepoRef, my_pubkey: &PublicKey) -> Option<RepoRe | |||
| 129 | .and_then(|e| RepoRef::try_from((e.clone(), None)).ok()) | 124 | .and_then(|e| RepoRef::try_from((e.clone(), None)).ok()) |
| 130 | } | 125 | } |
| 131 | 126 | ||
| 132 | /// Find the latest event (by `created_at`) across all maintainer events and | ||
| 133 | /// parse it into a `RepoRef` for shared metadata (name, description, web). | ||
| 134 | fn latest_event_repo_ref(repo_ref: &RepoRef) -> Option<RepoRef> { | ||
| 135 | repo_ref | ||
| 136 | .events | ||
| 137 | .values() | ||
| 138 | .max_by_key(|e| e.created_at) | ||
| 139 | .and_then(|e| RepoRef::try_from((e.clone(), None)).ok()) | ||
| 140 | } | ||
| 141 | |||
| 142 | /// Check if a grasp-format clone URL belongs to the given public key. | 127 | /// Check if a grasp-format clone URL belongs to the given public key. |
| 143 | fn is_my_grasp_clone_url(url: &str, my_pubkey: &PublicKey) -> bool { | 128 | fn is_my_grasp_clone_url(url: &str, my_pubkey: &PublicKey) -> bool { |
| 144 | if !is_grasp_server_clone_url(url) { | 129 | if !is_grasp_server_clone_url(url) { |
| @@ -261,60 +246,6 @@ fn resolve_hashtags(args_hashtag: &[String], state: &InitState) -> Result<Vec<St | |||
| 261 | Ok(vec![]) | 246 | Ok(vec![]) |
| 262 | } | 247 | } |
| 263 | 248 | ||
| 264 | /// Derive clone-urls and relays from selected grasp servers. | ||
| 265 | /// | ||
| 266 | /// For each grasp server, adds/replaces the corresponding clone URL in | ||
| 267 | /// `git_servers` and adds a relay URL to `relays`. Grasp-derived infrastructure | ||
| 268 | /// is always added — the other lists (`git_servers`, `relays`) | ||
| 269 | /// contain *additional* infrastructure beyond what grasp servers provide. | ||
| 270 | fn apply_grasp_infrastructure( | ||
| 271 | grasp_servers: &[String], | ||
| 272 | git_servers: &mut Vec<String>, | ||
| 273 | relays: &mut Vec<String>, | ||
| 274 | public_key: &PublicKey, | ||
| 275 | identifier: &str, | ||
| 276 | ) -> Result<()> { | ||
| 277 | for (grasp_relay_insert_idx, grasp_server) in grasp_servers.iter().enumerate() { | ||
| 278 | // Always add grasp-derived clone URL | ||
| 279 | let clone_url = format_grasp_server_url_as_clone_url(grasp_server, public_key, identifier)?; | ||
| 280 | |||
| 281 | let grasp_server_clone_root = if clone_url.contains("https://") { | ||
| 282 | format!("https://{grasp_server}") | ||
| 283 | } else { | ||
| 284 | grasp_server.to_string() | ||
| 285 | }; | ||
| 286 | |||
| 287 | let matching_positions: Vec<usize> = git_servers | ||
| 288 | .iter() | ||
| 289 | .enumerate() | ||
| 290 | .filter_map(|(idx, url)| { | ||
| 291 | if url.contains(&grasp_server_clone_root) { | ||
| 292 | Some(idx) | ||
| 293 | } else { | ||
| 294 | None | ||
| 295 | } | ||
| 296 | }) | ||
| 297 | .collect(); | ||
| 298 | |||
| 299 | if matching_positions.is_empty() { | ||
| 300 | git_servers.push(clone_url); | ||
| 301 | } else { | ||
| 302 | git_servers[matching_positions[0]] = clone_url; | ||
| 303 | for &position in matching_positions.iter().skip(1).rev() { | ||
| 304 | git_servers.remove(position); | ||
| 305 | } | ||
| 306 | } | ||
| 307 | |||
| 308 | // Prepend grasp-derived relay in order (for relay hint) so that the | ||
| 309 | // first grasp server in the list ends up at relays[0]. | ||
| 310 | let relay_url = format_grasp_server_url_as_relay_url(grasp_server)?; | ||
| 311 | if !relays.contains(&relay_url) { | ||
| 312 | relays.insert(grasp_relay_insert_idx, relay_url); | ||
| 313 | } | ||
| 314 | } | ||
| 315 | Ok(()) | ||
| 316 | } | ||
| 317 | |||
| 318 | /// Resolve which grasp servers to use. Handles flag overrides, detection from | 249 | /// Resolve which grasp servers to use. Handles flag overrides, detection from |
| 319 | /// existing URLs, user grasp list / system fallbacks, and interactive | 250 | /// existing URLs, user grasp list / system fallbacks, and interactive |
| 320 | /// prompting. | 251 | /// prompting. |
| @@ -392,26 +323,6 @@ fn resolve_grasp_servers( | |||
| 392 | Ok(selected) | 323 | Ok(selected) |
| 393 | } | 324 | } |
| 394 | 325 | ||
| 395 | fn grasp_servers_from_user_or_fallback( | ||
| 396 | user_ref: &ngit::login::user::UserRef, | ||
| 397 | client: &Client, | ||
| 398 | ) -> Vec<String> { | ||
| 399 | if user_ref.grasp_list.urls.is_empty() { | ||
| 400 | client | ||
| 401 | .get_grasp_default_set() | ||
| 402 | .iter() | ||
| 403 | .map(std::string::ToString::to_string) | ||
| 404 | .collect() | ||
| 405 | } else { | ||
| 406 | user_ref | ||
| 407 | .grasp_list | ||
| 408 | .urls | ||
| 409 | .iter() | ||
| 410 | .map(std::string::ToString::to_string) | ||
| 411 | .collect() | ||
| 412 | } | ||
| 413 | } | ||
| 414 | |||
| 415 | // --------------------------------------------------------------------------- | 326 | // --------------------------------------------------------------------------- |
| 416 | // Validation | 327 | // Validation |
| 417 | // --------------------------------------------------------------------------- | 328 | // --------------------------------------------------------------------------- |
| @@ -1626,24 +1537,6 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 1626 | .await | 1537 | .await |
| 1627 | } | 1538 | } |
| 1628 | 1539 | ||
| 1629 | fn format_grasp_server_url_as_clone_url( | ||
| 1630 | url: &str, | ||
| 1631 | public_key: &PublicKey, | ||
| 1632 | identifier: &str, | ||
| 1633 | ) -> Result<String> { | ||
| 1634 | let grasp_server_url = normalize_grasp_server_url(url)?; | ||
| 1635 | if grasp_server_url.contains("http://") { | ||
| 1636 | return Ok(format!( | ||
| 1637 | "{grasp_server_url}/{}/{identifier}.git", | ||
| 1638 | public_key.to_bech32()? | ||
| 1639 | )); | ||
| 1640 | } | ||
| 1641 | Ok(format!( | ||
| 1642 | "https://{grasp_server_url}/{}/{identifier}.git", | ||
| 1643 | public_key.to_bech32()? | ||
| 1644 | )) | ||
| 1645 | } | ||
| 1646 | |||
| 1647 | fn parse_relay_url(s: &str) -> Result<RelayUrl> { | 1540 | fn parse_relay_url(s: &str) -> Result<RelayUrl> { |
| 1648 | // Attempt to parse the original string | 1541 | // Attempt to parse the original string |
| 1649 | match RelayUrl::parse(s) { | 1542 | match RelayUrl::parse(s) { |
| @@ -1733,300 +1626,3 @@ fn run_ngit_sync() -> Result<()> { | |||
| 1733 | bail!("ngit sync process exited with an error: {exit_status}"); | 1626 | bail!("ngit sync process exited with an error: {exit_status}"); |
| 1734 | } | 1627 | } |
| 1735 | } | 1628 | } |
| 1736 | |||
| 1737 | fn check_git_server_ready(git_repo_path: &std::path::Path, git_server_url: &str) -> bool { | ||
| 1738 | let Ok(git_repo) = git2::Repository::open(git_repo_path) else { | ||
| 1739 | return false; | ||
| 1740 | }; | ||
| 1741 | let Ok(mut remote) = git_repo.remote_anonymous(git_server_url) else { | ||
| 1742 | return false; | ||
| 1743 | }; | ||
| 1744 | match remote.connect(git2::Direction::Fetch) { | ||
| 1745 | Ok(()) => { | ||
| 1746 | let _ = remote.disconnect(); | ||
| 1747 | true | ||
| 1748 | } | ||
| 1749 | Err(_) => false, | ||
| 1750 | } | ||
| 1751 | } | ||
| 1752 | |||
| 1753 | /// Holds the final style+message for a bar that completed before the detail | ||
| 1754 | /// view was revealed. | ||
| 1755 | struct DeferredServerFinish { | ||
| 1756 | bar: ProgressBar, | ||
| 1757 | style: ProgressStyle, | ||
| 1758 | message: String, | ||
| 1759 | } | ||
| 1760 | |||
| 1761 | struct ServerRevealState { | ||
| 1762 | revealed: AtomicBool, | ||
| 1763 | deferred: Mutex<Vec<DeferredServerFinish>>, | ||
| 1764 | } | ||
| 1765 | |||
| 1766 | struct PollContext { | ||
| 1767 | timeout_secs: u64, | ||
| 1768 | total: u64, | ||
| 1769 | ready_count: Arc<AtomicU64>, | ||
| 1770 | spinner_pb: ProgressBar, | ||
| 1771 | reveal_state: Arc<ServerRevealState>, | ||
| 1772 | } | ||
| 1773 | |||
| 1774 | fn create_server_bars(clone_urls: &[String], detail_multi: &MultiProgress) -> Vec<ProgressBar> { | ||
| 1775 | let waiting_style = ProgressStyle::with_template(" {spinner} {msg}") | ||
| 1776 | .unwrap() | ||
| 1777 | .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"); | ||
| 1778 | clone_urls | ||
| 1779 | .iter() | ||
| 1780 | .map(|url| { | ||
| 1781 | let name = url | ||
| 1782 | .trim_start_matches("https://") | ||
| 1783 | .trim_start_matches("http://") | ||
| 1784 | .to_string(); | ||
| 1785 | detail_multi.add( | ||
| 1786 | ProgressBar::new_spinner() | ||
| 1787 | .with_style(waiting_style.clone()) | ||
| 1788 | .with_message( | ||
| 1789 | console::style(format!("{name} - waiting")) | ||
| 1790 | .for_stderr() | ||
| 1791 | .dim() | ||
| 1792 | .to_string(), | ||
| 1793 | ), | ||
| 1794 | ) | ||
| 1795 | }) | ||
| 1796 | .collect() | ||
| 1797 | } | ||
| 1798 | |||
| 1799 | fn spawn_expand_timer( | ||
| 1800 | expand_delay_ms: u64, | ||
| 1801 | spinner_pb: ProgressBar, | ||
| 1802 | detail_multi: MultiProgress, | ||
| 1803 | heading_bar: ProgressBar, | ||
| 1804 | reveal_state: Arc<ServerRevealState>, | ||
| 1805 | server_bars: Vec<ProgressBar>, | ||
| 1806 | ) -> tokio::task::JoinHandle<()> { | ||
| 1807 | tokio::spawn(async move { | ||
| 1808 | tokio::time::sleep(Duration::from_millis(expand_delay_ms)).await; | ||
| 1809 | spinner_pb.finish_and_clear(); | ||
| 1810 | detail_multi.set_draw_target(ProgressDrawTarget::stderr()); | ||
| 1811 | heading_bar.finish_with_message("waiting for servers to create bare git repo..."); | ||
| 1812 | let mut deferred = reveal_state.deferred.lock().unwrap(); | ||
| 1813 | reveal_state.revealed.store(true, Ordering::Release); | ||
| 1814 | for df in deferred.drain(..) { | ||
| 1815 | df.bar.set_style(df.style); | ||
| 1816 | df.bar.finish_with_message(df.message); | ||
| 1817 | } | ||
| 1818 | for bar in &server_bars { | ||
| 1819 | if !bar.is_finished() { | ||
| 1820 | bar.enable_steady_tick(Duration::from_millis(100)); | ||
| 1821 | } | ||
| 1822 | } | ||
| 1823 | }) | ||
| 1824 | } | ||
| 1825 | |||
| 1826 | fn finalize_spinner(all_ready: bool, spinner_pb: &ProgressBar, final_ready: u64, total: u64) { | ||
| 1827 | if all_ready { | ||
| 1828 | spinner_pb.finish_and_clear(); | ||
| 1829 | } else { | ||
| 1830 | spinner_pb.set_style(ProgressStyle::with_template("{msg}").unwrap()); | ||
| 1831 | spinner_pb.finish_with_message(format!( | ||
| 1832 | "timed out waiting for servers to create bare git repo ({final_ready}/{total} - complete), proceeding anyway" | ||
| 1833 | )); | ||
| 1834 | } | ||
| 1835 | } | ||
| 1836 | |||
| 1837 | fn finish_server_bar( | ||
| 1838 | bar: &ProgressBar, | ||
| 1839 | style: ProgressStyle, | ||
| 1840 | message: String, | ||
| 1841 | reveal_state: &Arc<ServerRevealState>, | ||
| 1842 | ) { | ||
| 1843 | let mut deferred = reveal_state.deferred.lock().unwrap(); | ||
| 1844 | if reveal_state.revealed.load(Ordering::Acquire) { | ||
| 1845 | drop(deferred); | ||
| 1846 | bar.set_style(style); | ||
| 1847 | bar.finish_with_message(message); | ||
| 1848 | } else { | ||
| 1849 | // Style is set now so the timer can drain it correctly; finish is | ||
| 1850 | // deferred until the detail view becomes visible. | ||
| 1851 | bar.set_style(style.clone()); | ||
| 1852 | deferred.push(DeferredServerFinish { | ||
| 1853 | bar: bar.clone(), | ||
| 1854 | style, | ||
| 1855 | message, | ||
| 1856 | }); | ||
| 1857 | } | ||
| 1858 | } | ||
| 1859 | |||
| 1860 | async fn poll_single_server( | ||
| 1861 | url: String, | ||
| 1862 | git_repo_path: std::path::PathBuf, | ||
| 1863 | bar: ProgressBar, | ||
| 1864 | ctx: Arc<PollContext>, | ||
| 1865 | ) -> bool { | ||
| 1866 | let poll_interval = Duration::from_millis(500); | ||
| 1867 | let deadline = tokio::time::Instant::now() + Duration::from_secs(ctx.timeout_secs); | ||
| 1868 | let mut ready = false; | ||
| 1869 | loop { | ||
| 1870 | let is_ready = tokio::task::spawn_blocking({ | ||
| 1871 | let url = url.clone(); | ||
| 1872 | let path = git_repo_path.clone(); | ||
| 1873 | move || check_git_server_ready(&path, &url) | ||
| 1874 | }) | ||
| 1875 | .await | ||
| 1876 | .unwrap_or(false); | ||
| 1877 | |||
| 1878 | if is_ready { | ||
| 1879 | ready = true; | ||
| 1880 | break; | ||
| 1881 | } | ||
| 1882 | |||
| 1883 | if tokio::time::Instant::now() >= deadline { | ||
| 1884 | break; | ||
| 1885 | } | ||
| 1886 | |||
| 1887 | tokio::time::sleep(poll_interval).await; | ||
| 1888 | } | ||
| 1889 | |||
| 1890 | let count = if ready { | ||
| 1891 | ctx.ready_count.fetch_add(1, Ordering::Relaxed) + 1 | ||
| 1892 | } else { | ||
| 1893 | ctx.ready_count.load(Ordering::Relaxed) | ||
| 1894 | }; | ||
| 1895 | |||
| 1896 | ctx.spinner_pb.set_message(format!( | ||
| 1897 | "waiting for servers to create bare git repo... ({count}/{total} - complete)", | ||
| 1898 | total = ctx.total | ||
| 1899 | )); | ||
| 1900 | |||
| 1901 | let name = url | ||
| 1902 | .trim_start_matches("https://") | ||
| 1903 | .trim_start_matches("http://") | ||
| 1904 | .to_string(); | ||
| 1905 | if ready { | ||
| 1906 | let style = ProgressStyle::with_template(&format!( | ||
| 1907 | " {} {{msg}}", | ||
| 1908 | console::style("✔").for_stderr().green() | ||
| 1909 | )) | ||
| 1910 | .unwrap(); | ||
| 1911 | let msg = console::style(format!("{name} - ready")) | ||
| 1912 | .for_stderr() | ||
| 1913 | .green() | ||
| 1914 | .to_string(); | ||
| 1915 | finish_server_bar(&bar, style, msg, &ctx.reveal_state); | ||
| 1916 | } else { | ||
| 1917 | let style = ProgressStyle::with_template(&format!( | ||
| 1918 | " {} {{msg}}", | ||
| 1919 | console::style("✘").for_stderr().red() | ||
| 1920 | )) | ||
| 1921 | .unwrap(); | ||
| 1922 | let msg = console::style(format!("{name} - timeout")) | ||
| 1923 | .for_stderr() | ||
| 1924 | .red() | ||
| 1925 | .to_string(); | ||
| 1926 | finish_server_bar(&bar, style, msg, &ctx.reveal_state); | ||
| 1927 | } | ||
| 1928 | |||
| 1929 | ready | ||
| 1930 | } | ||
| 1931 | |||
| 1932 | /// Poll grasp servers in parallel until all are ready or timeout is reached. | ||
| 1933 | /// | ||
| 1934 | /// Shows a concise spinner with `x/y - complete` progress. After 5s without | ||
| 1935 | /// all servers responding, expands to show per-server status bars (including | ||
| 1936 | /// any that already finished). Times out after 15s (2s in tests) and proceeds | ||
| 1937 | /// anyway rather than failing. | ||
| 1938 | async fn wait_for_grasp_servers( | ||
| 1939 | git_repo: &Repo, | ||
| 1940 | grasp_servers: &[String], | ||
| 1941 | public_key: &PublicKey, | ||
| 1942 | identifier: &str, | ||
| 1943 | ) -> Result<()> { | ||
| 1944 | let clone_urls: Vec<String> = grasp_servers | ||
| 1945 | .iter() | ||
| 1946 | .filter_map(|gs| format_grasp_server_url_as_clone_url(gs, public_key, identifier).ok()) | ||
| 1947 | .collect(); | ||
| 1948 | |||
| 1949 | if clone_urls.is_empty() { | ||
| 1950 | return Ok(()); | ||
| 1951 | } | ||
| 1952 | |||
| 1953 | let is_test = std::env::var("NGITTEST").is_ok(); | ||
| 1954 | let timeout_secs: u64 = if is_test { 2 } else { 15 }; | ||
| 1955 | let expand_delay_ms: u64 = if is_test { 500 } else { 5000 }; | ||
| 1956 | let total = clone_urls.len() as u64; | ||
| 1957 | |||
| 1958 | // Spinner shown immediately with x/y count | ||
| 1959 | let spinner_multi = MultiProgress::new(); | ||
| 1960 | let spinner_pb = spinner_multi.add( | ||
| 1961 | ProgressBar::new_spinner() | ||
| 1962 | .with_style( | ||
| 1963 | ProgressStyle::with_template("{spinner} {msg}") | ||
| 1964 | .unwrap() | ||
| 1965 | .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"), | ||
| 1966 | ) | ||
| 1967 | .with_message(format!( | ||
| 1968 | "waiting for servers to create bare git repo... (0/{total} - complete)" | ||
| 1969 | )), | ||
| 1970 | ); | ||
| 1971 | spinner_pb.enable_steady_tick(Duration::from_millis(100)); | ||
| 1972 | |||
| 1973 | // Detail MultiProgress starts hidden; revealed after expand_delay_ms. | ||
| 1974 | // A heading bar is pre-added at position 0 so it holds its slot before | ||
| 1975 | // any per-server bars are added. | ||
| 1976 | let detail_multi = MultiProgress::with_draw_target(ProgressDrawTarget::hidden()); | ||
| 1977 | let heading_bar = detail_multi | ||
| 1978 | .add(ProgressBar::new(0).with_style(ProgressStyle::with_template("{msg}").unwrap())); | ||
| 1979 | |||
| 1980 | let ready_count = Arc::new(AtomicU64::new(0)); | ||
| 1981 | let reveal_state = Arc::new(ServerRevealState { | ||
| 1982 | revealed: AtomicBool::new(false), | ||
| 1983 | deferred: Mutex::new(Vec::new()), | ||
| 1984 | }); | ||
| 1985 | |||
| 1986 | let server_bars = create_server_bars(&clone_urls, &detail_multi); | ||
| 1987 | |||
| 1988 | let timer_handle = spawn_expand_timer( | ||
| 1989 | expand_delay_ms, | ||
| 1990 | spinner_pb.clone(), | ||
| 1991 | detail_multi.clone(), | ||
| 1992 | heading_bar, | ||
| 1993 | reveal_state.clone(), | ||
| 1994 | server_bars.clone(), | ||
| 1995 | ); | ||
| 1996 | |||
| 1997 | // Poll each server in parallel | ||
| 1998 | let git_repo_path = git_repo.get_path()?.to_path_buf(); | ||
| 1999 | let poll_ctx = Arc::new(PollContext { | ||
| 2000 | timeout_secs, | ||
| 2001 | total, | ||
| 2002 | ready_count: ready_count.clone(), | ||
| 2003 | spinner_pb: spinner_pb.clone(), | ||
| 2004 | reveal_state: reveal_state.clone(), | ||
| 2005 | }); | ||
| 2006 | let futures: Vec<_> = clone_urls | ||
| 2007 | .iter() | ||
| 2008 | .enumerate() | ||
| 2009 | .map(|(i, url)| { | ||
| 2010 | poll_single_server( | ||
| 2011 | url.clone(), | ||
| 2012 | git_repo_path.clone(), | ||
| 2013 | server_bars[i].clone(), | ||
| 2014 | poll_ctx.clone(), | ||
| 2015 | ) | ||
| 2016 | }) | ||
| 2017 | .collect(); | ||
| 2018 | |||
| 2019 | let results = join_all(futures).await; | ||
| 2020 | let final_ready = ready_count.load(Ordering::Relaxed); | ||
| 2021 | |||
| 2022 | timer_handle.abort(); | ||
| 2023 | |||
| 2024 | if reveal_state.revealed.load(Ordering::Acquire) { | ||
| 2025 | let _ = detail_multi.clear(); | ||
| 2026 | } | ||
| 2027 | |||
| 2028 | let all_ready = results.iter().all(|&r| r); | ||
| 2029 | finalize_spinner(all_ready, &spinner_pb, final_ready, total); | ||
| 2030 | |||
| 2031 | Ok(()) | ||
| 2032 | } | ||
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::*; |