diff options
Diffstat (limited to 'src')
| -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 |
6 files changed, 612 insertions, 422 deletions
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::*; |