upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/bin/ngit/sub_commands/init.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/bin/ngit/sub_commands/init.rs')
-rw-r--r--src/bin/ngit/sub_commands/init.rs412
1 files changed, 4 insertions, 408 deletions
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
13use anyhow::{Context, Result, bail}; 9use anyhow::{Context, Result, bail};
14use console::{Style, Term}; 10use console::{Style, Term};
15use futures::future::join_all;
16use git2::Oid; 11use git2::Oid;
17use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
18use ngit::{ 12use 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).
134fn 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.
143fn is_my_grasp_clone_url(url: &str, my_pubkey: &PublicKey) -> bool { 128fn 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.
270fn 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
395fn 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
1629fn 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
1647fn parse_relay_url(s: &str) -> Result<RelayUrl> { 1540fn 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
1737fn 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.
1755struct DeferredServerFinish {
1756 bar: ProgressBar,
1757 style: ProgressStyle,
1758 message: String,
1759}
1760
1761struct ServerRevealState {
1762 revealed: AtomicBool,
1763 deferred: Mutex<Vec<DeferredServerFinish>>,
1764}
1765
1766struct PollContext {
1767 timeout_secs: u64,
1768 total: u64,
1769 ready_count: Arc<AtomicU64>,
1770 spinner_pb: ProgressBar,
1771 reveal_state: Arc<ServerRevealState>,
1772}
1773
1774fn 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
1799fn 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
1826fn 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
1837fn 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
1860async 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.
1938async 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}