upleb.uk

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

summaryrefslogtreecommitdiff
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
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.
-rw-r--r--CHANGELOG.md1
-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
7 files changed, 613 insertions, 422 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75fe9a8..6d93f43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
20 20
21### Added 21### Added
22 22
23- **Auto-accept co-maintainership on push**: when pushing to a repo where you are listed as a maintainer but have not yet published an announcement, ngit now automatically publishes your announcement (using your grasp servers or the trusted maintainer's as fallback) and provisions your grasp server instead of failing
23- `ngit account login --signer-relay` - specify custom relays for nostrconnect (auto-prefixes with `wss://` if no scheme) 24- `ngit account login --signer-relay` - specify custom relays for nostrconnect (auto-prefixes with `wss://` if no scheme)
24- `ngit checkout <id>` - checkout a proposal branch by event-id or nevent 25- `ngit checkout <id>` - checkout a proposal branch by event-id or nevent
25- `ngit apply <id>` - apply proposal patches to current branch 26- `ngit apply <id>` - apply proposal patches to current branch
diff --git a/src/bin/git_remote_nostr/main.rs b/src/bin/git_remote_nostr/main.rs
index f670b7b..e0821e9 100644
--- a/src/bin/git_remote_nostr/main.rs
+++ b/src/bin/git_remote_nostr/main.rs
@@ -203,7 +203,7 @@ async fn main() -> Result<()> {
203 &repo_ref, 203 &repo_ref,
204 &stdin, 204 &stdin,
205 refspec, 205 refspec,
206 &client, 206 &mut client,
207 list_outputs.clone(), 207 list_outputs.clone(),
208 title_description, 208 title_description,
209 ) 209 )
diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs
index e1c94f8..b64cdd9 100644
--- a/src/bin/git_remote_nostr/push.rs
+++ b/src/bin/git_remote_nostr/push.rs
@@ -14,6 +14,7 @@ use git_events::{
14}; 14};
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::*;