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(-) 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 c40a5553c335c889390cb54f5fad85e29af7d502 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 10 Feb 2026 12:51:52 +0000 Subject: feat: add non-interactive mode support to CLI interactor Add CliError type for styled error output and cli_error() helper function. Update Interactor to support non-interactive mode with default values. Add prompt methods that respect non-interactive mode and provide better error messages when required values are missing. --- src/lib/cli_interactor.rs | 148 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/src/lib/cli_interactor.rs b/src/lib/cli_interactor.rs index e944bf9..881b988 100644 --- a/src/lib/cli_interactor.rs +++ b/src/lib/cli_interactor.rs @@ -1,4 +1,7 @@ -use anyhow::{Context, Result}; +use std::fmt; + +use anyhow::{Context, Result, bail}; +use console::Style; use dialoguer::{ Confirm, Input, Password, theme::{ColorfulTheme, Theme}, @@ -7,9 +10,93 @@ use indicatif::TermLike; #[cfg(test)] use mockall::*; +/// Sentinel error type indicating the error has already been printed to stderr. +/// +/// When this propagates up to `main()`, it signals "already printed styled +/// output to stderr, don't double-print". This is the same pattern clap uses +/// internally. +#[derive(Debug)] +pub struct CliError; + +impl fmt::Display for CliError { + fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Empty display — the error message was already printed to stderr + Ok(()) + } +} + +impl std::error::Error for CliError {} + +/// Print a styled CLI error to stderr and return an `anyhow::Error` wrapping +/// [`CliError`]. +/// +/// - `message`: the main error text (printed after the red `error:` prefix) +/// - `details`: flag/description pairs shown as gray indented lines (for +/// multiple missing fields). Descriptions are aligned to the longest flag. +/// - `suggestions`: command suggestions shown in yellow +/// +/// This function does NOT call `process::exit()`. It prints to stderr and +/// returns an error that the caller should propagate with `?` or `return Err`. +pub fn cli_error(message: &str, details: &[(&str, &str)], suggestions: &[&str]) -> anyhow::Error { + let dim = Style::new().for_stderr().color256(247); + + eprint!( + "{} {}", + console::style("error:").for_stderr().red(), + message + ); + if details.is_empty() { + eprintln!(); + } else { + let max_flag_len = details + .iter() + .map(|(flag, _)| flag.len()) + .max() + .unwrap_or(0); + eprintln!(); + for (flag, desc) in details { + eprintln!( + " {:width$} {}", + dim.apply_to(flag), + dim.apply_to(desc), + width = max_flag_len + ); + } + } + + if !suggestions.is_empty() { + eprintln!(); + for cmd in suggestions { + eprintln!( + "{}", + console::style(format!(" {cmd}")).for_stderr().yellow(), + ); + } + } + + CliError.into() +} + #[derive(Default)] pub struct Interactor { theme: ColorfulTheme, + non_interactive: bool, +} + +impl Interactor { + pub fn new(non_interactive: bool) -> Self { + Self { + theme: ColorfulTheme::default(), + non_interactive, + } + } + + /// Returns true if running in non-interactive mode (the default). + /// Interactive mode is only enabled when NGIT_INTERACTIVE_MODE env var is + /// set (via -i flag). + pub fn is_non_interactive() -> bool { + std::env::var("NGIT_INTERACTIVE_MODE").is_err() + } } #[cfg_attr(test, automock)] @@ -22,6 +109,21 @@ pub trait InteractorPrompt { } impl InteractorPrompt for Interactor { fn input(&self, parms: PromptInputParms) -> Result { + if self.non_interactive || Self::is_non_interactive() { + if parms.optional || !parms.default.is_empty() { + return Ok(parms.default); + } + let flag_hint = parms + .flag_name + .as_ref() + .map(|f| format!(" (provide {} or use -i/-d)", f)) + .unwrap_or_else(|| " (use -i for interactive mode or -d for defaults)".to_string()); + bail!( + "interactive input required but running in non-interactive mode: {}{}", + parms.prompt, + flag_hint + ); + } let mut input = Input::with_theme(&self.theme) .with_prompt(parms.prompt) .allow_empty(parms.optional) @@ -32,6 +134,12 @@ impl InteractorPrompt for Interactor { Ok(input.interact_text()?) } fn password(&self, parms: PromptPasswordParms) -> Result { + if self.non_interactive || Self::is_non_interactive() { + bail!( + "password input required but running in non-interactive mode: {}", + parms.prompt + ); + } let mut p = Password::with_theme(&self.theme) .with_prompt(parms.prompt) .report(parms.report); @@ -42,6 +150,9 @@ impl InteractorPrompt for Interactor { Ok(pass) } fn confirm(&self, params: PromptConfirmParms) -> Result { + if self.non_interactive || Self::is_non_interactive() { + return Ok(params.default); + } let confirm: bool = Confirm::with_theme(&self.theme) .with_prompt(params.prompt) .default(params.default) @@ -49,6 +160,15 @@ impl InteractorPrompt for Interactor { Ok(confirm) } fn choice(&self, parms: PromptChoiceParms) -> Result { + if self.non_interactive || Self::is_non_interactive() { + if let Some(default) = parms.default { + return Ok(default); + } + bail!( + "interactive choice required but running in non-interactive mode: {}", + parms.prompt + ); + } let mut choice = dialoguer::Select::with_theme(&self.theme) .with_prompt(parms.prompt) .report(parms.report) @@ -61,6 +181,17 @@ impl InteractorPrompt for Interactor { choice.interact().context("failed to get choice") } fn multi_choice(&self, parms: PromptMultiChoiceParms) -> Result> { + if self.non_interactive || Self::is_non_interactive() { + if let Some(defaults) = &parms.defaults { + return Ok(defaults + .iter() + .enumerate() + .filter(|(_, &selected)| selected) + .map(|(i, _)| i) + .collect()); + } + return Ok(vec![]); // Empty selection if no defaults + } // the colorful theme is not very clear so falling back to default let mut choice = dialoguer::MultiSelect::default() .with_prompt(parms.prompt) @@ -73,11 +204,20 @@ impl InteractorPrompt for Interactor { } } +/// Parameters for interactive input prompts. +/// +/// Supports both interactive and non-interactive modes: +/// - Interactive mode (NGIT_INTERACTIVE_MODE set): prompts user +/// - Non-interactive mode (default): returns default value or errors +/// +/// The `flag_name` field improves error messages by telling users +/// which CLI flag would provide the missing value. pub struct PromptInputParms { pub prompt: String, pub default: String, pub report: bool, pub optional: bool, + pub flag_name: Option, } impl Default for PromptInputParms { @@ -87,6 +227,7 @@ impl Default for PromptInputParms { default: String::new(), optional: false, report: true, + flag_name: None, } } } @@ -109,6 +250,11 @@ impl PromptInputParms { self.report = false; self } + + pub fn with_flag_name>(mut self, flag_name: S) -> Self { + self.flag_name = Some(flag_name.into()); + self + } } pub struct PromptPasswordParms { -- 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(-) 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 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(-) 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(-) 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(-) 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 From aae452697d152694a8f163219f707356e84b420b Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 10 Feb 2026 12:52:32 +0000 Subject: docs: update documentation for non-interactive mode Add maintainer-model architecture documentation. Update README to reflect non-interactive default behavior. --- README.md | 2 +- docs/architecture/maintainer-model.md | 84 +++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/maintainer-model.md diff --git a/README.md b/README.md index 3d172a2..074cde3 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ for code collaboration, nostr is used for: - state (ie. git refs) - proposals (PRs), issues and related discussion -a git server is still required for data storage and syncing state. multiple git servers can be used for reduncancy and they can be seemlessly swapped out by maintainers just like nostr relays. +a git server is still required for data storage and syncing state. multiple git servers can be used for reduncancy and they can be seemlessly swapped out by maintainers just like nostr relays. see [maintainer model](docs/architecture/maintainer-model.md) for details on how multi-maintainer repositories work. eg self-hosted, github, codeberg, etc. diff --git a/docs/architecture/maintainer-model.md b/docs/architecture/maintainer-model.md new file mode 100644 index 0000000..9aebea7 --- /dev/null +++ b/docs/architecture/maintainer-model.md @@ -0,0 +1,84 @@ +# Maintainer Model + +How ngit handles multi-maintainer repositories: coordinate discovery, maintainer sets, and the distinction between shared metadata and personal infrastructure. + +## Coordinate Discovery + +A **coordinate** is a `(kind, pubkey, identifier)` tuple that uniquely identifies a repository on nostr. The pubkey in the coordinate is the **trusted maintainer** (typically the original creator). + +ngit discovers the coordinate locally from (in priority order): + +1. `nostr://` git remotes +2. `nostr.repo` git config +3. `maintainers.yaml` + +No network access is required to find the coordinate. The coordinate may exist without a corresponding announcement event on relays. + +## Maintainer Set + +Each repository announcement (kind 30617) contains a `maintainers` tag listing public keys. These form a recursive set: if Alice lists Bob, and Bob lists Carol, then {Alice, Bob, Carol} are all in the maintainer set. + +Each maintainer independently decides who they list. Adding someone to your maintainers tag is an invitation to co-maintain. + +## Consuming vs Publishing + +The key architectural distinction is between **consuming** repository data (fetching, cloning, listing) and **publishing** it (`ngit init`). + +### Consuming: Union Across Maintainers + +When consuming repo data, `relays`, `clone` (git server URLs), and `blossoms` are **unioned** across all maintainers' announcement events. This means any maintainer can add a mirror git server or relay and all users benefit automatically. + +### Publishing: Personal Infrastructure, Shared Metadata + +When publishing via `ngit init`, fields are sourced differently depending on their type: + +#### Shared Metadata + +Sourced from the **latest event** (by `created_at`) across the maintainer set: + +- `name` +- `description` +- `web` +- `hashtags` + +Rationale: these are shared identity. If any maintainer updates the project name, all subsequent re-announcements should pick it up. + +#### Infrastructure (Personal) + +Each maintainer has their own infrastructure preferences. When publishing, infrastructure comes from **my own announcement only**, not the union: + +- **Grasp servers** -- where my git+nostr data is hosted. Each grasp server derives: + - Clone URL: `https://{server}/{npub}/{identifier}.git` + - Relay URL: `wss://{server}` + - Blossom URL: `https://{server}` +- **Additional relays, git servers, blossoms** -- beyond what grasp servers provide + +Grasp-format clone URLs belonging to other maintainers are kept as additional git servers (they're part of the union for consumers) but are not treated as my grasp servers. + +#### Maintainers + +Sourced from **my own announcement only**. Each maintainer independently decides who they list. + +If I don't have an existing announcement (first time co-maintaining), the default is `[me, trusted_maintainer]`. + +#### Earliest Unique Commit + +Cascade: my own event's value, then other maintainers' values, then the local root commit. A mismatch between maintainers may indicate a fork. + +#### Identifier + +From the existing coordinate. Cannot change without `--force` (changing it creates a new repository). + +## Init States + +When `ngit init` runs, there are 5 possible states based on what exists locally and on relays: + +| State | Condition | Behavior | +|-------|-----------|----------| +| **Fresh** | No coordinate found | Must provide name + server infrastructure | +| **Coordinate Only** | Coordinate exists, no announcement on relays | Requires `--force` (could be a relay/network issue) | +| **My Announcement** | Announcement exists, I'm the trusted maintainer | Re-publish/update, no force needed | +| **Co-Maintainer** | Announcement exists, I'm listed as maintainer | Publish own announcement, no force needed | +| **Not Listed** | Announcement exists, I'm not in maintainer set | Requires `--force` | + +See `src/bin/ngit/sub_commands/init.rs` (`InitState` enum) and `tests/ngit_init.rs` for the implementation and test coverage. -- cgit v1.2.3