upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin/ngit
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-20 20:09:09 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-20 21:21:48 +0000
commit64747526c9f6ab43f9dac461d056bb42992573b4 (patch)
treec2506828ae7b188e3e4b569cd73202ec37779278 /src/bin/ngit
parent365dfb9a1e986b68bc2389e2a3cd3da30b0d4636 (diff)
extract grasp/maintainership helpers to lib and auto-accept on push
move apply_grasp_infrastructure, latest_event_repo_ref to lib/repo_ref.rs and wait_for_grasp_servers + grasp_servers_from_user_or_fallback to a new lib/accept_maintainership.rs so both binaries can share them. add accept_maintainership_with_defaults which publishes the co-maintainer's own Kind:30617 announcement with defaults (user grasp servers, shared metadata from existing events) then waits for grasp server provisioning and updates nostr.repo config and origin remote. replace the push error block with a call to accept_maintainership_with_defaults so pushing now silently accepts co-maintainership instead of failing.
Diffstat (limited to 'src/bin/ngit')
-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}