From 9d142ee7046a415bb764f626e61476ed349c98ca Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 10 Feb 2026 12:51:45 +0000 Subject: feat: add global CLI flags for non-interactive mode Add --defaults, --interactive, and --force flags to support non-interactive operation. Non-interactive mode is now the default behavior, with interactive mode enabled via the -i/--interactive flag. Also add CliError handling in main() to support styled error output from subcommands. --- src/bin/ngit/cli.rs | 12 ++++++++++++ src/bin/ngit/main.rs | 26 ++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) (limited to 'src/bin') diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs index 76874c3..d2246d7 100644 --- a/src/bin/ngit/cli.rs +++ b/src/bin/ngit/cli.rs @@ -11,6 +11,7 @@ use crate::sub_commands; help_template = "{name} {version}\nnostr plugin for git\n - clone a nostr repository, or add as a remote, by using the url format nostr://pub123/identifier\n - remote branches beginning with `pr/` are open PRs from contributors; `ngit list` can be used to view all PRs\n - to open a PR, push a branch with the prefix `pr/` or use `ngit send` for advanced options\n- publish a repository to nostr with `ngit init`\n\n{usage}\n{all-args}" )] #[command(propagate_version = true)] +#[allow(clippy::struct_excessive_bools)] pub struct Cli { #[command(subcommand)] pub command: Option, @@ -32,6 +33,15 @@ pub struct Cli { /// show customization options via git config #[arg(short, long, global = true)] pub customize: bool, + /// Use default values without prompting (non-interactive mode) + #[arg(short = 'd', long, global = true, conflicts_with = "interactive")] + pub defaults: bool, + /// Enable interactive prompts (default behavior) + #[arg(short = 'i', long, global = true)] + pub interactive: bool, + /// Force operations, bypass safety guards + #[arg(short = 'f', long, global = true)] + pub force: bool, } pub const CUSTOMISE_TEMPLATE: &str = r" @@ -111,6 +121,8 @@ pub enum AccountCommands { Logout, /// export nostr keys to login to other nostr clients ExportKeys, + /// create a new nostr account + Create(sub_commands::create::SubCommandArgs), } #[derive(clap::Parser)] diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs index 71b6e85..5d29b02 100644 --- a/src/bin/ngit/main.rs +++ b/src/bin/ngit/main.rs @@ -2,13 +2,13 @@ #![allow(clippy::large_futures)] #![cfg_attr(not(test), warn(clippy::expect_used))] -use anyhow::Result; use clap::Parser; use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands}; mod cli; use ngit::{ - cli_interactor, client, + cli_interactor::{self, CliError}, + client, git::{self, utils::set_git_timeout}, git_events, login, repo_ref, }; @@ -16,9 +16,15 @@ use ngit::{ mod sub_commands; #[tokio::main] -async fn main() -> Result<()> { +async fn main() { let cli = Cli::parse(); + // Non-interactive by default; set NGIT_INTERACTIVE_MODE only when -i is + // specified + if cli.interactive { + std::env::set_var("NGIT_INTERACTIVE_MODE", "1"); + } + if cli.customize { print!("{CUSTOMISE_TEMPLATE}"); std::process::exit(0); // Exit the program @@ -26,7 +32,7 @@ async fn main() -> Result<()> { let _ = set_git_timeout(); - if let Some(command) = &cli.command { + let result = if let Some(command) = &cli.command { match command { Commands::Account(args) => match &args.account_command { AccountCommands::Login(sub_args) => { @@ -34,6 +40,9 @@ async fn main() -> Result<()> { } AccountCommands::Logout => sub_commands::logout::launch().await, AccountCommands::ExportKeys => sub_commands::export_keys::launch().await, + AccountCommands::Create(sub_args) => { + sub_commands::create::launch(&cli, sub_args).await + } }, Commands::Init(args) => sub_commands::init::launch(&cli, args).await, Commands::List => sub_commands::list::launch().await, @@ -44,5 +53,14 @@ async fn main() -> Result<()> { // Handle the case where no command is provided eprintln!("Error: A command must be provided. Use '--help' for more information."); std::process::exit(1); + }; + + if let Err(err) = result { + if err.downcast_ref::().is_some() { + // Already printed styled output to stderr + std::process::exit(1); + } + eprintln!("Error: {err:?}"); + std::process::exit(1); } } -- cgit v1.2.3 From 6b079f3715611605e348b8709927e0af1675392c Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 10 Feb 2026 12:51:58 +0000 Subject: fix: make git-remote-nostr push non-interactive Update push operations to use non-interactive mode by default, removing prompts that would block automated git operations. --- src/bin/git_remote_nostr/push.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) (limited to 'src/bin') diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs index 31c920a..5cbec7f 100644 --- a/src/bin/git_remote_nostr/push.rs +++ b/src/bin/git_remote_nostr/push.rs @@ -20,7 +20,7 @@ use ngit::{ self, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_to_cover_letter, get_event_root, }, list::list_from_remotes, - login::{self, user::UserRef}, + login::{existing::load_existing_login, user::UserRef}, push::{push_to_remote, select_servers_push_refs_and_generate_pr_or_pr_update_event}, repo_ref::{self, get_repo_config_from_yaml, is_grasp_server_clone_url}, repo_state, @@ -173,6 +173,7 @@ pub async fn run_push( Ok(()) } +#[allow(clippy::too_many_lines)] async fn create_and_publish_events_and_proposals( git_repo: &Repo, repo_ref: &RepoRef, @@ -182,8 +183,18 @@ async fn create_and_publish_events_and_proposals( existing_state: HashMap, term: &Term, ) -> Result<(Vec, bool)> { - let (signer, mut user_ref, _) = - login::login_or_signup(&Some(git_repo), &None, &None, Some(client), true).await?; + let (signer, mut user_ref, _) = load_existing_login( + &Some(git_repo), + &None, + &None, + &None, + Some(client), + true, // silent + false, // prompt_for_password - MUST be false for non-interactive + true, // fetch_profile_updates + ) + .await + .context("Authentication required. Run 'ngit account login' first, then try again.")?; if !repo_ref.maintainers.contains(&user_ref.public_key) { for refspec in git_server_refspecs { -- cgit v1.2.3 From 761344563507eb50726db96f7409a8f3d5928b98 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 10 Feb 2026 12:52:04 +0000 Subject: feat: add ngit account create command Add new 'ngit account create' subcommand to create nostr accounts. This replaces the previous 'signup' command and supports both interactive and non-interactive modes. --- src/bin/ngit/sub_commands/create.rs | 71 +++++++++++++++++++++++++++++++++++++ src/bin/ngit/sub_commands/mod.rs | 1 + 2 files changed, 72 insertions(+) create mode 100644 src/bin/ngit/sub_commands/create.rs (limited to 'src/bin') diff --git a/src/bin/ngit/sub_commands/create.rs b/src/bin/ngit/sub_commands/create.rs new file mode 100644 index 0000000..e0d89b5 --- /dev/null +++ b/src/bin/ngit/sub_commands/create.rs @@ -0,0 +1,71 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use ngit::client::Params; +use nostr_sdk::ToBech32; + +use crate::{ + cli::Cli, + client::{Client, Connect}, + git::Repo, + login::fresh::signup_non_interactive, +}; + +#[derive(Parser)] +pub struct SubCommandArgs { + /// Display name for the new account + #[arg(long, required = true)] + pub name: String, + + /// Don't publish metadata to relays (offline mode) + #[arg(long)] + pub offline: bool, + + /// Save credentials only to local git config + #[arg(long)] + pub local: bool, +} + +pub async fn launch(_cli: &Cli, args: &SubCommandArgs) -> Result<()> { + let git_repo = Repo::discover().ok(); + + let client = if args.offline { + None + } else { + Some(Client::new(Params::with_git_config_relay_defaults( + &git_repo.as_ref(), + ))) + }; + + let publish = !args.offline; + + let (_signer, public_key, _signer_info, keys) = + signup_non_interactive(args.name.clone(), client.as_ref(), args.local, publish) + .await + .context("failed to create account")?; + + // Display the generated nsec prominently + println!("\n✓ Account created successfully!"); + println!("\nDisplay name: {}", args.name); + println!("Public key (npub): {}", public_key.to_bech32()?); + println!("\n⚠️ IMPORTANT: Save your secret key (nsec) securely!"); + println!("nsec: {}", keys.secret_key().to_bech32()?); + println!("\nYou will need this key to log in from other devices."); + println!("Run 'ngit account export-keys' to see this again.\n"); + + if publish { + println!("✓ Published metadata to relays"); + } + + if args.local { + println!("✓ Saved credentials to local git config only"); + } else { + println!("✓ Saved credentials to global git config"); + } + + // Disconnect client if it was created + if let Some(client) = client { + client.disconnect().await?; + } + + Ok(()) +} diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs index b2e7c9a..9c84ef2 100644 --- a/src/bin/ngit/sub_commands/mod.rs +++ b/src/bin/ngit/sub_commands/mod.rs @@ -1,3 +1,4 @@ +pub mod create; pub mod export_keys; pub mod init; pub mod list; -- cgit v1.2.3 From 5db638d7350c35649d9fa4faac981395667e0609 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 10 Feb 2026 12:52:11 +0000 Subject: feat: update ngit account login for non-interactive mode Update login flow to support non-interactive mode with --nsec flag. Refactor login logic to handle both interactive and non-interactive cases. Add better error handling and validation. --- src/bin/ngit/sub_commands/login.rs | 56 +++++++++++- src/lib/client.rs | 14 +++ src/lib/login/fresh.rs | 179 ++++++++++++++++++++++++++++++------- 3 files changed, 215 insertions(+), 34 deletions(-) (limited to 'src/bin') diff --git a/src/bin/ngit/sub_commands/login.rs b/src/bin/ngit/sub_commands/login.rs index ed2414a..9081e66 100644 --- a/src/bin/ngit/sub_commands/login.rs +++ b/src/bin/ngit/sub_commands/login.rs @@ -26,6 +26,24 @@ pub struct SubCommandArgs { } pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { + // Early validation: check if we have required parameters in non-interactive + // mode + let signer_info = extract_signer_cli_arguments(args)?; + if Interactor::is_non_interactive() && signer_info.is_none() { + use ngit::cli_interactor::cli_error; + return Err(cli_error( + "requires --nsec or --interactive", + &[ + ("--nsec ", "provide secret key (nsec or hex)"), + ("--interactive", "for nostr connect or bunker login"), + ], + &[ + "ngit account login --nsec ", + "ngit account create", + ], + )); + } + let git_repo_result = Repo::discover().context("failed to find a git repository"); let git_repo = { git_repo_result.ok() }; @@ -42,7 +60,7 @@ pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { fresh_login_or_signup( &git_repo.as_ref(), client.as_ref(), - extract_signer_cli_arguments(args)?, + signer_info, log_in_locally_only || command_args.local, ) .await?; @@ -56,6 +74,7 @@ pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { } /// return ( bool - logged out, bool - log in to local git locally) +#[allow(clippy::too_many_lines)] async fn logout(git_repo: Option<&Repo>, local_only: bool) -> Result<(bool, bool)> { for source in if local_only || std::env::var("NGITTEST").is_ok() { vec![SignerInfoSource::GitLocal] @@ -74,6 +93,41 @@ async fn logout(git_repo: Option<&Repo>, local_only: bool) -> Result<(bool, bool ) .await { + // In non-interactive mode, automatically logout without prompting + if Interactor::is_non_interactive() { + for item in [ + "nostr.nsec", + "nostr.npub", + "nostr.bunker-uri", + "nostr.bunker-app-key", + ] { + if let Err(_error) = remove_git_config_item( + if source == SignerInfoSource::GitLocal { + &git_repo + } else { + &None + }, + item, + ) { + use ngit::cli_interactor::cli_error; + return Err(cli_error( + &format!( + "failed to edit {} git config item '{item}'", + if source == SignerInfoSource::GitGlobal { + "global" + } else { + "local" + }, + ), + &[], + &["ngit account login --local --nsec "], + )); + } + } + return Ok((true, local_only)); + } + + // Interactive mode: prompt user for what to do match Interactor::default().choice( PromptChoiceParms::default() .with_default(0) diff --git a/src/lib/client.rs b/src/lib/client.rs index 4643392..fcb7a40 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs @@ -1300,6 +1300,11 @@ pub async fn get_repo_ref_from_cache( Some(repo_coordinate.public_key), ))?; + // Use name/description/web from the latest event across all maintainers + let latest_metadata = repo_events + .last() + .and_then(|e| RepoRef::try_from((e.clone(), None)).ok()); + let mut events: HashMap = HashMap::new(); for m in &maintainers { if let Some(e) = repo_events.iter().find(|e| e.pubkey.eq(m)) { @@ -1364,6 +1369,15 @@ pub async fn get_repo_ref_from_cache( git_server, events, maintainers_without_annoucnement: Some(maintainers_without_annoucnement), + name: latest_metadata + .as_ref() + .map_or_else(|| repo_ref.name.clone(), |r| r.name.clone()), + description: latest_metadata + .as_ref() + .map_or_else(|| repo_ref.description.clone(), |r| r.description.clone()), + web: latest_metadata + .as_ref() + .map_or_else(|| repo_ref.web.clone(), |r| r.web.clone()), ..repo_ref }) } diff --git a/src/lib/login/fresh.rs b/src/lib/login/fresh.rs index e01d4c3..886b0e4 100644 --- a/src/lib/login/fresh.rs +++ b/src/lib/login/fresh.rs @@ -25,7 +25,7 @@ use crate::{ Interactor, InteractorPrompt, Printer, PromptChoiceParms, PromptConfirmParms, PromptInputParms, PromptPasswordParms, }, - client::{Connect, nip05_query, send_events}, + client::{Connect, nip05_query, save_event_in_global_cache, send_events}, git::{Repo, RepoActions, remove_git_config_item, save_git_config_item}, }; @@ -123,7 +123,7 @@ pub async fn get_fresh_nsec_signer() -> Result< .input( PromptInputParms::default() .with_prompt("nsec") - .optional() + .with_flag_name("--nsec") .dont_report(), ) .context("failed to get nsec input from interactor")?; @@ -509,6 +509,26 @@ async fn save_to_git_config( if let Err(error) = silently_save_to_git_config(git_repo, signer_info, global).context(err_msg.clone()) { + // Check if this is a read-only file system error + let is_readonly_error = error + .chain() + .any(|e| e.to_string().contains("Read-only file system")); + + if is_readonly_error && global { + // In non-interactive mode, provide a clear error with --local suggestion + if crate::cli_interactor::Interactor::is_non_interactive() { + use crate::cli_interactor::cli_error; + return Err(cli_error( + "failed to create account", + &[("cause", "global git config is read-only")], + &[ + "ngit account create --local --nsec ", + "ngit account login --local --nsec ", + ], + )); + } + } + eprintln!("Error: {error:?}"); match signer_info { SignerInfo::Nsec { @@ -678,6 +698,119 @@ fn silently_save_to_git_config( Ok(()) } +/// Non-interactive signup function for creating a new account +/// +/// # Arguments +/// * `name` - Display name for the new account +/// * `client` - Optional client for publishing metadata to relays +/// * `save_local` - If true, save credentials to local git config only +/// * `publish` - If true, publish metadata and relay list to relays +/// +/// # Returns +/// Returns a tuple of (signer, public_key, signer_info, keys) where keys can be +/// used to display the nsec +pub async fn signup_non_interactive( + name: String, + #[cfg(test)] client: Option<&MockConnect>, + #[cfg(not(test))] client: Option<&Client>, + save_local: bool, + publish: bool, +) -> Result<(Arc, PublicKey, SignerInfo, Keys)> { + // Generate new keypair + let keys = nostr::Keys::generate(); + let nsec = keys.secret_key().to_bech32()?; + let public_key = keys.public_key(); + + let signer_info = SignerInfo::Nsec { + nsec, + password: None, + npub: Some(public_key.to_bech32()?), + }; + + // Save to git config + let git_repo = Repo::discover().ok(); + if let Err(error) = silently_save_to_git_config(&git_repo.as_ref(), &signer_info, !save_local) { + let is_readonly = error + .chain() + .any(|e| e.to_string().contains("Read-only file system")); + + if is_readonly && !save_local { + use crate::cli_interactor::cli_error; + + let mut cmds: Vec = match &signer_info { + SignerInfo::Nsec { nsec, npub, .. } => { + let mut v = vec![format!("git config --global nostr.nsec {nsec}")]; + if let Some(npub) = npub { + v.push(format!("git config --global nostr.npub {npub}")); + } + v + } + SignerInfo::Bunker { + bunker_uri, + bunker_app_key, + npub, + } => { + let mut v = vec![ + format!("git config --global nostr.bunker-uri {bunker_uri}"), + format!("git config --global nostr.bunker-app-key {bunker_app_key}"), + ]; + if let Some(npub) = npub { + v.push(format!("git config --global nostr.npub {npub}")); + } + v + } + }; + cmds.push("ngit account create --local --name ".to_string()); + + let cmd_refs: Vec<&str> = cmds.iter().map(String::as_str).collect(); + return Err(cli_error( + "global git config is read-only. login to local repo or save git config manually", + &[("--local", "login scoped to this repositoriy")], + &cmd_refs, + )); + } + + return Err(error); + } + + let git_repo_path = if let Some(ref git_repo) = git_repo { + Some(git_repo.get_path()?) + } else { + None + }; + + // Build events, save to cache, and optionally publish to relays + if let Some(client) = client { + let profile = EventBuilder::metadata(&Metadata::new().name(name)).sign_with_keys(&keys)?; + let relay_list = EventBuilder::relay_list( + client + .get_relay_default_set() + .iter() + .map(|s| (RelayUrl::parse(s).unwrap(), None)), + ) + .sign_with_keys(&keys)?; + + // Save to global cache so subsequent commands don't need to fetch + save_event_in_global_cache(git_repo_path, &profile).await?; + save_event_in_global_cache(git_repo_path, &relay_list).await?; + + if publish { + send_events( + client, + git_repo_path, + vec![profile, relay_list], + client.get_relay_default_set().clone(), + vec![], + true, + false, + ) + .await?; + } + } + + Ok((Arc::new(keys.clone()), public_key, signer_info, keys)) +} + async fn signup( #[cfg(test)] client: Option<&MockConnect>, #[cfg(not(test))] client: Option<&Client>, @@ -714,42 +847,22 @@ async fn signup( _ => break Ok(None), } } - let keys = nostr::Keys::generate(); - let nsec = keys.secret_key().to_bech32()?; + + // Call the non-interactive function + let (signer, public_key, signer_info, _keys) = signup_non_interactive( + name.clone(), + client, + false, // save_local = false (will be saved globally by caller) + true, // publish = true (always publish in interactive mode) + ) + .await?; + show_prompt_success("user display name", &name); - let signer_info = SignerInfo::Nsec { - nsec, - password: None, - npub: Some(keys.public_key().to_bech32()?), - }; - let public_key = keys.public_key(); - if let Some(client) = client { - let profile = - EventBuilder::metadata(&Metadata::new().name(name)).sign_with_keys(&keys)?; - let relay_list = EventBuilder::relay_list( - client - .get_relay_default_set() - .iter() - .map(|s| (RelayUrl::parse(s).unwrap(), None)), - ) - .sign_with_keys(&keys)?; - eprintln!("publishing user profile to relays"); - send_events( - client, - None, - vec![profile, relay_list], - client.get_relay_default_set().clone(), - vec![], - true, - false, - ) - .await?; - } eprintln!( "to login to other nostr clients eg. gitworkshop.dev with this account run `ngit export-keys` at any time to reveal your nostr account secret" ); break Ok(Some(( - Arc::new(keys), + signer, public_key, signer_info, // TODO factor in source -- cgit v1.2.3 From 09bb21462ac5571cace5a7e71103156772a499fe Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 10 Feb 2026 12:52:19 +0000 Subject: feat: update ngit init for non-interactive mode Complete rewrite of ngit init to support non-interactive mode by default. Key changes: - Implement hybrid validation (validate all args upfront, fail fast) - Add --grasp-servers flag for specifying git servers - Prefer --name over --identifier for better UX - Add comprehensive validation with helpful error messages - Support both clone and init-from-existing-repo workflows - Add --force flag to bypass safety checks - Update tests for new non-interactive behavior - Add test utilities for non-interactive testing --- src/bin/ngit/sub_commands/init.rs | 1761 ++++++++++++++++++++++++------------- test_utils/src/git.rs | 24 + test_utils/src/lib.rs | 78 +- tests/ngit_init.rs | 1419 ++++++++++++++++++++++-------- tests/ngit_list.rs | 34 +- 5 files changed, 2335 insertions(+), 981 deletions(-) (limited to 'src/bin') diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs index 39fe670..827acf8 100644 --- a/src/bin/ngit/sub_commands/init.rs +++ b/src/bin/ngit/sub_commands/init.rs @@ -3,6 +3,7 @@ use std::{ env, process::{Command, Stdio}, str::FromStr, + sync::Arc, thread, time::Duration, }; @@ -13,7 +14,7 @@ use git2::Oid; use ngit::{ UrlWithoutSlash, cli_interactor::{ - PromptChoiceParms, PromptConfirmParms, multi_select_with_custom_value, + PromptChoiceParms, PromptConfirmParms, cli_error, multi_select_with_custom_value, show_multi_input_prompt_success, }, client::{Params, get_state_from_cache, send_events}, @@ -44,112 +45,638 @@ use crate::{ }, }; +// --------------------------------------------------------------------------- +// InitState: determines what scenario we're in +// --------------------------------------------------------------------------- + +enum InitState { + /// No coordinate found anywhere (State A) + Fresh, + /// Coordinate found but no announcement event on relays (State B) + CoordinateOnly { coordinate: Nip19Coordinate }, + /// Announcement exists, I am the trusted maintainer (State C) + MyAnnouncement { + coordinate: Nip19Coordinate, + repo_ref: RepoRef, + }, + /// Announcement exists, I'm in the maintainer set (State D) + CoMaintainer { + coordinate: Nip19Coordinate, + repo_ref: RepoRef, + }, + /// Announcement exists, I'm not in the maintainer set (State E) + NotListed { + coordinate: Nip19Coordinate, + repo_ref: RepoRef, + }, +} + +impl InitState { + fn coordinate(&self) -> Option<&Nip19Coordinate> { + match self { + Self::Fresh => None, + Self::CoordinateOnly { coordinate } + | Self::MyAnnouncement { coordinate, .. } + | Self::CoMaintainer { coordinate, .. } + | Self::NotListed { coordinate, .. } => Some(coordinate), + } + } + + fn repo_ref(&self) -> Option<&RepoRef> { + match self { + Self::Fresh | Self::CoordinateOnly { .. } => None, + Self::MyAnnouncement { repo_ref, .. } + | Self::CoMaintainer { repo_ref, .. } + | Self::NotListed { repo_ref, .. } => Some(repo_ref), + } + } + + /// Extract my own announcement's `RepoRef` from the events map. + /// Returns `None` if no coordinate, no announcement, or I have no event. + fn my_repo_ref(&self, my_pubkey: &PublicKey) -> Option { + self.repo_ref() + .and_then(|rr| my_event_repo_ref(rr, my_pubkey)) + } + + fn has_coordinate(&self) -> bool { + !matches!(self, Self::Fresh) + } +} + +struct ResolvedFields { + identifier: String, + name: String, + description: String, + git_servers: Vec, + relays: Vec, + blossoms: Vec, + web: Vec, + maintainers: Vec, + earliest_unique_commit: String, + hashtags: Vec, + selected_grasp_servers: Vec, +} + +/// Extract my own announcement's `RepoRef` from the events map. +fn my_event_repo_ref(repo_ref: &RepoRef, my_pubkey: &PublicKey) -> Option { + repo_ref + .events + .values() + .find(|e| e.pubkey == *my_pubkey) + .and_then(|e| RepoRef::try_from((e.clone(), None)).ok()) +} + +/// Find the latest event (by `created_at`) across all maintainer events and +/// parse it into a `RepoRef` for shared metadata (name, description, web). +fn latest_event_repo_ref(repo_ref: &RepoRef) -> Option { + repo_ref + .events + .values() + .max_by_key(|e| e.created_at) + .and_then(|e| RepoRef::try_from((e.clone(), None)).ok()) +} + +/// Check if a grasp-format clone URL belongs to the given public key. +fn is_my_grasp_clone_url(url: &str, my_pubkey: &PublicKey) -> bool { + if !is_grasp_server_clone_url(url) { + return false; + } + if let Ok(npub) = extract_npub(url) { + if let Ok(url_pk) = PublicKey::from_bech32(npub) { + return url_pk == *my_pubkey; + } + } + false +} + +/// Check if a relay URL corresponds to one of the given grasp servers. +fn is_grasp_derived_relay(relay: &str, grasp_servers: &[String]) -> bool { + let Ok(relay_normalized) = normalize_grasp_server_url(relay) else { + return false; + }; + grasp_servers.iter().any(|gs| { + normalize_grasp_server_url(gs).is_ok_and(|gs_normalized| gs_normalized == relay_normalized) + }) +} + +/// Check if a blossom URL corresponds to one of the given grasp servers. +fn is_grasp_derived_blossom(blossom: &str, grasp_servers: &[String]) -> bool { + // Blossom URLs are https://{grasp_server} — same normalization as relays + is_grasp_derived_relay(blossom, grasp_servers) +} + +fn dir_name_fallback() -> String { + env::current_dir() + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) + .unwrap_or_default() +} + +fn identifier_from_name(name: &str) -> String { + name.replace(' ', "-") + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c.eq(&'/') { + c + } else { + '-' + } + }) + .collect() +} + +fn build_gitworkshop_url( + public_key: &PublicKey, + identifier: &str, + first_relay: Option<&RelayUrl>, +) -> String { + NostrUrlDecoded { + original_string: String::new(), + coordinate: Nip19Coordinate { + coordinate: Coordinate { + public_key: *public_key, + kind: Kind::GitRepoAnnouncement, + identifier: identifier.to_string(), + }, + relays: first_relay.into_iter().cloned().collect(), + }, + protocol: None, + ssh_key_file: None, + nip05: None, + } + .to_string() + .replace("nostr://", "https://gitworkshop.dev/") +} + +/// Resolve the `web` field from args, existing announcement, or gitworkshop +/// default. +fn resolve_web( + args_web: &[String], + state: &InitState, + identifier: &str, + gitworkshop_url: &str, +) -> Vec { + if !args_web.is_empty() { + return args_web.to_vec(); + } + if let Some(rr) = state.repo_ref() { + let latest_web = latest_event_repo_ref(rr).map_or_else(|| rr.web.clone(), |lr| lr.web); + let joined = latest_web.join(" "); + // replace legacy gitworkshop.dev url format + if joined.contains(&format!("https://gitworkshop.dev/repo/{identifier}")) { + return vec![gitworkshop_url.to_string()]; + } + return latest_web; + } + vec![gitworkshop_url.to_string()] +} + +/// Derive clone-urls, relays, and blossoms from selected grasp servers. +/// +/// For each grasp server, adds/replaces the corresponding clone URL in +/// `git_servers`, adds a relay URL to `relays`, and adds a blossom URL to +/// `blossoms`. Grasp-derived infrastructure is always added — the other +/// lists (`git_servers`, `relays`, `blossoms`) contain *additional* +/// infrastructure beyond what grasp servers provide. +fn apply_grasp_infrastructure( + grasp_servers: &[String], + git_servers: &mut Vec, + relays: &mut Vec, + blossoms: &mut Vec, + public_key: &PublicKey, + identifier: &str, +) -> Result<()> { + for grasp_server in grasp_servers { + // Always add grasp-derived clone URL + let clone_url = format_grasp_server_url_as_clone_url(grasp_server, public_key, identifier)?; + + let grasp_server_clone_root = if clone_url.contains("https://") { + format!("https://{grasp_server}") + } else { + grasp_server.to_string() + }; + + let matching_positions: Vec = git_servers + .iter() + .enumerate() + .filter_map(|(idx, url)| { + if url.contains(&grasp_server_clone_root) { + Some(idx) + } else { + None + } + }) + .collect(); + + if matching_positions.is_empty() { + git_servers.push(clone_url); + } else { + git_servers[matching_positions[0]] = clone_url; + for &position in matching_positions.iter().skip(1).rev() { + git_servers.remove(position); + } + } + + // Always add grasp-derived relay + let relay_url = format_grasp_server_url_as_relay_url(grasp_server)?; + if !relays.contains(&relay_url) { + relays.push(relay_url); + } + + // Always add grasp-derived blossom + let blossom = format_grasp_server_url_as_blossom_url(grasp_server)?; + if !blossoms.contains(&blossom) { + blossoms.push(blossom); + } + } + Ok(()) +} + +/// Resolve which grasp servers to use. Handles flag overrides, detection from +/// existing URLs, user grasp list / system fallbacks, and interactive +/// prompting. +fn resolve_grasp_servers( + args: &SubCommandArgs, + cli: &Cli, + state: &InitState, + user_ref: &ngit::login::user::UserRef, + client: &Client, + identifier: &str, + interactive: bool, +) -> Result> { + if !args.grasp_servers.is_empty() { + return Ok(args.grasp_servers.clone()); + } + + let has_both_relays_and_clone_url = !args.relays.is_empty() && !args.clone.is_empty(); + if has_both_relays_and_clone_url { + return Ok(vec![]); + } + + // Use my own announcement (not the consolidated union) for grasp detection. + // Infrastructure is personal — each maintainer has their own servers. + let my_ref = state.my_repo_ref(&user_ref.public_key); + + if !args.clone.is_empty() { + return Ok(detect_existing_grasp_servers( + my_ref.as_ref(), + &args.relays, + &args.clone, + identifier, + )); + } + + if !interactive || cli.defaults || state.has_coordinate() || cli.force { + // Prefer grasp servers from my existing announcement, then user's grasp + // list, then system fallbacks + let existing = + detect_existing_grasp_servers(my_ref.as_ref(), &args.relays, &[], identifier); + if !existing.is_empty() { + return Ok(existing); + } + return Ok(grasp_servers_from_user_or_fallback(user_ref, client)); + } + + // Interactive prompt + let mut options: Vec = + detect_existing_grasp_servers(my_ref.as_ref(), &args.relays, &args.clone, identifier); + let mut selections: Vec = vec![true; options.len()]; + let empty = options.is_empty(); + for user_grasp_option in &user_ref.grasp_list.urls { + if !options + .iter() + .any(|option| option.contains(user_grasp_option.as_str())) + { + options.push(user_grasp_option.to_string()); + selections.push(empty); + } + } + let empty = options.is_empty(); + let fallback_grasp_servers = client.get_grasp_default_set(); + for fallback in fallback_grasp_servers { + if !options.iter().any(|option| option.contains(fallback)) { + options.push(fallback.clone()); + selections.push(empty); + } + } + let selected = multi_select_with_custom_value( + "grasp servers (ideally use between 2-4)", + "grasp server", + options, + selections, + normalize_grasp_server_url, + )?; + show_multi_input_prompt_success("grasp servers", &selected); + Ok(selected) +} + +fn grasp_servers_from_user_or_fallback( + user_ref: &ngit::login::user::UserRef, + client: &Client, +) -> Vec { + if user_ref.grasp_list.urls.is_empty() { + client + .get_grasp_default_set() + .iter() + .map(std::string::ToString::to_string) + .collect() + } else { + user_ref + .grasp_list + .urls + .iter() + .map(std::string::ToString::to_string) + .collect() + } +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/// Validation for State A (Fresh): no existing coordinate. +fn validate_fresh(cli: &Cli, args: &SubCommandArgs, user_has_grasp_list: bool) -> Result<()> { + // -d or -f with no substantive flags: proceed with all defaults + if !args.has_substantive_flags() && (cli.defaults || cli.force) { + return Ok(()); + } + + // Substantive flags provided: -d fills any gaps + if cli.defaults { + return Ok(()); + } + + // Validate essential fields + let mut missing: Vec<(&str, &str)> = Vec::new(); + + let missing_name = args.identifier.is_none() && args.name.is_none(); + if missing_name { + missing.push(("--name ", "repository name or identifier")); + } + + let has_grasp_servers = !args.grasp_servers.is_empty(); + let has_both_relays_and_clone_url = !args.relays.is_empty() && !args.clone.is_empty(); + let missing_servers = + !has_grasp_servers && !user_has_grasp_list && !has_both_relays_and_clone_url; + if missing_servers { + missing.push(( + "--grasp-servers ...", + "where your git+nostr data is hosted", + )); + } + + if missing.is_empty() { + return Ok(()); + } + + let message = if missing.len() == 1 { + let (flag, desc) = missing[0]; + format!("missing {flag} ({desc})") + } else { + "missing required fields".to_string() + }; + + let mut details: Vec<(&str, &str)> = if missing.len() > 1 { + missing.clone() + } else { + vec![] + }; + + details.push(("-d, --defaults", "or just use sensible defaults")); + let name_part = if missing_name { + " --name \"My Project\"" + } else { + "" + }; + let suggestion = + format!("ngit init{name_part} --description \"my project description\" --defaults"); + + Err(cli_error(&message, &details, &[&suggestion])) +} + #[derive(Debug, clap::Args)] pub struct SubCommandArgs { - #[clap(short, long)] - /// name of repository - title: Option, - #[clap(short, long)] + #[clap(long)] + /// name of repository (preferred over --identifier) + name: Option, + #[clap(long)] + /// shortname with no spaces or special characters + identifier: Option, + #[clap(long)] /// optional description description: Option, - #[clap(long)] - /// git server url users can clone from - clone_url: Vec, - #[clap(short, long, value_parser, num_args = 1..)] - /// homepage - web: Vec, #[clap(short, long, value_parser, num_args = 1..)] - /// relays contributors push patches and comments to + /// where your git+nostr data is hosted + grasp_servers: Vec, + #[clap(long, value_parser, num_args = 1..)] + /// additional relays beyond grasp servers relays: Vec, - #[clap(short, long, value_parser, num_args = 1..)] - /// blossom servers + #[clap(long)] + /// additional git server URLs beyond grasp servers + clone: Vec, + #[clap(long, value_parser, num_args = 1..)] + /// additional blossom servers beyond grasp servers blossoms: Vec, - #[clap(short, long, value_parser, num_args = 1..)] + #[clap(long, value_parser, num_args = 1..)] + /// homepage + web: Vec, + #[clap(long, value_parser, num_args = 1..)] /// npubs of other maintainers other_maintainers: Vec, #[clap(long)] /// usually root commit but will be more recent commit for forks earliest_unique_commit: Option, - #[clap(short, long)] - /// shortname with no spaces or special characters - identifier: Option, } -#[allow(clippy::too_many_lines)] -pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { - let git_repo = Repo::discover().context("failed to find a git repository")?; - let git_repo_path = git_repo.get_path()?; +impl SubCommandArgs { + fn has_substantive_flags(&self) -> bool { + self.name.is_some() + || self.identifier.is_some() + || self.description.is_some() + || !self.clone.is_empty() + || !self.relays.is_empty() + || !self.grasp_servers.is_empty() + || !self.web.is_empty() + || !self.blossoms.is_empty() + || !self.other_maintainers.is_empty() + || self.earliest_unique_commit.is_some() + } +} - let root_commit = git_repo - .get_root_commit() - .context("failed to get root commit of the repository")?; +// --------------------------------------------------------------------------- +// Pre/post-fetch validation +// --------------------------------------------------------------------------- + +fn validate_pre_fetch( + cli: &Cli, + args: &SubCommandArgs, + repo_coordinate: Option<&Nip19Coordinate>, + user_has_grasp_list: bool, +) -> Result<()> { + // Interactive mode bypasses pre-fetch validation + if cli.interactive { + return Ok(()); + } - // TODO: check for empty repo - // TODO: check for existing maintaiers file + // If no coordinate exists, we're in State A (Fresh) - validate now + if repo_coordinate.is_none() { + return validate_fresh(cli, args, user_has_grasp_list); + } - let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); + // Coordinate exists - we need to fetch before we can validate further + Ok(()) +} - let repo_coordinate = (try_and_get_repo_coordinates_when_remote_unknown(&git_repo).await).ok(); +fn validate_post_fetch(cli: &Cli, args: &SubCommandArgs, state: &InitState) -> Result<()> { + // Interactive mode bypasses all validation + if cli.interactive { + return Ok(()); + } - let repo_ref = if let Some(repo_coordinate) = &repo_coordinate { - fetching_with_report(git_repo_path, &client, repo_coordinate).await?; - (get_repo_ref_from_cache(Some(git_repo_path), repo_coordinate).await).ok() + match state { + InitState::Fresh => { + // Already validated in pre-fetch + Ok(()) + } + InitState::CoordinateOnly { coordinate } => { + if cli.force { + Ok(()) + } else { + let id = &coordinate.identifier; + Err(cli_error( + &format!( + "no announcement found for coordinate '{id}'\n\n\ + \x20 This could be a relay or network issue. Only proceed with --force\n\ + \x20 if you are sure there isn't an existing announcement event." + ), + &[], + &["ngit init --force"], + )) + } + } + InitState::MyAnnouncement { repo_ref, .. } => { + if let Some(new_id) = &args.identifier { + if *new_id != repo_ref.identifier && !cli.force { + let suggestion = format!("ngit init --identifier {new_id} --force"); + return Err(cli_error( + "changing identifier creates a new repository", + &[], + &[&suggestion], + )); + } + } + if !args.has_substantive_flags() && !cli.force { + return Err(cli_error( + "no arguments specified, use --force to publish with new timestamp", + &[], + &["ngit init --force"], + )); + } + Ok(()) + } + InitState::CoMaintainer { repo_ref, .. } => { + if let Some(new_id) = &args.identifier { + if *new_id != repo_ref.identifier && !cli.force { + let suggestion = format!("ngit init --identifier {new_id} --force"); + return Err(cli_error( + "changing identifier creates a new repository", + &[], + &[&suggestion], + )); + } + } + Ok(()) + } + InitState::NotListed { .. } => { + if cli.force { + Ok(()) + } else { + Err(cli_error( + "you are not listed as a maintainer", + &[], + &["ngit init --force"], + )) + } + } + } +} + +#[allow(clippy::too_many_lines)] +#[allow(clippy::too_many_arguments)] +fn resolve_fields( + state: &InitState, + user_ref: &ngit::login::user::UserRef, + args: &SubCommandArgs, + cli: &Cli, + git_repo: &Repo, + root_commit: &str, + client: &Client, + repo_config_result: &Result, + interactive: bool, +) -> Result { + let my_pubkey = &user_ref.public_key; + + // Shared lookups used by multiple fields below + let latest = state.repo_ref().and_then(latest_event_repo_ref); + let my_ref = state.my_repo_ref(my_pubkey); + + // --- Identifier default --- + let identifier_default = if let Some(coord) = state.coordinate() { + coord.identifier.clone() + } else if let Ok(config) = repo_config_result { + if let Some(id) = &config.identifier { + id.clone() + } else { + dir_name_fallback() + } } else { - None + dir_name_fallback() }; - let (signer, user_ref, _) = login::login_or_signup( - &Some(&git_repo), - &extract_signer_cli_arguments(cli_args).unwrap_or(None), - &cli_args.password, - Some(&client), - true, - ) - .await?; - - let repo_config_result = get_repo_config_from_yaml(&git_repo); - // TODO: check for other claims + // --- Name --- + let name_default = if let Some(ref lr) = latest { + lr.name.clone() + } else if let Some(coord) = state.coordinate() { + coord.identifier.clone() + } else { + dir_name_fallback() + }; - let name = match &args.title { - Some(t) => t.clone(), - None => Interactor::default().input( + let name = if let Some(v) = &args.name { + v.clone() + } else if interactive { + Interactor::default().input( PromptInputParms::default() .with_prompt("repo name") - .with_default(if let Some(repo_ref) = &repo_ref { - repo_ref.name.clone() - } else if let Some(coordinate) = &repo_coordinate { - coordinate.identifier.clone() - } else if let Ok(path) = env::current_dir() { - if let Some(current_dir_name) = path.file_name() { - current_dir_name.to_string_lossy().to_string() - } else { - String::new() - } - } else { - String::new() - }), - )?, + .with_default(name_default.clone()) + .with_flag_name("--name"), + )? + } else { + name_default.clone() }; - let description = match &args.description { - Some(t) => t.clone(), - None => Interactor::default().input( + // --- Description --- + let description_default = latest + .as_ref() + .map_or_else(String::new, |lr| lr.description.clone()); + + let description = if let Some(v) = &args.description { + v.clone() + } else if interactive { + Interactor::default().input( PromptInputParms::default() - .with_prompt("repo description (one sentance)") + .with_prompt("repo description (one sentence)") .optional() - .with_default(if let Some(repo_ref) = &repo_ref { - repo_ref.description.clone() - } else { - String::new() - }), - )?, + .with_default(description_default.clone()) + .with_flag_name("--description"), + )? + } else { + description_default }; - // this is important so init can be completed done without prompts - let has_server_and_relay_flags = !args.clone_url.is_empty() && !args.relays.is_empty(); - - let simple_mode = if has_server_and_relay_flags { - false + // --- Simple mode (interactive only) --- + let simple_mode = if !interactive || (!args.clone.is_empty() && !args.relays.is_empty()) { + false // not used in non-interactive, but avoids Option } else { Interactor::default().choice( PromptChoiceParms::default() @@ -162,216 +689,142 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { )? == 0 }; - let identifier_default = if let Some(repo_ref) = &repo_ref { - repo_ref.identifier.clone() - } else if let Some(repo_coordinate) = &repo_coordinate { - repo_coordinate.identifier.clone() - } else { - let fallback = name - .clone() - .replace(' ', "-") - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c.eq(&'/') { - c - } else { - '-' - } - }) - .collect(); - if let Ok(config) = &repo_config_result { - if let Some(identifier) = &config.identifier { - identifier.to_string() - } else { - fallback - } + // --- Identifier --- + let identifier = if let Some(id) = &args.identifier { + id.clone() + } else if state.has_coordinate() { + identifier_default.clone() + } else if !interactive || cli.defaults { + if args.name.is_some() && !state.has_coordinate() { + identifier_from_name(&name) } else { - fallback + identifier_default.clone() } + } else { + let id_default = if args.name.is_some() || name != name_default { + identifier_from_name(&name) + } else { + identifier_default.clone() + }; + Interactor::default().input( + PromptInputParms::default() + .with_prompt("repo identifier") + .with_default(id_default) + .with_flag_name("--identifier"), + )? }; - let identifier = match &args.identifier { - Some(t) => t.clone(), - None => { - if simple_mode { - identifier_default + // --- Grasp servers --- + let selected_grasp_servers = + resolve_grasp_servers(args, cli, state, user_ref, client, &identifier, interactive)?; + + // --- Base infrastructure (flag > my event > fallback) --- + // Grasp-derived infrastructure (my clone URLs, relays, blossoms) is handled + // by apply_grasp_infrastructure below. Defaults here are *additional* + // infrastructure only. My own grasp-format clone URLs are filtered out so + // they get re-derived from the resolved grasp servers. Grasp-format clone + // URLs belonging to other maintainers are kept as additional git servers. + let no_state = git_repo + .get_git_config_item("nostr.nostate", None) + .ok() + .flatten() + .is_some_and(|s| s == "true"); + + // Detect my grasp servers from my existing announcement (for filtering) + let my_existing_grasp_servers: Vec = my_ref + .as_ref() + .map(|mr| detect_existing_grasp_servers(Some(mr), &[], &[], &identifier)) + .unwrap_or_default(); + + let git_servers_default = if let Some(ref mr) = my_ref { + // Keep non-grasp URLs and grasp URLs from other maintainers; + // filter out my own grasp-derived clone URLs (re-derived from grasp servers) + mr.git_server + .iter() + .filter(|url| !is_my_grasp_clone_url(url, my_pubkey)) + .cloned() + .collect() + } else if no_state { + // Only fall back to origin URL when nostate is set (user pushes directly + // to a traditional git server rather than through grasp servers) + if let Ok(url) = git_repo.get_origin_url() { + if let Ok(fetch_url) = convert_clone_url_to_https(&url) { + vec![fetch_url] + } else if url.starts_with("nostr://") { + vec![] } else { - Interactor::default().input( - PromptInputParms::default() - .with_prompt( - "repo identifier (typically the short name with hypens instead of spaces)", - ) - .with_default(identifier_default), - )? + vec![url] } - } - }; - - let mut git_server_defaults: Vec = if !args.clone_url.is_empty() { - args.clone_url.clone() - } else if let Some(repo_ref) = &repo_ref { - // TODO dont default to git servers of other maintainers (?) - repo_ref.git_server.clone() - } else if let Ok(url) = git_repo.get_origin_url() { - if let Ok(fetch_url) = convert_clone_url_to_https(&url) { - vec![fetch_url] - } else if url.starts_with("nostr://") { - // nostr added as origin remote before repo announcement sent - vec![] } else { - // local repo or custom protocol - vec![url] + vec![] } } else { vec![] }; - let mut relay_defaults = if args.relays.is_empty() { - if let Ok(config) = &repo_config_result { - config.relays.clone() - } else if let Some(repo_ref) = &repo_ref { - repo_ref - .relays - .iter() - .map(std::string::ToString::to_string) - .collect::>() - } else { + let relays_default = if let Some(ref mr) = my_ref { + // Keep relays that don't correspond to my grasp servers + // (grasp-derived relays are re-added by apply_grasp_infrastructure) + mr.relays + .iter() + .map(std::string::ToString::to_string) + .filter(|r| !is_grasp_derived_relay(r, &my_existing_grasp_servers)) + .collect() + } else if let Ok(config) = repo_config_result { + if config.relays.is_empty() { client.get_relay_default_set().clone() - } - } else { - args.relays.clone() - }; - - let mut blossoms_defaults = if args.blossoms.is_empty() { - if let Some(repo_ref) = &repo_ref { - repo_ref - .blossoms - .iter() - .map(UrlWithoutSlash::to_string_without_trailing_slash) - .collect::>() - // } else if user_ref.blossoms.read().is_empty() { - // client.get_fallback_relays().clone() } else { - vec![] - // user_ref.relays.read().clone() + config.relays.clone() } } else { - args.blossoms.clone() + client.get_relay_default_set().clone() }; - let fallback_grasp_servers = client.get_grasp_default_set(); - - let selected_grasp_servers = if has_server_and_relay_flags { - // ignore so a script running `ngit init` can contiue without prompts - vec![] + let blossoms_default: Vec = if let Some(ref mr) = my_ref { + // Keep blossoms that don't correspond to my grasp servers + mr.blossoms + .iter() + .map(UrlWithoutSlash::to_string_without_trailing_slash) + .filter(|b| !is_grasp_derived_blossom(b, &my_existing_grasp_servers)) + .collect() } else { - let mut options: Vec = detect_existing_grasp_servers( - repo_ref.as_ref(), - &args.relays, - &args.clone_url, - &identifier, - ); - let mut selections: Vec = vec![true; options.len()]; // Initialize selections based on existing options - let empty = options.is_empty(); - for user_grasp_option in user_ref.grasp_list.urls { - // Check if any option contains the user_grasp_option as a substring - if !options - .iter() - .any(|option| option.contains(user_grasp_option.as_str())) - { - options.push(user_grasp_option.to_string()); // Add if not found - selections.push(empty); // mark as selected if no existing grasp otherwise not - } - } - - let empty = options.is_empty(); - for fallback in fallback_grasp_servers { - // Check if any option contains the fallback as a substring - if !options.iter().any(|option| option.contains(fallback)) { - options.push(fallback.clone()); // Add fallback if not found - selections.push(empty); // mark as selected if no existing selections otherwise not - } - } - let selected = multi_select_with_custom_value( - "grasp servers (ideally use between 2-4)", - "grasp server", - options, - selections, - normalize_grasp_server_url, - )?; - show_multi_input_prompt_success("grasp servers", &selected); - selected + vec![] }; - // ensure ngit relays are added as git server, relay and blossom entries - for grasp_server in &selected_grasp_servers { - if args.clone_url.is_empty() { - let clone_url = format_grasp_server_url_as_clone_url( - grasp_server, - &user_ref.public_key, - &identifier, - )?; - - let grasp_server_clone_root = if clone_url.contains("https://") { - format!("https://{grasp_server}") - } else { - grasp_server.to_string() - }; - - // Find all positions of entries containing the relay root - let matching_positions: Vec = git_server_defaults - .iter() - .enumerate() - .filter_map(|(idx, url)| { - if url.contains(&grasp_server_clone_root) { - Some(idx) - } else { - None - } - }) - .collect(); - - // If we found any matches - if matching_positions.is_empty() { - // No existing entries found, so add a new one - git_server_defaults.push(clone_url); - } else { - // Replace the first occurrence - git_server_defaults[matching_positions[0]] = clone_url; - - // Remove any subsequent occurrences (in reverse order to avoid index issues) - for &position in matching_positions.iter().skip(1).rev() { - git_server_defaults.remove(position); - } - } - } - if args.relays.is_empty() { - let relay_url = format_grasp_server_url_as_relay_url(grasp_server)?; - if !relay_defaults.contains(&relay_url) { - relay_defaults.push(relay_url); - } - } - if args.blossoms.is_empty() { - let blossom = format_grasp_server_url_as_blossom_url(grasp_server)?; - if !blossoms_defaults.contains(&blossom) { - blossoms_defaults.push(blossom); - } - } - } - - let no_state = if let Ok(Some(s)) = git_repo.get_git_config_item("nostr.nostate", None) { - s == "true" + let mut git_servers = if args.clone.is_empty() { + git_servers_default + } else { + args.clone.clone() + }; + let mut relay_strings = if args.relays.is_empty() { + relays_default } else { - false + args.relays.clone() + }; + let mut blossom_strings = if args.blossoms.is_empty() { + blossoms_default + } else { + args.blossoms.clone() }; - if no_state + + apply_grasp_infrastructure( + &selected_grasp_servers, + &mut git_servers, + &mut relay_strings, + &mut blossom_strings, + &user_ref.public_key, + &identifier, + )?; + + // --- Interactive: nostr.nostate prompt --- + if interactive + && no_state && Interactor::default().confirm( PromptConfirmParms::default() .with_prompt("store state on nostr? required for nostr-permissioned git servers") .with_default(true), )? { - // TODO check if grasp servers in use and if so turn this off: if git_repo .get_git_config_item("nostr.nostate", Some(true)) .unwrap_or(None) @@ -383,214 +836,146 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { } } - let git_server = if args.clone_url.is_empty() { - let grasp_server_git_servers: Vec = git_server_defaults + // --- Git servers (interactive prompting) --- + let git_servers = if !args.clone.is_empty() || !interactive { + git_servers + } else { + prompt_git_servers(git_servers, &selected_grasp_servers, simple_mode)? + }; + + // --- Relays --- + let relays: Vec = if !args.relays.is_empty() || !interactive { + relay_strings .iter() - .filter(|s| is_grasp_server_clone_url(s)) - .cloned() + .filter_map(|r| parse_relay_url(r).ok()) + .collect() + } else if simple_mode { + let grasp_relay_urls: Vec = selected_grasp_servers + .iter() + .filter_map(|r| format_grasp_server_url_as_relay_url(r).ok()) .collect(); - let mut additional_server_options: Vec = git_server_defaults + let mut options: Vec = relay_strings .iter() - .filter(|s| !is_grasp_server_clone_url(s)) + .filter(|s| !grasp_relay_urls.iter().any(|r| s.as_str() == r)) .cloned() .collect(); - - if simple_mode && !selected_grasp_servers.is_empty() { - if additional_server_options.is_empty() { - git_server_defaults - } else { - // additional git servers were listed - let selected = loop { - let selections: Vec = vec![true; additional_server_options.len()]; - let selected = multi_select_with_custom_value( - "additional git server(s) on top of grasp servers", - "git server remote url", - additional_server_options, - selections, - |s| { - CloneUrl::from_str(s) - .map(|_| s.to_string()) - .context(format!("Invalid git server URL format: {s}")) - }, - )?; - - if selected.is_empty() || Interactor::default().choice( - PromptChoiceParms::default() - .with_prompt("if you or another maintainer start pushing directly to these, nostr will be out of date") - .dont_report() - .with_choices(vec![ - "I'll always push to the nostr remote".to_string(), - "change setup".to_string(), - ]) - .with_default(0), - )? == 1 { - additional_server_options = selected; - continue - } - break selected; - }; - show_multi_input_prompt_success("additional git servers", &selected); - let mut combined = grasp_server_git_servers; - combined.extend(selected); - combined + let mut selections: Vec = vec![true; options.len()]; + for relay in client.get_relay_default_set().clone() { + if !options.iter().any(|r| r.contains(&relay)) + && !grasp_relay_urls.iter().any(|r| relay.contains(r)) + { + options.push(relay); + selections.push(selections.is_empty()); } - } else { - // show all git servers - let selections: Vec = vec![true; git_server_defaults.len()]; - - let selected = multi_select_with_custom_value( - "git server remote url(s)", - "git server remote url", - git_server_defaults, - selections, - |s| { - CloneUrl::from_str(s) - .map(|_| s.to_string()) - .context(format!("Invalid git server URL format: {s}")) - }, - )?; - show_multi_input_prompt_success("git servers", &selected); - selected } + let selected = multi_select_with_custom_value( + "additional nostr relays on top of nostr-relays - 1 or 2 public relays are reccomended", + "nostr relay", + options, + selections, + |s| { + parse_relay_url(s) + .map(|_| s.to_string()) + .context(format!("Invalid relay URL format: {s}")) + }, + )?; + show_multi_input_prompt_success("additional nostr relays", &selected); + [ + grasp_relay_urls + .iter() + .filter_map(|r| parse_relay_url(r).ok()) + .collect::>(), + selected + .iter() + .filter_map(|r| parse_relay_url(r).ok()) + .collect::>(), + ] + .concat() } else { - git_server_defaults + // advanced interactive + let selections: Vec = vec![true; relay_strings.len()]; + let selected = multi_select_with_custom_value( + "nostr relays", + "nostr relay", + relay_strings, + selections, + |s| { + parse_relay_url(s) + .map(|_| s.to_string()) + .context(format!("Invalid relay URL format: {s}")) + }, + )?; + show_multi_input_prompt_success("nostr relays", &selected); + selected + .iter() + .filter_map(|r| parse_relay_url(r).ok()) + .collect() }; - let relays: Vec = { - if simple_mode { - let formatted_selected_grasp_servers: Vec = selected_grasp_servers - .iter() - .filter_map(|r| format_grasp_server_url_as_relay_url(r).ok()) - .collect(); - let mut options: Vec = relay_defaults - .iter() - .filter(|s| { - !formatted_selected_grasp_servers - .iter() - .any(|r| s.as_str() == r) - }) - .cloned() - .collect(); - - let mut selections: Vec = vec![true; options.len()]; - - // add fallback relays as options - for relay in client.get_relay_default_set().clone() { - if !options.iter().any(|r| r.contains(&relay)) - && !formatted_selected_grasp_servers - .iter() - .any(|r| relay.contains(r)) - { - options.push(relay); - selections.push(selections.is_empty()); - } - } + // --- Blossoms --- + let blossoms: Vec = if !args.blossoms.is_empty() || !interactive { + blossom_strings + .iter() + .filter_map(|b| Url::parse(b).ok()) + .collect() + } else if !simple_mode { + let selections: Vec = vec![true; blossom_strings.len()]; + let selected = multi_select_with_custom_value( + "blossom servers", + "blossom server", + blossom_strings, + selections, + |s| { + format_grasp_server_url_as_blossom_url(s) + .context(format!("Invalid blossom URL format: {s}")) + }, + )?; + show_multi_input_prompt_success("blossom servers", &selected); + selected.iter().filter_map(|b| Url::parse(b).ok()).collect() + } else { + blossom_strings + .iter() + .filter_map(|b| Url::parse(b).ok()) + .collect() + }; - let selected = multi_select_with_custom_value( - "additional nostr relays on top of nostr-relays - 1 or 2 public relays are reccomended", - "nostr relay", - options, - selections, - |s| { - parse_relay_url(s) - .map(|_| s.to_string()) - .context(format!("Invalid relay URL format: {s}")) - }, - )?; - show_multi_input_prompt_success("additional nostr relays", &selected); - [ - formatted_selected_grasp_servers - .iter() - .filter_map(|r| parse_relay_url(r).ok()) - .collect::>(), - selected - .iter() - .filter_map(|r| parse_relay_url(r).ok()) - .collect::>(), - ] - .concat() - } else { - let selections: Vec = vec![true; relay_defaults.len()]; - if args.relays.is_empty() { - let selected = multi_select_with_custom_value( - "nostr relays", - "nostr relay", - relay_defaults, - selections, - |s| { - parse_relay_url(s) - .map(|_| s.to_string()) - .context(format!("Invalid relay URL format: {s}")) - }, - )?; - show_multi_input_prompt_success("nostr relays", &selected); - selected - .iter() - .filter_map(|r| parse_relay_url(r).ok()) - .collect() - } else { - relay_defaults - .iter() - .filter_map(|r| parse_relay_url(r).ok()) - .collect() + // --- Maintainers --- + let maintainers_default = if let Some(ref mr) = my_ref { + let mut m = vec![*my_pubkey]; + for pk in &mr.maintainers { + if !m.contains(pk) { + m.push(*pk); } } - }; - - let blossoms: Vec = { - if simple_mode || has_server_and_relay_flags { - blossoms_defaults - .iter() - .filter_map(|b| Url::parse(b).ok()) - .collect() + m + } else if let Some(coord) = state.coordinate() { + let trusted = coord.coordinate.public_key; + if trusted == *my_pubkey { + vec![*my_pubkey] } else { - let selections: Vec = vec![true; blossoms_defaults.len()]; - if args.blossoms.is_empty() { - let selected = multi_select_with_custom_value( - "blossom servers", - "blossom server", - blossoms_defaults, - selections, - |s| { - format_grasp_server_url_as_blossom_url(s) - .context(format!("Invalid blossom URL format: {s}")) - }, - )?; - show_multi_input_prompt_success("nostr relays", &selected); - selected.iter().filter_map(|b| Url::parse(b).ok()).collect() - } else { - blossoms_defaults - .iter() - .filter_map(|b| Url::parse(b).ok()) - .collect() - } + vec![*my_pubkey, trusted] } + } else { + vec![*my_pubkey] }; - let default_maintainers = { - let mut maintainers = vec![user_ref.public_key]; - if args.other_maintainers.is_empty() { - if let Some(repo_ref) = &repo_ref { - for m in &repo_ref.maintainers { - if !maintainers.contains(m) { - maintainers.push(*m); - } - } - } - } else { - for m in &args.other_maintainers { - if let Ok(pubkey) = PublicKey::from_bech32(m).context("invalid npub") { - if !maintainers.contains(&pubkey) { - maintainers.push(pubkey); - } + let base_maintainers = if args.other_maintainers.is_empty() { + maintainers_default + } else { + let mut m = vec![user_ref.public_key]; + for npub in &args.other_maintainers { + if let Ok(pk) = PublicKey::from_bech32(npub) { + if !m.contains(&pk) { + m.push(pk); } } } - maintainers + m }; - let maintainers: Vec = if args.other_maintainers.is_empty() { - if default_maintainers.len() == 1 + let maintainers = if !args.other_maintainers.is_empty() + || !interactive + || (base_maintainers.len() == 1 && Interactor::default().choice( PromptChoiceParms::default() .with_prompt("add other maintainers now?") @@ -600,41 +985,44 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { "add maintainers".to_string(), ]) .with_default(0), - )? == 0 - { - default_maintainers - } else { - let selections: Vec = vec![true; default_maintainers.len()]; - - let selected = multi_select_with_custom_value( - "maintainers", - "maintainer npub", - default_maintainers - .iter() - .filter_map(|m| m.to_bech32().ok()) - .collect(), - selections, - |s| { - extract_npub(s) - .map(|_| s.to_string()) - .context(format!("Invalid npub: {s}")) - }, - )?; - show_multi_input_prompt_success("maintainers", &selected); - selected - .iter() - .filter_map(|npub| PublicKey::parse(npub).ok()) - .collect() - } + )? == 0) + { + base_maintainers } else { - default_maintainers + let selections: Vec = vec![true; base_maintainers.len()]; + let selected = multi_select_with_custom_value( + "maintainers", + "maintainer npub", + base_maintainers + .iter() + .filter_map(|m| m.to_bech32().ok()) + .collect(), + selections, + |s| { + extract_npub(s) + .map(|_| s.to_string()) + .context(format!("Invalid npub: {s}")) + }, + )?; + show_multi_input_prompt_success("maintainers", &selected); + selected + .iter() + .filter_map(|npub| PublicKey::parse(npub).ok()) + .collect() }; - if selected_grasp_servers.is_empty() && git_server.iter().any(|s| s.contains("github.com") || s.contains("codeberg.org")) && Interactor::default().confirm( + // --- Interactive: github/codeberg warning --- + if interactive + && selected_grasp_servers.is_empty() + && git_servers + .iter() + .any(|s| s.contains("github.com") || s.contains("codeberg.org")) + && Interactor::default().confirm( PromptConfirmParms::default() .with_prompt("you have listed github / codeberg. Are you or other maintainers planning on pushing directly to github / codeberg rather than using your shiny new nostr clone url which will do this for you?") .with_default(false), - )? { + )? + { println!("This means people using the nostr URL won't get your latest branch updates."); if Interactor::default().confirm( PromptConfirmParms::default() @@ -645,124 +1033,228 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { } } - let gitworkshop_url = NostrUrlDecoded { - original_string: String::new(), - coordinate: Nip19Coordinate { - coordinate: Coordinate { - public_key: user_ref.public_key, - kind: Kind::GitRepoAnnouncement, - identifier: identifier.clone(), - }, - relays: if let Some(relay) = relays.first() { - vec![relay.clone()] - } else { - vec![] - }, - }, - protocol: None, - ssh_key_file: None, - nip05: None, - } - .to_string() - .replace("nostr://", "https://gitworkshop.dev/"); - - let web: Vec = if args.web.is_empty() { - let web_default = if let Some(repo_ref) = &repo_ref { - if repo_ref - .web - .clone() - .join(" ") - // replace legacy gitworkshop.dev url format with new one - .contains(format!("https://gitworkshop.dev/repo/{}", &identifier).as_str()) - { - gitworkshop_url.clone() - } else { - repo_ref.web.clone().join(" ") - } - } else { - gitworkshop_url.clone() - }; + // --- Web --- + let gitworkshop_url = build_gitworkshop_url(&user_ref.public_key, &identifier, relays.first()); + let web_default = resolve_web(&args.web, state, &identifier, &gitworkshop_url); - if simple_mode { - web_default - } else { - Interactor::default().input( + let web = if !args.web.is_empty() || !interactive || simple_mode { + web_default + } else { + // advanced interactive + let web_default_str = web_default.join(" "); + Interactor::default() + .input( PromptInputParms::default() .with_prompt("repo website") .optional() - .with_default(web_default), + .with_default(web_default_str) + .with_flag_name("--web"), )? - } - .split(' ') - .map(std::string::ToString::to_string) - .collect() - } else { - args.web.clone() + .split(' ') + .map(std::string::ToString::to_string) + .collect() }; - let earliest_unique_commit = if let Some(t) = &args.earliest_unique_commit { - t.clone() - } else { - let mut earliest_unique_commit = if let Some(repo_ref) = &repo_ref { - repo_ref.root_commit.clone() - } else { - root_commit.to_string() - }; - if simple_mode { - earliest_unique_commit + // --- Earliest unique commit --- + // Cascade: my event -> consolidated RepoRef (trusted maintainer's) -> local + // root commit + let my_euc = my_ref + .as_ref() + .map(|mr| &mr.root_commit) + .filter(|c| !c.is_empty()); + let repo_euc = state + .repo_ref() + .map(|rr| &rr.root_commit) + .filter(|c| !c.is_empty()); + let euc_default = my_euc + .or(repo_euc) + .cloned() + .unwrap_or_else(|| root_commit.to_string()); + + let earliest_unique_commit = if let Some(commit) = &args.earliest_unique_commit { + if let Ok(exists) = git_repo.does_commit_exist(commit) { + if !exists { + bail!("earliest unique commit does not exist on current repository"); + } } else { - println!( - "the earliest unique commit helps with discoverability. It defaults to the root commit. Only change this if your repo has completely forked off an has formed its own identity." - ); - loop { - earliest_unique_commit = Interactor::default().input( - PromptInputParms::default() - .with_prompt("earliest unique commit (to help with discoverability)") - .with_default(earliest_unique_commit.clone()), - )?; - if let Ok(exists) = git_repo.does_commit_exist(&earliest_unique_commit) { - if exists { - break earliest_unique_commit; - } - println!("commit does not exist on current repository"); - } else { - println!("commit id not formatted correctly"); + bail!("earliest unique commit id not formatted correctly"); + } + if commit.len() != 40 { + bail!("earliest unique commit id must be 40 characters long"); + } + commit.clone() + } else if interactive && !simple_mode { + println!( + "the earliest unique commit helps with discoverability. It defaults to the root commit. Only change this if your repo has completely forked off an has formed its own identity." + ); + let mut result = euc_default.clone(); + loop { + result = Interactor::default().input( + PromptInputParms::default() + .with_prompt("earliest unique commit (to help with discoverability)") + .with_default(result.clone()) + .with_flag_name("--earliest-unique-commit"), + )?; + if let Ok(exists) = git_repo.does_commit_exist(&result) { + if exists && result.len() == 40 { + break; } - if earliest_unique_commit.len().ne(&40) { - println!("commit id must be 40 characters long"); + if !exists { + println!("commit does not exist on current repository"); } + } else { + println!("commit id not formatted correctly"); + } + if result.len() != 40 { + println!("commit id must be 40 characters long"); } } + result + } else { + euc_default }; - println!("publishing repostory announcement to nostr..."); + // --- Hashtags (shared metadata — from latest event, like name/description/web) + // --- + let hashtags = latest + .as_ref() + .map_or_else(Vec::new, |lr| lr.hashtags.clone()); - let repo_ref = RepoRef { - identifier: identifier.clone(), + Ok(ResolvedFields { + identifier, name, description, - root_commit: earliest_unique_commit, - git_server, - web, - relays: relays.clone(), + git_servers, + relays, blossoms, - hashtags: if let Some(repo_ref) = repo_ref { - repo_ref.hashtags - } else { - vec![] - }, + web, + maintainers, + earliest_unique_commit, + hashtags, + selected_grasp_servers, + }) +} + +/// Interactive prompt for git server selection with simple/advanced modes. +fn prompt_git_servers( + git_servers: Vec, + selected_grasp_servers: &[String], + simple_mode: bool, +) -> Result> { + let grasp_server_git_servers: Vec = git_servers + .iter() + .filter(|s| is_grasp_server_clone_url(s)) + .cloned() + .collect(); + let mut additional_server_options: Vec = git_servers + .iter() + .filter(|s| !is_grasp_server_clone_url(s)) + .cloned() + .collect(); + + if simple_mode && !selected_grasp_servers.is_empty() { + if additional_server_options.is_empty() { + return Ok(git_servers); + } + let selected = loop { + let selections: Vec = vec![true; additional_server_options.len()]; + let selected = multi_select_with_custom_value( + "additional git server(s) on top of grasp servers", + "git server remote url", + additional_server_options, + selections, + |s| { + CloneUrl::from_str(s) + .map(|_| s.to_string()) + .context(format!("Invalid git server URL format: {s}")) + }, + )?; + + if selected.is_empty() + || Interactor::default().choice( + PromptChoiceParms::default() + .with_prompt("if you or another maintainer start pushing directly to these, nostr will be out of date") + .dont_report() + .with_choices(vec![ + "I'll always push to the nostr remote".to_string(), + "change setup".to_string(), + ]) + .with_default(0), + )? == 1 + { + additional_server_options = selected; + continue; + } + break selected; + }; + show_multi_input_prompt_success("additional git servers", &selected); + let mut combined = grasp_server_git_servers; + combined.extend(selected); + Ok(combined) + } else { + let selections: Vec = vec![true; git_servers.len()]; + let selected = multi_select_with_custom_value( + "git server remote url(s)", + "git server remote url", + git_servers, + selections, + |s| { + CloneUrl::from_str(s) + .map(|_| s.to_string()) + .context(format!("Invalid git server URL format: {s}")) + }, + )?; + show_multi_input_prompt_success("git servers", &selected); + Ok(selected) + } +} + +#[allow(clippy::too_many_lines)] +async fn publish_and_finalize( + fields: ResolvedFields, + signer: Arc, + user_ref: &ngit::login::user::UserRef, + client: &mut Client, + cli: &Cli, + git_repo: &Repo, + repo_config_result: &Result, +) -> Result<()> { + let git_repo_path = git_repo.get_path()?; + + // Step 1: Build RepoRef + let repo_ref = RepoRef { + identifier: fields.identifier.clone(), + name: fields.name, + description: fields.description, + root_commit: fields.earliest_unique_commit, + git_server: fields.git_servers, + web: fields.web, + relays: fields.relays.clone(), + blossoms: fields.blossoms, + hashtags: fields.hashtags, trusted_maintainer: user_ref.public_key, maintainers_without_annoucnement: None, - maintainers: maintainers.clone(), + maintainers: fields.maintainers.clone(), events: HashMap::new(), nostr_git_url: None, }; + + // Step 2: Create event + println!("publishing repostory announcement to nostr..."); let repo_event = repo_ref.to_event(&signer).await?; - let nostr_url_decoded = repo_ref.to_nostr_git_url(&Some(&git_repo)); + // Step 3: Build nostr URL + let nostr_url_decoded = repo_ref.to_nostr_git_url(&Some(git_repo)); let mut events = vec![repo_event]; + // Step 4: Handle state events and push/sync logic + let no_state = if let Ok(Some(s)) = git_repo.get_git_config_item("nostr.nostate", None) { + s == "true" + } else { + false + }; + let (need_push, need_sync) = if std::env::var("NGITTEST").is_ok() || no_state { // dont push or sync during tests as git-remote-nostr isn't installed during // ngit binary tests @@ -785,7 +1277,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { if let Some(url) = remote.url() { // issue a state event with origin state, to all (inc. new) repo relays if let Ok(mut origin_state) = - list_from_remote(&Term::stdout(), &git_repo, url, &nostr_url_decoded, false) + list_from_remote(&Term::stdout(), git_repo, url, &nostr_url_decoded, false) { origin_state.retain(|key, _| { key.starts_with("refs/heads/") @@ -809,7 +1301,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { if required_oids.is_empty() { println!("fetching refs missing locally from existing origin..."); if let Err(error) = fetch_from_git_server( - &git_repo, + git_repo, &required_oids, url, &nostr_url_decoded, @@ -839,27 +1331,28 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { (true, false) }; + // Step 5: Publish events client.set_signer(signer).await; send_events( - &client, + client, Some(git_repo_path), events, user_ref.relays.write(), - relays.clone(), - !cli_args.disable_cli_spinners, + fields.relays.clone(), + !cli.disable_cli_spinners, false, ) .await?; - // TODO - does this git config item do more harm than good? + // Step 6: Set git config git_repo.save_git_config_item( "nostr.repo", &Nip19Coordinate { coordinate: Coordinate { kind: Kind::GitRepoAnnouncement, public_key: user_ref.public_key, - identifier: identifier.clone(), + identifier: fields.identifier.clone(), }, relays: vec![], } @@ -867,9 +1360,8 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { false, )?; - // set origin remote + // Step 7: Set origin remote let nostr_url = nostr_url_decoded.to_string(); - if git_repo.git_repo.find_remote("origin").is_ok() { git_repo.git_repo.remote_set_url("origin", &nostr_url)?; } else { @@ -877,8 +1369,9 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { } println!("set remote origin to nostr url"); + // Step 8: Push/sync if need_push { - if selected_grasp_servers.is_empty() { + if fields.selected_grasp_servers.is_empty() { println!("running `ngit push` to publish your repository data"); } else { let countdown_start = 5; @@ -894,14 +1387,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { term.flush().unwrap(); // Ensure the output is flushed to the terminal } - if let Err(err) = push_main_or_master_branch(&git_repo) { + if let Err(err) = push_main_or_master_branch(git_repo) { println!( "your repository announcement was published to nostr but git push exited with an error: {err}" ); } } if need_sync { - if selected_grasp_servers.is_empty() { + if fields.selected_grasp_servers.is_empty() { println!( "running `ngit sync` to ensure your repository data is available on repository git servers" ); @@ -926,27 +1419,25 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { } } - // println!( - // "any remote branches beginning with `pr/` are open PRs from contributors. - // they can submit these by simply pushing a branch with this `pr/` prefix." - // ); + // Step 9: Print share URLs + let gitworkshop_url = nostr_url_decoded + .to_string() + .replace("nostr://", "https://gitworkshop.dev/"); println!("share your repository: {gitworkshop_url}"); println!("clone url: {nostr_url}"); - // no longer create a new maintainers.yaml file - its too confusing for users - // as it falls out of sync with data in nostr event . update if it already - // exists - - let relays = relays + // Step 10: Update maintainers.yaml if needed + let relays = fields + .relays .iter() .map(std::string::ToString::to_string) .collect::>(); - if match &repo_config_result { + if match repo_config_result { Ok(config) => { ! as Clone>::clone(&config.identifier) .unwrap_or_default() - .eq(&identifier) - || !extract_pks(config.maintainers.clone())?.eq(&maintainers) + .eq(&fields.identifier) + || !extract_pks(config.maintainers.clone())?.eq(&fields.maintainers) || !config.relays.eq(&relays) } Err(_) => false, @@ -954,9 +1445,9 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { let title_style = Style::new().bold().fg(console::Color::Yellow); println!("{}", title_style.apply_to("maintainers.yaml")); save_repo_config_to_yaml( - &git_repo, - identifier.clone(), - maintainers.clone(), + git_repo, + fields.identifier.clone(), + fields.maintainers.clone(), relays.clone(), )?; println!( @@ -974,6 +1465,98 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { Ok(()) } +#[allow(clippy::too_many_lines)] +pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { + // Phase 1: Local-only setup + let git_repo = Repo::discover().context("failed to find a git repository")?; + let git_repo_path = git_repo.get_path()?; + let root_commit = git_repo + .get_root_commit() + .context("failed to get root commit of the repository")?; + let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); + let (signer, user_ref, _) = login::login_or_signup( + &Some(&git_repo), + &extract_signer_cli_arguments(cli_args).unwrap_or(None), + &cli_args.password, + Some(&client), + false, + ) + .await?; + + let repo_coordinate = (try_and_get_repo_coordinates_when_remote_unknown(&git_repo).await).ok(); + + // Phase 2: Pre-fetch validation (fail fast) + let user_has_grasp_list = !user_ref.grasp_list.urls.is_empty(); + validate_pre_fetch( + cli_args, + args, + repo_coordinate.as_ref(), + user_has_grasp_list, + )?; + + // Phase 3: Network fetch (only if coordinate exists) + let repo_ref = if let Some(repo_coordinate) = &repo_coordinate { + fetching_with_report(git_repo_path, &client, repo_coordinate).await?; + (get_repo_ref_from_cache(Some(git_repo_path), repo_coordinate).await).ok() + } else { + None + }; + + // Phase 4: Determine state + post-fetch validation + let state = match (&repo_coordinate, &repo_ref) { + (None, _) => InitState::Fresh, + (Some(coord), None) => InitState::CoordinateOnly { + coordinate: coord.clone(), + }, + (Some(coord), Some(rr)) => { + if coord.coordinate.public_key == user_ref.public_key { + InitState::MyAnnouncement { + coordinate: coord.clone(), + repo_ref: rr.clone(), + } + } else if rr.maintainers.contains(&user_ref.public_key) { + InitState::CoMaintainer { + coordinate: coord.clone(), + repo_ref: rr.clone(), + } + } else { + InitState::NotListed { + coordinate: coord.clone(), + repo_ref: rr.clone(), + } + } + } + }; + + validate_post_fetch(cli_args, args, &state)?; + + // Phase 5: Resolve all fields + let repo_config_result = get_repo_config_from_yaml(&git_repo); + let fields = resolve_fields( + &state, + &user_ref, + args, + cli_args, + &git_repo, + &root_commit.to_string(), + &client, + &repo_config_result, + cli_args.interactive, + )?; + + // Phase 6: Build and publish + publish_and_finalize( + fields, + signer, + &user_ref, + &mut client, + cli_args, + &git_repo, + &repo_config_result, + ) + .await +} + fn format_grasp_server_url_as_clone_url( url: &str, public_key: &PublicKey, diff --git a/test_utils/src/git.rs b/test_utils/src/git.rs index ab21f38..a18f81c 100644 --- a/test_utils/src/git.rs +++ b/test_utils/src/git.rs @@ -282,6 +282,30 @@ impl GitTestRepo { branch.set_upstream(Some(&format!("origin/{branch_name}")))?; self.checkout(branch_name) } + + /// Set nostr.repo git config to point to a specific pubkey's coordinate. + /// Used for State D/E tests where the coordinate points to another user. + pub fn set_nostr_repo_coordinate( + &self, + pubkey: &nostr::PublicKey, + identifier: &str, + relays: &[&str], + ) { + let relay_urls: Vec = relays + .iter() + .map(|r| nostr::RelayUrl::parse(r).unwrap()) + .collect(); + let coordinate = Nip19Coordinate { + coordinate: Coordinate::new(nostr::Kind::GitRepoAnnouncement, *pubkey) + .identifier(identifier.to_string()), + relays: relay_urls, + }; + let _ = self + .git_repo + .config() + .unwrap() + .set_str("nostr.repo", &coordinate.to_bech32().unwrap()); + } } impl Drop for GitTestRepo { diff --git a/test_utils/src/lib.rs b/test_utils/src/lib.rs index bdfc550..a9a6d1e 100644 --- a/test_utils/src/lib.rs +++ b/test_utils/src/lib.rs @@ -208,6 +208,82 @@ pub fn generate_repo_ref_event_with_git_server_with_keys( .sign_with_keys(keys) .unwrap() } + +/// Generate a repo announcement event signed by TEST_KEY_2 that lists +/// TEST_KEY_1 as a maintainer. Used for State D tests (co-maintainer scenario). +pub fn generate_repo_ref_event_as_key_2_listing_key_1() -> nostr::Event { + generate_repo_ref_event_as_key_2_with_maintainers(vec![ + TEST_KEY_2_KEYS.public_key().to_string(), + TEST_KEY_1_KEYS.public_key().to_string(), + ]) +} + +/// Generate a repo announcement event signed by TEST_KEY_2 that does NOT list +/// TEST_KEY_1. Used for State E tests (not listed scenario). +pub fn generate_repo_ref_event_as_key_2_not_listing_key_1() -> nostr::Event { + generate_repo_ref_event_as_key_2_with_maintainers(vec![ + TEST_KEY_2_KEYS.public_key().to_string(), + ]) +} + +/// Generate a repo announcement event signed by TEST_KEY_2 with specific +/// maintainers. +fn generate_repo_ref_event_as_key_2_with_maintainers(maintainers: Vec) -> nostr::Event { + let root_commit = "9ee507fc4357d7ee16a5d8901bedcd103f23c17d"; + nostr::event::EventBuilder::new(nostr::Kind::GitRepoAnnouncement, "") + .tags([ + Tag::identifier(format!("{root_commit}-consider-it-random")), + Tag::from_standardized(TagStandard::Reference(root_commit.to_string())), + Tag::from_standardized(TagStandard::Name("example name".into())), + Tag::from_standardized(TagStandard::Description("example description".into())), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("clone")), + vec!["git:://123.gitexample.com/test".to_string()], + ), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("web")), + vec![ + "https://exampleproject.xyz".to_string(), + "https://gitworkshop.dev/123".to_string(), + ], + ), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("relays")), + vec![ + "ws://localhost:8055".to_string(), + "ws://localhost:8056".to_string(), + ], + ), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("maintainers")), + maintainers, + ), + ]) + .sign_with_keys(&TEST_KEY_2_KEYS) + .unwrap() +} + +/// Generate relay list event for TEST_KEY_2 (same relays as KEY_1 for +/// simplicity) +pub fn generate_test_key_2_relay_list_event() -> nostr::Event { + nostr::event::EventBuilder::new(nostr::Kind::RelayList, "") + .tags([ + nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata { + relay_url: nostr::RelayUrl::from_str("ws://localhost:8053").unwrap(), + metadata: Some(RelayMetadata::Write), + }), + nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata { + relay_url: nostr::RelayUrl::from_str("ws://localhost:8054").unwrap(), + metadata: Some(RelayMetadata::Read), + }), + nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata { + relay_url: nostr::RelayUrl::from_str("ws://localhost:8055").unwrap(), + metadata: None, + }), + ]) + .sign_with_keys(&TEST_KEY_2_KEYS) + .unwrap() +} /// enough to fool event_is_patch_set_root pub fn get_pretend_proposal_root_event() -> nostr::Event { serde_json::from_str(r#"{"id":"000c104861e34a453481ab23e7de21a6baf475b394479705363b035936732528","pubkey":"f53e4bcd7a9cdef049cf6467d638a1321958acd3b71eb09823fd6fadb023d768","created_at":1754322009,"kind":1617,"tags":[["a","30617:f53e4bcd7a9cdef049cf6467d638a1321958acd3b71eb09823fd6fadb023d768:9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random","ws://localhost:8055"],["a","30617:ba882566eff14f3baa976103998c452d27fe95b65a796a6a9f92628bced76fe5:9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random","ws://localhost:8055"],["r","9ee507fc4357d7ee16a5d8901bedcd103f23c17d"],["r","232efb37ebc67692c9e9ff58b83c0d3d63971a0a"],["alt","git patch: add t3.md"],["t","root"],["branch-name","feature"],["p","ba882566eff14f3baa976103998c452d27fe95b65a796a6a9f92628bced76fe5"],["commit","232efb37ebc67692c9e9ff58b83c0d3d63971a0a"],["parent-commit","431b84edc0d2fa118d63faa3c2db9c73d630a5ae"],["commit-pgp-sig",""],["description","add t3.md"],["author","Joe Bloggs","joe.bloggs@pm.me","0","0"],["committer","Joe Bloggs","joe.bloggs@pm.me","0","0"]],"content":"From 232efb37ebc67692c9e9ff58b83c0d3d63971a0a Mon Sep 17 00:00:00 2001\nFrom: Joe Bloggs \nDate: Thu, 1 Jan 1970 00:00:00 +0000\nSubject: [PATCH 1/2] add t3.md\n\n---\n t3.md | 1 +\n 1 file changed, 1 insertion(+)\n create mode 100644 t3.md\n\ndiff --git a/t3.md b/t3.md\nnew file mode 100644\nindex 0000000..f0eec86\n--- /dev/null\n+++ b/t3.md\n@@ -0,0 +1 @@\n+some content\n\\ No newline at end of file\n--\nlibgit2 1.9.1\n\n","sig":"65577fea803ea464bb073273a3fbfbdb5bfdaa64fb3b1d029ee8f3729fde051ad90610d08e441335f365b6c1d6f2270909bc37d12433ca82f0b2928b7a503e31"}"#).unwrap() @@ -1416,7 +1492,7 @@ pub fn use_ngit_list_to_download_and_checkout_proposal_branch( test_repo: &GitTestRepo, proposal_number: u16, ) -> Result<()> { - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here let mut c = p.expect_choice( diff --git a/tests/ngit_init.rs b/tests/ngit_init.rs index f6b30ef..5483315 100644 --- a/tests/ngit_init.rs +++ b/tests/ngit_init.rs @@ -1,77 +1,123 @@ use anyhow::Result; +use nostr::Event; use nostr_sdk::Kind; use rstest::*; use serial_test::serial; use test_utils::{git::GitTestRepo, *}; -fn expect_msgs_first(p: &mut CliTester) -> Result<()> { - p.expect("searching for profile...\r\n")?; - p.expect("logged in as fred via cli arguments\r\n")?; - // // p.expect("searching for existing claims on repository...\r\n")?; - p.expect("publishing repostory announcement to nostr...\r\n")?; - Ok(()) +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Extract the GitRepoAnnouncement event from a relay's collected events. +fn get_announcement(events: &[Event]) -> &Event { + events + .iter() + .find(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) + .expect("GitRepoAnnouncement event not found") +} + +/// Get the first value of a single-value tag (e.g. "d", "name", "description"). +fn get_tag_value<'a>(event: &'a Event, tag_name: &str) -> &'a str { + event + .tags + .iter() + .find(|t| t.as_slice()[0] == tag_name) + .map(|t| t.as_slice()[1].as_str()) + .unwrap_or_else(|| panic!("tag '{tag_name}' not found")) } -fn get_cli_args() -> Vec<&'static str> { - vec![ - "--nsec", - TEST_KEY_1_NSEC, - "--password", - TEST_PASSWORD, - "--disable-cli-spinners", - "init", - "--title", - "example-name", - "--identifier", - "example-identifier", - "--description", - "example-description", - "--web", - "https://exampleproject.xyz", - "https://gitworkshop.dev/123", - "--relays", - "ws://localhost:8055", - "ws://localhost:8056", - "--clone-url", - "https://git.myhosting.com/my-repo.git", - "--earliest-unique-commit", - "9ee507fc4357d7ee16a5d8901bedcd103f23c17d", - "--other-maintainers", - TEST_KEY_1_NPUB, - ] +/// Get all values of a multi-value tag (e.g. "relays", "web", "maintainers", +/// "clone"). Returns slice starting from index 1 (skipping the tag name). +fn get_tag_values(event: &Event, tag_name: &str) -> Vec { + event + .tags + .iter() + .find(|t| t.as_slice()[0] == tag_name) + .map(|t| t.as_slice()[1..].iter().map(|s| s.to_string()).collect()) + .unwrap_or_default() } -mod when_repo_not_previously_claimed { +// --------------------------------------------------------------------------- +// State A: Fresh (no coordinate) +// --------------------------------------------------------------------------- + +mod state_a_fresh { use super::*; - mod when_repo_relays_specified_as_arguments { - use futures::join; - use test_utils::relay::Relay; + fn prep_git_repo() -> Result { + let test_repo = GitTestRepo::without_repo_in_git_config(); + test_repo.populate()?; + test_repo.add_remote("origin", "https://localhost:1000")?; + Ok(test_repo) + } + mod errors { use super::*; - fn prep_git_repo() -> Result { - let test_repo = GitTestRepo::without_repo_in_git_config(); - test_repo.populate()?; - test_repo.add_remote("origin", "https://localhost:1000")?; - Ok(test_repo) + #[test] + #[serial] + fn bare_no_flags() -> Result<()> { + let git_repo = prep_git_repo()?; + let args = vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; + let mut p = CliTester::new_from_dir(&git_repo.dir, args); + p.expect_eventually("logged in as")?; + p.expect_eventually("missing required fields")?; + p.expect_eventually("--name ")?; + p.expect_eventually("--grasp-servers")?; + Ok(()) } - fn cli_tester_init(git_repo: &GitTestRepo) -> CliTester { - CliTester::new_from_dir(&git_repo.dir, get_cli_args()) + #[test] + #[serial] + fn name_only_missing_server_infra() -> Result<()> { + let git_repo = prep_git_repo()?; + let args = vec![ + "--nsec", + TEST_KEY_1_NSEC, + "--disable-cli-spinners", + "init", + "--name", + "My Project", + ]; + let mut p = CliTester::new_from_dir(&git_repo.dir, args); + p.expect_eventually("logged in as")?; + p.expect_eventually("missing --grasp-servers")?; + Ok(()) } - async fn prep_run_init() -> Result<( - Relay<'static>, - Relay<'static>, - Relay<'static>, - Relay<'static>, - Relay<'static>, - Relay<'static>, - )> { + #[test] + #[serial] + fn relays_only_missing_name_and_servers() -> Result<()> { + let git_repo = prep_git_repo()?; + let args = vec![ + "--nsec", + TEST_KEY_1_NSEC, + "--disable-cli-spinners", + "init", + "--relays", + "ws://localhost:8055", + ]; + let mut p = CliTester::new_from_dir(&git_repo.dir, args); + p.expect_eventually("logged in as")?; + p.expect_eventually("missing required fields")?; + p.expect_eventually("--name ")?; + p.expect_eventually("--grasp-servers")?; + Ok(()) + } + } + + mod success { + use futures::join; + use test_utils::relay::Relay; + + use super::*; + + async fn run_init_with_grasp_server( + extra_args: Vec<&str>, + ) -> Result<(nostr::Event, GitTestRepo)> { let git_repo = prep_git_repo()?; - // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) - let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( + let (mut r51, mut r52, mut r53, mut r55) = ( Relay::new( 8051, None, @@ -90,286 +136,599 @@ mod when_repo_not_previously_claimed { Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), - Relay::new(8056, None, None), - Relay::new(8057, None, None), ); - // // check relay had the right number of events - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = cli_tester_init(&git_repo); - p.expect_end_eventually()?; - for p in [51, 52, 53, 55, 56, 57] { - relay::shutdown_relay(8000 + p)?; + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + let extra_args_owned: Vec = + extra_args.iter().map(|s| s.to_string()).collect(); + move || -> Result<()> { + let mut args = + vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; + let extra_refs: Vec<&str> = + extra_args_owned.iter().map(|s| s.as_str()).collect(); + args.extend(extra_refs); + let mut p = CliTester::new_from_dir(&dir, args); + p.expect_end_eventually()?; + for port in [51, 52, 53, 55] { + relay::shutdown_relay(8000 + port)?; + } + Ok(()) } - Ok(()) }); - // launch relay let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), - r56.listen_until_close(), - r57.listen_until_close(), ); cli_tester_handle.join().unwrap()?; - Ok((r51, r52, r53, r55, r56, r57)) - } - mod sent_to_correct_relays { + let event = get_announcement(&r53.events).clone(); + Ok((event, git_repo)) + } + mod with_name_and_grasp_server { use super::*; - #[derive(Clone)] - pub struct SentToCorrectRelaysScenario { - pub r51_repo_event_count: usize, - pub r52_repo_event_count: usize, - pub r53_repo_event_count: usize, - pub r55_repo_event_count: usize, - pub r56_repo_event_count: usize, - pub r57_repo_event_count: usize, + #[fixture] + async fn scenario() -> (nostr::Event, GitTestRepo) { + run_init_with_grasp_server(vec![ + "--name", + "My Project", + "--grasp-servers", + "ws://localhost:8055", + ]) + .await + .expect("init failed") } - #[fixture] - async fn scenario() -> SentToCorrectRelaysScenario { - let (r51, r52, r53, r55, r56, r57) = - prep_run_init().await.expect("prep_run_init failed"); + #[rstest] + #[tokio::test] + #[serial] + async fn identifier_derived_from_name( + #[future] scenario: (nostr::Event, GitTestRepo), + ) -> Result<()> { + let (event, _) = scenario.await; + assert_eq!(get_tag_value(&event, "d"), "My-Project"); + Ok(()) + } - // Extract event counts for verification - let r51_repo_event_count = r51 - .events - .iter() - .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) - .count(); - let r52_repo_event_count = r52 - .events - .iter() - .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) - .count(); - let r53_repo_event_count = r53 - .events - .iter() - .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) - .count(); - let r55_repo_event_count = r55 - .events - .iter() - .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) - .count(); - let r56_repo_event_count = r56 - .events - .iter() - .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) - .count(); - let r57_repo_event_count = r57 - .events - .iter() - .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) - .count(); - - SentToCorrectRelaysScenario { - r51_repo_event_count, - r52_repo_event_count, - r53_repo_event_count, - r55_repo_event_count, - r56_repo_event_count, - r57_repo_event_count, - } + #[rstest] + #[tokio::test] + #[serial] + async fn name_tag_matches( + #[future] scenario: (nostr::Event, GitTestRepo), + ) -> Result<()> { + let (event, _) = scenario.await; + assert_eq!(get_tag_value(&event, "name"), "My Project"); + Ok(()) } #[rstest] #[tokio::test] #[serial] - async fn only_1_repository_kind_event_sent_to_user_relays( - #[future] scenario: SentToCorrectRelaysScenario, + async fn description_empty( + #[future] scenario: (nostr::Event, GitTestRepo), ) -> Result<()> { - let s = scenario.await; - assert_eq!(s.r53_repo_event_count, 1); - assert_eq!(s.r55_repo_event_count, 1); + let (event, _) = scenario.await; + assert_eq!(get_tag_value(&event, "description"), ""); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn clone_url_derived_from_grasp_server( + #[future] scenario: (nostr::Event, GitTestRepo), + ) -> Result<()> { + let (event, _) = scenario.await; + let clone_urls = get_tag_values(&event, "clone"); + assert_eq!(clone_urls.len(), 1); + assert!( + clone_urls[0].starts_with("http://localhost:8055/"), + "clone url should start with grasp server: {}", + clone_urls[0] + ); + assert!( + clone_urls[0].ends_with("/My-Project.git"), + "clone url should end with identifier.git: {}", + clone_urls[0] + ); + assert!( + clone_urls[0].contains(TEST_KEY_1_NPUB), + "clone url should contain npub: {}", + clone_urls[0] + ); Ok(()) } #[rstest] #[tokio::test] #[serial] - async fn only_1_repository_kind_event_sent_to_specified_repo_relays( - #[future] scenario: SentToCorrectRelaysScenario, + async fn relays_include_grasp_derived( + #[future] scenario: (nostr::Event, GitTestRepo), ) -> Result<()> { - let s = scenario.await; - assert_eq!(s.r55_repo_event_count, 1); - assert_eq!(s.r56_repo_event_count, 1); + let (event, _) = scenario.await; + let relays = get_tag_values(&event, "relays"); + assert!( + relays.contains(&"ws://localhost:8055".to_string()), + "relays should include grasp-derived relay: {:?}", + relays + ); Ok(()) } #[rstest] #[tokio::test] #[serial] - async fn only_1_repository_kind_event_sent_to_fallback_relays( - #[future] scenario: SentToCorrectRelaysScenario, + async fn maintainers_is_just_me( + #[future] scenario: (nostr::Event, GitTestRepo), ) -> Result<()> { - let s = scenario.await; - assert_eq!(s.r51_repo_event_count, 1); - assert_eq!(s.r52_repo_event_count, 1); + let (event, _) = scenario.await; + let maintainers = get_tag_values(&event, "maintainers"); + assert_eq!(maintainers.len(), 1); + assert_eq!(maintainers[0], TEST_KEY_1_KEYS.public_key().to_string()); Ok(()) } #[rstest] #[tokio::test] #[serial] - async fn only_1_repository_kind_event_sent_to_blaster_relays( - #[future] scenario: SentToCorrectRelaysScenario, + async fn earliest_unique_commit_is_root( + #[future] scenario: (nostr::Event, GitTestRepo), ) -> Result<()> { - let s = scenario.await; - assert_eq!(s.r57_repo_event_count, 1); + let (event, _) = scenario.await; + let euc_tag = event + .tags + .iter() + .find(|t| { + t.as_slice()[0] == "r" && t.as_slice().len() > 2 && t.as_slice()[2] == "euc" + }) + .expect("euc tag not found"); + assert_eq!( + euc_tag.as_slice()[1], + "9ee507fc4357d7ee16a5d8901bedcd103f23c17d" + ); Ok(()) } } + } +} - mod git_config_updated { +// --------------------------------------------------------------------------- +// State B: Coordinate exists, no announcement found +// --------------------------------------------------------------------------- - use nostr::nips::{nip01::Coordinate, nip19::Nip19Coordinate}; - use nostr_sdk::ToBech32; +mod state_b_coordinate_only { + use super::*; - use super::*; + fn prep_git_repo() -> Result { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + test_repo.add_remote("origin", "https://localhost:1000")?; + Ok(test_repo) + } - async fn async_run_test() -> Result<()> { - let git_repo = prep_git_repo()?; - // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) - let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( - Relay::new( - 8051, - None, - Some(&|relay, client_id, subscription_id, _| -> Result<()> { - relay.respond_events( - client_id, - &subscription_id, - &vec![ - generate_test_key_1_metadata_event("fred"), - generate_test_key_1_relay_list_event(), - ], - )?; - Ok(()) - }), - ), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new(8055, None, None), - Relay::new(8056, None, None), - Relay::new(8057, None, None), - ); + mod errors { + use futures::join; + use test_utils::relay::Relay; + + use super::*; + + async fn run_init_expecting_error(extra_args: Vec<&str>) -> Result { + let git_repo = prep_git_repo()?; + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + let extra_args_owned: Vec = + extra_args.iter().map(|s| s.to_string()).collect(); + move || -> Result { + let mut args = + vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; + let extra_refs: Vec<&str> = + extra_args_owned.iter().map(|s| s.as_str()).collect(); + args.extend(extra_refs); + let mut p = CliTester::new_from_dir(&dir, args); + let output = p.expect_end_eventually()?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; + } + Ok(output) + } + }); + + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap() + } - // // check relay had the right number of events - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = cli_tester_init(&git_repo); + #[tokio::test] + #[serial] + async fn bare_no_flags() -> Result<()> { + let output = run_init_expecting_error(vec![]).await?; + assert!( + output.contains("no announcement found for coordinate"), + "expected coordinate error, got: {output}" + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn defaults_still_requires_force() -> Result<()> { + let output = run_init_expecting_error(vec!["--defaults"]).await?; + assert!( + output.contains("no announcement found for coordinate"), + "expected coordinate error even with -d, got: {output}" + ); + Ok(()) + } + } + + mod success { + use futures::join; + use test_utils::relay::Relay; + + use super::*; + + #[fixture] + async fn state_b_force() -> nostr::Event { + let git_repo = prep_git_repo().expect("prep failed"); + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + move || -> Result<()> { + let args = vec![ + "--nsec", + TEST_KEY_1_NSEC, + "--disable-cli-spinners", + "init", + "--force", + "--grasp-servers", + "ws://localhost:8055", + ]; + let mut p = CliTester::new_from_dir(&dir, args); p.expect_end_eventually()?; - for p in [51, 52, 53, 55, 56, 57] { - relay::shutdown_relay(8000 + p)?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; } - assert_eq!( - git_repo - .git_repo - .config()? - .get_entry("nostr.repo")? - .value() - .unwrap(), - Nip19Coordinate { - coordinate: Coordinate { - kind: nostr_sdk::Kind::GitRepoAnnouncement, - identifier: "example-identifier".to_string(), - public_key: TEST_KEY_1_KEYS.public_key(), - }, - relays: vec![], - } - .to_bech32()?, - ); + Ok(()) + } + }); + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap().expect("cli failed"); + + get_announcement(&r53.events).clone() + } + + #[rstest] + #[tokio::test] + #[serial] + async fn identifier_from_coordinate(#[future] state_b_force: nostr::Event) -> Result<()> { + let event = state_b_force.await; + assert_eq!( + get_tag_value(&event, "d"), + "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random" + ); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn name_defaults_to_identifier(#[future] state_b_force: nostr::Event) -> Result<()> { + let event = state_b_force.await; + assert_eq!( + get_tag_value(&event, "name"), + "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random" + ); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn clone_url_from_grasp_server(#[future] state_b_force: nostr::Event) -> Result<()> { + let event = state_b_force.await; + let clone_urls = get_tag_values(&event, "clone"); + assert!( + clone_urls + .iter() + .any(|u| u.starts_with("http://localhost:8055/")), + "expected grasp-derived clone url, got: {:?}", + clone_urls + ); + Ok(()) + } + } +} + +// --------------------------------------------------------------------------- +// State C: Existing announcement, it's mine +// --------------------------------------------------------------------------- + +mod state_c_my_announcement { + use futures::join; + use test_utils::relay::Relay; + + use super::*; + + fn prep_git_repo() -> Result { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + test_repo.add_remote("origin", "https://localhost:1000")?; + Ok(test_repo) + } + + async fn run_init(extra_args: Vec<&str>) -> Result { + let git_repo = prep_git_repo()?; + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + generate_repo_ref_event(), + ], + )?; Ok(()) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - r57.listen_until_close(), - ); - cli_tester_handle.join().unwrap()?; - Ok(()) - } + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); - #[tokio::test] - #[serial] - async fn with_nostr_repo_set_to_user_and_identifer_naddr() -> Result<()> { - async_run_test().await?; + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + let extra_args_owned: Vec = extra_args.iter().map(|s| s.to_string()).collect(); + move || -> Result<()> { + let mut args = vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; + let extra_refs: Vec<&str> = extra_args_owned.iter().map(|s| s.as_str()).collect(); + args.extend(extra_refs); + let mut p = CliTester::new_from_dir(&dir, args); + p.expect_end_eventually()?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; + } Ok(()) } + }); + + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap()?; + + Ok(get_announcement(&r53.events).clone()) + } + + mod errors { + use super::*; + + #[tokio::test] + #[serial] + async fn identifier_change_requires_force() -> Result<()> { + let git_repo = prep_git_repo()?; + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + generate_repo_ref_event(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + move || -> Result { + let args = vec![ + "--nsec", + TEST_KEY_1_NSEC, + "--disable-cli-spinners", + "init", + "--identifier", + "new-id", + ]; + let mut p = CliTester::new_from_dir(&dir, args); + let output = p.expect_end_eventually()?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; + } + Ok(output) + } + }); + + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + let output = cli_tester_handle.join().unwrap()?; + assert!( + output.contains("changing identifier creates a new repository"), + "expected identifier change error, got: {output}" + ); + Ok(()) } - mod tags_as_specified_in_args { - use super::*; + #[tokio::test] + #[serial] + async fn bare_no_flags_requires_force() -> Result<()> { + let git_repo = prep_git_repo()?; + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + generate_repo_ref_event(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); - #[derive(Clone)] - pub struct TagsAsSpecifiedScenario { - pub event: nostr::Event, - } + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + move || -> Result { + let args = vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; + let mut p = CliTester::new_from_dir(&dir, args); + let output = p.expect_end_eventually()?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; + } + Ok(output) + } + }); - #[fixture] - async fn scenario() -> TagsAsSpecifiedScenario { - let (_, _, r53, _r55, _r56, _r57) = - prep_run_init().await.expect("prep_run_init failed"); + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + let output = cli_tester_handle.join().unwrap()?; + assert!( + output.contains("no arguments specified"), + "expected 'no arguments specified' error, got: {output}" + ); + Ok(()) + } + } - // Extract the GitRepoAnnouncement event (should be same on all relays) - let event = r53 - .events - .iter() - .find(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) - .expect("GitRepoAnnouncement event not found") - .clone(); + mod success { + use super::*; + + mod force_refresh { + use super::*; - TagsAsSpecifiedScenario { event } + #[fixture] + async fn scenario() -> nostr::Event { + run_init(vec!["--force"]).await.expect("init failed") } #[rstest] #[tokio::test] #[serial] - async fn d_replaceable_event_identifier( - #[future] scenario: TagsAsSpecifiedScenario, - ) -> Result<()> { - let s = scenario.await; - assert!( - s.event.tags.iter().any( - |t| t.as_slice()[0].eq("d") && t.as_slice()[1].eq("example-identifier") - ) - ); + async fn name_preserved(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + assert_eq!(get_tag_value(&event, "name"), "example name"); Ok(()) } #[rstest] #[tokio::test] #[serial] - async fn earliest_unique_commit_as_reference_with_euc_marker( - #[future] scenario: TagsAsSpecifiedScenario, - ) -> Result<()> { - let s = scenario.await; - assert!(s.event.tags.iter().any(|t| t.as_slice()[0].eq("r") - && t.as_slice()[1].eq("9ee507fc4357d7ee16a5d8901bedcd103f23c17d") - && t.as_slice()[2].eq("euc"))); + async fn description_preserved(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + assert_eq!(get_tag_value(&event, "description"), "example description"); Ok(()) } #[rstest] #[tokio::test] #[serial] - async fn name(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { - let s = scenario.await; + async fn relays_from_my_event(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + let relays = get_tag_values(&event, "relays"); assert!( - s.event - .tags - .iter() - .any(|t| t.as_slice()[0].eq("name") && t.as_slice()[1].eq("example-name")) + relays.contains(&"ws://localhost:8055".to_string()), + "relays should include my existing relay: {:?}", + relays ); Ok(()) } @@ -377,160 +736,472 @@ mod when_repo_not_previously_claimed { #[rstest] #[tokio::test] #[serial] - async fn alt(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { - let s = scenario.await; - assert!(s.event.tags.iter().any(|t| t.as_slice()[0].eq("alt") - && t.as_slice()[1].eq("git repository: example-name"))); - Ok(()) - } - - #[rstest] - #[tokio::test] - #[serial] - async fn description(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { - let s = scenario.await; + async fn maintainers_preserved(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + let maintainers = get_tag_values(&event, "maintainers"); assert!( - s.event - .tags - .iter() - .any(|t| t.as_slice()[0].eq("description") - && t.as_slice()[1].eq("example-description")) + maintainers.contains(&TEST_KEY_1_KEYS.public_key().to_string()), + "maintainers should include KEY_1: {:?}", + maintainers ); - Ok(()) - } - - #[rstest] - #[tokio::test] - #[serial] - async fn git_server(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { - let s = scenario.await; assert!( - s.event.tags.iter().any(|t| t.as_slice()[0].eq("clone") - && t.as_slice()[1].eq("https://git.myhosting.com/my-repo.git")) /* todo check it defaults to origin */ + maintainers.contains(&TEST_KEY_2_KEYS.public_key().to_string()), + "maintainers should include KEY_2: {:?}", + maintainers ); Ok(()) } + } - #[rstest] - #[tokio::test] - #[serial] - async fn relays(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { - let s = scenario.await; - let relays_tag = s - .event - .tags - .iter() - .find(|t| t.as_slice()[0].eq("relays")) - .unwrap() - .as_slice(); - assert_eq!(relays_tag[1], "ws://localhost:8055",); - assert_eq!(relays_tag[2], "ws://localhost:8056",); - Ok(()) + mod name_override { + use super::*; + + #[fixture] + async fn scenario() -> nostr::Event { + run_init(vec!["--name", "New Name"]) + .await + .expect("init failed") } #[rstest] #[tokio::test] #[serial] - async fn web(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { - let s = scenario.await; - let web_tag = s - .event - .tags - .iter() - .find(|t| t.as_slice()[0].eq("web")) - .unwrap() - .as_slice(); - assert_eq!(web_tag[1], "https://exampleproject.xyz",); - assert_eq!(web_tag[2], "https://gitworkshop.dev/123",); + async fn name_overridden(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + assert_eq!(get_tag_value(&event, "name"), "New Name"); Ok(()) } #[rstest] #[tokio::test] #[serial] - async fn maintainers(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { - let s = scenario.await; - let maintainers_tag = s - .event - .tags - .iter() - .find(|t| t.as_slice()[0].eq("maintainers")) - .unwrap() - .as_slice(); - assert_eq!(maintainers_tag[1], TEST_KEY_1_KEYS.public_key().to_string()); + async fn identifier_unchanged(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + assert_eq!( + get_tag_value(&event, "d"), + "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random" + ); Ok(()) } } + } +} - mod cli_ouput { - use super::*; +// --------------------------------------------------------------------------- +// State D: Existing announcement, not mine, I'm listed as maintainer +// --------------------------------------------------------------------------- - #[tokio::test] - #[serial] - async fn check_cli_output() -> Result<()> { - let git_repo = prep_git_repo()?; - - // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) - let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( - Relay::new( - 8051, - None, - Some(&|relay, client_id, subscription_id, _| -> Result<()> { - relay.respond_events( - client_id, - &subscription_id, - &vec![ - generate_test_key_1_metadata_event("fred"), - generate_test_key_1_relay_list_event(), - ], - )?; - Ok(()) - }), - ), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new(8055, None, None), - Relay::new(8056, None, None), - Relay::new(8057, None, None), - ); +mod state_d_co_maintainer { + use futures::join; + use test_utils::relay::Relay; + + use super::*; + + fn prep_git_repo() -> Result { + let test_repo = GitTestRepo::without_repo_in_git_config(); + test_repo.populate()?; + test_repo.add_remote("origin", "https://localhost:1000")?; + test_repo.set_nostr_repo_coordinate( + &TEST_KEY_2_KEYS.public_key(), + "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random", + &["ws://localhost:8055", "ws://localhost:8056"], + ); + Ok(test_repo) + } + + mod success { + use super::*; + + #[fixture] + async fn scenario() -> nostr::Event { + let git_repo = prep_git_repo().expect("prep failed"); + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + generate_test_key_2_metadata_event("carole"), + generate_test_key_2_relay_list_event(), + generate_repo_ref_event_as_key_2_listing_key_1(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); - // // check relay had the right number of events - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = cli_tester_init(&git_repo); - expect_msgs_first(&mut p)?; - relay::expect_send_with_progress( - &mut p, - vec![ - (" [my-relay] [repo-relay] ws://localhost:8055", true, ""), - (" [my-relay] ws://localhost:8053", true, ""), - (" [repo-relay] ws://localhost:8056", true, ""), - (" [default] ws://localhost:8051", true, ""), - (" [default] ws://localhost:8052", true, ""), - (" [default] ws://localhost:8057", true, ""), + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + move || -> Result<()> { + let args = vec![ + "--nsec", + TEST_KEY_1_NSEC, + "--disable-cli-spinners", + "init", + "--grasp-servers", + "ws://localhost:8055", + ]; + let mut p = CliTester::new_from_dir(&dir, args); + p.expect_end_eventually()?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; + } + Ok(()) + } + }); + + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap().expect("cli failed"); + + get_announcement(&r53.events).clone() + } + + #[rstest] + #[tokio::test] + #[serial] + async fn name_inherited_from_other_maintainer( + #[future] scenario: nostr::Event, + ) -> Result<()> { + let event = scenario.await; + assert_eq!(get_tag_value(&event, "name"), "example name"); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn description_inherited_from_other_maintainer( + #[future] scenario: nostr::Event, + ) -> Result<()> { + let event = scenario.await; + assert_eq!(get_tag_value(&event, "description"), "example description"); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn web_inherited_from_other_maintainer( + #[future] scenario: nostr::Event, + ) -> Result<()> { + let event = scenario.await; + let web = get_tag_values(&event, "web"); + assert!( + web.iter().any(|w| w.contains("exampleproject.xyz")), + "web should be inherited from KEY_2's announcement: {:?}", + web + ); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn clone_url_from_my_grasp_server_not_theirs( + #[future] scenario: nostr::Event, + ) -> Result<()> { + let event = scenario.await; + let clone_urls = get_tag_values(&event, "clone"); + assert!( + clone_urls + .iter() + .any(|u| u.starts_with("http://localhost:8055/")), + "clone url should be from my grasp server: {:?}", + clone_urls + ); + assert!( + !clone_urls.iter().any(|u| u.contains("123.gitexample.com")), + "clone url should NOT contain KEY_2's git server: {:?}", + clone_urls + ); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn relays_from_my_grasp_server(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + let relays = get_tag_values(&event, "relays"); + assert!( + relays.contains(&"ws://localhost:8055".to_string()), + "relays should include my grasp-derived relay: {:?}", + relays + ); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn maintainers_is_me_and_trusted(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + let maintainers = get_tag_values(&event, "maintainers"); + assert_eq!( + maintainers.len(), + 2, + "should have exactly 2 maintainers: {:?}", + maintainers + ); + assert!( + maintainers.contains(&TEST_KEY_1_KEYS.public_key().to_string()), + "maintainers should include KEY_1 (me): {:?}", + maintainers + ); + assert!( + maintainers.contains(&TEST_KEY_2_KEYS.public_key().to_string()), + "maintainers should include KEY_2 (trusted): {:?}", + maintainers + ); + Ok(()) + } + } +} + +// --------------------------------------------------------------------------- +// State E: Existing announcement, not mine, I'm NOT listed as maintainer +// --------------------------------------------------------------------------- + +mod state_e_not_listed { + use futures::join; + use test_utils::relay::Relay; + + use super::*; + + fn prep_git_repo() -> Result { + let test_repo = GitTestRepo::without_repo_in_git_config(); + test_repo.populate()?; + test_repo.add_remote("origin", "https://localhost:1000")?; + // Point coordinate to KEY_2 (not the logged-in user) + test_repo.set_nostr_repo_coordinate( + &TEST_KEY_2_KEYS.public_key(), + "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random", + &["ws://localhost:8055", "ws://localhost:8056"], + ); + Ok(test_repo) + } + + /// Run init with relays that serve KEY_2's announcement NOT listing KEY_1. + async fn run_init_expecting_error(extra_args: Vec<&str>) -> Result { + let git_repo = prep_git_repo()?; + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + generate_test_key_2_metadata_event("carole"), + generate_test_key_2_relay_list_event(), + generate_repo_ref_event_as_key_2_not_listing_key_1(), ], - 1, )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + let extra_args_owned: Vec = extra_args.iter().map(|s| s.to_string()).collect(); + move || -> Result { + let mut args = vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; + let extra_refs: Vec<&str> = extra_args_owned.iter().map(|s| s.as_str()).collect(); + args.extend(extra_refs); + let mut p = CliTester::new_from_dir(&dir, args); + let output = p.expect_end_eventually()?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; + } + Ok(output) + } + }); + + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap() + } + + mod errors { + use super::*; + + #[tokio::test] + #[serial] + async fn bare_no_flags() -> Result<()> { + let output = run_init_expecting_error(vec![]).await?; + assert!( + output.contains("you are not listed as a maintainer"), + "expected not-listed error, got: {output}" + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn defaults_still_requires_force() -> Result<()> { + let output = run_init_expecting_error(vec!["--defaults"]).await?; + assert!( + output.contains("you are not listed as a maintainer"), + "expected not-listed error even with -d, got: {output}" + ); + Ok(()) + } + } + + mod success { + use super::*; + + #[fixture] + async fn scenario() -> nostr::Event { + let git_repo = prep_git_repo().expect("prep failed"); + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + generate_test_key_2_metadata_event("carole"), + generate_test_key_2_relay_list_event(), + generate_repo_ref_event_as_key_2_not_listing_key_1(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + let cli_tester_handle = std::thread::spawn({ + let dir = git_repo.dir.clone(); + move || -> Result<()> { + let args = vec![ + "--nsec", + TEST_KEY_1_NSEC, + "--disable-cli-spinners", + "init", + "--force", + "--grasp-servers", + "ws://localhost:8055", + ]; + let mut p = CliTester::new_from_dir(&dir, args); p.expect_end_eventually()?; - for p in [51, 52, 53, 55, 56, 57] { - relay::shutdown_relay(8000 + p)?; + for port in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + port)?; } Ok(()) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - r57.listen_until_close(), - ); - cli_tester_handle.join().unwrap()?; - Ok(()) - } + } + }); + + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap().expect("cli failed"); + + get_announcement(&r53.events).clone() + } + + #[rstest] + #[tokio::test] + #[serial] + async fn name_inherited_from_other_maintainer( + #[future] scenario: nostr::Event, + ) -> Result<()> { + let event = scenario.await; + assert_eq!(get_tag_value(&event, "name"), "example name"); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn description_inherited_from_other_maintainer( + #[future] scenario: nostr::Event, + ) -> Result<()> { + let event = scenario.await; + assert_eq!(get_tag_value(&event, "description"), "example description"); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn web_inherited_from_other_maintainer( + #[future] scenario: nostr::Event, + ) -> Result<()> { + let event = scenario.await; + let web = get_tag_values(&event, "web"); + assert!( + web.iter().any(|w| w.contains("exampleproject.xyz")), + "web should be inherited from KEY_2's announcement: {:?}", + web + ); + Ok(()) + } + + #[rstest] + #[tokio::test] + #[serial] + async fn maintainers_is_me_and_trusted(#[future] scenario: nostr::Event) -> Result<()> { + let event = scenario.await; + let maintainers = get_tag_values(&event, "maintainers"); + assert_eq!( + maintainers.len(), + 2, + "should have exactly 2 maintainers: {:?}", + maintainers + ); + assert!( + maintainers.contains(&TEST_KEY_1_KEYS.public_key().to_string()), + "maintainers should include KEY_1 (me): {:?}", + maintainers + ); + assert!( + maintainers.contains(&TEST_KEY_2_KEYS.public_key().to_string()), + "maintainers should include KEY_2 (trusted): {:?}", + maintainers + ); + Ok(()) } } - // TODO: cli caputuring input } -// TODO: when_updating_existing_repoistory correct defaults are used diff --git a/tests/ngit_list.rs b/tests/ngit_list.rs index 39385d6..59e326a 100644 --- a/tests/ngit_list.rs +++ b/tests/ngit_list.rs @@ -77,7 +77,7 @@ mod cannot_find_repo_event { let cli_tester_handle = std::thread::spawn(move || -> Result<()> { let test_repo = GitTestRepo::without_repo_in_git_config(); test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect( "hint: https://gitworkshop.dev/search lists repositories and their nostr address\r\n", )?; @@ -197,7 +197,7 @@ mod when_main_branch_is_uptodate { let test_repo = GitTestRepo::default(); test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here @@ -316,7 +316,7 @@ mod when_main_branch_is_uptodate { let test_repo = GitTestRepo::default(); test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here @@ -438,7 +438,7 @@ mod when_main_branch_is_uptodate { )?; let test_repo = GitTestRepo::default(); test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here @@ -516,7 +516,7 @@ mod when_main_branch_is_uptodate { )?; let test_repo = GitTestRepo::default(); test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here @@ -639,7 +639,7 @@ mod when_main_branch_is_uptodate { let test_repo = GitTestRepo::default(); test_repo.populate()?; // create proposal branch - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here let mut c = p.expect_choice( @@ -664,7 +664,7 @@ mod when_main_branch_is_uptodate { test_repo.checkout("main")?; // run test - p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here let mut c = p.expect_choice( @@ -735,7 +735,7 @@ mod when_main_branch_is_uptodate { let test_repo = GitTestRepo::default(); test_repo.populate()?; // create proposal branch - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here let mut c = p.expect_choice( @@ -760,7 +760,7 @@ mod when_main_branch_is_uptodate { test_repo.checkout("main")?; // run test - p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here let mut c = p.expect_choice( @@ -850,7 +850,7 @@ mod when_main_branch_is_uptodate { )?; // run test - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here let mut c = p.expect_choice( @@ -926,7 +926,7 @@ mod when_main_branch_is_uptodate { )?; // run test - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here let mut c = p.expect_choice( @@ -1039,7 +1039,7 @@ mod when_main_branch_is_uptodate { test_repo.checkout("main")?; // run test - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here let mut c = p.expect_choice( @@ -1118,7 +1118,7 @@ mod when_main_branch_is_uptodate { test_repo.checkout("main")?; // run test - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here let mut c = p.expect_choice( @@ -1223,7 +1223,7 @@ mod when_main_branch_is_uptodate { test_repo.checkout("main")?; // run test - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here let mut c = p.expect_choice( @@ -1305,7 +1305,7 @@ mod when_main_branch_is_uptodate { test_repo.checkout("main")?; // run test - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here let mut c = p.expect_choice( @@ -1407,7 +1407,7 @@ mod when_main_branch_is_uptodate { let (originating_repo, test_repo) = create_proposals_with_first_rebased_and_repo_with_latest_main_and_unrebased_proposal()?; test_repo.checkout("main")?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here let mut c = p.expect_choice( @@ -1480,7 +1480,7 @@ mod when_main_branch_is_uptodate { let (_, test_repo) = create_proposals_with_first_rebased_and_repo_with_latest_main_and_unrebased_proposal()?; test_repo.checkout("main")?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // some updates listed here let mut c = p.expect_choice( -- cgit v1.2.3 From 3383477386916e82a19fa1e9c4d95b232ba0a40e Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 10 Feb 2026 12:52:26 +0000 Subject: feat: update ngit send for non-interactive mode Rewrite ngit send to support non-interactive mode: - Add validation for required arguments (title/description) - Add --force flag to bypass commit suitability checks - Add --no-cover-letter flag to skip cover letter - Improve error messages for missing required fields - Update title/description/cover-letter logic for non-interactive mode - Add comprehensive tests for non-interactive behavior --- src/bin/ngit/sub_commands/send.rs | 326 +++++++++++++++++++++++++++++--------- tests/ngit_login.rs | 28 ++-- tests/ngit_send.rs | 148 ++++++++++++++++- 3 files changed, 411 insertions(+), 91 deletions(-) (limited to 'src/bin') diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs index 6ae0cda..325ad89 100644 --- a/src/bin/ngit/sub_commands/send.rs +++ b/src/bin/ngit/sub_commands/send.rs @@ -15,6 +15,7 @@ use crate::{ cli::{Cli, extract_signer_cli_arguments}, cli_interactor::{ Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms, + cli_error, }, client::{ Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, @@ -38,9 +39,9 @@ pub struct SubCommandArgs { #[arg(long, action)] pub(crate) no_cover_letter: bool, /// optional cover letter title - #[clap(short, long)] + #[clap(long)] pub(crate) title: Option, - #[clap(short, long)] + #[clap(long)] /// optional cover letter description pub(crate) description: Option, /// publish as Pull Request even if each commit is < 60kb @@ -51,6 +52,83 @@ pub struct SubCommandArgs { pub(crate) force_patch: bool, } +/// Validates send command arguments for non-interactive mode. +/// +/// Returns Ok(()) if: +/// - Interactive mode is enabled (all validation happens interactively) +/// - Updating an existing proposal (`in_reply_to` is non-empty) +/// - Using defaults mode (--defaults will fill in gaps) +/// - Both title and description are provided +/// +/// Returns an error if: +/// - Description provided without title +/// - Title provided without description +/// - Missing required arguments in non-interactive mode +fn validate_send_args(cli: &Cli, args: &SubCommandArgs) -> Result<()> { + // Interactive mode handles all validation interactively + if cli.interactive { + return Ok(()); + } + + // Description requires title + if args.description.is_some() && args.title.is_none() { + let message = "ngit send requires --title when --description is provided"; + let details = vec![("--title ", "cover letter title")]; + let suggestions = vec![ + "ngit send HEAD~2 --title \"My Feature\" --description \"Details\"", + "ngit send --interactive", + ]; + return Err(cli_error(message, &details, &suggestions)); + } + + // Title requires description + if args.title.is_some() && args.description.is_none() { + let message = "ngit send requires --description when --title is provided"; + let details = vec![("--description ", "cover letter description")]; + let suggestions = vec![ + "ngit send HEAD~2 --title \"My Feature\" --description \"Details\"", + "ngit send --interactive", + ]; + return Err(cli_error(message, &details, &suggestions)); + } + + // Updating existing proposal - no additional validation needed + if !args.in_reply_to.is_empty() { + return Ok(()); + } + + // Defaults mode will fill in gaps + if cli.defaults { + return Ok(()); + } + + // Both title and description provided - all good + if args.title.is_some() && args.description.is_some() { + return Ok(()); + } + + // --no-cover-letter with a range is valid (patches without cover letter) + if args.no_cover_letter && !args.since_or_range.is_empty() { + return Ok(()); + } + + // Missing required arguments for non-interactive mode + let message = "ngit send requires additional arguments"; + let mut details = vec![]; + if args.since_or_range.is_empty() { + details.push(("", "commits to send (eg. HEAD~2)")); + } + details.push(("--title --description ", "cover letter details")); + details.push(("-d, --defaults", "use sensible defaults")); + details.push(("--interactive", "prompt for values")); + let suggestions = vec![ + "ngit send HEAD~2 --title \"My Feature\" --description \"Details\"", + "ngit send --defaults", + "ngit send --interactive", + ]; + Err(cli_error(message, &details, &suggestions)) +} + #[allow(clippy::too_many_lines)] pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Result<()> { let git_repo = Repo::discover().context("failed to find a git repository")?; @@ -60,6 +138,9 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re .get_main_or_master_branch() .context("the default branches (main or master) do not exist")?; + // Validate arguments early, before any network calls + validate_send_args(cli_args, args)?; + let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; @@ -82,14 +163,32 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re let mut commits: Vec = { if args.since_or_range.is_empty() { - let branch_name = git_repo.get_checked_out_branch_name()?; - let proposed_commits = if branch_name.eq(main_branch_name) { - vec![main_tip] + if cli_args.interactive { + let branch_name = git_repo.get_checked_out_branch_name()?; + let proposed_commits = if branch_name.eq(main_branch_name) { + vec![main_tip] + } else { + let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?; + ahead + }; + choose_commits(&git_repo, proposed_commits)? } else { - let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?; - ahead - }; - choose_commits(&git_repo, proposed_commits)? + // --defaults was validated above, so we know it's set + let branch_name = git_repo.get_checked_out_branch_name()?; + let proposed_commits = if branch_name.eq(main_branch_name) { + vec![main_tip] + } else { + let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?; + ahead + }; + if proposed_commits.len() > 10 && !cli_args.force { + bail!( + "too many commits ({}). use --force to proceed or specify a range", + proposed_commits.len() + ); + } + proposed_commits + } } else { git_repo .parse_starting_commits(&args.since_or_range) @@ -97,6 +196,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re } }; + // Check for too many commits with explicit range + if commits.len() > 10 && !cli_args.force && !cli_args.interactive { + bail!( + "too many commits ({}). use --force to proceed or specify a smaller range", + commits.len() + ); + } + if commits.is_empty() { bail!("no commits selected"); } @@ -115,6 +222,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?; check_commits_are_suitable_for_proposal( + cli_args, &first_commit_ahead, &commits, &behind, @@ -138,57 +246,92 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re should_be_pr }; - let title = if as_pr { - match &args.title { - Some(t) => Some(t.clone()), - None => { - if root_proposal.is_none() { - Some( - Interactor::default() - .input(PromptInputParms::default().with_prompt("title"))? - .clone(), - ) - } else { - None + let cover_letter_title_description = if cli_args.interactive { + // Interactive flow: prompt for cover letter confirm, title, description + let title = if as_pr { + match &args.title { + Some(t) => Some(t.clone()), + None => { + if root_proposal.is_none() { + Some( + Interactor::default() + .input(PromptInputParms::default().with_prompt("title"))? + .clone(), + ) + } else { + None + } } } - } - } else if args.no_cover_letter { - None - } else { - match &args.title { - Some(t) => Some(t.clone()), - None => { - if Interactor::default().confirm( - PromptConfirmParms::default() - .with_default(false) - .with_prompt("include cover letter?"), - )? { - Some( - Interactor::default() - .input(PromptInputParms::default().with_prompt("title"))? - .clone(), - ) - } else { - None + } else if args.no_cover_letter { + None + } else { + match &args.title { + Some(t) => Some(t.clone()), + None => { + if Interactor::default().confirm( + PromptConfirmParms::default() + .with_default(false) + .with_prompt("include cover letter?"), + )? { + Some( + Interactor::default() + .input(PromptInputParms::default().with_prompt("title"))? + .clone(), + ) + } else { + None + } } } - } - }; + }; - let cover_letter_title_description = if let Some(title) = title { - Some(( - title, - if let Some(t) = &args.description { - t.clone() - } else { - Interactor::default() - .input(PromptInputParms::default().with_prompt("description"))? - .clone() - }, - )) + if let Some(title) = title { + Some(( + title, + if let Some(t) = &args.description { + t.clone() + } else { + Interactor::default() + .input(PromptInputParms::default().with_prompt("description"))? + .clone() + }, + )) + } else { + None + } + } else if as_pr { + // PR always needs cover letter + let title = match &args.title { + Some(t) => t.clone(), + None if cli_args.defaults => { + git_repo.get_commit_message_summary(commits.first().context("no commits")?)? + } + None => bail!("PR requires --title and --description (or use --defaults)"), + }; + let description = match &args.description { + Some(d) => d.clone(), + None if cli_args.defaults => { + let commit = commits.first().context("no commits")?; + let full_message = git_repo.get_commit_message(commit)?; + let summary = git_repo.get_commit_message_summary(commit)?; + full_message + .strip_prefix(&summary) + .unwrap_or(&full_message) + .trim() + .to_string() + } + None => bail!("PR requires --title and --description (or use --defaults)"), + }; + Some((title, description)) } else { - None + // Patch mode + match (&args.title, &args.description) { + (Some(t), Some(d)) => Some((t.clone(), d.clone())), + (Some(_), None) => bail!("--title requires --description"), + (None, Some(_)) => bail!("--description requires --title"), + (None, None) => None, // no cover letter + } }; let (signer, mut user_ref, _) = login::login_or_signup( @@ -303,6 +446,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re } fn check_commits_are_suitable_for_proposal( + cli: &Cli, first_commit_ahead: &[Sha1Hash], commits: &[Sha1Hash], behind: &[Sha1Hash], @@ -310,37 +454,63 @@ fn check_commits_are_suitable_for_proposal( main_tip: &Sha1Hash, ) -> Result<()> { // check proposal ahead of origin/main - if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt( - format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1) - ) - .with_default(false) - ).context("failed to get confirmation response from interactor confirm")? { - bail!("aborting because selected commits were ahead of origin/master"); + if first_commit_ahead.len().gt(&1) { + if cli.interactive { + if !Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt( + format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1) + ) + .with_default(false) + ).context("failed to get confirmation response from interactor confirm")? { + bail!("aborting ..."); + } + } else if !cli.force { + bail!( + "proposal builds on a commit {} ahead of '{}'. use --force to proceed", + first_commit_ahead.len() - 1, + main_branch_name + ); + } } // check if a selected commit is already in origin if commits.iter().any(|c| c.eq(main_tip)) { - if !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt( - format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?") - ) - .with_default(false) - ).context("failed to get confirmation response from interactor confirm")? { - bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'"); + if cli.interactive { + if !Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt( + format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?") + ) + .with_default(false) + ).context("failed to get confirmation response from interactor confirm")? { + bail!("aborting ..."); + } + } else if !cli.force { + bail!( + "proposal contains commit(s) already in '{main_branch_name}'. use --force to proceed" + ); } } // check proposal isn't behind origin/main - else if !behind.is_empty() && !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt( - format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len()) - ) - .with_default(false) - ).context("failed to get confirmation response from interactor confirm")? { - bail!("aborting so commits can be rebased"); + else if !behind.is_empty() { + if cli.interactive { + if !Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt( + format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len()) + ) + .with_default(false) + ).context("failed to get confirmation response from interactor confirm")? { + bail!("aborting so commits can be rebased"); + } + } else if !cli.force { + bail!( + "proposal is {} behind '{}'. rebase first or use --force to proceed", + behind.len(), + main_branch_name + ); + } } Ok(()) } diff --git a/tests/ngit_login.rs b/tests/ngit_login.rs index 31c6edf..0d397ae 100644 --- a/tests/ngit_login.rs +++ b/tests/ngit_login.rs @@ -38,7 +38,7 @@ fn first_time_login_choices_succeeds_with_nsec(p: &mut CliTester, nsec: &str) -> fn standard_first_time_login_with_nsec() -> Result { let test_repo = GitTestRepo::default(); - let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login", "--offline"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login", "--offline"]); first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; @@ -77,7 +77,8 @@ mod with_relays { let cli_tester_handle = std::thread::spawn(move || -> Result<()> { let test_repo = GitTestRepo::default(); - let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); + let mut p = + CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; @@ -108,7 +109,8 @@ mod with_relays { let cli_tester_handle = std::thread::spawn(move || -> Result<()> { let test_repo = GitTestRepo::default(); - let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); + let mut p = + CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; @@ -456,7 +458,8 @@ mod with_relays { let cli_tester_handle = std::thread::spawn(move || -> Result<()> { let test_repo = GitTestRepo::default(); - let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); + let mut p = + CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; @@ -510,7 +513,8 @@ mod with_relays { let cli_tester_handle = std::thread::spawn(move || -> Result<()> { let test_repo = GitTestRepo::default(); - let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); + let mut p = + CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; @@ -551,7 +555,7 @@ mod with_relays { let cli_tester_handle = std::thread::spawn(move || -> Result<()> { let test_repo = GitTestRepo::default(); - let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; @@ -626,7 +630,8 @@ mod with_offline_flag { #[test] fn succeeds_with_text_logged_in_as_npub() -> Result<()> { let test_repo = GitTestRepo::default(); - let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login", "--offline"]); + let mut p = + CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login", "--offline"]); show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; @@ -641,7 +646,8 @@ mod with_offline_flag { #[test] fn succeeds_with_hex_secret_key_in_place_of_nsec() -> Result<()> { let test_repo = GitTestRepo::default(); - let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login", "--offline"]); + let mut p = + CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login", "--offline"]); show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; @@ -659,8 +665,10 @@ mod with_offline_flag { #[test] fn prompts_for_nsec_until_valid() -> Result<()> { let test_repo = GitTestRepo::default(); - let mut p = - CliTester::new_from_dir(&test_repo.dir, ["account", "login", "--offline"]); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + ["-i", "account", "login", "--offline"], + ); show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; diff --git a/tests/ngit_send.rs b/tests/ngit_send.rs index 2ae858a..7946aef 100644 --- a/tests/ngit_send.rs +++ b/tests/ngit_send.rs @@ -75,7 +75,7 @@ mod when_commits_behind_ask_to_proceed { let mut r51 = create_relay_51()?; // // check relay had the right number of events let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "send", "HEAD~2"]); expect_confirm_prompt(&mut p)?; p.exit()?; relay::shutdown_relay(8051)?; @@ -94,7 +94,7 @@ mod when_commits_behind_ask_to_proceed { let test_repo = prep_test_repo()?; let mut r51 = create_relay_51()?; let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "send", "HEAD~2"]); expect_confirm_prompt(&mut p)?.succeeds_with(Some(false))?; p.expect_end_with("Error: aborting so commits can be rebased\r\n")?; relay::shutdown_relay(8051)?; @@ -113,7 +113,7 @@ mod when_commits_behind_ask_to_proceed { let test_repo = prep_test_repo()?; let mut r51 = create_relay_51()?; let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "send", "HEAD~2"]); expect_confirm_prompt(&mut p)?.succeeds_with(Some(true))?; p.expect("? include cover letter")?; p.exit()?; @@ -1235,6 +1235,7 @@ mod when_range_ommited_prompts_for_selection_defaulting_ahead_of_main { fn cli_tester_create_proposal(git_repo: &GitTestRepo) -> CliTester { let args = vec![ + "-i", "--nsec", TEST_KEY_1_NSEC, "--password", @@ -1943,3 +1944,144 @@ mod in_reply_to_mentions_npub_and_nprofile_which_get_mentioned_in_proposal_root Ok(()) } } + +mod non_interactive_validation { + use super::*; + + #[test] + fn bare_send_errors_with_helpful_message() -> Result<()> { + let test_repo = prep_git_repo()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send"]); + let output = p.expect_end_eventually()?; + assert!(output.contains("ngit send requires additional arguments")); + assert!(output.contains("")); + assert!(output.contains("--title")); + assert!(output.contains("--description")); + assert!(output.contains("--defaults")); + assert!(output.contains("--interactive")); + Ok(()) + } + + #[test] + fn send_with_range_only_errors() -> Result<()> { + let test_repo = prep_git_repo()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); + let output = p.expect_end_eventually()?; + assert!(output.contains("ngit send requires additional arguments")); + assert!(output.contains("--title")); + assert!(output.contains("--description")); + assert!(output.contains("--defaults")); + Ok(()) + } + + #[test] + fn send_force_pr_without_title_errors() -> Result<()> { + let test_repo = prep_git_repo()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "--force-pr", "HEAD~2"]); + let output = p.expect_end_eventually()?; + assert!(output.contains("ngit send requires additional arguments")); + assert!(output.contains("--title")); + assert!(output.contains("--description")); + assert!(output.contains("--defaults")); + Ok(()) + } + + #[test] + fn send_description_without_title_errors() -> Result<()> { + let test_repo = prep_git_repo()?; + let mut p = + CliTester::new_from_dir(&test_repo.dir, ["send", "--description", "Y", "HEAD~2"]); + let output = p.expect_end_eventually()?; + assert!(output.contains("ngit send requires --title when --description is provided")); + assert!(output.contains("--title")); + Ok(()) + } + + #[test] + fn send_title_without_description_errors() -> Result<()> { + let test_repo = prep_git_repo()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "--title", "X", "HEAD~2"]); + let output = p.expect_end_eventually()?; + assert!(output.contains("ngit send requires --description when --title is provided")); + assert!(output.contains("--description")); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn send_defaults_sends_patches_without_cover_letter() -> Result<()> { + let git_repo = prep_git_repo()?; + + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new( + 8055, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![generate_repo_ref_event()], + )?; + Ok(()) + }), + ), + Relay::new(8056, None, None), + ); + + let cli_tester_handle = std::thread::spawn(move || -> Result<()> { + let mut p = CliTester::new_from_dir( + &git_repo.dir, + [ + "--nsec", + TEST_KEY_1_NSEC, + "--password", + TEST_PASSWORD, + "--disable-cli-spinners", + "--defaults", + "send", + ], + ); + p.expect_end_eventually()?; + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok(()) + }); + + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap()?; + + // verify patches sent without cover letter + for relay in [&r53, &r55, &r56] { + assert_eq!( + relay.events.iter().filter(|e| is_cover_letter(e)).count(), + 0, + ); + assert_eq!(relay.events.iter().filter(|e| is_patch(e)).count(), 2); + } + Ok(()) + } +} -- cgit v1.2.3