upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/bin/git_remote_nostr/main.rs2
-rw-r--r--src/bin/git_remote_nostr/push.rs25
-rw-r--r--src/bin/ngit/sub_commands/init.rs412
-rw-r--r--src/lib/accept_maintainership.rs529
-rw-r--r--src/lib/mod.rs1
-rw-r--r--src/lib/repo_ref.rs65
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};
15use git2::{Oid, Repository}; 15use git2::{Oid, Repository};
16use ngit::{ 16use 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
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}
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.
13use std::{
14 collections::HashMap,
15 sync::{
16 Arc, Mutex,
17 atomic::{AtomicBool, AtomicU64, Ordering},
18 },
19 time::Duration,
20};
21
22use anyhow::{Context, Result};
23use futures::future::join_all;
24use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
25use nostr::{
26 PublicKey, ToBech32,
27 nips::{nip01::Coordinate, nip19::Nip19Coordinate},
28};
29use nostr_sdk::{Kind, NostrSigner, RelayUrl};
30
31#[cfg(not(test))]
32use crate::client::Client;
33#[cfg(test)]
34use crate::client::MockConnect;
35use 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.
56pub 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.
216pub 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.
243struct DeferredServerFinish {
244 bar: ProgressBar,
245 style: ProgressStyle,
246 message: String,
247}
248
249struct ServerRevealState {
250 revealed: AtomicBool,
251 deferred: Mutex<Vec<DeferredServerFinish>>,
252}
253
254struct PollContext {
255 timeout_secs: u64,
256 total: u64,
257 ready_count: Arc<AtomicU64>,
258 spinner_pb: ProgressBar,
259 reveal_state: Arc<ServerRevealState>,
260}
261
262fn 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
278fn 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
303fn 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
330fn 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
341fn 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
362async 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.
440pub 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 @@
1pub mod accept_maintainership;
1pub mod cli_interactor; 2pub mod cli_interactor;
2pub mod client; 3pub mod client;
3pub mod fetch; 4pub 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.).
820pub 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.
834pub 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)]
818mod tests { 883mod tests {
819 use test_utils::*; 884 use test_utils::*;