diff options
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | docs/architecture/maintainer-model.md | 84 | ||||
| -rw-r--r-- | src/bin/git_remote_nostr/push.rs | 17 | ||||
| -rw-r--r-- | src/bin/ngit/cli.rs | 12 | ||||
| -rw-r--r-- | src/bin/ngit/main.rs | 26 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/create.rs | 71 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/init.rs | 1759 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/login.rs | 56 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/mod.rs | 1 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/send.rs | 326 | ||||
| -rw-r--r-- | src/lib/cli_interactor.rs | 148 | ||||
| -rw-r--r-- | src/lib/client.rs | 14 | ||||
| -rw-r--r-- | src/lib/login/fresh.rs | 179 | ||||
| -rw-r--r-- | test_utils/src/git.rs | 24 | ||||
| -rw-r--r-- | test_utils/src/lib.rs | 78 | ||||
| -rw-r--r-- | tests/ngit_init.rs | 1419 | ||||
| -rw-r--r-- | tests/ngit_list.rs | 34 | ||||
| -rw-r--r-- | tests/ngit_login.rs | 28 | ||||
| -rw-r--r-- | tests/ngit_send.rs | 148 |
19 files changed, 3312 insertions, 1114 deletions
| @@ -41,7 +41,7 @@ for code collaboration, nostr is used for: | |||
| 41 | - state (ie. git refs) | 41 | - state (ie. git refs) |
| 42 | - proposals (PRs), issues and related discussion | 42 | - proposals (PRs), issues and related discussion |
| 43 | 43 | ||
| 44 | 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. | 44 | 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. |
| 45 | 45 | ||
| 46 | eg self-hosted, github, codeberg, etc. | 46 | eg self-hosted, github, codeberg, etc. |
| 47 | 47 | ||
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 @@ | |||
| 1 | # Maintainer Model | ||
| 2 | |||
| 3 | How ngit handles multi-maintainer repositories: coordinate discovery, maintainer sets, and the distinction between shared metadata and personal infrastructure. | ||
| 4 | |||
| 5 | ## Coordinate Discovery | ||
| 6 | |||
| 7 | 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). | ||
| 8 | |||
| 9 | ngit discovers the coordinate locally from (in priority order): | ||
| 10 | |||
| 11 | 1. `nostr://` git remotes | ||
| 12 | 2. `nostr.repo` git config | ||
| 13 | 3. `maintainers.yaml` | ||
| 14 | |||
| 15 | No network access is required to find the coordinate. The coordinate may exist without a corresponding announcement event on relays. | ||
| 16 | |||
| 17 | ## Maintainer Set | ||
| 18 | |||
| 19 | 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. | ||
| 20 | |||
| 21 | Each maintainer independently decides who they list. Adding someone to your maintainers tag is an invitation to co-maintain. | ||
| 22 | |||
| 23 | ## Consuming vs Publishing | ||
| 24 | |||
| 25 | The key architectural distinction is between **consuming** repository data (fetching, cloning, listing) and **publishing** it (`ngit init`). | ||
| 26 | |||
| 27 | ### Consuming: Union Across Maintainers | ||
| 28 | |||
| 29 | 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. | ||
| 30 | |||
| 31 | ### Publishing: Personal Infrastructure, Shared Metadata | ||
| 32 | |||
| 33 | When publishing via `ngit init`, fields are sourced differently depending on their type: | ||
| 34 | |||
| 35 | #### Shared Metadata | ||
| 36 | |||
| 37 | Sourced from the **latest event** (by `created_at`) across the maintainer set: | ||
| 38 | |||
| 39 | - `name` | ||
| 40 | - `description` | ||
| 41 | - `web` | ||
| 42 | - `hashtags` | ||
| 43 | |||
| 44 | Rationale: these are shared identity. If any maintainer updates the project name, all subsequent re-announcements should pick it up. | ||
| 45 | |||
| 46 | #### Infrastructure (Personal) | ||
| 47 | |||
| 48 | Each maintainer has their own infrastructure preferences. When publishing, infrastructure comes from **my own announcement only**, not the union: | ||
| 49 | |||
| 50 | - **Grasp servers** -- where my git+nostr data is hosted. Each grasp server derives: | ||
| 51 | - Clone URL: `https://{server}/{npub}/{identifier}.git` | ||
| 52 | - Relay URL: `wss://{server}` | ||
| 53 | - Blossom URL: `https://{server}` | ||
| 54 | - **Additional relays, git servers, blossoms** -- beyond what grasp servers provide | ||
| 55 | |||
| 56 | 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. | ||
| 57 | |||
| 58 | #### Maintainers | ||
| 59 | |||
| 60 | Sourced from **my own announcement only**. Each maintainer independently decides who they list. | ||
| 61 | |||
| 62 | If I don't have an existing announcement (first time co-maintaining), the default is `[me, trusted_maintainer]`. | ||
| 63 | |||
| 64 | #### Earliest Unique Commit | ||
| 65 | |||
| 66 | Cascade: my own event's value, then other maintainers' values, then the local root commit. A mismatch between maintainers may indicate a fork. | ||
| 67 | |||
| 68 | #### Identifier | ||
| 69 | |||
| 70 | From the existing coordinate. Cannot change without `--force` (changing it creates a new repository). | ||
| 71 | |||
| 72 | ## Init States | ||
| 73 | |||
| 74 | When `ngit init` runs, there are 5 possible states based on what exists locally and on relays: | ||
| 75 | |||
| 76 | | State | Condition | Behavior | | ||
| 77 | |-------|-----------|----------| | ||
| 78 | | **Fresh** | No coordinate found | Must provide name + server infrastructure | | ||
| 79 | | **Coordinate Only** | Coordinate exists, no announcement on relays | Requires `--force` (could be a relay/network issue) | | ||
| 80 | | **My Announcement** | Announcement exists, I'm the trusted maintainer | Re-publish/update, no force needed | | ||
| 81 | | **Co-Maintainer** | Announcement exists, I'm listed as maintainer | Publish own announcement, no force needed | | ||
| 82 | | **Not Listed** | Announcement exists, I'm not in maintainer set | Requires `--force` | | ||
| 83 | |||
| 84 | See `src/bin/ngit/sub_commands/init.rs` (`InitState` enum) and `tests/ngit_init.rs` for the implementation and test coverage. | ||
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::{ | |||
| 20 | self, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_to_cover_letter, get_event_root, | 20 | self, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_to_cover_letter, get_event_root, |
| 21 | }, | 21 | }, |
| 22 | list::list_from_remotes, | 22 | list::list_from_remotes, |
| 23 | login::{self, user::UserRef}, | 23 | login::{existing::load_existing_login, user::UserRef}, |
| 24 | push::{push_to_remote, select_servers_push_refs_and_generate_pr_or_pr_update_event}, | 24 | push::{push_to_remote, select_servers_push_refs_and_generate_pr_or_pr_update_event}, |
| 25 | repo_ref::{self, get_repo_config_from_yaml, is_grasp_server_clone_url}, | 25 | repo_ref::{self, get_repo_config_from_yaml, is_grasp_server_clone_url}, |
| 26 | repo_state, | 26 | repo_state, |
| @@ -173,6 +173,7 @@ pub async fn run_push( | |||
| 173 | Ok(()) | 173 | Ok(()) |
| 174 | } | 174 | } |
| 175 | 175 | ||
| 176 | #[allow(clippy::too_many_lines)] | ||
| 176 | async fn create_and_publish_events_and_proposals( | 177 | async fn create_and_publish_events_and_proposals( |
| 177 | git_repo: &Repo, | 178 | git_repo: &Repo, |
| 178 | repo_ref: &RepoRef, | 179 | repo_ref: &RepoRef, |
| @@ -182,8 +183,18 @@ async fn create_and_publish_events_and_proposals( | |||
| 182 | existing_state: HashMap<String, String>, | 183 | existing_state: HashMap<String, String>, |
| 183 | term: &Term, | 184 | term: &Term, |
| 184 | ) -> Result<(Vec<String>, bool)> { | 185 | ) -> Result<(Vec<String>, bool)> { |
| 185 | let (signer, mut user_ref, _) = | 186 | let (signer, mut user_ref, _) = load_existing_login( |
| 186 | login::login_or_signup(&Some(git_repo), &None, &None, Some(client), true).await?; | 187 | &Some(git_repo), |
| 188 | &None, | ||
| 189 | &None, | ||
| 190 | &None, | ||
| 191 | Some(client), | ||
| 192 | true, // silent | ||
| 193 | false, // prompt_for_password - MUST be false for non-interactive | ||
| 194 | true, // fetch_profile_updates | ||
| 195 | ) | ||
| 196 | .await | ||
| 197 | .context("Authentication required. Run 'ngit account login' first, then try again.")?; | ||
| 187 | 198 | ||
| 188 | if !repo_ref.maintainers.contains(&user_ref.public_key) { | 199 | if !repo_ref.maintainers.contains(&user_ref.public_key) { |
| 189 | for refspec in git_server_refspecs { | 200 | for refspec in git_server_refspecs { |
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; | |||
| 11 | 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}" | 11 | 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}" |
| 12 | )] | 12 | )] |
| 13 | #[command(propagate_version = true)] | 13 | #[command(propagate_version = true)] |
| 14 | #[allow(clippy::struct_excessive_bools)] | ||
| 14 | pub struct Cli { | 15 | pub struct Cli { |
| 15 | #[command(subcommand)] | 16 | #[command(subcommand)] |
| 16 | pub command: Option<Commands>, | 17 | pub command: Option<Commands>, |
| @@ -32,6 +33,15 @@ pub struct Cli { | |||
| 32 | /// show customization options via git config | 33 | /// show customization options via git config |
| 33 | #[arg(short, long, global = true)] | 34 | #[arg(short, long, global = true)] |
| 34 | pub customize: bool, | 35 | pub customize: bool, |
| 36 | /// Use default values without prompting (non-interactive mode) | ||
| 37 | #[arg(short = 'd', long, global = true, conflicts_with = "interactive")] | ||
| 38 | pub defaults: bool, | ||
| 39 | /// Enable interactive prompts (default behavior) | ||
| 40 | #[arg(short = 'i', long, global = true)] | ||
| 41 | pub interactive: bool, | ||
| 42 | /// Force operations, bypass safety guards | ||
| 43 | #[arg(short = 'f', long, global = true)] | ||
| 44 | pub force: bool, | ||
| 35 | } | 45 | } |
| 36 | 46 | ||
| 37 | pub const CUSTOMISE_TEMPLATE: &str = r" | 47 | pub const CUSTOMISE_TEMPLATE: &str = r" |
| @@ -111,6 +121,8 @@ pub enum AccountCommands { | |||
| 111 | Logout, | 121 | Logout, |
| 112 | /// export nostr keys to login to other nostr clients | 122 | /// export nostr keys to login to other nostr clients |
| 113 | ExportKeys, | 123 | ExportKeys, |
| 124 | /// create a new nostr account | ||
| 125 | Create(sub_commands::create::SubCommandArgs), | ||
| 114 | } | 126 | } |
| 115 | 127 | ||
| 116 | #[derive(clap::Parser)] | 128 | #[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 @@ | |||
| 2 | #![allow(clippy::large_futures)] | 2 | #![allow(clippy::large_futures)] |
| 3 | #![cfg_attr(not(test), warn(clippy::expect_used))] | 3 | #![cfg_attr(not(test), warn(clippy::expect_used))] |
| 4 | 4 | ||
| 5 | use anyhow::Result; | ||
| 6 | use clap::Parser; | 5 | use clap::Parser; |
| 7 | use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands}; | 6 | use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands}; |
| 8 | 7 | ||
| 9 | mod cli; | 8 | mod cli; |
| 10 | use ngit::{ | 9 | use ngit::{ |
| 11 | cli_interactor, client, | 10 | cli_interactor::{self, CliError}, |
| 11 | client, | ||
| 12 | git::{self, utils::set_git_timeout}, | 12 | git::{self, utils::set_git_timeout}, |
| 13 | git_events, login, repo_ref, | 13 | git_events, login, repo_ref, |
| 14 | }; | 14 | }; |
| @@ -16,9 +16,15 @@ use ngit::{ | |||
| 16 | mod sub_commands; | 16 | mod sub_commands; |
| 17 | 17 | ||
| 18 | #[tokio::main] | 18 | #[tokio::main] |
| 19 | async fn main() -> Result<()> { | 19 | async fn main() { |
| 20 | let cli = Cli::parse(); | 20 | let cli = Cli::parse(); |
| 21 | 21 | ||
| 22 | // Non-interactive by default; set NGIT_INTERACTIVE_MODE only when -i is | ||
| 23 | // specified | ||
| 24 | if cli.interactive { | ||
| 25 | std::env::set_var("NGIT_INTERACTIVE_MODE", "1"); | ||
| 26 | } | ||
| 27 | |||
| 22 | if cli.customize { | 28 | if cli.customize { |
| 23 | print!("{CUSTOMISE_TEMPLATE}"); | 29 | print!("{CUSTOMISE_TEMPLATE}"); |
| 24 | std::process::exit(0); // Exit the program | 30 | std::process::exit(0); // Exit the program |
| @@ -26,7 +32,7 @@ async fn main() -> Result<()> { | |||
| 26 | 32 | ||
| 27 | let _ = set_git_timeout(); | 33 | let _ = set_git_timeout(); |
| 28 | 34 | ||
| 29 | if let Some(command) = &cli.command { | 35 | let result = if let Some(command) = &cli.command { |
| 30 | match command { | 36 | match command { |
| 31 | Commands::Account(args) => match &args.account_command { | 37 | Commands::Account(args) => match &args.account_command { |
| 32 | AccountCommands::Login(sub_args) => { | 38 | AccountCommands::Login(sub_args) => { |
| @@ -34,6 +40,9 @@ async fn main() -> Result<()> { | |||
| 34 | } | 40 | } |
| 35 | AccountCommands::Logout => sub_commands::logout::launch().await, | 41 | AccountCommands::Logout => sub_commands::logout::launch().await, |
| 36 | AccountCommands::ExportKeys => sub_commands::export_keys::launch().await, | 42 | AccountCommands::ExportKeys => sub_commands::export_keys::launch().await, |
| 43 | AccountCommands::Create(sub_args) => { | ||
| 44 | sub_commands::create::launch(&cli, sub_args).await | ||
| 45 | } | ||
| 37 | }, | 46 | }, |
| 38 | Commands::Init(args) => sub_commands::init::launch(&cli, args).await, | 47 | Commands::Init(args) => sub_commands::init::launch(&cli, args).await, |
| 39 | Commands::List => sub_commands::list::launch().await, | 48 | Commands::List => sub_commands::list::launch().await, |
| @@ -44,5 +53,14 @@ async fn main() -> Result<()> { | |||
| 44 | // Handle the case where no command is provided | 53 | // Handle the case where no command is provided |
| 45 | eprintln!("Error: A command must be provided. Use '--help' for more information."); | 54 | eprintln!("Error: A command must be provided. Use '--help' for more information."); |
| 46 | std::process::exit(1); | 55 | std::process::exit(1); |
| 56 | }; | ||
| 57 | |||
| 58 | if let Err(err) = result { | ||
| 59 | if err.downcast_ref::<CliError>().is_some() { | ||
| 60 | // Already printed styled output to stderr | ||
| 61 | std::process::exit(1); | ||
| 62 | } | ||
| 63 | eprintln!("Error: {err:?}"); | ||
| 64 | std::process::exit(1); | ||
| 47 | } | 65 | } |
| 48 | } | 66 | } |
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 @@ | |||
| 1 | use anyhow::{Context, Result}; | ||
| 2 | use clap::Parser; | ||
| 3 | use ngit::client::Params; | ||
| 4 | use nostr_sdk::ToBech32; | ||
| 5 | |||
| 6 | use crate::{ | ||
| 7 | cli::Cli, | ||
| 8 | client::{Client, Connect}, | ||
| 9 | git::Repo, | ||
| 10 | login::fresh::signup_non_interactive, | ||
| 11 | }; | ||
| 12 | |||
| 13 | #[derive(Parser)] | ||
| 14 | pub struct SubCommandArgs { | ||
| 15 | /// Display name for the new account | ||
| 16 | #[arg(long, required = true)] | ||
| 17 | pub name: String, | ||
| 18 | |||
| 19 | /// Don't publish metadata to relays (offline mode) | ||
| 20 | #[arg(long)] | ||
| 21 | pub offline: bool, | ||
| 22 | |||
| 23 | /// Save credentials only to local git config | ||
| 24 | #[arg(long)] | ||
| 25 | pub local: bool, | ||
| 26 | } | ||
| 27 | |||
| 28 | pub async fn launch(_cli: &Cli, args: &SubCommandArgs) -> Result<()> { | ||
| 29 | let git_repo = Repo::discover().ok(); | ||
| 30 | |||
| 31 | let client = if args.offline { | ||
| 32 | None | ||
| 33 | } else { | ||
| 34 | Some(Client::new(Params::with_git_config_relay_defaults( | ||
| 35 | &git_repo.as_ref(), | ||
| 36 | ))) | ||
| 37 | }; | ||
| 38 | |||
| 39 | let publish = !args.offline; | ||
| 40 | |||
| 41 | let (_signer, public_key, _signer_info, keys) = | ||
| 42 | signup_non_interactive(args.name.clone(), client.as_ref(), args.local, publish) | ||
| 43 | .await | ||
| 44 | .context("failed to create account")?; | ||
| 45 | |||
| 46 | // Display the generated nsec prominently | ||
| 47 | println!("\n✓ Account created successfully!"); | ||
| 48 | println!("\nDisplay name: {}", args.name); | ||
| 49 | println!("Public key (npub): {}", public_key.to_bech32()?); | ||
| 50 | println!("\n⚠️ IMPORTANT: Save your secret key (nsec) securely!"); | ||
| 51 | println!("nsec: {}", keys.secret_key().to_bech32()?); | ||
| 52 | println!("\nYou will need this key to log in from other devices."); | ||
| 53 | println!("Run 'ngit account export-keys' to see this again.\n"); | ||
| 54 | |||
| 55 | if publish { | ||
| 56 | println!("✓ Published metadata to relays"); | ||
| 57 | } | ||
| 58 | |||
| 59 | if args.local { | ||
| 60 | println!("✓ Saved credentials to local git config only"); | ||
| 61 | } else { | ||
| 62 | println!("✓ Saved credentials to global git config"); | ||
| 63 | } | ||
| 64 | |||
| 65 | // Disconnect client if it was created | ||
| 66 | if let Some(client) = client { | ||
| 67 | client.disconnect().await?; | ||
| 68 | } | ||
| 69 | |||
| 70 | Ok(()) | ||
| 71 | } | ||
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::{ | |||
| 3 | env, | 3 | env, |
| 4 | process::{Command, Stdio}, | 4 | process::{Command, Stdio}, |
| 5 | str::FromStr, | 5 | str::FromStr, |
| 6 | sync::Arc, | ||
| 6 | thread, | 7 | thread, |
| 7 | time::Duration, | 8 | time::Duration, |
| 8 | }; | 9 | }; |
| @@ -13,7 +14,7 @@ use git2::Oid; | |||
| 13 | use ngit::{ | 14 | use ngit::{ |
| 14 | UrlWithoutSlash, | 15 | UrlWithoutSlash, |
| 15 | cli_interactor::{ | 16 | cli_interactor::{ |
| 16 | PromptChoiceParms, PromptConfirmParms, multi_select_with_custom_value, | 17 | PromptChoiceParms, PromptConfirmParms, cli_error, multi_select_with_custom_value, |
| 17 | show_multi_input_prompt_success, | 18 | show_multi_input_prompt_success, |
| 18 | }, | 19 | }, |
| 19 | client::{Params, get_state_from_cache, send_events}, | 20 | client::{Params, get_state_from_cache, send_events}, |
| @@ -44,112 +45,638 @@ use crate::{ | |||
| 44 | }, | 45 | }, |
| 45 | }; | 46 | }; |
| 46 | 47 | ||
| 48 | // --------------------------------------------------------------------------- | ||
| 49 | // InitState: determines what scenario we're in | ||
| 50 | // --------------------------------------------------------------------------- | ||
| 51 | |||
| 52 | enum InitState { | ||
| 53 | /// No coordinate found anywhere (State A) | ||
| 54 | Fresh, | ||
| 55 | /// Coordinate found but no announcement event on relays (State B) | ||
| 56 | CoordinateOnly { coordinate: Nip19Coordinate }, | ||
| 57 | /// Announcement exists, I am the trusted maintainer (State C) | ||
| 58 | MyAnnouncement { | ||
| 59 | coordinate: Nip19Coordinate, | ||
| 60 | repo_ref: RepoRef, | ||
| 61 | }, | ||
| 62 | /// Announcement exists, I'm in the maintainer set (State D) | ||
| 63 | CoMaintainer { | ||
| 64 | coordinate: Nip19Coordinate, | ||
| 65 | repo_ref: RepoRef, | ||
| 66 | }, | ||
| 67 | /// Announcement exists, I'm not in the maintainer set (State E) | ||
| 68 | NotListed { | ||
| 69 | coordinate: Nip19Coordinate, | ||
| 70 | repo_ref: RepoRef, | ||
| 71 | }, | ||
| 72 | } | ||
| 73 | |||
| 74 | impl InitState { | ||
| 75 | fn coordinate(&self) -> Option<&Nip19Coordinate> { | ||
| 76 | match self { | ||
| 77 | Self::Fresh => None, | ||
| 78 | Self::CoordinateOnly { coordinate } | ||
| 79 | | Self::MyAnnouncement { coordinate, .. } | ||
| 80 | | Self::CoMaintainer { coordinate, .. } | ||
| 81 | | Self::NotListed { coordinate, .. } => Some(coordinate), | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | fn repo_ref(&self) -> Option<&RepoRef> { | ||
| 86 | match self { | ||
| 87 | Self::Fresh | Self::CoordinateOnly { .. } => None, | ||
| 88 | Self::MyAnnouncement { repo_ref, .. } | ||
| 89 | | Self::CoMaintainer { repo_ref, .. } | ||
| 90 | | Self::NotListed { repo_ref, .. } => Some(repo_ref), | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | /// Extract my own announcement's `RepoRef` from the events map. | ||
| 95 | /// Returns `None` if no coordinate, no announcement, or I have no event. | ||
| 96 | fn my_repo_ref(&self, my_pubkey: &PublicKey) -> Option<RepoRef> { | ||
| 97 | self.repo_ref() | ||
| 98 | .and_then(|rr| my_event_repo_ref(rr, my_pubkey)) | ||
| 99 | } | ||
| 100 | |||
| 101 | fn has_coordinate(&self) -> bool { | ||
| 102 | !matches!(self, Self::Fresh) | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | struct ResolvedFields { | ||
| 107 | identifier: String, | ||
| 108 | name: String, | ||
| 109 | description: String, | ||
| 110 | git_servers: Vec<String>, | ||
| 111 | relays: Vec<RelayUrl>, | ||
| 112 | blossoms: Vec<Url>, | ||
| 113 | web: Vec<String>, | ||
| 114 | maintainers: Vec<PublicKey>, | ||
| 115 | earliest_unique_commit: String, | ||
| 116 | hashtags: Vec<String>, | ||
| 117 | selected_grasp_servers: Vec<String>, | ||
| 118 | } | ||
| 119 | |||
| 120 | /// Extract my own announcement's `RepoRef` from the events map. | ||
| 121 | fn my_event_repo_ref(repo_ref: &RepoRef, my_pubkey: &PublicKey) -> Option<RepoRef> { | ||
| 122 | repo_ref | ||
| 123 | .events | ||
| 124 | .values() | ||
| 125 | .find(|e| e.pubkey == *my_pubkey) | ||
| 126 | .and_then(|e| RepoRef::try_from((e.clone(), None)).ok()) | ||
| 127 | } | ||
| 128 | |||
| 129 | /// Find the latest event (by `created_at`) across all maintainer events and | ||
| 130 | /// parse it into a `RepoRef` for shared metadata (name, description, web). | ||
| 131 | fn latest_event_repo_ref(repo_ref: &RepoRef) -> Option<RepoRef> { | ||
| 132 | repo_ref | ||
| 133 | .events | ||
| 134 | .values() | ||
| 135 | .max_by_key(|e| e.created_at) | ||
| 136 | .and_then(|e| RepoRef::try_from((e.clone(), None)).ok()) | ||
| 137 | } | ||
| 138 | |||
| 139 | /// Check if a grasp-format clone URL belongs to the given public key. | ||
| 140 | fn is_my_grasp_clone_url(url: &str, my_pubkey: &PublicKey) -> bool { | ||
| 141 | if !is_grasp_server_clone_url(url) { | ||
| 142 | return false; | ||
| 143 | } | ||
| 144 | if let Ok(npub) = extract_npub(url) { | ||
| 145 | if let Ok(url_pk) = PublicKey::from_bech32(npub) { | ||
| 146 | return url_pk == *my_pubkey; | ||
| 147 | } | ||
| 148 | } | ||
| 149 | false | ||
| 150 | } | ||
| 151 | |||
| 152 | /// Check if a relay URL corresponds to one of the given grasp servers. | ||
| 153 | fn is_grasp_derived_relay(relay: &str, grasp_servers: &[String]) -> bool { | ||
| 154 | let Ok(relay_normalized) = normalize_grasp_server_url(relay) else { | ||
| 155 | return false; | ||
| 156 | }; | ||
| 157 | grasp_servers.iter().any(|gs| { | ||
| 158 | normalize_grasp_server_url(gs).is_ok_and(|gs_normalized| gs_normalized == relay_normalized) | ||
| 159 | }) | ||
| 160 | } | ||
| 161 | |||
| 162 | /// Check if a blossom URL corresponds to one of the given grasp servers. | ||
| 163 | fn is_grasp_derived_blossom(blossom: &str, grasp_servers: &[String]) -> bool { | ||
| 164 | // Blossom URLs are https://{grasp_server} — same normalization as relays | ||
| 165 | is_grasp_derived_relay(blossom, grasp_servers) | ||
| 166 | } | ||
| 167 | |||
| 168 | fn dir_name_fallback() -> String { | ||
| 169 | env::current_dir() | ||
| 170 | .ok() | ||
| 171 | .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) | ||
| 172 | .unwrap_or_default() | ||
| 173 | } | ||
| 174 | |||
| 175 | fn identifier_from_name(name: &str) -> String { | ||
| 176 | name.replace(' ', "-") | ||
| 177 | .chars() | ||
| 178 | .map(|c| { | ||
| 179 | if c.is_ascii_alphanumeric() || c.eq(&'/') { | ||
| 180 | c | ||
| 181 | } else { | ||
| 182 | '-' | ||
| 183 | } | ||
| 184 | }) | ||
| 185 | .collect() | ||
| 186 | } | ||
| 187 | |||
| 188 | fn build_gitworkshop_url( | ||
| 189 | public_key: &PublicKey, | ||
| 190 | identifier: &str, | ||
| 191 | first_relay: Option<&RelayUrl>, | ||
| 192 | ) -> String { | ||
| 193 | NostrUrlDecoded { | ||
| 194 | original_string: String::new(), | ||
| 195 | coordinate: Nip19Coordinate { | ||
| 196 | coordinate: Coordinate { | ||
| 197 | public_key: *public_key, | ||
| 198 | kind: Kind::GitRepoAnnouncement, | ||
| 199 | identifier: identifier.to_string(), | ||
| 200 | }, | ||
| 201 | relays: first_relay.into_iter().cloned().collect(), | ||
| 202 | }, | ||
| 203 | protocol: None, | ||
| 204 | ssh_key_file: None, | ||
| 205 | nip05: None, | ||
| 206 | } | ||
| 207 | .to_string() | ||
| 208 | .replace("nostr://", "https://gitworkshop.dev/") | ||
| 209 | } | ||
| 210 | |||
| 211 | /// Resolve the `web` field from args, existing announcement, or gitworkshop | ||
| 212 | /// default. | ||
| 213 | fn resolve_web( | ||
| 214 | args_web: &[String], | ||
| 215 | state: &InitState, | ||
| 216 | identifier: &str, | ||
| 217 | gitworkshop_url: &str, | ||
| 218 | ) -> Vec<String> { | ||
| 219 | if !args_web.is_empty() { | ||
| 220 | return args_web.to_vec(); | ||
| 221 | } | ||
| 222 | if let Some(rr) = state.repo_ref() { | ||
| 223 | let latest_web = latest_event_repo_ref(rr).map_or_else(|| rr.web.clone(), |lr| lr.web); | ||
| 224 | let joined = latest_web.join(" "); | ||
| 225 | // replace legacy gitworkshop.dev url format | ||
| 226 | if joined.contains(&format!("https://gitworkshop.dev/repo/{identifier}")) { | ||
| 227 | return vec![gitworkshop_url.to_string()]; | ||
| 228 | } | ||
| 229 | return latest_web; | ||
| 230 | } | ||
| 231 | vec![gitworkshop_url.to_string()] | ||
| 232 | } | ||
| 233 | |||
| 234 | /// Derive clone-urls, relays, and blossoms from selected grasp servers. | ||
| 235 | /// | ||
| 236 | /// For each grasp server, adds/replaces the corresponding clone URL in | ||
| 237 | /// `git_servers`, adds a relay URL to `relays`, and adds a blossom URL to | ||
| 238 | /// `blossoms`. Grasp-derived infrastructure is always added — the other | ||
| 239 | /// lists (`git_servers`, `relays`, `blossoms`) contain *additional* | ||
| 240 | /// infrastructure beyond what grasp servers provide. | ||
| 241 | fn apply_grasp_infrastructure( | ||
| 242 | grasp_servers: &[String], | ||
| 243 | git_servers: &mut Vec<String>, | ||
| 244 | relays: &mut Vec<String>, | ||
| 245 | blossoms: &mut Vec<String>, | ||
| 246 | public_key: &PublicKey, | ||
| 247 | identifier: &str, | ||
| 248 | ) -> Result<()> { | ||
| 249 | for grasp_server in grasp_servers { | ||
| 250 | // Always add grasp-derived clone URL | ||
| 251 | let clone_url = format_grasp_server_url_as_clone_url(grasp_server, public_key, identifier)?; | ||
| 252 | |||
| 253 | let grasp_server_clone_root = if clone_url.contains("https://") { | ||
| 254 | format!("https://{grasp_server}") | ||
| 255 | } else { | ||
| 256 | grasp_server.to_string() | ||
| 257 | }; | ||
| 258 | |||
| 259 | let matching_positions: Vec<usize> = git_servers | ||
| 260 | .iter() | ||
| 261 | .enumerate() | ||
| 262 | .filter_map(|(idx, url)| { | ||
| 263 | if url.contains(&grasp_server_clone_root) { | ||
| 264 | Some(idx) | ||
| 265 | } else { | ||
| 266 | None | ||
| 267 | } | ||
| 268 | }) | ||
| 269 | .collect(); | ||
| 270 | |||
| 271 | if matching_positions.is_empty() { | ||
| 272 | git_servers.push(clone_url); | ||
| 273 | } else { | ||
| 274 | git_servers[matching_positions[0]] = clone_url; | ||
| 275 | for &position in matching_positions.iter().skip(1).rev() { | ||
| 276 | git_servers.remove(position); | ||
| 277 | } | ||
| 278 | } | ||
| 279 | |||
| 280 | // Always add grasp-derived relay | ||
| 281 | let relay_url = format_grasp_server_url_as_relay_url(grasp_server)?; | ||
| 282 | if !relays.contains(&relay_url) { | ||
| 283 | relays.push(relay_url); | ||
| 284 | } | ||
| 285 | |||
| 286 | // Always add grasp-derived blossom | ||
| 287 | let blossom = format_grasp_server_url_as_blossom_url(grasp_server)?; | ||
| 288 | if !blossoms.contains(&blossom) { | ||
| 289 | blossoms.push(blossom); | ||
| 290 | } | ||
| 291 | } | ||
| 292 | Ok(()) | ||
| 293 | } | ||
| 294 | |||
| 295 | /// Resolve which grasp servers to use. Handles flag overrides, detection from | ||
| 296 | /// existing URLs, user grasp list / system fallbacks, and interactive | ||
| 297 | /// prompting. | ||
| 298 | fn resolve_grasp_servers( | ||
| 299 | args: &SubCommandArgs, | ||
| 300 | cli: &Cli, | ||
| 301 | state: &InitState, | ||
| 302 | user_ref: &ngit::login::user::UserRef, | ||
| 303 | client: &Client, | ||
| 304 | identifier: &str, | ||
| 305 | interactive: bool, | ||
| 306 | ) -> Result<Vec<String>> { | ||
| 307 | if !args.grasp_servers.is_empty() { | ||
| 308 | return Ok(args.grasp_servers.clone()); | ||
| 309 | } | ||
| 310 | |||
| 311 | let has_both_relays_and_clone_url = !args.relays.is_empty() && !args.clone.is_empty(); | ||
| 312 | if has_both_relays_and_clone_url { | ||
| 313 | return Ok(vec![]); | ||
| 314 | } | ||
| 315 | |||
| 316 | // Use my own announcement (not the consolidated union) for grasp detection. | ||
| 317 | // Infrastructure is personal — each maintainer has their own servers. | ||
| 318 | let my_ref = state.my_repo_ref(&user_ref.public_key); | ||
| 319 | |||
| 320 | if !args.clone.is_empty() { | ||
| 321 | return Ok(detect_existing_grasp_servers( | ||
| 322 | my_ref.as_ref(), | ||
| 323 | &args.relays, | ||
| 324 | &args.clone, | ||
| 325 | identifier, | ||
| 326 | )); | ||
| 327 | } | ||
| 328 | |||
| 329 | if !interactive || cli.defaults || state.has_coordinate() || cli.force { | ||
| 330 | // Prefer grasp servers from my existing announcement, then user's grasp | ||
| 331 | // list, then system fallbacks | ||
| 332 | let existing = | ||
| 333 | detect_existing_grasp_servers(my_ref.as_ref(), &args.relays, &[], identifier); | ||
| 334 | if !existing.is_empty() { | ||
| 335 | return Ok(existing); | ||
| 336 | } | ||
| 337 | return Ok(grasp_servers_from_user_or_fallback(user_ref, client)); | ||
| 338 | } | ||
| 339 | |||
| 340 | // Interactive prompt | ||
| 341 | let mut options: Vec<String> = | ||
| 342 | detect_existing_grasp_servers(my_ref.as_ref(), &args.relays, &args.clone, identifier); | ||
| 343 | let mut selections: Vec<bool> = vec![true; options.len()]; | ||
| 344 | let empty = options.is_empty(); | ||
| 345 | for user_grasp_option in &user_ref.grasp_list.urls { | ||
| 346 | if !options | ||
| 347 | .iter() | ||
| 348 | .any(|option| option.contains(user_grasp_option.as_str())) | ||
| 349 | { | ||
| 350 | options.push(user_grasp_option.to_string()); | ||
| 351 | selections.push(empty); | ||
| 352 | } | ||
| 353 | } | ||
| 354 | let empty = options.is_empty(); | ||
| 355 | let fallback_grasp_servers = client.get_grasp_default_set(); | ||
| 356 | for fallback in fallback_grasp_servers { | ||
| 357 | if !options.iter().any(|option| option.contains(fallback)) { | ||
| 358 | options.push(fallback.clone()); | ||
| 359 | selections.push(empty); | ||
| 360 | } | ||
| 361 | } | ||
| 362 | let selected = multi_select_with_custom_value( | ||
| 363 | "grasp servers (ideally use between 2-4)", | ||
| 364 | "grasp server", | ||
| 365 | options, | ||
| 366 | selections, | ||
| 367 | normalize_grasp_server_url, | ||
| 368 | )?; | ||
| 369 | show_multi_input_prompt_success("grasp servers", &selected); | ||
| 370 | Ok(selected) | ||
| 371 | } | ||
| 372 | |||
| 373 | fn grasp_servers_from_user_or_fallback( | ||
| 374 | user_ref: &ngit::login::user::UserRef, | ||
| 375 | client: &Client, | ||
| 376 | ) -> Vec<String> { | ||
| 377 | if user_ref.grasp_list.urls.is_empty() { | ||
| 378 | client | ||
| 379 | .get_grasp_default_set() | ||
| 380 | .iter() | ||
| 381 | .map(std::string::ToString::to_string) | ||
| 382 | .collect() | ||
| 383 | } else { | ||
| 384 | user_ref | ||
| 385 | .grasp_list | ||
| 386 | .urls | ||
| 387 | .iter() | ||
| 388 | .map(std::string::ToString::to_string) | ||
| 389 | .collect() | ||
| 390 | } | ||
| 391 | } | ||
| 392 | |||
| 393 | // --------------------------------------------------------------------------- | ||
| 394 | // Validation | ||
| 395 | // --------------------------------------------------------------------------- | ||
| 396 | |||
| 397 | /// Validation for State A (Fresh): no existing coordinate. | ||
| 398 | fn validate_fresh(cli: &Cli, args: &SubCommandArgs, user_has_grasp_list: bool) -> Result<()> { | ||
| 399 | // -d or -f with no substantive flags: proceed with all defaults | ||
| 400 | if !args.has_substantive_flags() && (cli.defaults || cli.force) { | ||
| 401 | return Ok(()); | ||
| 402 | } | ||
| 403 | |||
| 404 | // Substantive flags provided: -d fills any gaps | ||
| 405 | if cli.defaults { | ||
| 406 | return Ok(()); | ||
| 407 | } | ||
| 408 | |||
| 409 | // Validate essential fields | ||
| 410 | let mut missing: Vec<(&str, &str)> = Vec::new(); | ||
| 411 | |||
| 412 | let missing_name = args.identifier.is_none() && args.name.is_none(); | ||
| 413 | if missing_name { | ||
| 414 | missing.push(("--name <NAME>", "repository name or identifier")); | ||
| 415 | } | ||
| 416 | |||
| 417 | let has_grasp_servers = !args.grasp_servers.is_empty(); | ||
| 418 | let has_both_relays_and_clone_url = !args.relays.is_empty() && !args.clone.is_empty(); | ||
| 419 | let missing_servers = | ||
| 420 | !has_grasp_servers && !user_has_grasp_list && !has_both_relays_and_clone_url; | ||
| 421 | if missing_servers { | ||
| 422 | missing.push(( | ||
| 423 | "--grasp-servers <URL>...", | ||
| 424 | "where your git+nostr data is hosted", | ||
| 425 | )); | ||
| 426 | } | ||
| 427 | |||
| 428 | if missing.is_empty() { | ||
| 429 | return Ok(()); | ||
| 430 | } | ||
| 431 | |||
| 432 | let message = if missing.len() == 1 { | ||
| 433 | let (flag, desc) = missing[0]; | ||
| 434 | format!("missing {flag} ({desc})") | ||
| 435 | } else { | ||
| 436 | "missing required fields".to_string() | ||
| 437 | }; | ||
| 438 | |||
| 439 | let mut details: Vec<(&str, &str)> = if missing.len() > 1 { | ||
| 440 | missing.clone() | ||
| 441 | } else { | ||
| 442 | vec![] | ||
| 443 | }; | ||
| 444 | |||
| 445 | details.push(("-d, --defaults", "or just use sensible defaults")); | ||
| 446 | let name_part = if missing_name { | ||
| 447 | " --name \"My Project\"" | ||
| 448 | } else { | ||
| 449 | "" | ||
| 450 | }; | ||
| 451 | let suggestion = | ||
| 452 | format!("ngit init{name_part} --description \"my project description\" --defaults"); | ||
| 453 | |||
| 454 | Err(cli_error(&message, &details, &[&suggestion])) | ||
| 455 | } | ||
| 456 | |||
| 47 | #[derive(Debug, clap::Args)] | 457 | #[derive(Debug, clap::Args)] |
| 48 | pub struct SubCommandArgs { | 458 | pub struct SubCommandArgs { |
| 49 | #[clap(short, long)] | 459 | #[clap(long)] |
| 50 | /// name of repository | 460 | /// name of repository (preferred over --identifier) |
| 51 | title: Option<String>, | 461 | name: Option<String>, |
| 52 | #[clap(short, long)] | 462 | #[clap(long)] |
| 463 | /// shortname with no spaces or special characters | ||
| 464 | identifier: Option<String>, | ||
| 465 | #[clap(long)] | ||
| 53 | /// optional description | 466 | /// optional description |
| 54 | description: Option<String>, | 467 | description: Option<String>, |
| 55 | #[clap(long)] | ||
| 56 | /// git server url users can clone from | ||
| 57 | clone_url: Vec<String>, | ||
| 58 | #[clap(short, long, value_parser, num_args = 1..)] | 468 | #[clap(short, long, value_parser, num_args = 1..)] |
| 59 | /// homepage | 469 | /// where your git+nostr data is hosted |
| 60 | web: Vec<String>, | 470 | grasp_servers: Vec<String>, |
| 61 | #[clap(short, long, value_parser, num_args = 1..)] | 471 | #[clap(long, value_parser, num_args = 1..)] |
| 62 | /// relays contributors push patches and comments to | 472 | /// additional relays beyond grasp servers |
| 63 | relays: Vec<String>, | 473 | relays: Vec<String>, |
| 64 | #[clap(short, long, value_parser, num_args = 1..)] | 474 | #[clap(long)] |
| 65 | /// blossom servers | 475 | /// additional git server URLs beyond grasp servers |
| 476 | clone: Vec<String>, | ||
| 477 | #[clap(long, value_parser, num_args = 1..)] | ||
| 478 | /// additional blossom servers beyond grasp servers | ||
| 66 | blossoms: Vec<String>, | 479 | blossoms: Vec<String>, |
| 67 | #[clap(short, long, value_parser, num_args = 1..)] | 480 | #[clap(long, value_parser, num_args = 1..)] |
| 481 | /// homepage | ||
| 482 | web: Vec<String>, | ||
| 483 | #[clap(long, value_parser, num_args = 1..)] | ||
| 68 | /// npubs of other maintainers | 484 | /// npubs of other maintainers |
| 69 | other_maintainers: Vec<String>, | 485 | other_maintainers: Vec<String>, |
| 70 | #[clap(long)] | 486 | #[clap(long)] |
| 71 | /// usually root commit but will be more recent commit for forks | 487 | /// usually root commit but will be more recent commit for forks |
| 72 | earliest_unique_commit: Option<String>, | 488 | earliest_unique_commit: Option<String>, |
| 73 | #[clap(short, long)] | ||
| 74 | /// shortname with no spaces or special characters | ||
| 75 | identifier: Option<String>, | ||
| 76 | } | 489 | } |
| 77 | 490 | ||
| 78 | #[allow(clippy::too_many_lines)] | 491 | impl SubCommandArgs { |
| 79 | pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | 492 | fn has_substantive_flags(&self) -> bool { |
| 80 | let git_repo = Repo::discover().context("failed to find a git repository")?; | 493 | self.name.is_some() |
| 81 | let git_repo_path = git_repo.get_path()?; | 494 | || self.identifier.is_some() |
| 495 | || self.description.is_some() | ||
| 496 | || !self.clone.is_empty() | ||
| 497 | || !self.relays.is_empty() | ||
| 498 | || !self.grasp_servers.is_empty() | ||
| 499 | || !self.web.is_empty() | ||
| 500 | || !self.blossoms.is_empty() | ||
| 501 | || !self.other_maintainers.is_empty() | ||
| 502 | || self.earliest_unique_commit.is_some() | ||
| 503 | } | ||
| 504 | } | ||
| 82 | 505 | ||
| 83 | let root_commit = git_repo | 506 | // --------------------------------------------------------------------------- |
| 84 | .get_root_commit() | 507 | // Pre/post-fetch validation |
| 85 | .context("failed to get root commit of the repository")?; | 508 | // --------------------------------------------------------------------------- |
| 509 | |||
| 510 | fn validate_pre_fetch( | ||
| 511 | cli: &Cli, | ||
| 512 | args: &SubCommandArgs, | ||
| 513 | repo_coordinate: Option<&Nip19Coordinate>, | ||
| 514 | user_has_grasp_list: bool, | ||
| 515 | ) -> Result<()> { | ||
| 516 | // Interactive mode bypasses pre-fetch validation | ||
| 517 | if cli.interactive { | ||
| 518 | return Ok(()); | ||
| 519 | } | ||
| 86 | 520 | ||
| 87 | // TODO: check for empty repo | 521 | // If no coordinate exists, we're in State A (Fresh) - validate now |
| 88 | // TODO: check for existing maintaiers file | 522 | if repo_coordinate.is_none() { |
| 523 | return validate_fresh(cli, args, user_has_grasp_list); | ||
| 524 | } | ||
| 89 | 525 | ||
| 90 | let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); | 526 | // Coordinate exists - we need to fetch before we can validate further |
| 527 | Ok(()) | ||
| 528 | } | ||
| 91 | 529 | ||
| 92 | let repo_coordinate = (try_and_get_repo_coordinates_when_remote_unknown(&git_repo).await).ok(); | 530 | fn validate_post_fetch(cli: &Cli, args: &SubCommandArgs, state: &InitState) -> Result<()> { |
| 531 | // Interactive mode bypasses all validation | ||
| 532 | if cli.interactive { | ||
| 533 | return Ok(()); | ||
| 534 | } | ||
| 93 | 535 | ||
| 94 | let repo_ref = if let Some(repo_coordinate) = &repo_coordinate { | 536 | match state { |
| 95 | fetching_with_report(git_repo_path, &client, repo_coordinate).await?; | 537 | InitState::Fresh => { |
| 96 | (get_repo_ref_from_cache(Some(git_repo_path), repo_coordinate).await).ok() | 538 | // Already validated in pre-fetch |
| 539 | Ok(()) | ||
| 540 | } | ||
| 541 | InitState::CoordinateOnly { coordinate } => { | ||
| 542 | if cli.force { | ||
| 543 | Ok(()) | ||
| 544 | } else { | ||
| 545 | let id = &coordinate.identifier; | ||
| 546 | Err(cli_error( | ||
| 547 | &format!( | ||
| 548 | "no announcement found for coordinate '{id}'\n\n\ | ||
| 549 | \x20 This could be a relay or network issue. Only proceed with --force\n\ | ||
| 550 | \x20 if you are sure there isn't an existing announcement event." | ||
| 551 | ), | ||
| 552 | &[], | ||
| 553 | &["ngit init --force"], | ||
| 554 | )) | ||
| 555 | } | ||
| 556 | } | ||
| 557 | InitState::MyAnnouncement { repo_ref, .. } => { | ||
| 558 | if let Some(new_id) = &args.identifier { | ||
| 559 | if *new_id != repo_ref.identifier && !cli.force { | ||
| 560 | let suggestion = format!("ngit init --identifier {new_id} --force"); | ||
| 561 | return Err(cli_error( | ||
| 562 | "changing identifier creates a new repository", | ||
| 563 | &[], | ||
| 564 | &[&suggestion], | ||
| 565 | )); | ||
| 566 | } | ||
| 567 | } | ||
| 568 | if !args.has_substantive_flags() && !cli.force { | ||
| 569 | return Err(cli_error( | ||
| 570 | "no arguments specified, use --force to publish with new timestamp", | ||
| 571 | &[], | ||
| 572 | &["ngit init --force"], | ||
| 573 | )); | ||
| 574 | } | ||
| 575 | Ok(()) | ||
| 576 | } | ||
| 577 | InitState::CoMaintainer { repo_ref, .. } => { | ||
| 578 | if let Some(new_id) = &args.identifier { | ||
| 579 | if *new_id != repo_ref.identifier && !cli.force { | ||
| 580 | let suggestion = format!("ngit init --identifier {new_id} --force"); | ||
| 581 | return Err(cli_error( | ||
| 582 | "changing identifier creates a new repository", | ||
| 583 | &[], | ||
| 584 | &[&suggestion], | ||
| 585 | )); | ||
| 586 | } | ||
| 587 | } | ||
| 588 | Ok(()) | ||
| 589 | } | ||
| 590 | InitState::NotListed { .. } => { | ||
| 591 | if cli.force { | ||
| 592 | Ok(()) | ||
| 593 | } else { | ||
| 594 | Err(cli_error( | ||
| 595 | "you are not listed as a maintainer", | ||
| 596 | &[], | ||
| 597 | &["ngit init --force"], | ||
| 598 | )) | ||
| 599 | } | ||
| 600 | } | ||
| 601 | } | ||
| 602 | } | ||
| 603 | |||
| 604 | #[allow(clippy::too_many_lines)] | ||
| 605 | #[allow(clippy::too_many_arguments)] | ||
| 606 | fn resolve_fields( | ||
| 607 | state: &InitState, | ||
| 608 | user_ref: &ngit::login::user::UserRef, | ||
| 609 | args: &SubCommandArgs, | ||
| 610 | cli: &Cli, | ||
| 611 | git_repo: &Repo, | ||
| 612 | root_commit: &str, | ||
| 613 | client: &Client, | ||
| 614 | repo_config_result: &Result<ngit::repo_ref::RepoConfigYaml>, | ||
| 615 | interactive: bool, | ||
| 616 | ) -> Result<ResolvedFields> { | ||
| 617 | let my_pubkey = &user_ref.public_key; | ||
| 618 | |||
| 619 | // Shared lookups used by multiple fields below | ||
| 620 | let latest = state.repo_ref().and_then(latest_event_repo_ref); | ||
| 621 | let my_ref = state.my_repo_ref(my_pubkey); | ||
| 622 | |||
| 623 | // --- Identifier default --- | ||
| 624 | let identifier_default = if let Some(coord) = state.coordinate() { | ||
| 625 | coord.identifier.clone() | ||
| 626 | } else if let Ok(config) = repo_config_result { | ||
| 627 | if let Some(id) = &config.identifier { | ||
| 628 | id.clone() | ||
| 629 | } else { | ||
| 630 | dir_name_fallback() | ||
| 631 | } | ||
| 97 | } else { | 632 | } else { |
| 98 | None | 633 | dir_name_fallback() |
| 99 | }; | 634 | }; |
| 100 | 635 | ||
| 101 | let (signer, user_ref, _) = login::login_or_signup( | 636 | // --- Name --- |
| 102 | &Some(&git_repo), | 637 | let name_default = if let Some(ref lr) = latest { |
| 103 | &extract_signer_cli_arguments(cli_args).unwrap_or(None), | 638 | lr.name.clone() |
| 104 | &cli_args.password, | 639 | } else if let Some(coord) = state.coordinate() { |
| 105 | Some(&client), | 640 | coord.identifier.clone() |
| 106 | true, | 641 | } else { |
| 107 | ) | 642 | dir_name_fallback() |
| 108 | .await?; | 643 | }; |
| 109 | |||
| 110 | let repo_config_result = get_repo_config_from_yaml(&git_repo); | ||
| 111 | // TODO: check for other claims | ||
| 112 | 644 | ||
| 113 | let name = match &args.title { | 645 | let name = if let Some(v) = &args.name { |
| 114 | Some(t) => t.clone(), | 646 | v.clone() |
| 115 | None => Interactor::default().input( | 647 | } else if interactive { |
| 648 | Interactor::default().input( | ||
| 116 | PromptInputParms::default() | 649 | PromptInputParms::default() |
| 117 | .with_prompt("repo name") | 650 | .with_prompt("repo name") |
| 118 | .with_default(if let Some(repo_ref) = &repo_ref { | 651 | .with_default(name_default.clone()) |
| 119 | repo_ref.name.clone() | 652 | .with_flag_name("--name"), |
| 120 | } else if let Some(coordinate) = &repo_coordinate { | 653 | )? |
| 121 | coordinate.identifier.clone() | 654 | } else { |
| 122 | } else if let Ok(path) = env::current_dir() { | 655 | name_default.clone() |
| 123 | if let Some(current_dir_name) = path.file_name() { | ||
| 124 | current_dir_name.to_string_lossy().to_string() | ||
| 125 | } else { | ||
| 126 | String::new() | ||
| 127 | } | ||
| 128 | } else { | ||
| 129 | String::new() | ||
| 130 | }), | ||
| 131 | )?, | ||
| 132 | }; | 656 | }; |
| 133 | 657 | ||
| 134 | let description = match &args.description { | 658 | // --- Description --- |
| 135 | Some(t) => t.clone(), | 659 | let description_default = latest |
| 136 | None => Interactor::default().input( | 660 | .as_ref() |
| 661 | .map_or_else(String::new, |lr| lr.description.clone()); | ||
| 662 | |||
| 663 | let description = if let Some(v) = &args.description { | ||
| 664 | v.clone() | ||
| 665 | } else if interactive { | ||
| 666 | Interactor::default().input( | ||
| 137 | PromptInputParms::default() | 667 | PromptInputParms::default() |
| 138 | .with_prompt("repo description (one sentance)") | 668 | .with_prompt("repo description (one sentence)") |
| 139 | .optional() | 669 | .optional() |
| 140 | .with_default(if let Some(repo_ref) = &repo_ref { | 670 | .with_default(description_default.clone()) |
| 141 | repo_ref.description.clone() | 671 | .with_flag_name("--description"), |
| 142 | } else { | 672 | )? |
| 143 | String::new() | 673 | } else { |
| 144 | }), | 674 | description_default |
| 145 | )?, | ||
| 146 | }; | 675 | }; |
| 147 | 676 | ||
| 148 | // this is important so init can be completed done without prompts | 677 | // --- Simple mode (interactive only) --- |
| 149 | let has_server_and_relay_flags = !args.clone_url.is_empty() && !args.relays.is_empty(); | 678 | let simple_mode = if !interactive || (!args.clone.is_empty() && !args.relays.is_empty()) { |
| 150 | 679 | false // not used in non-interactive, but avoids Option | |
| 151 | let simple_mode = if has_server_and_relay_flags { | ||
| 152 | false | ||
| 153 | } else { | 680 | } else { |
| 154 | Interactor::default().choice( | 681 | Interactor::default().choice( |
| 155 | PromptChoiceParms::default() | 682 | PromptChoiceParms::default() |
| @@ -162,216 +689,142 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 162 | )? == 0 | 689 | )? == 0 |
| 163 | }; | 690 | }; |
| 164 | 691 | ||
| 165 | let identifier_default = if let Some(repo_ref) = &repo_ref { | 692 | // --- Identifier --- |
| 166 | repo_ref.identifier.clone() | 693 | let identifier = if let Some(id) = &args.identifier { |
| 167 | } else if let Some(repo_coordinate) = &repo_coordinate { | 694 | id.clone() |
| 168 | repo_coordinate.identifier.clone() | 695 | } else if state.has_coordinate() { |
| 169 | } else { | 696 | identifier_default.clone() |
| 170 | let fallback = name | 697 | } else if !interactive || cli.defaults { |
| 171 | .clone() | 698 | if args.name.is_some() && !state.has_coordinate() { |
| 172 | .replace(' ', "-") | 699 | identifier_from_name(&name) |
| 173 | .chars() | ||
| 174 | .map(|c| { | ||
| 175 | if c.is_ascii_alphanumeric() || c.eq(&'/') { | ||
| 176 | c | ||
| 177 | } else { | ||
| 178 | '-' | ||
| 179 | } | ||
| 180 | }) | ||
| 181 | .collect(); | ||
| 182 | if let Ok(config) = &repo_config_result { | ||
| 183 | if let Some(identifier) = &config.identifier { | ||
| 184 | identifier.to_string() | ||
| 185 | } else { | ||
| 186 | fallback | ||
| 187 | } | ||
| 188 | } else { | 700 | } else { |
| 189 | fallback | 701 | identifier_default.clone() |
| 190 | } | 702 | } |
| 703 | } else { | ||
| 704 | let id_default = if args.name.is_some() || name != name_default { | ||
| 705 | identifier_from_name(&name) | ||
| 706 | } else { | ||
| 707 | identifier_default.clone() | ||
| 708 | }; | ||
| 709 | Interactor::default().input( | ||
| 710 | PromptInputParms::default() | ||
| 711 | .with_prompt("repo identifier") | ||
| 712 | .with_default(id_default) | ||
| 713 | .with_flag_name("--identifier"), | ||
| 714 | )? | ||
| 191 | }; | 715 | }; |
| 192 | 716 | ||
| 193 | let identifier = match &args.identifier { | 717 | // --- Grasp servers --- |
| 194 | Some(t) => t.clone(), | 718 | let selected_grasp_servers = |
| 195 | None => { | 719 | resolve_grasp_servers(args, cli, state, user_ref, client, &identifier, interactive)?; |
| 196 | if simple_mode { | 720 | |
| 197 | identifier_default | 721 | // --- Base infrastructure (flag > my event > fallback) --- |
| 722 | // Grasp-derived infrastructure (my clone URLs, relays, blossoms) is handled | ||
| 723 | // by apply_grasp_infrastructure below. Defaults here are *additional* | ||
| 724 | // infrastructure only. My own grasp-format clone URLs are filtered out so | ||
| 725 | // they get re-derived from the resolved grasp servers. Grasp-format clone | ||
| 726 | // URLs belonging to other maintainers are kept as additional git servers. | ||
| 727 | let no_state = git_repo | ||
| 728 | .get_git_config_item("nostr.nostate", None) | ||
| 729 | .ok() | ||
| 730 | .flatten() | ||
| 731 | .is_some_and(|s| s == "true"); | ||
| 732 | |||
| 733 | // Detect my grasp servers from my existing announcement (for filtering) | ||
| 734 | let my_existing_grasp_servers: Vec<String> = my_ref | ||
| 735 | .as_ref() | ||
| 736 | .map(|mr| detect_existing_grasp_servers(Some(mr), &[], &[], &identifier)) | ||
| 737 | .unwrap_or_default(); | ||
| 738 | |||
| 739 | let git_servers_default = if let Some(ref mr) = my_ref { | ||
| 740 | // Keep non-grasp URLs and grasp URLs from other maintainers; | ||
| 741 | // filter out my own grasp-derived clone URLs (re-derived from grasp servers) | ||
| 742 | mr.git_server | ||
| 743 | .iter() | ||
| 744 | .filter(|url| !is_my_grasp_clone_url(url, my_pubkey)) | ||
| 745 | .cloned() | ||
| 746 | .collect() | ||
| 747 | } else if no_state { | ||
| 748 | // Only fall back to origin URL when nostate is set (user pushes directly | ||
| 749 | // to a traditional git server rather than through grasp servers) | ||
| 750 | if let Ok(url) = git_repo.get_origin_url() { | ||
| 751 | if let Ok(fetch_url) = convert_clone_url_to_https(&url) { | ||
| 752 | vec![fetch_url] | ||
| 753 | } else if url.starts_with("nostr://") { | ||
| 754 | vec![] | ||
| 198 | } else { | 755 | } else { |
| 199 | Interactor::default().input( | 756 | vec![url] |
| 200 | PromptInputParms::default() | ||
| 201 | .with_prompt( | ||
| 202 | "repo identifier (typically the short name with hypens instead of spaces)", | ||
| 203 | ) | ||
| 204 | .with_default(identifier_default), | ||
| 205 | )? | ||
| 206 | } | 757 | } |
| 207 | } | ||
| 208 | }; | ||
| 209 | |||
| 210 | let mut git_server_defaults: Vec<String> = if !args.clone_url.is_empty() { | ||
| 211 | args.clone_url.clone() | ||
| 212 | } else if let Some(repo_ref) = &repo_ref { | ||
| 213 | // TODO dont default to git servers of other maintainers (?) | ||
| 214 | repo_ref.git_server.clone() | ||
| 215 | } else if let Ok(url) = git_repo.get_origin_url() { | ||
| 216 | if let Ok(fetch_url) = convert_clone_url_to_https(&url) { | ||
| 217 | vec![fetch_url] | ||
| 218 | } else if url.starts_with("nostr://") { | ||
| 219 | // nostr added as origin remote before repo announcement sent | ||
| 220 | vec![] | ||
| 221 | } else { | 758 | } else { |
| 222 | // local repo or custom protocol | 759 | vec![] |
| 223 | vec![url] | ||
| 224 | } | 760 | } |
| 225 | } else { | 761 | } else { |
| 226 | vec![] | 762 | vec![] |
| 227 | }; | 763 | }; |
| 228 | 764 | ||
| 229 | let mut relay_defaults = if args.relays.is_empty() { | 765 | let relays_default = if let Some(ref mr) = my_ref { |
| 230 | if let Ok(config) = &repo_config_result { | 766 | // Keep relays that don't correspond to my grasp servers |
| 231 | config.relays.clone() | 767 | // (grasp-derived relays are re-added by apply_grasp_infrastructure) |
| 232 | } else if let Some(repo_ref) = &repo_ref { | 768 | mr.relays |
| 233 | repo_ref | 769 | .iter() |
| 234 | .relays | 770 | .map(std::string::ToString::to_string) |
| 235 | .iter() | 771 | .filter(|r| !is_grasp_derived_relay(r, &my_existing_grasp_servers)) |
| 236 | .map(std::string::ToString::to_string) | 772 | .collect() |
| 237 | .collect::<Vec<String>>() | 773 | } else if let Ok(config) = repo_config_result { |
| 238 | } else { | 774 | if config.relays.is_empty() { |
| 239 | client.get_relay_default_set().clone() | 775 | client.get_relay_default_set().clone() |
| 776 | } else { | ||
| 777 | config.relays.clone() | ||
| 240 | } | 778 | } |
| 241 | } else { | 779 | } else { |
| 242 | args.relays.clone() | 780 | client.get_relay_default_set().clone() |
| 243 | }; | 781 | }; |
| 244 | 782 | ||
| 245 | let mut blossoms_defaults = if args.blossoms.is_empty() { | 783 | let blossoms_default: Vec<String> = if let Some(ref mr) = my_ref { |
| 246 | if let Some(repo_ref) = &repo_ref { | 784 | // Keep blossoms that don't correspond to my grasp servers |
| 247 | repo_ref | 785 | mr.blossoms |
| 248 | .blossoms | 786 | .iter() |
| 249 | .iter() | 787 | .map(UrlWithoutSlash::to_string_without_trailing_slash) |
| 250 | .map(UrlWithoutSlash::to_string_without_trailing_slash) | 788 | .filter(|b| !is_grasp_derived_blossom(b, &my_existing_grasp_servers)) |
| 251 | .collect::<Vec<String>>() | 789 | .collect() |
| 252 | // } else if user_ref.blossoms.read().is_empty() { | ||
| 253 | // client.get_fallback_relays().clone() | ||
| 254 | } else { | ||
| 255 | vec![] | ||
| 256 | // user_ref.relays.read().clone() | ||
| 257 | } | ||
| 258 | } else { | 790 | } else { |
| 259 | args.blossoms.clone() | 791 | vec![] |
| 260 | }; | 792 | }; |
| 261 | 793 | ||
| 262 | let fallback_grasp_servers = client.get_grasp_default_set(); | 794 | let mut git_servers = if args.clone.is_empty() { |
| 263 | 795 | git_servers_default | |
| 264 | let selected_grasp_servers = if has_server_and_relay_flags { | ||
| 265 | // ignore so a script running `ngit init` can contiue without prompts | ||
| 266 | vec![] | ||
| 267 | } else { | 796 | } else { |
| 268 | let mut options: Vec<String> = detect_existing_grasp_servers( | 797 | args.clone.clone() |
| 269 | repo_ref.as_ref(), | ||
| 270 | &args.relays, | ||
| 271 | &args.clone_url, | ||
| 272 | &identifier, | ||
| 273 | ); | ||
| 274 | let mut selections: Vec<bool> = vec![true; options.len()]; // Initialize selections based on existing options | ||
| 275 | let empty = options.is_empty(); | ||
| 276 | for user_grasp_option in user_ref.grasp_list.urls { | ||
| 277 | // Check if any option contains the user_grasp_option as a substring | ||
| 278 | if !options | ||
| 279 | .iter() | ||
| 280 | .any(|option| option.contains(user_grasp_option.as_str())) | ||
| 281 | { | ||
| 282 | options.push(user_grasp_option.to_string()); // Add if not found | ||
| 283 | selections.push(empty); // mark as selected if no existing grasp otherwise not | ||
| 284 | } | ||
| 285 | } | ||
| 286 | |||
| 287 | let empty = options.is_empty(); | ||
| 288 | for fallback in fallback_grasp_servers { | ||
| 289 | // Check if any option contains the fallback as a substring | ||
| 290 | if !options.iter().any(|option| option.contains(fallback)) { | ||
| 291 | options.push(fallback.clone()); // Add fallback if not found | ||
| 292 | selections.push(empty); // mark as selected if no existing selections otherwise not | ||
| 293 | } | ||
| 294 | } | ||
| 295 | let selected = multi_select_with_custom_value( | ||
| 296 | "grasp servers (ideally use between 2-4)", | ||
| 297 | "grasp server", | ||
| 298 | options, | ||
| 299 | selections, | ||
| 300 | normalize_grasp_server_url, | ||
| 301 | )?; | ||
| 302 | show_multi_input_prompt_success("grasp servers", &selected); | ||
| 303 | selected | ||
| 304 | }; | 798 | }; |
| 305 | 799 | let mut relay_strings = if args.relays.is_empty() { | |
| 306 | // ensure ngit relays are added as git server, relay and blossom entries | 800 | relays_default |
| 307 | for grasp_server in &selected_grasp_servers { | ||
| 308 | if args.clone_url.is_empty() { | ||
| 309 | let clone_url = format_grasp_server_url_as_clone_url( | ||
| 310 | grasp_server, | ||
| 311 | &user_ref.public_key, | ||
| 312 | &identifier, | ||
| 313 | )?; | ||
| 314 | |||
| 315 | let grasp_server_clone_root = if clone_url.contains("https://") { | ||
| 316 | format!("https://{grasp_server}") | ||
| 317 | } else { | ||
| 318 | grasp_server.to_string() | ||
| 319 | }; | ||
| 320 | |||
| 321 | // Find all positions of entries containing the relay root | ||
| 322 | let matching_positions: Vec<usize> = git_server_defaults | ||
| 323 | .iter() | ||
| 324 | .enumerate() | ||
| 325 | .filter_map(|(idx, url)| { | ||
| 326 | if url.contains(&grasp_server_clone_root) { | ||
| 327 | Some(idx) | ||
| 328 | } else { | ||
| 329 | None | ||
| 330 | } | ||
| 331 | }) | ||
| 332 | .collect(); | ||
| 333 | |||
| 334 | // If we found any matches | ||
| 335 | if matching_positions.is_empty() { | ||
| 336 | // No existing entries found, so add a new one | ||
| 337 | git_server_defaults.push(clone_url); | ||
| 338 | } else { | ||
| 339 | // Replace the first occurrence | ||
| 340 | git_server_defaults[matching_positions[0]] = clone_url; | ||
| 341 | |||
| 342 | // Remove any subsequent occurrences (in reverse order to avoid index issues) | ||
| 343 | for &position in matching_positions.iter().skip(1).rev() { | ||
| 344 | git_server_defaults.remove(position); | ||
| 345 | } | ||
| 346 | } | ||
| 347 | } | ||
| 348 | if args.relays.is_empty() { | ||
| 349 | let relay_url = format_grasp_server_url_as_relay_url(grasp_server)?; | ||
| 350 | if !relay_defaults.contains(&relay_url) { | ||
| 351 | relay_defaults.push(relay_url); | ||
| 352 | } | ||
| 353 | } | ||
| 354 | if args.blossoms.is_empty() { | ||
| 355 | let blossom = format_grasp_server_url_as_blossom_url(grasp_server)?; | ||
| 356 | if !blossoms_defaults.contains(&blossom) { | ||
| 357 | blossoms_defaults.push(blossom); | ||
| 358 | } | ||
| 359 | } | ||
| 360 | } | ||
| 361 | |||
| 362 | let no_state = if let Ok(Some(s)) = git_repo.get_git_config_item("nostr.nostate", None) { | ||
| 363 | s == "true" | ||
| 364 | } else { | 801 | } else { |
| 365 | false | 802 | args.relays.clone() |
| 803 | }; | ||
| 804 | let mut blossom_strings = if args.blossoms.is_empty() { | ||
| 805 | blossoms_default | ||
| 806 | } else { | ||
| 807 | args.blossoms.clone() | ||
| 366 | }; | 808 | }; |
| 367 | if no_state | 809 | |
| 810 | apply_grasp_infrastructure( | ||
| 811 | &selected_grasp_servers, | ||
| 812 | &mut git_servers, | ||
| 813 | &mut relay_strings, | ||
| 814 | &mut blossom_strings, | ||
| 815 | &user_ref.public_key, | ||
| 816 | &identifier, | ||
| 817 | )?; | ||
| 818 | |||
| 819 | // --- Interactive: nostr.nostate prompt --- | ||
| 820 | if interactive | ||
| 821 | && no_state | ||
| 368 | && Interactor::default().confirm( | 822 | && Interactor::default().confirm( |
| 369 | PromptConfirmParms::default() | 823 | PromptConfirmParms::default() |
| 370 | .with_prompt("store state on nostr? required for nostr-permissioned git servers") | 824 | .with_prompt("store state on nostr? required for nostr-permissioned git servers") |
| 371 | .with_default(true), | 825 | .with_default(true), |
| 372 | )? | 826 | )? |
| 373 | { | 827 | { |
| 374 | // TODO check if grasp servers in use and if so turn this off: | ||
| 375 | if git_repo | 828 | if git_repo |
| 376 | .get_git_config_item("nostr.nostate", Some(true)) | 829 | .get_git_config_item("nostr.nostate", Some(true)) |
| 377 | .unwrap_or(None) | 830 | .unwrap_or(None) |
| @@ -383,214 +836,146 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 383 | } | 836 | } |
| 384 | } | 837 | } |
| 385 | 838 | ||
| 386 | let git_server = if args.clone_url.is_empty() { | 839 | // --- Git servers (interactive prompting) --- |
| 387 | let grasp_server_git_servers: Vec<String> = git_server_defaults | 840 | let git_servers = if !args.clone.is_empty() || !interactive { |
| 841 | git_servers | ||
| 842 | } else { | ||
| 843 | prompt_git_servers(git_servers, &selected_grasp_servers, simple_mode)? | ||
| 844 | }; | ||
| 845 | |||
| 846 | // --- Relays --- | ||
| 847 | let relays: Vec<RelayUrl> = if !args.relays.is_empty() || !interactive { | ||
| 848 | relay_strings | ||
| 388 | .iter() | 849 | .iter() |
| 389 | .filter(|s| is_grasp_server_clone_url(s)) | 850 | .filter_map(|r| parse_relay_url(r).ok()) |
| 390 | .cloned() | 851 | .collect() |
| 852 | } else if simple_mode { | ||
| 853 | let grasp_relay_urls: Vec<String> = selected_grasp_servers | ||
| 854 | .iter() | ||
| 855 | .filter_map(|r| format_grasp_server_url_as_relay_url(r).ok()) | ||
| 391 | .collect(); | 856 | .collect(); |
| 392 | let mut additional_server_options: Vec<String> = git_server_defaults | 857 | let mut options: Vec<String> = relay_strings |
| 393 | .iter() | 858 | .iter() |
| 394 | .filter(|s| !is_grasp_server_clone_url(s)) | 859 | .filter(|s| !grasp_relay_urls.iter().any(|r| s.as_str() == r)) |
| 395 | .cloned() | 860 | .cloned() |
| 396 | .collect(); | 861 | .collect(); |
| 397 | 862 | let mut selections: Vec<bool> = vec![true; options.len()]; | |
| 398 | if simple_mode && !selected_grasp_servers.is_empty() { | 863 | for relay in client.get_relay_default_set().clone() { |
| 399 | if additional_server_options.is_empty() { | 864 | if !options.iter().any(|r| r.contains(&relay)) |
| 400 | git_server_defaults | 865 | && !grasp_relay_urls.iter().any(|r| relay.contains(r)) |
| 401 | } else { | 866 | { |
| 402 | // additional git servers were listed | 867 | options.push(relay); |
| 403 | let selected = loop { | 868 | selections.push(selections.is_empty()); |
| 404 | let selections: Vec<bool> = vec![true; additional_server_options.len()]; | ||
| 405 | let selected = multi_select_with_custom_value( | ||
| 406 | "additional git server(s) on top of grasp servers", | ||
| 407 | "git server remote url", | ||
| 408 | additional_server_options, | ||
| 409 | selections, | ||
| 410 | |s| { | ||
| 411 | CloneUrl::from_str(s) | ||
| 412 | .map(|_| s.to_string()) | ||
| 413 | .context(format!("Invalid git server URL format: {s}")) | ||
| 414 | }, | ||
| 415 | )?; | ||
| 416 | |||
| 417 | if selected.is_empty() || Interactor::default().choice( | ||
| 418 | PromptChoiceParms::default() | ||
| 419 | .with_prompt("if you or another maintainer start pushing directly to these, nostr will be out of date") | ||
| 420 | .dont_report() | ||
| 421 | .with_choices(vec![ | ||
| 422 | "I'll always push to the nostr remote".to_string(), | ||
| 423 | "change setup".to_string(), | ||
| 424 | ]) | ||
| 425 | .with_default(0), | ||
| 426 | )? == 1 { | ||
| 427 | additional_server_options = selected; | ||
| 428 | continue | ||
| 429 | } | ||
| 430 | break selected; | ||
| 431 | }; | ||
| 432 | show_multi_input_prompt_success("additional git servers", &selected); | ||
| 433 | let mut combined = grasp_server_git_servers; | ||
| 434 | combined.extend(selected); | ||
| 435 | combined | ||
| 436 | } | 869 | } |
| 437 | } else { | ||
| 438 | // show all git servers | ||
| 439 | let selections: Vec<bool> = vec![true; git_server_defaults.len()]; | ||
| 440 | |||
| 441 | let selected = multi_select_with_custom_value( | ||
| 442 | "git server remote url(s)", | ||
| 443 | "git server remote url", | ||
| 444 | git_server_defaults, | ||
| 445 | selections, | ||
| 446 | |s| { | ||
| 447 | CloneUrl::from_str(s) | ||
| 448 | .map(|_| s.to_string()) | ||
| 449 | .context(format!("Invalid git server URL format: {s}")) | ||
| 450 | }, | ||
| 451 | )?; | ||
| 452 | show_multi_input_prompt_success("git servers", &selected); | ||
| 453 | selected | ||
| 454 | } | 870 | } |
| 871 | let selected = multi_select_with_custom_value( | ||
| 872 | "additional nostr relays on top of nostr-relays - 1 or 2 public relays are reccomended", | ||
| 873 | "nostr relay", | ||
| 874 | options, | ||
| 875 | selections, | ||
| 876 | |s| { | ||
| 877 | parse_relay_url(s) | ||
| 878 | .map(|_| s.to_string()) | ||
| 879 | .context(format!("Invalid relay URL format: {s}")) | ||
| 880 | }, | ||
| 881 | )?; | ||
| 882 | show_multi_input_prompt_success("additional nostr relays", &selected); | ||
| 883 | [ | ||
| 884 | grasp_relay_urls | ||
| 885 | .iter() | ||
| 886 | .filter_map(|r| parse_relay_url(r).ok()) | ||
| 887 | .collect::<Vec<RelayUrl>>(), | ||
| 888 | selected | ||
| 889 | .iter() | ||
| 890 | .filter_map(|r| parse_relay_url(r).ok()) | ||
| 891 | .collect::<Vec<RelayUrl>>(), | ||
| 892 | ] | ||
| 893 | .concat() | ||
| 455 | } else { | 894 | } else { |
| 456 | git_server_defaults | 895 | // advanced interactive |
| 896 | let selections: Vec<bool> = vec![true; relay_strings.len()]; | ||
| 897 | let selected = multi_select_with_custom_value( | ||
| 898 | "nostr relays", | ||
| 899 | "nostr relay", | ||
| 900 | relay_strings, | ||
| 901 | selections, | ||
| 902 | |s| { | ||
| 903 | parse_relay_url(s) | ||
| 904 | .map(|_| s.to_string()) | ||
| 905 | .context(format!("Invalid relay URL format: {s}")) | ||
| 906 | }, | ||
| 907 | )?; | ||
| 908 | show_multi_input_prompt_success("nostr relays", &selected); | ||
| 909 | selected | ||
| 910 | .iter() | ||
| 911 | .filter_map(|r| parse_relay_url(r).ok()) | ||
| 912 | .collect() | ||
| 457 | }; | 913 | }; |
| 458 | 914 | ||
| 459 | let relays: Vec<RelayUrl> = { | 915 | // --- Blossoms --- |
| 460 | if simple_mode { | 916 | let blossoms: Vec<Url> = if !args.blossoms.is_empty() || !interactive { |
| 461 | let formatted_selected_grasp_servers: Vec<String> = selected_grasp_servers | 917 | blossom_strings |
| 462 | .iter() | 918 | .iter() |
| 463 | .filter_map(|r| format_grasp_server_url_as_relay_url(r).ok()) | 919 | .filter_map(|b| Url::parse(b).ok()) |
| 464 | .collect(); | 920 | .collect() |
| 465 | let mut options: Vec<String> = relay_defaults | 921 | } else if !simple_mode { |
| 466 | .iter() | 922 | let selections: Vec<bool> = vec![true; blossom_strings.len()]; |
| 467 | .filter(|s| { | 923 | let selected = multi_select_with_custom_value( |
| 468 | !formatted_selected_grasp_servers | 924 | "blossom servers", |
| 469 | .iter() | 925 | "blossom server", |
| 470 | .any(|r| s.as_str() == r) | 926 | blossom_strings, |
| 471 | }) | 927 | selections, |
| 472 | .cloned() | 928 | |s| { |
| 473 | .collect(); | 929 | format_grasp_server_url_as_blossom_url(s) |
| 474 | 930 | .context(format!("Invalid blossom URL format: {s}")) | |
| 475 | let mut selections: Vec<bool> = vec![true; options.len()]; | 931 | }, |
| 476 | 932 | )?; | |
| 477 | // add fallback relays as options | 933 | show_multi_input_prompt_success("blossom servers", &selected); |
| 478 | for relay in client.get_relay_default_set().clone() { | 934 | selected.iter().filter_map(|b| Url::parse(b).ok()).collect() |
| 479 | if !options.iter().any(|r| r.contains(&relay)) | 935 | } else { |
| 480 | && !formatted_selected_grasp_servers | 936 | blossom_strings |
| 481 | .iter() | 937 | .iter() |
| 482 | .any(|r| relay.contains(r)) | 938 | .filter_map(|b| Url::parse(b).ok()) |
| 483 | { | 939 | .collect() |
| 484 | options.push(relay); | 940 | }; |
| 485 | selections.push(selections.is_empty()); | ||
| 486 | } | ||
| 487 | } | ||
| 488 | 941 | ||
| 489 | let selected = multi_select_with_custom_value( | 942 | // --- Maintainers --- |
| 490 | "additional nostr relays on top of nostr-relays - 1 or 2 public relays are reccomended", | 943 | let maintainers_default = if let Some(ref mr) = my_ref { |
| 491 | "nostr relay", | 944 | let mut m = vec![*my_pubkey]; |
| 492 | options, | 945 | for pk in &mr.maintainers { |
| 493 | selections, | 946 | if !m.contains(pk) { |
| 494 | |s| { | 947 | m.push(*pk); |
| 495 | parse_relay_url(s) | ||
| 496 | .map(|_| s.to_string()) | ||
| 497 | .context(format!("Invalid relay URL format: {s}")) | ||
| 498 | }, | ||
| 499 | )?; | ||
| 500 | show_multi_input_prompt_success("additional nostr relays", &selected); | ||
| 501 | [ | ||
| 502 | formatted_selected_grasp_servers | ||
| 503 | .iter() | ||
| 504 | .filter_map(|r| parse_relay_url(r).ok()) | ||
| 505 | .collect::<Vec<RelayUrl>>(), | ||
| 506 | selected | ||
| 507 | .iter() | ||
| 508 | .filter_map(|r| parse_relay_url(r).ok()) | ||
| 509 | .collect::<Vec<RelayUrl>>(), | ||
| 510 | ] | ||
| 511 | .concat() | ||
| 512 | } else { | ||
| 513 | let selections: Vec<bool> = vec![true; relay_defaults.len()]; | ||
| 514 | if args.relays.is_empty() { | ||
| 515 | let selected = multi_select_with_custom_value( | ||
| 516 | "nostr relays", | ||
| 517 | "nostr relay", | ||
| 518 | relay_defaults, | ||
| 519 | selections, | ||
| 520 | |s| { | ||
| 521 | parse_relay_url(s) | ||
| 522 | .map(|_| s.to_string()) | ||
| 523 | .context(format!("Invalid relay URL format: {s}")) | ||
| 524 | }, | ||
| 525 | )?; | ||
| 526 | show_multi_input_prompt_success("nostr relays", &selected); | ||
| 527 | selected | ||
| 528 | .iter() | ||
| 529 | .filter_map(|r| parse_relay_url(r).ok()) | ||
| 530 | .collect() | ||
| 531 | } else { | ||
| 532 | relay_defaults | ||
| 533 | .iter() | ||
| 534 | .filter_map(|r| parse_relay_url(r).ok()) | ||
| 535 | .collect() | ||
| 536 | } | 948 | } |
| 537 | } | 949 | } |
| 538 | }; | 950 | m |
| 539 | 951 | } else if let Some(coord) = state.coordinate() { | |
| 540 | let blossoms: Vec<Url> = { | 952 | let trusted = coord.coordinate.public_key; |
| 541 | if simple_mode || has_server_and_relay_flags { | 953 | if trusted == *my_pubkey { |
| 542 | blossoms_defaults | 954 | vec![*my_pubkey] |
| 543 | .iter() | ||
| 544 | .filter_map(|b| Url::parse(b).ok()) | ||
| 545 | .collect() | ||
| 546 | } else { | 955 | } else { |
| 547 | let selections: Vec<bool> = vec![true; blossoms_defaults.len()]; | 956 | vec![*my_pubkey, trusted] |
| 548 | if args.blossoms.is_empty() { | ||
| 549 | let selected = multi_select_with_custom_value( | ||
| 550 | "blossom servers", | ||
| 551 | "blossom server", | ||
| 552 | blossoms_defaults, | ||
| 553 | selections, | ||
| 554 | |s| { | ||
| 555 | format_grasp_server_url_as_blossom_url(s) | ||
| 556 | .context(format!("Invalid blossom URL format: {s}")) | ||
| 557 | }, | ||
| 558 | )?; | ||
| 559 | show_multi_input_prompt_success("nostr relays", &selected); | ||
| 560 | selected.iter().filter_map(|b| Url::parse(b).ok()).collect() | ||
| 561 | } else { | ||
| 562 | blossoms_defaults | ||
| 563 | .iter() | ||
| 564 | .filter_map(|b| Url::parse(b).ok()) | ||
| 565 | .collect() | ||
| 566 | } | ||
| 567 | } | 957 | } |
| 958 | } else { | ||
| 959 | vec![*my_pubkey] | ||
| 568 | }; | 960 | }; |
| 569 | 961 | ||
| 570 | let default_maintainers = { | 962 | let base_maintainers = if args.other_maintainers.is_empty() { |
| 571 | let mut maintainers = vec![user_ref.public_key]; | 963 | maintainers_default |
| 572 | if args.other_maintainers.is_empty() { | 964 | } else { |
| 573 | if let Some(repo_ref) = &repo_ref { | 965 | let mut m = vec![user_ref.public_key]; |
| 574 | for m in &repo_ref.maintainers { | 966 | for npub in &args.other_maintainers { |
| 575 | if !maintainers.contains(m) { | 967 | if let Ok(pk) = PublicKey::from_bech32(npub) { |
| 576 | maintainers.push(*m); | 968 | if !m.contains(&pk) { |
| 577 | } | 969 | m.push(pk); |
| 578 | } | ||
| 579 | } | ||
| 580 | } else { | ||
| 581 | for m in &args.other_maintainers { | ||
| 582 | if let Ok(pubkey) = PublicKey::from_bech32(m).context("invalid npub") { | ||
| 583 | if !maintainers.contains(&pubkey) { | ||
| 584 | maintainers.push(pubkey); | ||
| 585 | } | ||
| 586 | } | 970 | } |
| 587 | } | 971 | } |
| 588 | } | 972 | } |
| 589 | maintainers | 973 | m |
| 590 | }; | 974 | }; |
| 591 | 975 | ||
| 592 | let maintainers: Vec<PublicKey> = if args.other_maintainers.is_empty() { | 976 | let maintainers = if !args.other_maintainers.is_empty() |
| 593 | if default_maintainers.len() == 1 | 977 | || !interactive |
| 978 | || (base_maintainers.len() == 1 | ||
| 594 | && Interactor::default().choice( | 979 | && Interactor::default().choice( |
| 595 | PromptChoiceParms::default() | 980 | PromptChoiceParms::default() |
| 596 | .with_prompt("add other maintainers now?") | 981 | .with_prompt("add other maintainers now?") |
| @@ -600,41 +985,44 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 600 | "add maintainers".to_string(), | 985 | "add maintainers".to_string(), |
| 601 | ]) | 986 | ]) |
| 602 | .with_default(0), | 987 | .with_default(0), |
| 603 | )? == 0 | 988 | )? == 0) |
| 604 | { | 989 | { |
| 605 | default_maintainers | 990 | base_maintainers |
| 606 | } else { | ||
| 607 | let selections: Vec<bool> = vec![true; default_maintainers.len()]; | ||
| 608 | |||
| 609 | let selected = multi_select_with_custom_value( | ||
| 610 | "maintainers", | ||
| 611 | "maintainer npub", | ||
| 612 | default_maintainers | ||
| 613 | .iter() | ||
| 614 | .filter_map(|m| m.to_bech32().ok()) | ||
| 615 | .collect(), | ||
| 616 | selections, | ||
| 617 | |s| { | ||
| 618 | extract_npub(s) | ||
| 619 | .map(|_| s.to_string()) | ||
| 620 | .context(format!("Invalid npub: {s}")) | ||
| 621 | }, | ||
| 622 | )?; | ||
| 623 | show_multi_input_prompt_success("maintainers", &selected); | ||
| 624 | selected | ||
| 625 | .iter() | ||
| 626 | .filter_map(|npub| PublicKey::parse(npub).ok()) | ||
| 627 | .collect() | ||
| 628 | } | ||
| 629 | } else { | 991 | } else { |
| 630 | default_maintainers | 992 | let selections: Vec<bool> = vec![true; base_maintainers.len()]; |
| 993 | let selected = multi_select_with_custom_value( | ||
| 994 | "maintainers", | ||
| 995 | "maintainer npub", | ||
| 996 | base_maintainers | ||
| 997 | .iter() | ||
| 998 | .filter_map(|m| m.to_bech32().ok()) | ||
| 999 | .collect(), | ||
| 1000 | selections, | ||
| 1001 | |s| { | ||
| 1002 | extract_npub(s) | ||
| 1003 | .map(|_| s.to_string()) | ||
| 1004 | .context(format!("Invalid npub: {s}")) | ||
| 1005 | }, | ||
| 1006 | )?; | ||
| 1007 | show_multi_input_prompt_success("maintainers", &selected); | ||
| 1008 | selected | ||
| 1009 | .iter() | ||
| 1010 | .filter_map(|npub| PublicKey::parse(npub).ok()) | ||
| 1011 | .collect() | ||
| 631 | }; | 1012 | }; |
| 632 | 1013 | ||
| 633 | if selected_grasp_servers.is_empty() && git_server.iter().any(|s| s.contains("github.com") || s.contains("codeberg.org")) && Interactor::default().confirm( | 1014 | // --- Interactive: github/codeberg warning --- |
| 1015 | if interactive | ||
| 1016 | && selected_grasp_servers.is_empty() | ||
| 1017 | && git_servers | ||
| 1018 | .iter() | ||
| 1019 | .any(|s| s.contains("github.com") || s.contains("codeberg.org")) | ||
| 1020 | && Interactor::default().confirm( | ||
| 634 | PromptConfirmParms::default() | 1021 | PromptConfirmParms::default() |
| 635 | .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?") | 1022 | .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?") |
| 636 | .with_default(false), | 1023 | .with_default(false), |
| 637 | )? { | 1024 | )? |
| 1025 | { | ||
| 638 | println!("This means people using the nostr URL won't get your latest branch updates."); | 1026 | println!("This means people using the nostr URL won't get your latest branch updates."); |
| 639 | if Interactor::default().confirm( | 1027 | if Interactor::default().confirm( |
| 640 | PromptConfirmParms::default() | 1028 | PromptConfirmParms::default() |
| @@ -645,124 +1033,228 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 645 | } | 1033 | } |
| 646 | } | 1034 | } |
| 647 | 1035 | ||
| 648 | let gitworkshop_url = NostrUrlDecoded { | 1036 | // --- Web --- |
| 649 | original_string: String::new(), | 1037 | let gitworkshop_url = build_gitworkshop_url(&user_ref.public_key, &identifier, relays.first()); |
| 650 | coordinate: Nip19Coordinate { | 1038 | let web_default = resolve_web(&args.web, state, &identifier, &gitworkshop_url); |
| 651 | coordinate: Coordinate { | ||
| 652 | public_key: user_ref.public_key, | ||
| 653 | kind: Kind::GitRepoAnnouncement, | ||
| 654 | identifier: identifier.clone(), | ||
| 655 | }, | ||
| 656 | relays: if let Some(relay) = relays.first() { | ||
| 657 | vec![relay.clone()] | ||
| 658 | } else { | ||
| 659 | vec![] | ||
| 660 | }, | ||
| 661 | }, | ||
| 662 | protocol: None, | ||
| 663 | ssh_key_file: None, | ||
| 664 | nip05: None, | ||
| 665 | } | ||
| 666 | .to_string() | ||
| 667 | .replace("nostr://", "https://gitworkshop.dev/"); | ||
| 668 | |||
| 669 | let web: Vec<String> = if args.web.is_empty() { | ||
| 670 | let web_default = if let Some(repo_ref) = &repo_ref { | ||
| 671 | if repo_ref | ||
| 672 | .web | ||
| 673 | .clone() | ||
| 674 | .join(" ") | ||
| 675 | // replace legacy gitworkshop.dev url format with new one | ||
| 676 | .contains(format!("https://gitworkshop.dev/repo/{}", &identifier).as_str()) | ||
| 677 | { | ||
| 678 | gitworkshop_url.clone() | ||
| 679 | } else { | ||
| 680 | repo_ref.web.clone().join(" ") | ||
| 681 | } | ||
| 682 | } else { | ||
| 683 | gitworkshop_url.clone() | ||
| 684 | }; | ||
| 685 | 1039 | ||
| 686 | if simple_mode { | 1040 | let web = if !args.web.is_empty() || !interactive || simple_mode { |
| 687 | web_default | 1041 | web_default |
| 688 | } else { | 1042 | } else { |
| 689 | Interactor::default().input( | 1043 | // advanced interactive |
| 1044 | let web_default_str = web_default.join(" "); | ||
| 1045 | Interactor::default() | ||
| 1046 | .input( | ||
| 690 | PromptInputParms::default() | 1047 | PromptInputParms::default() |
| 691 | .with_prompt("repo website") | 1048 | .with_prompt("repo website") |
| 692 | .optional() | 1049 | .optional() |
| 693 | .with_default(web_default), | 1050 | .with_default(web_default_str) |
| 1051 | .with_flag_name("--web"), | ||
| 694 | )? | 1052 | )? |
| 695 | } | 1053 | .split(' ') |
| 696 | .split(' ') | 1054 | .map(std::string::ToString::to_string) |
| 697 | .map(std::string::ToString::to_string) | 1055 | .collect() |
| 698 | .collect() | ||
| 699 | } else { | ||
| 700 | args.web.clone() | ||
| 701 | }; | 1056 | }; |
| 702 | 1057 | ||
| 703 | let earliest_unique_commit = if let Some(t) = &args.earliest_unique_commit { | 1058 | // --- Earliest unique commit --- |
| 704 | t.clone() | 1059 | // Cascade: my event -> consolidated RepoRef (trusted maintainer's) -> local |
| 705 | } else { | 1060 | // root commit |
| 706 | let mut earliest_unique_commit = if let Some(repo_ref) = &repo_ref { | 1061 | let my_euc = my_ref |
| 707 | repo_ref.root_commit.clone() | 1062 | .as_ref() |
| 708 | } else { | 1063 | .map(|mr| &mr.root_commit) |
| 709 | root_commit.to_string() | 1064 | .filter(|c| !c.is_empty()); |
| 710 | }; | 1065 | let repo_euc = state |
| 711 | if simple_mode { | 1066 | .repo_ref() |
| 712 | earliest_unique_commit | 1067 | .map(|rr| &rr.root_commit) |
| 1068 | .filter(|c| !c.is_empty()); | ||
| 1069 | let euc_default = my_euc | ||
| 1070 | .or(repo_euc) | ||
| 1071 | .cloned() | ||
| 1072 | .unwrap_or_else(|| root_commit.to_string()); | ||
| 1073 | |||
| 1074 | let earliest_unique_commit = if let Some(commit) = &args.earliest_unique_commit { | ||
| 1075 | if let Ok(exists) = git_repo.does_commit_exist(commit) { | ||
| 1076 | if !exists { | ||
| 1077 | bail!("earliest unique commit does not exist on current repository"); | ||
| 1078 | } | ||
| 713 | } else { | 1079 | } else { |
| 714 | println!( | 1080 | bail!("earliest unique commit id not formatted correctly"); |
| 715 | "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." | 1081 | } |
| 716 | ); | 1082 | if commit.len() != 40 { |
| 717 | loop { | 1083 | bail!("earliest unique commit id must be 40 characters long"); |
| 718 | earliest_unique_commit = Interactor::default().input( | 1084 | } |
| 719 | PromptInputParms::default() | 1085 | commit.clone() |
| 720 | .with_prompt("earliest unique commit (to help with discoverability)") | 1086 | } else if interactive && !simple_mode { |
| 721 | .with_default(earliest_unique_commit.clone()), | 1087 | println!( |
| 722 | )?; | 1088 | "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." |
| 723 | if let Ok(exists) = git_repo.does_commit_exist(&earliest_unique_commit) { | 1089 | ); |
| 724 | if exists { | 1090 | let mut result = euc_default.clone(); |
| 725 | break earliest_unique_commit; | 1091 | loop { |
| 726 | } | 1092 | result = Interactor::default().input( |
| 727 | println!("commit does not exist on current repository"); | 1093 | PromptInputParms::default() |
| 728 | } else { | 1094 | .with_prompt("earliest unique commit (to help with discoverability)") |
| 729 | println!("commit id not formatted correctly"); | 1095 | .with_default(result.clone()) |
| 1096 | .with_flag_name("--earliest-unique-commit"), | ||
| 1097 | )?; | ||
| 1098 | if let Ok(exists) = git_repo.does_commit_exist(&result) { | ||
| 1099 | if exists && result.len() == 40 { | ||
| 1100 | break; | ||
| 730 | } | 1101 | } |
| 731 | if earliest_unique_commit.len().ne(&40) { | 1102 | if !exists { |
| 732 | println!("commit id must be 40 characters long"); | 1103 | println!("commit does not exist on current repository"); |
| 733 | } | 1104 | } |
| 1105 | } else { | ||
| 1106 | println!("commit id not formatted correctly"); | ||
| 1107 | } | ||
| 1108 | if result.len() != 40 { | ||
| 1109 | println!("commit id must be 40 characters long"); | ||
| 734 | } | 1110 | } |
| 735 | } | 1111 | } |
| 1112 | result | ||
| 1113 | } else { | ||
| 1114 | euc_default | ||
| 736 | }; | 1115 | }; |
| 737 | 1116 | ||
| 738 | println!("publishing repostory announcement to nostr..."); | 1117 | // --- Hashtags (shared metadata — from latest event, like name/description/web) |
| 1118 | // --- | ||
| 1119 | let hashtags = latest | ||
| 1120 | .as_ref() | ||
| 1121 | .map_or_else(Vec::new, |lr| lr.hashtags.clone()); | ||
| 739 | 1122 | ||
| 740 | let repo_ref = RepoRef { | 1123 | Ok(ResolvedFields { |
| 741 | identifier: identifier.clone(), | 1124 | identifier, |
| 742 | name, | 1125 | name, |
| 743 | description, | 1126 | description, |
| 744 | root_commit: earliest_unique_commit, | 1127 | git_servers, |
| 745 | git_server, | 1128 | relays, |
| 746 | web, | ||
| 747 | relays: relays.clone(), | ||
| 748 | blossoms, | 1129 | blossoms, |
| 749 | hashtags: if let Some(repo_ref) = repo_ref { | 1130 | web, |
| 750 | repo_ref.hashtags | 1131 | maintainers, |
| 751 | } else { | 1132 | earliest_unique_commit, |
| 752 | vec![] | 1133 | hashtags, |
| 753 | }, | 1134 | selected_grasp_servers, |
| 1135 | }) | ||
| 1136 | } | ||
| 1137 | |||
| 1138 | /// Interactive prompt for git server selection with simple/advanced modes. | ||
| 1139 | fn prompt_git_servers( | ||
| 1140 | git_servers: Vec<String>, | ||
| 1141 | selected_grasp_servers: &[String], | ||
| 1142 | simple_mode: bool, | ||
| 1143 | ) -> Result<Vec<String>> { | ||
| 1144 | let grasp_server_git_servers: Vec<String> = git_servers | ||
| 1145 | .iter() | ||
| 1146 | .filter(|s| is_grasp_server_clone_url(s)) | ||
| 1147 | .cloned() | ||
| 1148 | .collect(); | ||
| 1149 | let mut additional_server_options: Vec<String> = git_servers | ||
| 1150 | .iter() | ||
| 1151 | .filter(|s| !is_grasp_server_clone_url(s)) | ||
| 1152 | .cloned() | ||
| 1153 | .collect(); | ||
| 1154 | |||
| 1155 | if simple_mode && !selected_grasp_servers.is_empty() { | ||
| 1156 | if additional_server_options.is_empty() { | ||
| 1157 | return Ok(git_servers); | ||
| 1158 | } | ||
| 1159 | let selected = loop { | ||
| 1160 | let selections: Vec<bool> = vec![true; additional_server_options.len()]; | ||
| 1161 | let selected = multi_select_with_custom_value( | ||
| 1162 | "additional git server(s) on top of grasp servers", | ||
| 1163 | "git server remote url", | ||
| 1164 | additional_server_options, | ||
| 1165 | selections, | ||
| 1166 | |s| { | ||
| 1167 | CloneUrl::from_str(s) | ||
| 1168 | .map(|_| s.to_string()) | ||
| 1169 | .context(format!("Invalid git server URL format: {s}")) | ||
| 1170 | }, | ||
| 1171 | )?; | ||
| 1172 | |||
| 1173 | if selected.is_empty() | ||
| 1174 | || Interactor::default().choice( | ||
| 1175 | PromptChoiceParms::default() | ||
| 1176 | .with_prompt("if you or another maintainer start pushing directly to these, nostr will be out of date") | ||
| 1177 | .dont_report() | ||
| 1178 | .with_choices(vec![ | ||
| 1179 | "I'll always push to the nostr remote".to_string(), | ||
| 1180 | "change setup".to_string(), | ||
| 1181 | ]) | ||
| 1182 | .with_default(0), | ||
| 1183 | )? == 1 | ||
| 1184 | { | ||
| 1185 | additional_server_options = selected; | ||
| 1186 | continue; | ||
| 1187 | } | ||
| 1188 | break selected; | ||
| 1189 | }; | ||
| 1190 | show_multi_input_prompt_success("additional git servers", &selected); | ||
| 1191 | let mut combined = grasp_server_git_servers; | ||
| 1192 | combined.extend(selected); | ||
| 1193 | Ok(combined) | ||
| 1194 | } else { | ||
| 1195 | let selections: Vec<bool> = vec![true; git_servers.len()]; | ||
| 1196 | let selected = multi_select_with_custom_value( | ||
| 1197 | "git server remote url(s)", | ||
| 1198 | "git server remote url", | ||
| 1199 | git_servers, | ||
| 1200 | selections, | ||
| 1201 | |s| { | ||
| 1202 | CloneUrl::from_str(s) | ||
| 1203 | .map(|_| s.to_string()) | ||
| 1204 | .context(format!("Invalid git server URL format: {s}")) | ||
| 1205 | }, | ||
| 1206 | )?; | ||
| 1207 | show_multi_input_prompt_success("git servers", &selected); | ||
| 1208 | Ok(selected) | ||
| 1209 | } | ||
| 1210 | } | ||
| 1211 | |||
| 1212 | #[allow(clippy::too_many_lines)] | ||
| 1213 | async fn publish_and_finalize( | ||
| 1214 | fields: ResolvedFields, | ||
| 1215 | signer: Arc<dyn nostr::prelude::NostrSigner>, | ||
| 1216 | user_ref: &ngit::login::user::UserRef, | ||
| 1217 | client: &mut Client, | ||
| 1218 | cli: &Cli, | ||
| 1219 | git_repo: &Repo, | ||
| 1220 | repo_config_result: &Result<ngit::repo_ref::RepoConfigYaml>, | ||
| 1221 | ) -> Result<()> { | ||
| 1222 | let git_repo_path = git_repo.get_path()?; | ||
| 1223 | |||
| 1224 | // Step 1: Build RepoRef | ||
| 1225 | let repo_ref = RepoRef { | ||
| 1226 | identifier: fields.identifier.clone(), | ||
| 1227 | name: fields.name, | ||
| 1228 | description: fields.description, | ||
| 1229 | root_commit: fields.earliest_unique_commit, | ||
| 1230 | git_server: fields.git_servers, | ||
| 1231 | web: fields.web, | ||
| 1232 | relays: fields.relays.clone(), | ||
| 1233 | blossoms: fields.blossoms, | ||
| 1234 | hashtags: fields.hashtags, | ||
| 754 | trusted_maintainer: user_ref.public_key, | 1235 | trusted_maintainer: user_ref.public_key, |
| 755 | maintainers_without_annoucnement: None, | 1236 | maintainers_without_annoucnement: None, |
| 756 | maintainers: maintainers.clone(), | 1237 | maintainers: fields.maintainers.clone(), |
| 757 | events: HashMap::new(), | 1238 | events: HashMap::new(), |
| 758 | nostr_git_url: None, | 1239 | nostr_git_url: None, |
| 759 | }; | 1240 | }; |
| 1241 | |||
| 1242 | // Step 2: Create event | ||
| 1243 | println!("publishing repostory announcement to nostr..."); | ||
| 760 | let repo_event = repo_ref.to_event(&signer).await?; | 1244 | let repo_event = repo_ref.to_event(&signer).await?; |
| 761 | 1245 | ||
| 762 | let nostr_url_decoded = repo_ref.to_nostr_git_url(&Some(&git_repo)); | 1246 | // Step 3: Build nostr URL |
| 1247 | let nostr_url_decoded = repo_ref.to_nostr_git_url(&Some(git_repo)); | ||
| 763 | 1248 | ||
| 764 | let mut events = vec![repo_event]; | 1249 | let mut events = vec![repo_event]; |
| 765 | 1250 | ||
| 1251 | // Step 4: Handle state events and push/sync logic | ||
| 1252 | let no_state = if let Ok(Some(s)) = git_repo.get_git_config_item("nostr.nostate", None) { | ||
| 1253 | s == "true" | ||
| 1254 | } else { | ||
| 1255 | false | ||
| 1256 | }; | ||
| 1257 | |||
| 766 | let (need_push, need_sync) = if std::env::var("NGITTEST").is_ok() || no_state { | 1258 | let (need_push, need_sync) = if std::env::var("NGITTEST").is_ok() || no_state { |
| 767 | // dont push or sync during tests as git-remote-nostr isn't installed during | 1259 | // dont push or sync during tests as git-remote-nostr isn't installed during |
| 768 | // ngit binary tests | 1260 | // ngit binary tests |
| @@ -785,7 +1277,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 785 | if let Some(url) = remote.url() { | 1277 | if let Some(url) = remote.url() { |
| 786 | // issue a state event with origin state, to all (inc. new) repo relays | 1278 | // issue a state event with origin state, to all (inc. new) repo relays |
| 787 | if let Ok(mut origin_state) = | 1279 | if let Ok(mut origin_state) = |
| 788 | list_from_remote(&Term::stdout(), &git_repo, url, &nostr_url_decoded, false) | 1280 | list_from_remote(&Term::stdout(), git_repo, url, &nostr_url_decoded, false) |
| 789 | { | 1281 | { |
| 790 | origin_state.retain(|key, _| { | 1282 | origin_state.retain(|key, _| { |
| 791 | key.starts_with("refs/heads/") | 1283 | key.starts_with("refs/heads/") |
| @@ -809,7 +1301,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 809 | if required_oids.is_empty() { | 1301 | if required_oids.is_empty() { |
| 810 | println!("fetching refs missing locally from existing origin..."); | 1302 | println!("fetching refs missing locally from existing origin..."); |
| 811 | if let Err(error) = fetch_from_git_server( | 1303 | if let Err(error) = fetch_from_git_server( |
| 812 | &git_repo, | 1304 | git_repo, |
| 813 | &required_oids, | 1305 | &required_oids, |
| 814 | url, | 1306 | url, |
| 815 | &nostr_url_decoded, | 1307 | &nostr_url_decoded, |
| @@ -839,27 +1331,28 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 839 | (true, false) | 1331 | (true, false) |
| 840 | }; | 1332 | }; |
| 841 | 1333 | ||
| 1334 | // Step 5: Publish events | ||
| 842 | client.set_signer(signer).await; | 1335 | client.set_signer(signer).await; |
| 843 | 1336 | ||
| 844 | send_events( | 1337 | send_events( |
| 845 | &client, | 1338 | client, |
| 846 | Some(git_repo_path), | 1339 | Some(git_repo_path), |
| 847 | events, | 1340 | events, |
| 848 | user_ref.relays.write(), | 1341 | user_ref.relays.write(), |
| 849 | relays.clone(), | 1342 | fields.relays.clone(), |
| 850 | !cli_args.disable_cli_spinners, | 1343 | !cli.disable_cli_spinners, |
| 851 | false, | 1344 | false, |
| 852 | ) | 1345 | ) |
| 853 | .await?; | 1346 | .await?; |
| 854 | 1347 | ||
| 855 | // TODO - does this git config item do more harm than good? | 1348 | // Step 6: Set git config |
| 856 | git_repo.save_git_config_item( | 1349 | git_repo.save_git_config_item( |
| 857 | "nostr.repo", | 1350 | "nostr.repo", |
| 858 | &Nip19Coordinate { | 1351 | &Nip19Coordinate { |
| 859 | coordinate: Coordinate { | 1352 | coordinate: Coordinate { |
| 860 | kind: Kind::GitRepoAnnouncement, | 1353 | kind: Kind::GitRepoAnnouncement, |
| 861 | public_key: user_ref.public_key, | 1354 | public_key: user_ref.public_key, |
| 862 | identifier: identifier.clone(), | 1355 | identifier: fields.identifier.clone(), |
| 863 | }, | 1356 | }, |
| 864 | relays: vec![], | 1357 | relays: vec![], |
| 865 | } | 1358 | } |
| @@ -867,9 +1360,8 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 867 | false, | 1360 | false, |
| 868 | )?; | 1361 | )?; |
| 869 | 1362 | ||
| 870 | // set origin remote | 1363 | // Step 7: Set origin remote |
| 871 | let nostr_url = nostr_url_decoded.to_string(); | 1364 | let nostr_url = nostr_url_decoded.to_string(); |
| 872 | |||
| 873 | if git_repo.git_repo.find_remote("origin").is_ok() { | 1365 | if git_repo.git_repo.find_remote("origin").is_ok() { |
| 874 | git_repo.git_repo.remote_set_url("origin", &nostr_url)?; | 1366 | git_repo.git_repo.remote_set_url("origin", &nostr_url)?; |
| 875 | } else { | 1367 | } else { |
| @@ -877,8 +1369,9 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 877 | } | 1369 | } |
| 878 | println!("set remote origin to nostr url"); | 1370 | println!("set remote origin to nostr url"); |
| 879 | 1371 | ||
| 1372 | // Step 8: Push/sync | ||
| 880 | if need_push { | 1373 | if need_push { |
| 881 | if selected_grasp_servers.is_empty() { | 1374 | if fields.selected_grasp_servers.is_empty() { |
| 882 | println!("running `ngit push` to publish your repository data"); | 1375 | println!("running `ngit push` to publish your repository data"); |
| 883 | } else { | 1376 | } else { |
| 884 | let countdown_start = 5; | 1377 | let countdown_start = 5; |
| @@ -894,14 +1387,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 894 | term.flush().unwrap(); // Ensure the output is flushed to the terminal | 1387 | term.flush().unwrap(); // Ensure the output is flushed to the terminal |
| 895 | } | 1388 | } |
| 896 | 1389 | ||
| 897 | if let Err(err) = push_main_or_master_branch(&git_repo) { | 1390 | if let Err(err) = push_main_or_master_branch(git_repo) { |
| 898 | println!( | 1391 | println!( |
| 899 | "your repository announcement was published to nostr but git push exited with an error: {err}" | 1392 | "your repository announcement was published to nostr but git push exited with an error: {err}" |
| 900 | ); | 1393 | ); |
| 901 | } | 1394 | } |
| 902 | } | 1395 | } |
| 903 | if need_sync { | 1396 | if need_sync { |
| 904 | if selected_grasp_servers.is_empty() { | 1397 | if fields.selected_grasp_servers.is_empty() { |
| 905 | println!( | 1398 | println!( |
| 906 | "running `ngit sync` to ensure your repository data is available on repository git servers" | 1399 | "running `ngit sync` to ensure your repository data is available on repository git servers" |
| 907 | ); | 1400 | ); |
| @@ -926,27 +1419,25 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 926 | } | 1419 | } |
| 927 | } | 1420 | } |
| 928 | 1421 | ||
| 929 | // println!( | 1422 | // Step 9: Print share URLs |
| 930 | // "any remote branches beginning with `pr/` are open PRs from contributors. | 1423 | let gitworkshop_url = nostr_url_decoded |
| 931 | // they can submit these by simply pushing a branch with this `pr/` prefix." | 1424 | .to_string() |
| 932 | // ); | 1425 | .replace("nostr://", "https://gitworkshop.dev/"); |
| 933 | println!("share your repository: {gitworkshop_url}"); | 1426 | println!("share your repository: {gitworkshop_url}"); |
| 934 | println!("clone url: {nostr_url}"); | 1427 | println!("clone url: {nostr_url}"); |
| 935 | 1428 | ||
| 936 | // no longer create a new maintainers.yaml file - its too confusing for users | 1429 | // Step 10: Update maintainers.yaml if needed |
| 937 | // as it falls out of sync with data in nostr event . update if it already | 1430 | let relays = fields |
| 938 | // exists | 1431 | .relays |
| 939 | |||
| 940 | let relays = relays | ||
| 941 | .iter() | 1432 | .iter() |
| 942 | .map(std::string::ToString::to_string) | 1433 | .map(std::string::ToString::to_string) |
| 943 | .collect::<Vec<String>>(); | 1434 | .collect::<Vec<String>>(); |
| 944 | if match &repo_config_result { | 1435 | if match repo_config_result { |
| 945 | Ok(config) => { | 1436 | Ok(config) => { |
| 946 | !<std::option::Option<std::string::String> as Clone>::clone(&config.identifier) | 1437 | !<std::option::Option<std::string::String> as Clone>::clone(&config.identifier) |
| 947 | .unwrap_or_default() | 1438 | .unwrap_or_default() |
| 948 | .eq(&identifier) | 1439 | .eq(&fields.identifier) |
| 949 | || !extract_pks(config.maintainers.clone())?.eq(&maintainers) | 1440 | || !extract_pks(config.maintainers.clone())?.eq(&fields.maintainers) |
| 950 | || !config.relays.eq(&relays) | 1441 | || !config.relays.eq(&relays) |
| 951 | } | 1442 | } |
| 952 | Err(_) => false, | 1443 | Err(_) => false, |
| @@ -954,9 +1445,9 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 954 | let title_style = Style::new().bold().fg(console::Color::Yellow); | 1445 | let title_style = Style::new().bold().fg(console::Color::Yellow); |
| 955 | println!("{}", title_style.apply_to("maintainers.yaml")); | 1446 | println!("{}", title_style.apply_to("maintainers.yaml")); |
| 956 | save_repo_config_to_yaml( | 1447 | save_repo_config_to_yaml( |
| 957 | &git_repo, | 1448 | git_repo, |
| 958 | identifier.clone(), | 1449 | fields.identifier.clone(), |
| 959 | maintainers.clone(), | 1450 | fields.maintainers.clone(), |
| 960 | relays.clone(), | 1451 | relays.clone(), |
| 961 | )?; | 1452 | )?; |
| 962 | println!( | 1453 | println!( |
| @@ -974,6 +1465,98 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | |||
| 974 | Ok(()) | 1465 | Ok(()) |
| 975 | } | 1466 | } |
| 976 | 1467 | ||
| 1468 | #[allow(clippy::too_many_lines)] | ||
| 1469 | pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | ||
| 1470 | // Phase 1: Local-only setup | ||
| 1471 | let git_repo = Repo::discover().context("failed to find a git repository")?; | ||
| 1472 | let git_repo_path = git_repo.get_path()?; | ||
| 1473 | let root_commit = git_repo | ||
| 1474 | .get_root_commit() | ||
| 1475 | .context("failed to get root commit of the repository")?; | ||
| 1476 | let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); | ||
| 1477 | let (signer, user_ref, _) = login::login_or_signup( | ||
| 1478 | &Some(&git_repo), | ||
| 1479 | &extract_signer_cli_arguments(cli_args).unwrap_or(None), | ||
| 1480 | &cli_args.password, | ||
| 1481 | Some(&client), | ||
| 1482 | false, | ||
| 1483 | ) | ||
| 1484 | .await?; | ||
| 1485 | |||
| 1486 | let repo_coordinate = (try_and_get_repo_coordinates_when_remote_unknown(&git_repo).await).ok(); | ||
| 1487 | |||
| 1488 | // Phase 2: Pre-fetch validation (fail fast) | ||
| 1489 | let user_has_grasp_list = !user_ref.grasp_list.urls.is_empty(); | ||
| 1490 | validate_pre_fetch( | ||
| 1491 | cli_args, | ||
| 1492 | args, | ||
| 1493 | repo_coordinate.as_ref(), | ||
| 1494 | user_has_grasp_list, | ||
| 1495 | )?; | ||
| 1496 | |||
| 1497 | // Phase 3: Network fetch (only if coordinate exists) | ||
| 1498 | let repo_ref = if let Some(repo_coordinate) = &repo_coordinate { | ||
| 1499 | fetching_with_report(git_repo_path, &client, repo_coordinate).await?; | ||
| 1500 | (get_repo_ref_from_cache(Some(git_repo_path), repo_coordinate).await).ok() | ||
| 1501 | } else { | ||
| 1502 | None | ||
| 1503 | }; | ||
| 1504 | |||
| 1505 | // Phase 4: Determine state + post-fetch validation | ||
| 1506 | let state = match (&repo_coordinate, &repo_ref) { | ||
| 1507 | (None, _) => InitState::Fresh, | ||
| 1508 | (Some(coord), None) => InitState::CoordinateOnly { | ||
| 1509 | coordinate: coord.clone(), | ||
| 1510 | }, | ||
| 1511 | (Some(coord), Some(rr)) => { | ||
| 1512 | if coord.coordinate.public_key == user_ref.public_key { | ||
| 1513 | InitState::MyAnnouncement { | ||
| 1514 | coordinate: coord.clone(), | ||
| 1515 | repo_ref: rr.clone(), | ||
| 1516 | } | ||
| 1517 | } else if rr.maintainers.contains(&user_ref.public_key) { | ||
| 1518 | InitState::CoMaintainer { | ||
| 1519 | coordinate: coord.clone(), | ||
| 1520 | repo_ref: rr.clone(), | ||
| 1521 | } | ||
| 1522 | } else { | ||
| 1523 | InitState::NotListed { | ||
| 1524 | coordinate: coord.clone(), | ||
| 1525 | repo_ref: rr.clone(), | ||
| 1526 | } | ||
| 1527 | } | ||
| 1528 | } | ||
| 1529 | }; | ||
| 1530 | |||
| 1531 | validate_post_fetch(cli_args, args, &state)?; | ||
| 1532 | |||
| 1533 | // Phase 5: Resolve all fields | ||
| 1534 | let repo_config_result = get_repo_config_from_yaml(&git_repo); | ||
| 1535 | let fields = resolve_fields( | ||
| 1536 | &state, | ||
| 1537 | &user_ref, | ||
| 1538 | args, | ||
| 1539 | cli_args, | ||
| 1540 | &git_repo, | ||
| 1541 | &root_commit.to_string(), | ||
| 1542 | &client, | ||
| 1543 | &repo_config_result, | ||
| 1544 | cli_args.interactive, | ||
| 1545 | )?; | ||
| 1546 | |||
| 1547 | // Phase 6: Build and publish | ||
| 1548 | publish_and_finalize( | ||
| 1549 | fields, | ||
| 1550 | signer, | ||
| 1551 | &user_ref, | ||
| 1552 | &mut client, | ||
| 1553 | cli_args, | ||
| 1554 | &git_repo, | ||
| 1555 | &repo_config_result, | ||
| 1556 | ) | ||
| 1557 | .await | ||
| 1558 | } | ||
| 1559 | |||
| 977 | fn format_grasp_server_url_as_clone_url( | 1560 | fn format_grasp_server_url_as_clone_url( |
| 978 | url: &str, | 1561 | url: &str, |
| 979 | public_key: &PublicKey, | 1562 | public_key: &PublicKey, |
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 { | |||
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { | 28 | pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { |
| 29 | // Early validation: check if we have required parameters in non-interactive | ||
| 30 | // mode | ||
| 31 | let signer_info = extract_signer_cli_arguments(args)?; | ||
| 32 | if Interactor::is_non_interactive() && signer_info.is_none() { | ||
| 33 | use ngit::cli_interactor::cli_error; | ||
| 34 | return Err(cli_error( | ||
| 35 | "requires --nsec or --interactive", | ||
| 36 | &[ | ||
| 37 | ("--nsec <key>", "provide secret key (nsec or hex)"), | ||
| 38 | ("--interactive", "for nostr connect or bunker login"), | ||
| 39 | ], | ||
| 40 | &[ | ||
| 41 | "ngit account login --nsec <your-nsec>", | ||
| 42 | "ngit account create", | ||
| 43 | ], | ||
| 44 | )); | ||
| 45 | } | ||
| 46 | |||
| 29 | let git_repo_result = Repo::discover().context("failed to find a git repository"); | 47 | let git_repo_result = Repo::discover().context("failed to find a git repository"); |
| 30 | let git_repo = { git_repo_result.ok() }; | 48 | let git_repo = { git_repo_result.ok() }; |
| 31 | 49 | ||
| @@ -42,7 +60,7 @@ pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { | |||
| 42 | fresh_login_or_signup( | 60 | fresh_login_or_signup( |
| 43 | &git_repo.as_ref(), | 61 | &git_repo.as_ref(), |
| 44 | client.as_ref(), | 62 | client.as_ref(), |
| 45 | extract_signer_cli_arguments(args)?, | 63 | signer_info, |
| 46 | log_in_locally_only || command_args.local, | 64 | log_in_locally_only || command_args.local, |
| 47 | ) | 65 | ) |
| 48 | .await?; | 66 | .await?; |
| @@ -56,6 +74,7 @@ pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { | |||
| 56 | } | 74 | } |
| 57 | 75 | ||
| 58 | /// return ( bool - logged out, bool - log in to local git locally) | 76 | /// return ( bool - logged out, bool - log in to local git locally) |
| 77 | #[allow(clippy::too_many_lines)] | ||
| 59 | async fn logout(git_repo: Option<&Repo>, local_only: bool) -> Result<(bool, bool)> { | 78 | async fn logout(git_repo: Option<&Repo>, local_only: bool) -> Result<(bool, bool)> { |
| 60 | for source in if local_only || std::env::var("NGITTEST").is_ok() { | 79 | for source in if local_only || std::env::var("NGITTEST").is_ok() { |
| 61 | vec![SignerInfoSource::GitLocal] | 80 | vec![SignerInfoSource::GitLocal] |
| @@ -74,6 +93,41 @@ async fn logout(git_repo: Option<&Repo>, local_only: bool) -> Result<(bool, bool | |||
| 74 | ) | 93 | ) |
| 75 | .await | 94 | .await |
| 76 | { | 95 | { |
| 96 | // In non-interactive mode, automatically logout without prompting | ||
| 97 | if Interactor::is_non_interactive() { | ||
| 98 | for item in [ | ||
| 99 | "nostr.nsec", | ||
| 100 | "nostr.npub", | ||
| 101 | "nostr.bunker-uri", | ||
| 102 | "nostr.bunker-app-key", | ||
| 103 | ] { | ||
| 104 | if let Err(_error) = remove_git_config_item( | ||
| 105 | if source == SignerInfoSource::GitLocal { | ||
| 106 | &git_repo | ||
| 107 | } else { | ||
| 108 | &None | ||
| 109 | }, | ||
| 110 | item, | ||
| 111 | ) { | ||
| 112 | use ngit::cli_interactor::cli_error; | ||
| 113 | return Err(cli_error( | ||
| 114 | &format!( | ||
| 115 | "failed to edit {} git config item '{item}'", | ||
| 116 | if source == SignerInfoSource::GitGlobal { | ||
| 117 | "global" | ||
| 118 | } else { | ||
| 119 | "local" | ||
| 120 | }, | ||
| 121 | ), | ||
| 122 | &[], | ||
| 123 | &["ngit account login --local --nsec <your-nsec>"], | ||
| 124 | )); | ||
| 125 | } | ||
| 126 | } | ||
| 127 | return Ok((true, local_only)); | ||
| 128 | } | ||
| 129 | |||
| 130 | // Interactive mode: prompt user for what to do | ||
| 77 | match Interactor::default().choice( | 131 | match Interactor::default().choice( |
| 78 | PromptChoiceParms::default() | 132 | PromptChoiceParms::default() |
| 79 | .with_default(0) | 133 | .with_default(0) |
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 @@ | |||
| 1 | pub mod create; | ||
| 1 | pub mod export_keys; | 2 | pub mod export_keys; |
| 2 | pub mod init; | 3 | pub mod init; |
| 3 | pub mod list; | 4 | pub mod list; |
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::{ | |||
| 15 | cli::{Cli, extract_signer_cli_arguments}, | 15 | cli::{Cli, extract_signer_cli_arguments}, |
| 16 | cli_interactor::{ | 16 | cli_interactor::{ |
| 17 | Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms, | 17 | Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms, |
| 18 | cli_error, | ||
| 18 | }, | 19 | }, |
| 19 | client::{ | 20 | client::{ |
| 20 | Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, | 21 | Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, |
| @@ -38,9 +39,9 @@ pub struct SubCommandArgs { | |||
| 38 | #[arg(long, action)] | 39 | #[arg(long, action)] |
| 39 | pub(crate) no_cover_letter: bool, | 40 | pub(crate) no_cover_letter: bool, |
| 40 | /// optional cover letter title | 41 | /// optional cover letter title |
| 41 | #[clap(short, long)] | 42 | #[clap(long)] |
| 42 | pub(crate) title: Option<String>, | 43 | pub(crate) title: Option<String>, |
| 43 | #[clap(short, long)] | 44 | #[clap(long)] |
| 44 | /// optional cover letter description | 45 | /// optional cover letter description |
| 45 | pub(crate) description: Option<String>, | 46 | pub(crate) description: Option<String>, |
| 46 | /// publish as Pull Request even if each commit is < 60kb | 47 | /// publish as Pull Request even if each commit is < 60kb |
| @@ -51,6 +52,83 @@ pub struct SubCommandArgs { | |||
| 51 | pub(crate) force_patch: bool, | 52 | pub(crate) force_patch: bool, |
| 52 | } | 53 | } |
| 53 | 54 | ||
| 55 | /// Validates send command arguments for non-interactive mode. | ||
| 56 | /// | ||
| 57 | /// Returns Ok(()) if: | ||
| 58 | /// - Interactive mode is enabled (all validation happens interactively) | ||
| 59 | /// - Updating an existing proposal (`in_reply_to` is non-empty) | ||
| 60 | /// - Using defaults mode (--defaults will fill in gaps) | ||
| 61 | /// - Both title and description are provided | ||
| 62 | /// | ||
| 63 | /// Returns an error if: | ||
| 64 | /// - Description provided without title | ||
| 65 | /// - Title provided without description | ||
| 66 | /// - Missing required arguments in non-interactive mode | ||
| 67 | fn validate_send_args(cli: &Cli, args: &SubCommandArgs) -> Result<()> { | ||
| 68 | // Interactive mode handles all validation interactively | ||
| 69 | if cli.interactive { | ||
| 70 | return Ok(()); | ||
| 71 | } | ||
| 72 | |||
| 73 | // Description requires title | ||
| 74 | if args.description.is_some() && args.title.is_none() { | ||
| 75 | let message = "ngit send requires --title when --description is provided"; | ||
| 76 | let details = vec![("--title <T>", "cover letter title")]; | ||
| 77 | let suggestions = vec![ | ||
| 78 | "ngit send HEAD~2 --title \"My Feature\" --description \"Details\"", | ||
| 79 | "ngit send --interactive", | ||
| 80 | ]; | ||
| 81 | return Err(cli_error(message, &details, &suggestions)); | ||
| 82 | } | ||
| 83 | |||
| 84 | // Title requires description | ||
| 85 | if args.title.is_some() && args.description.is_none() { | ||
| 86 | let message = "ngit send requires --description when --title is provided"; | ||
| 87 | let details = vec![("--description <D>", "cover letter description")]; | ||
| 88 | let suggestions = vec![ | ||
| 89 | "ngit send HEAD~2 --title \"My Feature\" --description \"Details\"", | ||
| 90 | "ngit send --interactive", | ||
| 91 | ]; | ||
| 92 | return Err(cli_error(message, &details, &suggestions)); | ||
| 93 | } | ||
| 94 | |||
| 95 | // Updating existing proposal - no additional validation needed | ||
| 96 | if !args.in_reply_to.is_empty() { | ||
| 97 | return Ok(()); | ||
| 98 | } | ||
| 99 | |||
| 100 | // Defaults mode will fill in gaps | ||
| 101 | if cli.defaults { | ||
| 102 | return Ok(()); | ||
| 103 | } | ||
| 104 | |||
| 105 | // Both title and description provided - all good | ||
| 106 | if args.title.is_some() && args.description.is_some() { | ||
| 107 | return Ok(()); | ||
| 108 | } | ||
| 109 | |||
| 110 | // --no-cover-letter with a range is valid (patches without cover letter) | ||
| 111 | if args.no_cover_letter && !args.since_or_range.is_empty() { | ||
| 112 | return Ok(()); | ||
| 113 | } | ||
| 114 | |||
| 115 | // Missing required arguments for non-interactive mode | ||
| 116 | let message = "ngit send requires additional arguments"; | ||
| 117 | let mut details = vec![]; | ||
| 118 | if args.since_or_range.is_empty() { | ||
| 119 | details.push(("<SINCE_OR_RANGE>", "commits to send (eg. HEAD~2)")); | ||
| 120 | } | ||
| 121 | details.push(("--title <T> --description <D>", "cover letter details")); | ||
| 122 | details.push(("-d, --defaults", "use sensible defaults")); | ||
| 123 | details.push(("--interactive", "prompt for values")); | ||
| 124 | let suggestions = vec![ | ||
| 125 | "ngit send HEAD~2 --title \"My Feature\" --description \"Details\"", | ||
| 126 | "ngit send --defaults", | ||
| 127 | "ngit send --interactive", | ||
| 128 | ]; | ||
| 129 | Err(cli_error(message, &details, &suggestions)) | ||
| 130 | } | ||
| 131 | |||
| 54 | #[allow(clippy::too_many_lines)] | 132 | #[allow(clippy::too_many_lines)] |
| 55 | pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Result<()> { | 133 | pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Result<()> { |
| 56 | let git_repo = Repo::discover().context("failed to find a git repository")?; | 134 | 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 | |||
| 60 | .get_main_or_master_branch() | 138 | .get_main_or_master_branch() |
| 61 | .context("the default branches (main or master) do not exist")?; | 139 | .context("the default branches (main or master) do not exist")?; |
| 62 | 140 | ||
| 141 | // Validate arguments early, before any network calls | ||
| 142 | validate_send_args(cli_args, args)?; | ||
| 143 | |||
| 63 | let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); | 144 | let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); |
| 64 | 145 | ||
| 65 | let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; | 146 | 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 | |||
| 82 | 163 | ||
| 83 | let mut commits: Vec<Sha1Hash> = { | 164 | let mut commits: Vec<Sha1Hash> = { |
| 84 | if args.since_or_range.is_empty() { | 165 | if args.since_or_range.is_empty() { |
| 85 | let branch_name = git_repo.get_checked_out_branch_name()?; | 166 | if cli_args.interactive { |
| 86 | let proposed_commits = if branch_name.eq(main_branch_name) { | 167 | let branch_name = git_repo.get_checked_out_branch_name()?; |
| 87 | vec![main_tip] | 168 | let proposed_commits = if branch_name.eq(main_branch_name) { |
| 169 | vec![main_tip] | ||
| 170 | } else { | ||
| 171 | let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?; | ||
| 172 | ahead | ||
| 173 | }; | ||
| 174 | choose_commits(&git_repo, proposed_commits)? | ||
| 88 | } else { | 175 | } else { |
| 89 | let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?; | 176 | // --defaults was validated above, so we know it's set |
| 90 | ahead | 177 | let branch_name = git_repo.get_checked_out_branch_name()?; |
| 91 | }; | 178 | let proposed_commits = if branch_name.eq(main_branch_name) { |
| 92 | choose_commits(&git_repo, proposed_commits)? | 179 | vec![main_tip] |
| 180 | } else { | ||
| 181 | let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?; | ||
| 182 | ahead | ||
| 183 | }; | ||
| 184 | if proposed_commits.len() > 10 && !cli_args.force { | ||
| 185 | bail!( | ||
| 186 | "too many commits ({}). use --force to proceed or specify a range", | ||
| 187 | proposed_commits.len() | ||
| 188 | ); | ||
| 189 | } | ||
| 190 | proposed_commits | ||
| 191 | } | ||
| 93 | } else { | 192 | } else { |
| 94 | git_repo | 193 | git_repo |
| 95 | .parse_starting_commits(&args.since_or_range) | 194 | .parse_starting_commits(&args.since_or_range) |
| @@ -97,6 +196,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re | |||
| 97 | } | 196 | } |
| 98 | }; | 197 | }; |
| 99 | 198 | ||
| 199 | // Check for too many commits with explicit range | ||
| 200 | if commits.len() > 10 && !cli_args.force && !cli_args.interactive { | ||
| 201 | bail!( | ||
| 202 | "too many commits ({}). use --force to proceed or specify a smaller range", | ||
| 203 | commits.len() | ||
| 204 | ); | ||
| 205 | } | ||
| 206 | |||
| 100 | if commits.is_empty() { | 207 | if commits.is_empty() { |
| 101 | bail!("no commits selected"); | 208 | bail!("no commits selected"); |
| 102 | } | 209 | } |
| @@ -115,6 +222,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re | |||
| 115 | git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?; | 222 | git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?; |
| 116 | 223 | ||
| 117 | check_commits_are_suitable_for_proposal( | 224 | check_commits_are_suitable_for_proposal( |
| 225 | cli_args, | ||
| 118 | &first_commit_ahead, | 226 | &first_commit_ahead, |
| 119 | &commits, | 227 | &commits, |
| 120 | &behind, | 228 | &behind, |
| @@ -138,57 +246,92 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re | |||
| 138 | should_be_pr | 246 | should_be_pr |
| 139 | }; | 247 | }; |
| 140 | 248 | ||
| 141 | let title = if as_pr { | 249 | let cover_letter_title_description = if cli_args.interactive { |
| 142 | match &args.title { | 250 | // Interactive flow: prompt for cover letter confirm, title, description |
| 143 | Some(t) => Some(t.clone()), | 251 | let title = if as_pr { |
| 144 | None => { | 252 | match &args.title { |
| 145 | if root_proposal.is_none() { | 253 | Some(t) => Some(t.clone()), |
| 146 | Some( | 254 | None => { |
| 147 | Interactor::default() | 255 | if root_proposal.is_none() { |
| 148 | .input(PromptInputParms::default().with_prompt("title"))? | 256 | Some( |
| 149 | .clone(), | 257 | Interactor::default() |
| 150 | ) | 258 | .input(PromptInputParms::default().with_prompt("title"))? |
| 151 | } else { | 259 | .clone(), |
| 152 | None | 260 | ) |
| 261 | } else { | ||
| 262 | None | ||
| 263 | } | ||
| 153 | } | 264 | } |
| 154 | } | 265 | } |
| 155 | } | 266 | } else if args.no_cover_letter { |
| 156 | } else if args.no_cover_letter { | 267 | None |
| 157 | None | 268 | } else { |
| 158 | } else { | 269 | match &args.title { |
| 159 | match &args.title { | 270 | Some(t) => Some(t.clone()), |
| 160 | Some(t) => Some(t.clone()), | 271 | None => { |
| 161 | None => { | 272 | if Interactor::default().confirm( |
| 162 | if Interactor::default().confirm( | 273 | PromptConfirmParms::default() |
| 163 | PromptConfirmParms::default() | 274 | .with_default(false) |
| 164 | .with_default(false) | 275 | .with_prompt("include cover letter?"), |
| 165 | .with_prompt("include cover letter?"), | 276 | )? { |
| 166 | )? { | 277 | Some( |
| 167 | Some( | 278 | Interactor::default() |
| 168 | Interactor::default() | 279 | .input(PromptInputParms::default().with_prompt("title"))? |
| 169 | .input(PromptInputParms::default().with_prompt("title"))? | 280 | .clone(), |
| 170 | .clone(), | 281 | ) |
| 171 | ) | 282 | } else { |
| 172 | } else { | 283 | None |
| 173 | None | 284 | } |
| 174 | } | 285 | } |
| 175 | } | 286 | } |
| 176 | } | 287 | }; |
| 177 | }; | ||
| 178 | 288 | ||
| 179 | let cover_letter_title_description = if let Some(title) = title { | 289 | if let Some(title) = title { |
| 180 | Some(( | 290 | Some(( |
| 181 | title, | 291 | title, |
| 182 | if let Some(t) = &args.description { | 292 | if let Some(t) = &args.description { |
| 183 | t.clone() | 293 | t.clone() |
| 184 | } else { | 294 | } else { |
| 185 | Interactor::default() | 295 | Interactor::default() |
| 186 | .input(PromptInputParms::default().with_prompt("description"))? | 296 | .input(PromptInputParms::default().with_prompt("description"))? |
| 187 | .clone() | 297 | .clone() |
| 188 | }, | 298 | }, |
| 189 | )) | 299 | )) |
| 300 | } else { | ||
| 301 | None | ||
| 302 | } | ||
| 303 | } else if as_pr { | ||
| 304 | // PR always needs cover letter | ||
| 305 | let title = match &args.title { | ||
| 306 | Some(t) => t.clone(), | ||
| 307 | None if cli_args.defaults => { | ||
| 308 | git_repo.get_commit_message_summary(commits.first().context("no commits")?)? | ||
| 309 | } | ||
| 310 | None => bail!("PR requires --title and --description (or use --defaults)"), | ||
| 311 | }; | ||
| 312 | let description = match &args.description { | ||
| 313 | Some(d) => d.clone(), | ||
| 314 | None if cli_args.defaults => { | ||
| 315 | let commit = commits.first().context("no commits")?; | ||
| 316 | let full_message = git_repo.get_commit_message(commit)?; | ||
| 317 | let summary = git_repo.get_commit_message_summary(commit)?; | ||
| 318 | full_message | ||
| 319 | .strip_prefix(&summary) | ||
| 320 | .unwrap_or(&full_message) | ||
| 321 | .trim() | ||
| 322 | .to_string() | ||
| 323 | } | ||
| 324 | None => bail!("PR requires --title and --description (or use --defaults)"), | ||
| 325 | }; | ||
| 326 | Some((title, description)) | ||
| 190 | } else { | 327 | } else { |
| 191 | None | 328 | // Patch mode |
| 329 | match (&args.title, &args.description) { | ||
| 330 | (Some(t), Some(d)) => Some((t.clone(), d.clone())), | ||
| 331 | (Some(_), None) => bail!("--title requires --description"), | ||
| 332 | (None, Some(_)) => bail!("--description requires --title"), | ||
| 333 | (None, None) => None, // no cover letter | ||
| 334 | } | ||
| 192 | }; | 335 | }; |
| 193 | 336 | ||
| 194 | let (signer, mut user_ref, _) = login::login_or_signup( | 337 | 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 | |||
| 303 | } | 446 | } |
| 304 | 447 | ||
| 305 | fn check_commits_are_suitable_for_proposal( | 448 | fn check_commits_are_suitable_for_proposal( |
| 449 | cli: &Cli, | ||
| 306 | first_commit_ahead: &[Sha1Hash], | 450 | first_commit_ahead: &[Sha1Hash], |
| 307 | commits: &[Sha1Hash], | 451 | commits: &[Sha1Hash], |
| 308 | behind: &[Sha1Hash], | 452 | behind: &[Sha1Hash], |
| @@ -310,37 +454,63 @@ fn check_commits_are_suitable_for_proposal( | |||
| 310 | main_tip: &Sha1Hash, | 454 | main_tip: &Sha1Hash, |
| 311 | ) -> Result<()> { | 455 | ) -> Result<()> { |
| 312 | // check proposal ahead of origin/main | 456 | // check proposal ahead of origin/main |
| 313 | if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm( | 457 | if first_commit_ahead.len().gt(&1) { |
| 314 | PromptConfirmParms::default() | 458 | if cli.interactive { |
| 315 | .with_prompt( | 459 | if !Interactor::default().confirm( |
| 316 | format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1) | 460 | PromptConfirmParms::default() |
| 317 | ) | 461 | .with_prompt( |
| 318 | .with_default(false) | 462 | format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1) |
| 319 | ).context("failed to get confirmation response from interactor confirm")? { | 463 | ) |
| 320 | bail!("aborting because selected commits were ahead of origin/master"); | 464 | .with_default(false) |
| 465 | ).context("failed to get confirmation response from interactor confirm")? { | ||
| 466 | bail!("aborting ..."); | ||
| 467 | } | ||
| 468 | } else if !cli.force { | ||
| 469 | bail!( | ||
| 470 | "proposal builds on a commit {} ahead of '{}'. use --force to proceed", | ||
| 471 | first_commit_ahead.len() - 1, | ||
| 472 | main_branch_name | ||
| 473 | ); | ||
| 474 | } | ||
| 321 | } | 475 | } |
| 322 | 476 | ||
| 323 | // check if a selected commit is already in origin | 477 | // check if a selected commit is already in origin |
| 324 | if commits.iter().any(|c| c.eq(main_tip)) { | 478 | if commits.iter().any(|c| c.eq(main_tip)) { |
| 325 | if !Interactor::default().confirm( | 479 | if cli.interactive { |
| 326 | PromptConfirmParms::default() | 480 | if !Interactor::default().confirm( |
| 327 | .with_prompt( | 481 | PromptConfirmParms::default() |
| 328 | format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?") | 482 | .with_prompt( |
| 329 | ) | 483 | format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?") |
| 330 | .with_default(false) | 484 | ) |
| 331 | ).context("failed to get confirmation response from interactor confirm")? { | 485 | .with_default(false) |
| 332 | bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'"); | 486 | ).context("failed to get confirmation response from interactor confirm")? { |
| 487 | bail!("aborting ..."); | ||
| 488 | } | ||
| 489 | } else if !cli.force { | ||
| 490 | bail!( | ||
| 491 | "proposal contains commit(s) already in '{main_branch_name}'. use --force to proceed" | ||
| 492 | ); | ||
| 333 | } | 493 | } |
| 334 | } | 494 | } |
| 335 | // check proposal isn't behind origin/main | 495 | // check proposal isn't behind origin/main |
| 336 | else if !behind.is_empty() && !Interactor::default().confirm( | 496 | else if !behind.is_empty() { |
| 337 | PromptConfirmParms::default() | 497 | if cli.interactive { |
| 338 | .with_prompt( | 498 | if !Interactor::default().confirm( |
| 339 | format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len()) | 499 | PromptConfirmParms::default() |
| 340 | ) | 500 | .with_prompt( |
| 341 | .with_default(false) | 501 | format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len()) |
| 342 | ).context("failed to get confirmation response from interactor confirm")? { | 502 | ) |
| 343 | bail!("aborting so commits can be rebased"); | 503 | .with_default(false) |
| 504 | ).context("failed to get confirmation response from interactor confirm")? { | ||
| 505 | bail!("aborting so commits can be rebased"); | ||
| 506 | } | ||
| 507 | } else if !cli.force { | ||
| 508 | bail!( | ||
| 509 | "proposal is {} behind '{}'. rebase first or use --force to proceed", | ||
| 510 | behind.len(), | ||
| 511 | main_branch_name | ||
| 512 | ); | ||
| 513 | } | ||
| 344 | } | 514 | } |
| 345 | Ok(()) | 515 | Ok(()) |
| 346 | } | 516 | } |
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 @@ | |||
| 1 | use anyhow::{Context, Result}; | 1 | use std::fmt; |
| 2 | |||
| 3 | use anyhow::{Context, Result, bail}; | ||
| 4 | use console::Style; | ||
| 2 | use dialoguer::{ | 5 | use dialoguer::{ |
| 3 | Confirm, Input, Password, | 6 | Confirm, Input, Password, |
| 4 | theme::{ColorfulTheme, Theme}, | 7 | theme::{ColorfulTheme, Theme}, |
| @@ -7,9 +10,93 @@ use indicatif::TermLike; | |||
| 7 | #[cfg(test)] | 10 | #[cfg(test)] |
| 8 | use mockall::*; | 11 | use mockall::*; |
| 9 | 12 | ||
| 13 | /// Sentinel error type indicating the error has already been printed to stderr. | ||
| 14 | /// | ||
| 15 | /// When this propagates up to `main()`, it signals "already printed styled | ||
| 16 | /// output to stderr, don't double-print". This is the same pattern clap uses | ||
| 17 | /// internally. | ||
| 18 | #[derive(Debug)] | ||
| 19 | pub struct CliError; | ||
| 20 | |||
| 21 | impl fmt::Display for CliError { | ||
| 22 | fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| 23 | // Empty display — the error message was already printed to stderr | ||
| 24 | Ok(()) | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | impl std::error::Error for CliError {} | ||
| 29 | |||
| 30 | /// Print a styled CLI error to stderr and return an `anyhow::Error` wrapping | ||
| 31 | /// [`CliError`]. | ||
| 32 | /// | ||
| 33 | /// - `message`: the main error text (printed after the red `error:` prefix) | ||
| 34 | /// - `details`: flag/description pairs shown as gray indented lines (for | ||
| 35 | /// multiple missing fields). Descriptions are aligned to the longest flag. | ||
| 36 | /// - `suggestions`: command suggestions shown in yellow | ||
| 37 | /// | ||
| 38 | /// This function does NOT call `process::exit()`. It prints to stderr and | ||
| 39 | /// returns an error that the caller should propagate with `?` or `return Err`. | ||
| 40 | pub fn cli_error(message: &str, details: &[(&str, &str)], suggestions: &[&str]) -> anyhow::Error { | ||
| 41 | let dim = Style::new().for_stderr().color256(247); | ||
| 42 | |||
| 43 | eprint!( | ||
| 44 | "{} {}", | ||
| 45 | console::style("error:").for_stderr().red(), | ||
| 46 | message | ||
| 47 | ); | ||
| 48 | if details.is_empty() { | ||
| 49 | eprintln!(); | ||
| 50 | } else { | ||
| 51 | let max_flag_len = details | ||
| 52 | .iter() | ||
| 53 | .map(|(flag, _)| flag.len()) | ||
| 54 | .max() | ||
| 55 | .unwrap_or(0); | ||
| 56 | eprintln!(); | ||
| 57 | for (flag, desc) in details { | ||
| 58 | eprintln!( | ||
| 59 | " {:width$} {}", | ||
| 60 | dim.apply_to(flag), | ||
| 61 | dim.apply_to(desc), | ||
| 62 | width = max_flag_len | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | if !suggestions.is_empty() { | ||
| 68 | eprintln!(); | ||
| 69 | for cmd in suggestions { | ||
| 70 | eprintln!( | ||
| 71 | "{}", | ||
| 72 | console::style(format!(" {cmd}")).for_stderr().yellow(), | ||
| 73 | ); | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | CliError.into() | ||
| 78 | } | ||
| 79 | |||
| 10 | #[derive(Default)] | 80 | #[derive(Default)] |
| 11 | pub struct Interactor { | 81 | pub struct Interactor { |
| 12 | theme: ColorfulTheme, | 82 | theme: ColorfulTheme, |
| 83 | non_interactive: bool, | ||
| 84 | } | ||
| 85 | |||
| 86 | impl Interactor { | ||
| 87 | pub fn new(non_interactive: bool) -> Self { | ||
| 88 | Self { | ||
| 89 | theme: ColorfulTheme::default(), | ||
| 90 | non_interactive, | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | /// Returns true if running in non-interactive mode (the default). | ||
| 95 | /// Interactive mode is only enabled when NGIT_INTERACTIVE_MODE env var is | ||
| 96 | /// set (via -i flag). | ||
| 97 | pub fn is_non_interactive() -> bool { | ||
| 98 | std::env::var("NGIT_INTERACTIVE_MODE").is_err() | ||
| 99 | } | ||
| 13 | } | 100 | } |
| 14 | 101 | ||
| 15 | #[cfg_attr(test, automock)] | 102 | #[cfg_attr(test, automock)] |
| @@ -22,6 +109,21 @@ pub trait InteractorPrompt { | |||
| 22 | } | 109 | } |
| 23 | impl InteractorPrompt for Interactor { | 110 | impl InteractorPrompt for Interactor { |
| 24 | fn input(&self, parms: PromptInputParms) -> Result<String> { | 111 | fn input(&self, parms: PromptInputParms) -> Result<String> { |
| 112 | if self.non_interactive || Self::is_non_interactive() { | ||
| 113 | if parms.optional || !parms.default.is_empty() { | ||
| 114 | return Ok(parms.default); | ||
| 115 | } | ||
| 116 | let flag_hint = parms | ||
| 117 | .flag_name | ||
| 118 | .as_ref() | ||
| 119 | .map(|f| format!(" (provide {} or use -i/-d)", f)) | ||
| 120 | .unwrap_or_else(|| " (use -i for interactive mode or -d for defaults)".to_string()); | ||
| 121 | bail!( | ||
| 122 | "interactive input required but running in non-interactive mode: {}{}", | ||
| 123 | parms.prompt, | ||
| 124 | flag_hint | ||
| 125 | ); | ||
| 126 | } | ||
| 25 | let mut input = Input::with_theme(&self.theme) | 127 | let mut input = Input::with_theme(&self.theme) |
| 26 | .with_prompt(parms.prompt) | 128 | .with_prompt(parms.prompt) |
| 27 | .allow_empty(parms.optional) | 129 | .allow_empty(parms.optional) |
| @@ -32,6 +134,12 @@ impl InteractorPrompt for Interactor { | |||
| 32 | Ok(input.interact_text()?) | 134 | Ok(input.interact_text()?) |
| 33 | } | 135 | } |
| 34 | fn password(&self, parms: PromptPasswordParms) -> Result<String> { | 136 | fn password(&self, parms: PromptPasswordParms) -> Result<String> { |
| 137 | if self.non_interactive || Self::is_non_interactive() { | ||
| 138 | bail!( | ||
| 139 | "password input required but running in non-interactive mode: {}", | ||
| 140 | parms.prompt | ||
| 141 | ); | ||
| 142 | } | ||
| 35 | let mut p = Password::with_theme(&self.theme) | 143 | let mut p = Password::with_theme(&self.theme) |
| 36 | .with_prompt(parms.prompt) | 144 | .with_prompt(parms.prompt) |
| 37 | .report(parms.report); | 145 | .report(parms.report); |
| @@ -42,6 +150,9 @@ impl InteractorPrompt for Interactor { | |||
| 42 | Ok(pass) | 150 | Ok(pass) |
| 43 | } | 151 | } |
| 44 | fn confirm(&self, params: PromptConfirmParms) -> Result<bool> { | 152 | fn confirm(&self, params: PromptConfirmParms) -> Result<bool> { |
| 153 | if self.non_interactive || Self::is_non_interactive() { | ||
| 154 | return Ok(params.default); | ||
| 155 | } | ||
| 45 | let confirm: bool = Confirm::with_theme(&self.theme) | 156 | let confirm: bool = Confirm::with_theme(&self.theme) |
| 46 | .with_prompt(params.prompt) | 157 | .with_prompt(params.prompt) |
| 47 | .default(params.default) | 158 | .default(params.default) |
| @@ -49,6 +160,15 @@ impl InteractorPrompt for Interactor { | |||
| 49 | Ok(confirm) | 160 | Ok(confirm) |
| 50 | } | 161 | } |
| 51 | fn choice(&self, parms: PromptChoiceParms) -> Result<usize> { | 162 | fn choice(&self, parms: PromptChoiceParms) -> Result<usize> { |
| 163 | if self.non_interactive || Self::is_non_interactive() { | ||
| 164 | if let Some(default) = parms.default { | ||
| 165 | return Ok(default); | ||
| 166 | } | ||
| 167 | bail!( | ||
| 168 | "interactive choice required but running in non-interactive mode: {}", | ||
| 169 | parms.prompt | ||
| 170 | ); | ||
| 171 | } | ||
| 52 | let mut choice = dialoguer::Select::with_theme(&self.theme) | 172 | let mut choice = dialoguer::Select::with_theme(&self.theme) |
| 53 | .with_prompt(parms.prompt) | 173 | .with_prompt(parms.prompt) |
| 54 | .report(parms.report) | 174 | .report(parms.report) |
| @@ -61,6 +181,17 @@ impl InteractorPrompt for Interactor { | |||
| 61 | choice.interact().context("failed to get choice") | 181 | choice.interact().context("failed to get choice") |
| 62 | } | 182 | } |
| 63 | fn multi_choice(&self, parms: PromptMultiChoiceParms) -> Result<Vec<usize>> { | 183 | fn multi_choice(&self, parms: PromptMultiChoiceParms) -> Result<Vec<usize>> { |
| 184 | if self.non_interactive || Self::is_non_interactive() { | ||
| 185 | if let Some(defaults) = &parms.defaults { | ||
| 186 | return Ok(defaults | ||
| 187 | .iter() | ||
| 188 | .enumerate() | ||
| 189 | .filter(|(_, &selected)| selected) | ||
| 190 | .map(|(i, _)| i) | ||
| 191 | .collect()); | ||
| 192 | } | ||
| 193 | return Ok(vec![]); // Empty selection if no defaults | ||
| 194 | } | ||
| 64 | // the colorful theme is not very clear so falling back to default | 195 | // the colorful theme is not very clear so falling back to default |
| 65 | let mut choice = dialoguer::MultiSelect::default() | 196 | let mut choice = dialoguer::MultiSelect::default() |
| 66 | .with_prompt(parms.prompt) | 197 | .with_prompt(parms.prompt) |
| @@ -73,11 +204,20 @@ impl InteractorPrompt for Interactor { | |||
| 73 | } | 204 | } |
| 74 | } | 205 | } |
| 75 | 206 | ||
| 207 | /// Parameters for interactive input prompts. | ||
| 208 | /// | ||
| 209 | /// Supports both interactive and non-interactive modes: | ||
| 210 | /// - Interactive mode (NGIT_INTERACTIVE_MODE set): prompts user | ||
| 211 | /// - Non-interactive mode (default): returns default value or errors | ||
| 212 | /// | ||
| 213 | /// The `flag_name` field improves error messages by telling users | ||
| 214 | /// which CLI flag would provide the missing value. | ||
| 76 | pub struct PromptInputParms { | 215 | pub struct PromptInputParms { |
| 77 | pub prompt: String, | 216 | pub prompt: String, |
| 78 | pub default: String, | 217 | pub default: String, |
| 79 | pub report: bool, | 218 | pub report: bool, |
| 80 | pub optional: bool, | 219 | pub optional: bool, |
| 220 | pub flag_name: Option<String>, | ||
| 81 | } | 221 | } |
| 82 | 222 | ||
| 83 | impl Default for PromptInputParms { | 223 | impl Default for PromptInputParms { |
| @@ -87,6 +227,7 @@ impl Default for PromptInputParms { | |||
| 87 | default: String::new(), | 227 | default: String::new(), |
| 88 | optional: false, | 228 | optional: false, |
| 89 | report: true, | 229 | report: true, |
| 230 | flag_name: None, | ||
| 90 | } | 231 | } |
| 91 | } | 232 | } |
| 92 | } | 233 | } |
| @@ -109,6 +250,11 @@ impl PromptInputParms { | |||
| 109 | self.report = false; | 250 | self.report = false; |
| 110 | self | 251 | self |
| 111 | } | 252 | } |
| 253 | |||
| 254 | pub fn with_flag_name<S: Into<String>>(mut self, flag_name: S) -> Self { | ||
| 255 | self.flag_name = Some(flag_name.into()); | ||
| 256 | self | ||
| 257 | } | ||
| 112 | } | 258 | } |
| 113 | 259 | ||
| 114 | pub struct PromptPasswordParms { | 260 | pub struct PromptPasswordParms { |
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( | |||
| 1300 | Some(repo_coordinate.public_key), | 1300 | Some(repo_coordinate.public_key), |
| 1301 | ))?; | 1301 | ))?; |
| 1302 | 1302 | ||
| 1303 | // Use name/description/web from the latest event across all maintainers | ||
| 1304 | let latest_metadata = repo_events | ||
| 1305 | .last() | ||
| 1306 | .and_then(|e| RepoRef::try_from((e.clone(), None)).ok()); | ||
| 1307 | |||
| 1303 | let mut events: HashMap<Nip19Coordinate, nostr::Event> = HashMap::new(); | 1308 | let mut events: HashMap<Nip19Coordinate, nostr::Event> = HashMap::new(); |
| 1304 | for m in &maintainers { | 1309 | for m in &maintainers { |
| 1305 | if let Some(e) = repo_events.iter().find(|e| e.pubkey.eq(m)) { | 1310 | 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( | |||
| 1364 | git_server, | 1369 | git_server, |
| 1365 | events, | 1370 | events, |
| 1366 | maintainers_without_annoucnement: Some(maintainers_without_annoucnement), | 1371 | maintainers_without_annoucnement: Some(maintainers_without_annoucnement), |
| 1372 | name: latest_metadata | ||
| 1373 | .as_ref() | ||
| 1374 | .map_or_else(|| repo_ref.name.clone(), |r| r.name.clone()), | ||
| 1375 | description: latest_metadata | ||
| 1376 | .as_ref() | ||
| 1377 | .map_or_else(|| repo_ref.description.clone(), |r| r.description.clone()), | ||
| 1378 | web: latest_metadata | ||
| 1379 | .as_ref() | ||
| 1380 | .map_or_else(|| repo_ref.web.clone(), |r| r.web.clone()), | ||
| 1367 | ..repo_ref | 1381 | ..repo_ref |
| 1368 | }) | 1382 | }) |
| 1369 | } | 1383 | } |
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::{ | |||
| 25 | Interactor, InteractorPrompt, Printer, PromptChoiceParms, PromptConfirmParms, | 25 | Interactor, InteractorPrompt, Printer, PromptChoiceParms, PromptConfirmParms, |
| 26 | PromptInputParms, PromptPasswordParms, | 26 | PromptInputParms, PromptPasswordParms, |
| 27 | }, | 27 | }, |
| 28 | client::{Connect, nip05_query, send_events}, | 28 | client::{Connect, nip05_query, save_event_in_global_cache, send_events}, |
| 29 | git::{Repo, RepoActions, remove_git_config_item, save_git_config_item}, | 29 | git::{Repo, RepoActions, remove_git_config_item, save_git_config_item}, |
| 30 | }; | 30 | }; |
| 31 | 31 | ||
| @@ -123,7 +123,7 @@ pub async fn get_fresh_nsec_signer() -> Result< | |||
| 123 | .input( | 123 | .input( |
| 124 | PromptInputParms::default() | 124 | PromptInputParms::default() |
| 125 | .with_prompt("nsec") | 125 | .with_prompt("nsec") |
| 126 | .optional() | 126 | .with_flag_name("--nsec") |
| 127 | .dont_report(), | 127 | .dont_report(), |
| 128 | ) | 128 | ) |
| 129 | .context("failed to get nsec input from interactor")?; | 129 | .context("failed to get nsec input from interactor")?; |
| @@ -509,6 +509,26 @@ async fn save_to_git_config( | |||
| 509 | if let Err(error) = | 509 | if let Err(error) = |
| 510 | silently_save_to_git_config(git_repo, signer_info, global).context(err_msg.clone()) | 510 | silently_save_to_git_config(git_repo, signer_info, global).context(err_msg.clone()) |
| 511 | { | 511 | { |
| 512 | // Check if this is a read-only file system error | ||
| 513 | let is_readonly_error = error | ||
| 514 | .chain() | ||
| 515 | .any(|e| e.to_string().contains("Read-only file system")); | ||
| 516 | |||
| 517 | if is_readonly_error && global { | ||
| 518 | // In non-interactive mode, provide a clear error with --local suggestion | ||
| 519 | if crate::cli_interactor::Interactor::is_non_interactive() { | ||
| 520 | use crate::cli_interactor::cli_error; | ||
| 521 | return Err(cli_error( | ||
| 522 | "failed to create account", | ||
| 523 | &[("cause", "global git config is read-only")], | ||
| 524 | &[ | ||
| 525 | "ngit account create --local --nsec <your-nsec>", | ||
| 526 | "ngit account login --local --nsec <your-nsec>", | ||
| 527 | ], | ||
| 528 | )); | ||
| 529 | } | ||
| 530 | } | ||
| 531 | |||
| 512 | eprintln!("Error: {error:?}"); | 532 | eprintln!("Error: {error:?}"); |
| 513 | match signer_info { | 533 | match signer_info { |
| 514 | SignerInfo::Nsec { | 534 | SignerInfo::Nsec { |
| @@ -678,6 +698,119 @@ fn silently_save_to_git_config( | |||
| 678 | Ok(()) | 698 | Ok(()) |
| 679 | } | 699 | } |
| 680 | 700 | ||
| 701 | /// Non-interactive signup function for creating a new account | ||
| 702 | /// | ||
| 703 | /// # Arguments | ||
| 704 | /// * `name` - Display name for the new account | ||
| 705 | /// * `client` - Optional client for publishing metadata to relays | ||
| 706 | /// * `save_local` - If true, save credentials to local git config only | ||
| 707 | /// * `publish` - If true, publish metadata and relay list to relays | ||
| 708 | /// | ||
| 709 | /// # Returns | ||
| 710 | /// Returns a tuple of (signer, public_key, signer_info, keys) where keys can be | ||
| 711 | /// used to display the nsec | ||
| 712 | pub async fn signup_non_interactive( | ||
| 713 | name: String, | ||
| 714 | #[cfg(test)] client: Option<&MockConnect>, | ||
| 715 | #[cfg(not(test))] client: Option<&Client>, | ||
| 716 | save_local: bool, | ||
| 717 | publish: bool, | ||
| 718 | ) -> Result<(Arc<dyn NostrSigner>, PublicKey, SignerInfo, Keys)> { | ||
| 719 | // Generate new keypair | ||
| 720 | let keys = nostr::Keys::generate(); | ||
| 721 | let nsec = keys.secret_key().to_bech32()?; | ||
| 722 | let public_key = keys.public_key(); | ||
| 723 | |||
| 724 | let signer_info = SignerInfo::Nsec { | ||
| 725 | nsec, | ||
| 726 | password: None, | ||
| 727 | npub: Some(public_key.to_bech32()?), | ||
| 728 | }; | ||
| 729 | |||
| 730 | // Save to git config | ||
| 731 | let git_repo = Repo::discover().ok(); | ||
| 732 | if let Err(error) = silently_save_to_git_config(&git_repo.as_ref(), &signer_info, !save_local) { | ||
| 733 | let is_readonly = error | ||
| 734 | .chain() | ||
| 735 | .any(|e| e.to_string().contains("Read-only file system")); | ||
| 736 | |||
| 737 | if is_readonly && !save_local { | ||
| 738 | use crate::cli_interactor::cli_error; | ||
| 739 | |||
| 740 | let mut cmds: Vec<String> = match &signer_info { | ||
| 741 | SignerInfo::Nsec { nsec, npub, .. } => { | ||
| 742 | let mut v = vec![format!("git config --global nostr.nsec {nsec}")]; | ||
| 743 | if let Some(npub) = npub { | ||
| 744 | v.push(format!("git config --global nostr.npub {npub}")); | ||
| 745 | } | ||
| 746 | v | ||
| 747 | } | ||
| 748 | SignerInfo::Bunker { | ||
| 749 | bunker_uri, | ||
| 750 | bunker_app_key, | ||
| 751 | npub, | ||
| 752 | } => { | ||
| 753 | let mut v = vec![ | ||
| 754 | format!("git config --global nostr.bunker-uri {bunker_uri}"), | ||
| 755 | format!("git config --global nostr.bunker-app-key {bunker_app_key}"), | ||
| 756 | ]; | ||
| 757 | if let Some(npub) = npub { | ||
| 758 | v.push(format!("git config --global nostr.npub {npub}")); | ||
| 759 | } | ||
| 760 | v | ||
| 761 | } | ||
| 762 | }; | ||
| 763 | cmds.push("ngit account create --local --name <your-name>".to_string()); | ||
| 764 | |||
| 765 | let cmd_refs: Vec<&str> = cmds.iter().map(String::as_str).collect(); | ||
| 766 | return Err(cli_error( | ||
| 767 | "global git config is read-only. login to local repo or save git config manually", | ||
| 768 | &[("--local", "login scoped to this repositoriy")], | ||
| 769 | &cmd_refs, | ||
| 770 | )); | ||
| 771 | } | ||
| 772 | |||
| 773 | return Err(error); | ||
| 774 | } | ||
| 775 | |||
| 776 | let git_repo_path = if let Some(ref git_repo) = git_repo { | ||
| 777 | Some(git_repo.get_path()?) | ||
| 778 | } else { | ||
| 779 | None | ||
| 780 | }; | ||
| 781 | |||
| 782 | // Build events, save to cache, and optionally publish to relays | ||
| 783 | if let Some(client) = client { | ||
| 784 | let profile = EventBuilder::metadata(&Metadata::new().name(name)).sign_with_keys(&keys)?; | ||
| 785 | let relay_list = EventBuilder::relay_list( | ||
| 786 | client | ||
| 787 | .get_relay_default_set() | ||
| 788 | .iter() | ||
| 789 | .map(|s| (RelayUrl::parse(s).unwrap(), None)), | ||
| 790 | ) | ||
| 791 | .sign_with_keys(&keys)?; | ||
| 792 | |||
| 793 | // Save to global cache so subsequent commands don't need to fetch | ||
| 794 | save_event_in_global_cache(git_repo_path, &profile).await?; | ||
| 795 | save_event_in_global_cache(git_repo_path, &relay_list).await?; | ||
| 796 | |||
| 797 | if publish { | ||
| 798 | send_events( | ||
| 799 | client, | ||
| 800 | git_repo_path, | ||
| 801 | vec![profile, relay_list], | ||
| 802 | client.get_relay_default_set().clone(), | ||
| 803 | vec![], | ||
| 804 | true, | ||
| 805 | false, | ||
| 806 | ) | ||
| 807 | .await?; | ||
| 808 | } | ||
| 809 | } | ||
| 810 | |||
| 811 | Ok((Arc::new(keys.clone()), public_key, signer_info, keys)) | ||
| 812 | } | ||
| 813 | |||
| 681 | async fn signup( | 814 | async fn signup( |
| 682 | #[cfg(test)] client: Option<&MockConnect>, | 815 | #[cfg(test)] client: Option<&MockConnect>, |
| 683 | #[cfg(not(test))] client: Option<&Client>, | 816 | #[cfg(not(test))] client: Option<&Client>, |
| @@ -714,42 +847,22 @@ async fn signup( | |||
| 714 | _ => break Ok(None), | 847 | _ => break Ok(None), |
| 715 | } | 848 | } |
| 716 | } | 849 | } |
| 717 | let keys = nostr::Keys::generate(); | 850 | |
| 718 | let nsec = keys.secret_key().to_bech32()?; | 851 | // Call the non-interactive function |
| 852 | let (signer, public_key, signer_info, _keys) = signup_non_interactive( | ||
| 853 | name.clone(), | ||
| 854 | client, | ||
| 855 | false, // save_local = false (will be saved globally by caller) | ||
| 856 | true, // publish = true (always publish in interactive mode) | ||
| 857 | ) | ||
| 858 | .await?; | ||
| 859 | |||
| 719 | show_prompt_success("user display name", &name); | 860 | show_prompt_success("user display name", &name); |
| 720 | let signer_info = SignerInfo::Nsec { | ||
| 721 | nsec, | ||
| 722 | password: None, | ||
| 723 | npub: Some(keys.public_key().to_bech32()?), | ||
| 724 | }; | ||
| 725 | let public_key = keys.public_key(); | ||
| 726 | if let Some(client) = client { | ||
| 727 | let profile = | ||
| 728 | EventBuilder::metadata(&Metadata::new().name(name)).sign_with_keys(&keys)?; | ||
| 729 | let relay_list = EventBuilder::relay_list( | ||
| 730 | client | ||
| 731 | .get_relay_default_set() | ||
| 732 | .iter() | ||
| 733 | .map(|s| (RelayUrl::parse(s).unwrap(), None)), | ||
| 734 | ) | ||
| 735 | .sign_with_keys(&keys)?; | ||
| 736 | eprintln!("publishing user profile to relays"); | ||
| 737 | send_events( | ||
| 738 | client, | ||
| 739 | None, | ||
| 740 | vec![profile, relay_list], | ||
| 741 | client.get_relay_default_set().clone(), | ||
| 742 | vec![], | ||
| 743 | true, | ||
| 744 | false, | ||
| 745 | ) | ||
| 746 | .await?; | ||
| 747 | } | ||
| 748 | eprintln!( | 861 | eprintln!( |
| 749 | "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" | 862 | "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" |
| 750 | ); | 863 | ); |
| 751 | break Ok(Some(( | 864 | break Ok(Some(( |
| 752 | Arc::new(keys), | 865 | signer, |
| 753 | public_key, | 866 | public_key, |
| 754 | signer_info, | 867 | signer_info, |
| 755 | // TODO factor in source | 868 | // TODO factor in source |
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 { | |||
| 282 | branch.set_upstream(Some(&format!("origin/{branch_name}")))?; | 282 | branch.set_upstream(Some(&format!("origin/{branch_name}")))?; |
| 283 | self.checkout(branch_name) | 283 | self.checkout(branch_name) |
| 284 | } | 284 | } |
| 285 | |||
| 286 | /// Set nostr.repo git config to point to a specific pubkey's coordinate. | ||
| 287 | /// Used for State D/E tests where the coordinate points to another user. | ||
| 288 | pub fn set_nostr_repo_coordinate( | ||
| 289 | &self, | ||
| 290 | pubkey: &nostr::PublicKey, | ||
| 291 | identifier: &str, | ||
| 292 | relays: &[&str], | ||
| 293 | ) { | ||
| 294 | let relay_urls: Vec<nostr::RelayUrl> = relays | ||
| 295 | .iter() | ||
| 296 | .map(|r| nostr::RelayUrl::parse(r).unwrap()) | ||
| 297 | .collect(); | ||
| 298 | let coordinate = Nip19Coordinate { | ||
| 299 | coordinate: Coordinate::new(nostr::Kind::GitRepoAnnouncement, *pubkey) | ||
| 300 | .identifier(identifier.to_string()), | ||
| 301 | relays: relay_urls, | ||
| 302 | }; | ||
| 303 | let _ = self | ||
| 304 | .git_repo | ||
| 305 | .config() | ||
| 306 | .unwrap() | ||
| 307 | .set_str("nostr.repo", &coordinate.to_bech32().unwrap()); | ||
| 308 | } | ||
| 285 | } | 309 | } |
| 286 | 310 | ||
| 287 | impl Drop for GitTestRepo { | 311 | 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( | |||
| 208 | .sign_with_keys(keys) | 208 | .sign_with_keys(keys) |
| 209 | .unwrap() | 209 | .unwrap() |
| 210 | } | 210 | } |
| 211 | |||
| 212 | /// Generate a repo announcement event signed by TEST_KEY_2 that lists | ||
| 213 | /// TEST_KEY_1 as a maintainer. Used for State D tests (co-maintainer scenario). | ||
| 214 | pub fn generate_repo_ref_event_as_key_2_listing_key_1() -> nostr::Event { | ||
| 215 | generate_repo_ref_event_as_key_2_with_maintainers(vec![ | ||
| 216 | TEST_KEY_2_KEYS.public_key().to_string(), | ||
| 217 | TEST_KEY_1_KEYS.public_key().to_string(), | ||
| 218 | ]) | ||
| 219 | } | ||
| 220 | |||
| 221 | /// Generate a repo announcement event signed by TEST_KEY_2 that does NOT list | ||
| 222 | /// TEST_KEY_1. Used for State E tests (not listed scenario). | ||
| 223 | pub fn generate_repo_ref_event_as_key_2_not_listing_key_1() -> nostr::Event { | ||
| 224 | generate_repo_ref_event_as_key_2_with_maintainers(vec![ | ||
| 225 | TEST_KEY_2_KEYS.public_key().to_string(), | ||
| 226 | ]) | ||
| 227 | } | ||
| 228 | |||
| 229 | /// Generate a repo announcement event signed by TEST_KEY_2 with specific | ||
| 230 | /// maintainers. | ||
| 231 | fn generate_repo_ref_event_as_key_2_with_maintainers(maintainers: Vec<String>) -> nostr::Event { | ||
| 232 | let root_commit = "9ee507fc4357d7ee16a5d8901bedcd103f23c17d"; | ||
| 233 | nostr::event::EventBuilder::new(nostr::Kind::GitRepoAnnouncement, "") | ||
| 234 | .tags([ | ||
| 235 | Tag::identifier(format!("{root_commit}-consider-it-random")), | ||
| 236 | Tag::from_standardized(TagStandard::Reference(root_commit.to_string())), | ||
| 237 | Tag::from_standardized(TagStandard::Name("example name".into())), | ||
| 238 | Tag::from_standardized(TagStandard::Description("example description".into())), | ||
| 239 | Tag::custom( | ||
| 240 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("clone")), | ||
| 241 | vec!["git:://123.gitexample.com/test".to_string()], | ||
| 242 | ), | ||
| 243 | Tag::custom( | ||
| 244 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("web")), | ||
| 245 | vec![ | ||
| 246 | "https://exampleproject.xyz".to_string(), | ||
| 247 | "https://gitworkshop.dev/123".to_string(), | ||
| 248 | ], | ||
| 249 | ), | ||
| 250 | Tag::custom( | ||
| 251 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("relays")), | ||
| 252 | vec![ | ||
| 253 | "ws://localhost:8055".to_string(), | ||
| 254 | "ws://localhost:8056".to_string(), | ||
| 255 | ], | ||
| 256 | ), | ||
| 257 | Tag::custom( | ||
| 258 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("maintainers")), | ||
| 259 | maintainers, | ||
| 260 | ), | ||
| 261 | ]) | ||
| 262 | .sign_with_keys(&TEST_KEY_2_KEYS) | ||
| 263 | .unwrap() | ||
| 264 | } | ||
| 265 | |||
| 266 | /// Generate relay list event for TEST_KEY_2 (same relays as KEY_1 for | ||
| 267 | /// simplicity) | ||
| 268 | pub fn generate_test_key_2_relay_list_event() -> nostr::Event { | ||
| 269 | nostr::event::EventBuilder::new(nostr::Kind::RelayList, "") | ||
| 270 | .tags([ | ||
| 271 | nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata { | ||
| 272 | relay_url: nostr::RelayUrl::from_str("ws://localhost:8053").unwrap(), | ||
| 273 | metadata: Some(RelayMetadata::Write), | ||
| 274 | }), | ||
| 275 | nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata { | ||
| 276 | relay_url: nostr::RelayUrl::from_str("ws://localhost:8054").unwrap(), | ||
| 277 | metadata: Some(RelayMetadata::Read), | ||
| 278 | }), | ||
| 279 | nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata { | ||
| 280 | relay_url: nostr::RelayUrl::from_str("ws://localhost:8055").unwrap(), | ||
| 281 | metadata: None, | ||
| 282 | }), | ||
| 283 | ]) | ||
| 284 | .sign_with_keys(&TEST_KEY_2_KEYS) | ||
| 285 | .unwrap() | ||
| 286 | } | ||
| 211 | /// enough to fool event_is_patch_set_root | 287 | /// enough to fool event_is_patch_set_root |
| 212 | pub fn get_pretend_proposal_root_event() -> nostr::Event { | 288 | pub fn get_pretend_proposal_root_event() -> nostr::Event { |
| 213 | 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 <joe.bloggs@pm.me>\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() | 289 | 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 <joe.bloggs@pm.me>\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( | |||
| 1416 | test_repo: &GitTestRepo, | 1492 | test_repo: &GitTestRepo, |
| 1417 | proposal_number: u16, | 1493 | proposal_number: u16, |
| 1418 | ) -> Result<()> { | 1494 | ) -> Result<()> { |
| 1419 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 1495 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 1420 | p.expect("fetching updates...\r\n")?; | 1496 | p.expect("fetching updates...\r\n")?; |
| 1421 | p.expect_eventually("\r\n")?; // some updates listed here | 1497 | p.expect_eventually("\r\n")?; // some updates listed here |
| 1422 | let mut c = p.expect_choice( | 1498 | 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 @@ | |||
| 1 | use anyhow::Result; | 1 | use anyhow::Result; |
| 2 | use nostr::Event; | ||
| 2 | use nostr_sdk::Kind; | 3 | use nostr_sdk::Kind; |
| 3 | use rstest::*; | 4 | use rstest::*; |
| 4 | use serial_test::serial; | 5 | use serial_test::serial; |
| 5 | use test_utils::{git::GitTestRepo, *}; | 6 | use test_utils::{git::GitTestRepo, *}; |
| 6 | 7 | ||
| 7 | fn expect_msgs_first(p: &mut CliTester) -> Result<()> { | 8 | // --------------------------------------------------------------------------- |
| 8 | p.expect("searching for profile...\r\n")?; | 9 | // Helpers |
| 9 | p.expect("logged in as fred via cli arguments\r\n")?; | 10 | // --------------------------------------------------------------------------- |
| 10 | // // p.expect("searching for existing claims on repository...\r\n")?; | 11 | |
| 11 | p.expect("publishing repostory announcement to nostr...\r\n")?; | 12 | /// Extract the GitRepoAnnouncement event from a relay's collected events. |
| 12 | Ok(()) | 13 | fn get_announcement(events: &[Event]) -> &Event { |
| 14 | events | ||
| 15 | .iter() | ||
| 16 | .find(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) | ||
| 17 | .expect("GitRepoAnnouncement event not found") | ||
| 18 | } | ||
| 19 | |||
| 20 | /// Get the first value of a single-value tag (e.g. "d", "name", "description"). | ||
| 21 | fn get_tag_value<'a>(event: &'a Event, tag_name: &str) -> &'a str { | ||
| 22 | event | ||
| 23 | .tags | ||
| 24 | .iter() | ||
| 25 | .find(|t| t.as_slice()[0] == tag_name) | ||
| 26 | .map(|t| t.as_slice()[1].as_str()) | ||
| 27 | .unwrap_or_else(|| panic!("tag '{tag_name}' not found")) | ||
| 13 | } | 28 | } |
| 14 | 29 | ||
| 15 | fn get_cli_args() -> Vec<&'static str> { | 30 | /// Get all values of a multi-value tag (e.g. "relays", "web", "maintainers", |
| 16 | vec![ | 31 | /// "clone"). Returns slice starting from index 1 (skipping the tag name). |
| 17 | "--nsec", | 32 | fn get_tag_values(event: &Event, tag_name: &str) -> Vec<String> { |
| 18 | TEST_KEY_1_NSEC, | 33 | event |
| 19 | "--password", | 34 | .tags |
| 20 | TEST_PASSWORD, | 35 | .iter() |
| 21 | "--disable-cli-spinners", | 36 | .find(|t| t.as_slice()[0] == tag_name) |
| 22 | "init", | 37 | .map(|t| t.as_slice()[1..].iter().map(|s| s.to_string()).collect()) |
| 23 | "--title", | 38 | .unwrap_or_default() |
| 24 | "example-name", | ||
| 25 | "--identifier", | ||
| 26 | "example-identifier", | ||
| 27 | "--description", | ||
| 28 | "example-description", | ||
| 29 | "--web", | ||
| 30 | "https://exampleproject.xyz", | ||
| 31 | "https://gitworkshop.dev/123", | ||
| 32 | "--relays", | ||
| 33 | "ws://localhost:8055", | ||
| 34 | "ws://localhost:8056", | ||
| 35 | "--clone-url", | ||
| 36 | "https://git.myhosting.com/my-repo.git", | ||
| 37 | "--earliest-unique-commit", | ||
| 38 | "9ee507fc4357d7ee16a5d8901bedcd103f23c17d", | ||
| 39 | "--other-maintainers", | ||
| 40 | TEST_KEY_1_NPUB, | ||
| 41 | ] | ||
| 42 | } | 39 | } |
| 43 | 40 | ||
| 44 | mod when_repo_not_previously_claimed { | 41 | // --------------------------------------------------------------------------- |
| 42 | // State A: Fresh (no coordinate) | ||
| 43 | // --------------------------------------------------------------------------- | ||
| 44 | |||
| 45 | mod state_a_fresh { | ||
| 45 | use super::*; | 46 | use super::*; |
| 46 | 47 | ||
| 47 | mod when_repo_relays_specified_as_arguments { | 48 | fn prep_git_repo() -> Result<GitTestRepo> { |
| 48 | use futures::join; | 49 | let test_repo = GitTestRepo::without_repo_in_git_config(); |
| 49 | use test_utils::relay::Relay; | 50 | test_repo.populate()?; |
| 51 | test_repo.add_remote("origin", "https://localhost:1000")?; | ||
| 52 | Ok(test_repo) | ||
| 53 | } | ||
| 50 | 54 | ||
| 55 | mod errors { | ||
| 51 | use super::*; | 56 | use super::*; |
| 52 | 57 | ||
| 53 | fn prep_git_repo() -> Result<GitTestRepo> { | 58 | #[test] |
| 54 | let test_repo = GitTestRepo::without_repo_in_git_config(); | 59 | #[serial] |
| 55 | test_repo.populate()?; | 60 | fn bare_no_flags() -> Result<()> { |
| 56 | test_repo.add_remote("origin", "https://localhost:1000")?; | 61 | let git_repo = prep_git_repo()?; |
| 57 | Ok(test_repo) | 62 | let args = vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; |
| 63 | let mut p = CliTester::new_from_dir(&git_repo.dir, args); | ||
| 64 | p.expect_eventually("logged in as")?; | ||
| 65 | p.expect_eventually("missing required fields")?; | ||
| 66 | p.expect_eventually("--name <NAME>")?; | ||
| 67 | p.expect_eventually("--grasp-servers")?; | ||
| 68 | Ok(()) | ||
| 58 | } | 69 | } |
| 59 | 70 | ||
| 60 | fn cli_tester_init(git_repo: &GitTestRepo) -> CliTester { | 71 | #[test] |
| 61 | CliTester::new_from_dir(&git_repo.dir, get_cli_args()) | 72 | #[serial] |
| 73 | fn name_only_missing_server_infra() -> Result<()> { | ||
| 74 | let git_repo = prep_git_repo()?; | ||
| 75 | let args = vec![ | ||
| 76 | "--nsec", | ||
| 77 | TEST_KEY_1_NSEC, | ||
| 78 | "--disable-cli-spinners", | ||
| 79 | "init", | ||
| 80 | "--name", | ||
| 81 | "My Project", | ||
| 82 | ]; | ||
| 83 | let mut p = CliTester::new_from_dir(&git_repo.dir, args); | ||
| 84 | p.expect_eventually("logged in as")?; | ||
| 85 | p.expect_eventually("missing --grasp-servers")?; | ||
| 86 | Ok(()) | ||
| 62 | } | 87 | } |
| 63 | 88 | ||
| 64 | async fn prep_run_init() -> Result<( | 89 | #[test] |
| 65 | Relay<'static>, | 90 | #[serial] |
| 66 | Relay<'static>, | 91 | fn relays_only_missing_name_and_servers() -> Result<()> { |
| 67 | Relay<'static>, | 92 | let git_repo = prep_git_repo()?; |
| 68 | Relay<'static>, | 93 | let args = vec![ |
| 69 | Relay<'static>, | 94 | "--nsec", |
| 70 | Relay<'static>, | 95 | TEST_KEY_1_NSEC, |
| 71 | )> { | 96 | "--disable-cli-spinners", |
| 97 | "init", | ||
| 98 | "--relays", | ||
| 99 | "ws://localhost:8055", | ||
| 100 | ]; | ||
| 101 | let mut p = CliTester::new_from_dir(&git_repo.dir, args); | ||
| 102 | p.expect_eventually("logged in as")?; | ||
| 103 | p.expect_eventually("missing required fields")?; | ||
| 104 | p.expect_eventually("--name <NAME>")?; | ||
| 105 | p.expect_eventually("--grasp-servers")?; | ||
| 106 | Ok(()) | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | mod success { | ||
| 111 | use futures::join; | ||
| 112 | use test_utils::relay::Relay; | ||
| 113 | |||
| 114 | use super::*; | ||
| 115 | |||
| 116 | async fn run_init_with_grasp_server( | ||
| 117 | extra_args: Vec<&str>, | ||
| 118 | ) -> Result<(nostr::Event, GitTestRepo)> { | ||
| 72 | let git_repo = prep_git_repo()?; | 119 | let git_repo = prep_git_repo()?; |
| 73 | // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) | 120 | let (mut r51, mut r52, mut r53, mut r55) = ( |
| 74 | let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( | ||
| 75 | Relay::new( | 121 | Relay::new( |
| 76 | 8051, | 122 | 8051, |
| 77 | None, | 123 | None, |
| @@ -90,286 +136,599 @@ mod when_repo_not_previously_claimed { | |||
| 90 | Relay::new(8052, None, None), | 136 | Relay::new(8052, None, None), |
| 91 | Relay::new(8053, None, None), | 137 | Relay::new(8053, None, None), |
| 92 | Relay::new(8055, None, None), | 138 | Relay::new(8055, None, None), |
| 93 | Relay::new(8056, None, None), | ||
| 94 | Relay::new(8057, None, None), | ||
| 95 | ); | 139 | ); |
| 96 | 140 | ||
| 97 | // // check relay had the right number of events | 141 | let cli_tester_handle = std::thread::spawn({ |
| 98 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 142 | let dir = git_repo.dir.clone(); |
| 99 | let mut p = cli_tester_init(&git_repo); | 143 | let extra_args_owned: Vec<String> = |
| 100 | p.expect_end_eventually()?; | 144 | extra_args.iter().map(|s| s.to_string()).collect(); |
| 101 | for p in [51, 52, 53, 55, 56, 57] { | 145 | move || -> Result<()> { |
| 102 | relay::shutdown_relay(8000 + p)?; | 146 | let mut args = |
| 147 | vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; | ||
| 148 | let extra_refs: Vec<&str> = | ||
| 149 | extra_args_owned.iter().map(|s| s.as_str()).collect(); | ||
| 150 | args.extend(extra_refs); | ||
| 151 | let mut p = CliTester::new_from_dir(&dir, args); | ||
| 152 | p.expect_end_eventually()?; | ||
| 153 | for port in [51, 52, 53, 55] { | ||
| 154 | relay::shutdown_relay(8000 + port)?; | ||
| 155 | } | ||
| 156 | Ok(()) | ||
| 103 | } | 157 | } |
| 104 | Ok(()) | ||
| 105 | }); | 158 | }); |
| 106 | 159 | ||
| 107 | // launch relay | ||
| 108 | let _ = join!( | 160 | let _ = join!( |
| 109 | r51.listen_until_close(), | 161 | r51.listen_until_close(), |
| 110 | r52.listen_until_close(), | 162 | r52.listen_until_close(), |
| 111 | r53.listen_until_close(), | 163 | r53.listen_until_close(), |
| 112 | r55.listen_until_close(), | 164 | r55.listen_until_close(), |
| 113 | r56.listen_until_close(), | ||
| 114 | r57.listen_until_close(), | ||
| 115 | ); | 165 | ); |
| 116 | cli_tester_handle.join().unwrap()?; | 166 | cli_tester_handle.join().unwrap()?; |
| 117 | Ok((r51, r52, r53, r55, r56, r57)) | ||
| 118 | } | ||
| 119 | 167 | ||
| 120 | mod sent_to_correct_relays { | 168 | let event = get_announcement(&r53.events).clone(); |
| 169 | Ok((event, git_repo)) | ||
| 170 | } | ||
| 121 | 171 | ||
| 172 | mod with_name_and_grasp_server { | ||
| 122 | use super::*; | 173 | use super::*; |
| 123 | 174 | ||
| 124 | #[derive(Clone)] | 175 | #[fixture] |
| 125 | pub struct SentToCorrectRelaysScenario { | 176 | async fn scenario() -> (nostr::Event, GitTestRepo) { |
| 126 | pub r51_repo_event_count: usize, | 177 | run_init_with_grasp_server(vec![ |
| 127 | pub r52_repo_event_count: usize, | 178 | "--name", |
| 128 | pub r53_repo_event_count: usize, | 179 | "My Project", |
| 129 | pub r55_repo_event_count: usize, | 180 | "--grasp-servers", |
| 130 | pub r56_repo_event_count: usize, | 181 | "ws://localhost:8055", |
| 131 | pub r57_repo_event_count: usize, | 182 | ]) |
| 183 | .await | ||
| 184 | .expect("init failed") | ||
| 132 | } | 185 | } |
| 133 | 186 | ||
| 134 | #[fixture] | 187 | #[rstest] |
| 135 | async fn scenario() -> SentToCorrectRelaysScenario { | 188 | #[tokio::test] |
| 136 | let (r51, r52, r53, r55, r56, r57) = | 189 | #[serial] |
| 137 | prep_run_init().await.expect("prep_run_init failed"); | 190 | async fn identifier_derived_from_name( |
| 191 | #[future] scenario: (nostr::Event, GitTestRepo), | ||
| 192 | ) -> Result<()> { | ||
| 193 | let (event, _) = scenario.await; | ||
| 194 | assert_eq!(get_tag_value(&event, "d"), "My-Project"); | ||
| 195 | Ok(()) | ||
| 196 | } | ||
| 138 | 197 | ||
| 139 | // Extract event counts for verification | 198 | #[rstest] |
| 140 | let r51_repo_event_count = r51 | 199 | #[tokio::test] |
| 141 | .events | 200 | #[serial] |
| 142 | .iter() | 201 | async fn name_tag_matches( |
| 143 | .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) | 202 | #[future] scenario: (nostr::Event, GitTestRepo), |
| 144 | .count(); | 203 | ) -> Result<()> { |
| 145 | let r52_repo_event_count = r52 | 204 | let (event, _) = scenario.await; |
| 146 | .events | 205 | assert_eq!(get_tag_value(&event, "name"), "My Project"); |
| 147 | .iter() | 206 | Ok(()) |
| 148 | .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) | ||
| 149 | .count(); | ||
| 150 | let r53_repo_event_count = r53 | ||
| 151 | .events | ||
| 152 | .iter() | ||
| 153 | .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) | ||
| 154 | .count(); | ||
| 155 | let r55_repo_event_count = r55 | ||
| 156 | .events | ||
| 157 | .iter() | ||
| 158 | .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) | ||
| 159 | .count(); | ||
| 160 | let r56_repo_event_count = r56 | ||
| 161 | .events | ||
| 162 | .iter() | ||
| 163 | .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) | ||
| 164 | .count(); | ||
| 165 | let r57_repo_event_count = r57 | ||
| 166 | .events | ||
| 167 | .iter() | ||
| 168 | .filter(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) | ||
| 169 | .count(); | ||
| 170 | |||
| 171 | SentToCorrectRelaysScenario { | ||
| 172 | r51_repo_event_count, | ||
| 173 | r52_repo_event_count, | ||
| 174 | r53_repo_event_count, | ||
| 175 | r55_repo_event_count, | ||
| 176 | r56_repo_event_count, | ||
| 177 | r57_repo_event_count, | ||
| 178 | } | ||
| 179 | } | 207 | } |
| 180 | 208 | ||
| 181 | #[rstest] | 209 | #[rstest] |
| 182 | #[tokio::test] | 210 | #[tokio::test] |
| 183 | #[serial] | 211 | #[serial] |
| 184 | async fn only_1_repository_kind_event_sent_to_user_relays( | 212 | async fn description_empty( |
| 185 | #[future] scenario: SentToCorrectRelaysScenario, | 213 | #[future] scenario: (nostr::Event, GitTestRepo), |
| 186 | ) -> Result<()> { | 214 | ) -> Result<()> { |
| 187 | let s = scenario.await; | 215 | let (event, _) = scenario.await; |
| 188 | assert_eq!(s.r53_repo_event_count, 1); | 216 | assert_eq!(get_tag_value(&event, "description"), ""); |
| 189 | assert_eq!(s.r55_repo_event_count, 1); | 217 | Ok(()) |
| 218 | } | ||
| 219 | |||
| 220 | #[rstest] | ||
| 221 | #[tokio::test] | ||
| 222 | #[serial] | ||
| 223 | async fn clone_url_derived_from_grasp_server( | ||
| 224 | #[future] scenario: (nostr::Event, GitTestRepo), | ||
| 225 | ) -> Result<()> { | ||
| 226 | let (event, _) = scenario.await; | ||
| 227 | let clone_urls = get_tag_values(&event, "clone"); | ||
| 228 | assert_eq!(clone_urls.len(), 1); | ||
| 229 | assert!( | ||
| 230 | clone_urls[0].starts_with("http://localhost:8055/"), | ||
| 231 | "clone url should start with grasp server: {}", | ||
| 232 | clone_urls[0] | ||
| 233 | ); | ||
| 234 | assert!( | ||
| 235 | clone_urls[0].ends_with("/My-Project.git"), | ||
| 236 | "clone url should end with identifier.git: {}", | ||
| 237 | clone_urls[0] | ||
| 238 | ); | ||
| 239 | assert!( | ||
| 240 | clone_urls[0].contains(TEST_KEY_1_NPUB), | ||
| 241 | "clone url should contain npub: {}", | ||
| 242 | clone_urls[0] | ||
| 243 | ); | ||
| 190 | Ok(()) | 244 | Ok(()) |
| 191 | } | 245 | } |
| 192 | 246 | ||
| 193 | #[rstest] | 247 | #[rstest] |
| 194 | #[tokio::test] | 248 | #[tokio::test] |
| 195 | #[serial] | 249 | #[serial] |
| 196 | async fn only_1_repository_kind_event_sent_to_specified_repo_relays( | 250 | async fn relays_include_grasp_derived( |
| 197 | #[future] scenario: SentToCorrectRelaysScenario, | 251 | #[future] scenario: (nostr::Event, GitTestRepo), |
| 198 | ) -> Result<()> { | 252 | ) -> Result<()> { |
| 199 | let s = scenario.await; | 253 | let (event, _) = scenario.await; |
| 200 | assert_eq!(s.r55_repo_event_count, 1); | 254 | let relays = get_tag_values(&event, "relays"); |
| 201 | assert_eq!(s.r56_repo_event_count, 1); | 255 | assert!( |
| 256 | relays.contains(&"ws://localhost:8055".to_string()), | ||
| 257 | "relays should include grasp-derived relay: {:?}", | ||
| 258 | relays | ||
| 259 | ); | ||
| 202 | Ok(()) | 260 | Ok(()) |
| 203 | } | 261 | } |
| 204 | 262 | ||
| 205 | #[rstest] | 263 | #[rstest] |
| 206 | #[tokio::test] | 264 | #[tokio::test] |
| 207 | #[serial] | 265 | #[serial] |
| 208 | async fn only_1_repository_kind_event_sent_to_fallback_relays( | 266 | async fn maintainers_is_just_me( |
| 209 | #[future] scenario: SentToCorrectRelaysScenario, | 267 | #[future] scenario: (nostr::Event, GitTestRepo), |
| 210 | ) -> Result<()> { | 268 | ) -> Result<()> { |
| 211 | let s = scenario.await; | 269 | let (event, _) = scenario.await; |
| 212 | assert_eq!(s.r51_repo_event_count, 1); | 270 | let maintainers = get_tag_values(&event, "maintainers"); |
| 213 | assert_eq!(s.r52_repo_event_count, 1); | 271 | assert_eq!(maintainers.len(), 1); |
| 272 | assert_eq!(maintainers[0], TEST_KEY_1_KEYS.public_key().to_string()); | ||
| 214 | Ok(()) | 273 | Ok(()) |
| 215 | } | 274 | } |
| 216 | 275 | ||
| 217 | #[rstest] | 276 | #[rstest] |
| 218 | #[tokio::test] | 277 | #[tokio::test] |
| 219 | #[serial] | 278 | #[serial] |
| 220 | async fn only_1_repository_kind_event_sent_to_blaster_relays( | 279 | async fn earliest_unique_commit_is_root( |
| 221 | #[future] scenario: SentToCorrectRelaysScenario, | 280 | #[future] scenario: (nostr::Event, GitTestRepo), |
| 222 | ) -> Result<()> { | 281 | ) -> Result<()> { |
| 223 | let s = scenario.await; | 282 | let (event, _) = scenario.await; |
| 224 | assert_eq!(s.r57_repo_event_count, 1); | 283 | let euc_tag = event |
| 284 | .tags | ||
| 285 | .iter() | ||
| 286 | .find(|t| { | ||
| 287 | t.as_slice()[0] == "r" && t.as_slice().len() > 2 && t.as_slice()[2] == "euc" | ||
| 288 | }) | ||
| 289 | .expect("euc tag not found"); | ||
| 290 | assert_eq!( | ||
| 291 | euc_tag.as_slice()[1], | ||
| 292 | "9ee507fc4357d7ee16a5d8901bedcd103f23c17d" | ||
| 293 | ); | ||
| 225 | Ok(()) | 294 | Ok(()) |
| 226 | } | 295 | } |
| 227 | } | 296 | } |
| 297 | } | ||
| 298 | } | ||
| 228 | 299 | ||
| 229 | mod git_config_updated { | 300 | // --------------------------------------------------------------------------- |
| 301 | // State B: Coordinate exists, no announcement found | ||
| 302 | // --------------------------------------------------------------------------- | ||
| 230 | 303 | ||
| 231 | use nostr::nips::{nip01::Coordinate, nip19::Nip19Coordinate}; | 304 | mod state_b_coordinate_only { |
| 232 | use nostr_sdk::ToBech32; | 305 | use super::*; |
| 233 | 306 | ||
| 234 | use super::*; | 307 | fn prep_git_repo() -> Result<GitTestRepo> { |
| 308 | let test_repo = GitTestRepo::default(); | ||
| 309 | test_repo.populate()?; | ||
| 310 | test_repo.add_remote("origin", "https://localhost:1000")?; | ||
| 311 | Ok(test_repo) | ||
| 312 | } | ||
| 235 | 313 | ||
| 236 | async fn async_run_test() -> Result<()> { | 314 | mod errors { |
| 237 | let git_repo = prep_git_repo()?; | 315 | use futures::join; |
| 238 | // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) | 316 | use test_utils::relay::Relay; |
| 239 | let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( | 317 | |
| 240 | Relay::new( | 318 | use super::*; |
| 241 | 8051, | 319 | |
| 242 | None, | 320 | async fn run_init_expecting_error(extra_args: Vec<&str>) -> Result<String> { |
| 243 | Some(&|relay, client_id, subscription_id, _| -> Result<()> { | 321 | let git_repo = prep_git_repo()?; |
| 244 | relay.respond_events( | 322 | let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( |
| 245 | client_id, | 323 | Relay::new( |
| 246 | &subscription_id, | 324 | 8051, |
| 247 | &vec![ | 325 | None, |
| 248 | generate_test_key_1_metadata_event("fred"), | 326 | Some(&|relay, client_id, subscription_id, _| -> Result<()> { |
| 249 | generate_test_key_1_relay_list_event(), | 327 | relay.respond_events( |
| 250 | ], | 328 | client_id, |
| 251 | )?; | 329 | &subscription_id, |
| 252 | Ok(()) | 330 | &vec![ |
| 253 | }), | 331 | generate_test_key_1_metadata_event("fred"), |
| 254 | ), | 332 | generate_test_key_1_relay_list_event(), |
| 255 | Relay::new(8052, None, None), | 333 | ], |
| 256 | Relay::new(8053, None, None), | 334 | )?; |
| 257 | Relay::new(8055, None, None), | 335 | Ok(()) |
| 258 | Relay::new(8056, None, None), | 336 | }), |
| 259 | Relay::new(8057, None, None), | 337 | ), |
| 260 | ); | 338 | Relay::new(8052, None, None), |
| 339 | Relay::new(8053, None, None), | ||
| 340 | Relay::new(8055, None, None), | ||
| 341 | Relay::new(8056, None, None), | ||
| 342 | ); | ||
| 343 | |||
| 344 | let cli_tester_handle = std::thread::spawn({ | ||
| 345 | let dir = git_repo.dir.clone(); | ||
| 346 | let extra_args_owned: Vec<String> = | ||
| 347 | extra_args.iter().map(|s| s.to_string()).collect(); | ||
| 348 | move || -> Result<String> { | ||
| 349 | let mut args = | ||
| 350 | vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; | ||
| 351 | let extra_refs: Vec<&str> = | ||
| 352 | extra_args_owned.iter().map(|s| s.as_str()).collect(); | ||
| 353 | args.extend(extra_refs); | ||
| 354 | let mut p = CliTester::new_from_dir(&dir, args); | ||
| 355 | let output = p.expect_end_eventually()?; | ||
| 356 | for port in [51, 52, 53, 55, 56] { | ||
| 357 | relay::shutdown_relay(8000 + port)?; | ||
| 358 | } | ||
| 359 | Ok(output) | ||
| 360 | } | ||
| 361 | }); | ||
| 362 | |||
| 363 | let _ = join!( | ||
| 364 | r51.listen_until_close(), | ||
| 365 | r52.listen_until_close(), | ||
| 366 | r53.listen_until_close(), | ||
| 367 | r55.listen_until_close(), | ||
| 368 | r56.listen_until_close(), | ||
| 369 | ); | ||
| 370 | cli_tester_handle.join().unwrap() | ||
| 371 | } | ||
| 261 | 372 | ||
| 262 | // // check relay had the right number of events | 373 | #[tokio::test] |
| 263 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 374 | #[serial] |
| 264 | let mut p = cli_tester_init(&git_repo); | 375 | async fn bare_no_flags() -> Result<()> { |
| 376 | let output = run_init_expecting_error(vec![]).await?; | ||
| 377 | assert!( | ||
| 378 | output.contains("no announcement found for coordinate"), | ||
| 379 | "expected coordinate error, got: {output}" | ||
| 380 | ); | ||
| 381 | Ok(()) | ||
| 382 | } | ||
| 383 | |||
| 384 | #[tokio::test] | ||
| 385 | #[serial] | ||
| 386 | async fn defaults_still_requires_force() -> Result<()> { | ||
| 387 | let output = run_init_expecting_error(vec!["--defaults"]).await?; | ||
| 388 | assert!( | ||
| 389 | output.contains("no announcement found for coordinate"), | ||
| 390 | "expected coordinate error even with -d, got: {output}" | ||
| 391 | ); | ||
| 392 | Ok(()) | ||
| 393 | } | ||
| 394 | } | ||
| 395 | |||
| 396 | mod success { | ||
| 397 | use futures::join; | ||
| 398 | use test_utils::relay::Relay; | ||
| 399 | |||
| 400 | use super::*; | ||
| 401 | |||
| 402 | #[fixture] | ||
| 403 | async fn state_b_force() -> nostr::Event { | ||
| 404 | let git_repo = prep_git_repo().expect("prep failed"); | ||
| 405 | let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( | ||
| 406 | Relay::new( | ||
| 407 | 8051, | ||
| 408 | None, | ||
| 409 | Some(&|relay, client_id, subscription_id, _| -> Result<()> { | ||
| 410 | relay.respond_events( | ||
| 411 | client_id, | ||
| 412 | &subscription_id, | ||
| 413 | &vec![ | ||
| 414 | generate_test_key_1_metadata_event("fred"), | ||
| 415 | generate_test_key_1_relay_list_event(), | ||
| 416 | ], | ||
| 417 | )?; | ||
| 418 | Ok(()) | ||
| 419 | }), | ||
| 420 | ), | ||
| 421 | Relay::new(8052, None, None), | ||
| 422 | Relay::new(8053, None, None), | ||
| 423 | Relay::new(8055, None, None), | ||
| 424 | Relay::new(8056, None, None), | ||
| 425 | ); | ||
| 426 | |||
| 427 | let cli_tester_handle = std::thread::spawn({ | ||
| 428 | let dir = git_repo.dir.clone(); | ||
| 429 | move || -> Result<()> { | ||
| 430 | let args = vec![ | ||
| 431 | "--nsec", | ||
| 432 | TEST_KEY_1_NSEC, | ||
| 433 | "--disable-cli-spinners", | ||
| 434 | "init", | ||
| 435 | "--force", | ||
| 436 | "--grasp-servers", | ||
| 437 | "ws://localhost:8055", | ||
| 438 | ]; | ||
| 439 | let mut p = CliTester::new_from_dir(&dir, args); | ||
| 265 | p.expect_end_eventually()?; | 440 | p.expect_end_eventually()?; |
| 266 | for p in [51, 52, 53, 55, 56, 57] { | 441 | for port in [51, 52, 53, 55, 56] { |
| 267 | relay::shutdown_relay(8000 + p)?; | 442 | relay::shutdown_relay(8000 + port)?; |
| 268 | } | 443 | } |
| 269 | assert_eq!( | 444 | Ok(()) |
| 270 | git_repo | 445 | } |
| 271 | .git_repo | 446 | }); |
| 272 | .config()? | ||
| 273 | .get_entry("nostr.repo")? | ||
| 274 | .value() | ||
| 275 | .unwrap(), | ||
| 276 | Nip19Coordinate { | ||
| 277 | coordinate: Coordinate { | ||
| 278 | kind: nostr_sdk::Kind::GitRepoAnnouncement, | ||
| 279 | identifier: "example-identifier".to_string(), | ||
| 280 | public_key: TEST_KEY_1_KEYS.public_key(), | ||
| 281 | }, | ||
| 282 | relays: vec![], | ||
| 283 | } | ||
| 284 | .to_bech32()?, | ||
| 285 | ); | ||
| 286 | 447 | ||
| 448 | let _ = join!( | ||
| 449 | r51.listen_until_close(), | ||
| 450 | r52.listen_until_close(), | ||
| 451 | r53.listen_until_close(), | ||
| 452 | r55.listen_until_close(), | ||
| 453 | r56.listen_until_close(), | ||
| 454 | ); | ||
| 455 | cli_tester_handle.join().unwrap().expect("cli failed"); | ||
| 456 | |||
| 457 | get_announcement(&r53.events).clone() | ||
| 458 | } | ||
| 459 | |||
| 460 | #[rstest] | ||
| 461 | #[tokio::test] | ||
| 462 | #[serial] | ||
| 463 | async fn identifier_from_coordinate(#[future] state_b_force: nostr::Event) -> Result<()> { | ||
| 464 | let event = state_b_force.await; | ||
| 465 | assert_eq!( | ||
| 466 | get_tag_value(&event, "d"), | ||
| 467 | "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random" | ||
| 468 | ); | ||
| 469 | Ok(()) | ||
| 470 | } | ||
| 471 | |||
| 472 | #[rstest] | ||
| 473 | #[tokio::test] | ||
| 474 | #[serial] | ||
| 475 | async fn name_defaults_to_identifier(#[future] state_b_force: nostr::Event) -> Result<()> { | ||
| 476 | let event = state_b_force.await; | ||
| 477 | assert_eq!( | ||
| 478 | get_tag_value(&event, "name"), | ||
| 479 | "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random" | ||
| 480 | ); | ||
| 481 | Ok(()) | ||
| 482 | } | ||
| 483 | |||
| 484 | #[rstest] | ||
| 485 | #[tokio::test] | ||
| 486 | #[serial] | ||
| 487 | async fn clone_url_from_grasp_server(#[future] state_b_force: nostr::Event) -> Result<()> { | ||
| 488 | let event = state_b_force.await; | ||
| 489 | let clone_urls = get_tag_values(&event, "clone"); | ||
| 490 | assert!( | ||
| 491 | clone_urls | ||
| 492 | .iter() | ||
| 493 | .any(|u| u.starts_with("http://localhost:8055/")), | ||
| 494 | "expected grasp-derived clone url, got: {:?}", | ||
| 495 | clone_urls | ||
| 496 | ); | ||
| 497 | Ok(()) | ||
| 498 | } | ||
| 499 | } | ||
| 500 | } | ||
| 501 | |||
| 502 | // --------------------------------------------------------------------------- | ||
| 503 | // State C: Existing announcement, it's mine | ||
| 504 | // --------------------------------------------------------------------------- | ||
| 505 | |||
| 506 | mod state_c_my_announcement { | ||
| 507 | use futures::join; | ||
| 508 | use test_utils::relay::Relay; | ||
| 509 | |||
| 510 | use super::*; | ||
| 511 | |||
| 512 | fn prep_git_repo() -> Result<GitTestRepo> { | ||
| 513 | let test_repo = GitTestRepo::default(); | ||
| 514 | test_repo.populate()?; | ||
| 515 | test_repo.add_remote("origin", "https://localhost:1000")?; | ||
| 516 | Ok(test_repo) | ||
| 517 | } | ||
| 518 | |||
| 519 | async fn run_init(extra_args: Vec<&str>) -> Result<nostr::Event> { | ||
| 520 | let git_repo = prep_git_repo()?; | ||
| 521 | let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( | ||
| 522 | Relay::new( | ||
| 523 | 8051, | ||
| 524 | None, | ||
| 525 | Some(&|relay, client_id, subscription_id, _| -> Result<()> { | ||
| 526 | relay.respond_events( | ||
| 527 | client_id, | ||
| 528 | &subscription_id, | ||
| 529 | &vec![ | ||
| 530 | generate_test_key_1_metadata_event("fred"), | ||
| 531 | generate_test_key_1_relay_list_event(), | ||
| 532 | generate_repo_ref_event(), | ||
| 533 | ], | ||
| 534 | )?; | ||
| 287 | Ok(()) | 535 | Ok(()) |
| 288 | }); | 536 | }), |
| 289 | 537 | ), | |
| 290 | // launch relay | 538 | Relay::new(8052, None, None), |
| 291 | let _ = join!( | 539 | Relay::new(8053, None, None), |
| 292 | r51.listen_until_close(), | 540 | Relay::new(8055, None, None), |
| 293 | r52.listen_until_close(), | 541 | Relay::new(8056, None, None), |
| 294 | r53.listen_until_close(), | 542 | ); |
| 295 | r55.listen_until_close(), | ||
| 296 | r56.listen_until_close(), | ||
| 297 | r57.listen_until_close(), | ||
| 298 | ); | ||
| 299 | cli_tester_handle.join().unwrap()?; | ||
| 300 | Ok(()) | ||
| 301 | } | ||
| 302 | 543 | ||
| 303 | #[tokio::test] | 544 | let cli_tester_handle = std::thread::spawn({ |
| 304 | #[serial] | 545 | let dir = git_repo.dir.clone(); |
| 305 | async fn with_nostr_repo_set_to_user_and_identifer_naddr() -> Result<()> { | 546 | let extra_args_owned: Vec<String> = extra_args.iter().map(|s| s.to_string()).collect(); |
| 306 | async_run_test().await?; | 547 | move || -> Result<()> { |
| 548 | let mut args = vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; | ||
| 549 | let extra_refs: Vec<&str> = extra_args_owned.iter().map(|s| s.as_str()).collect(); | ||
| 550 | args.extend(extra_refs); | ||
| 551 | let mut p = CliTester::new_from_dir(&dir, args); | ||
| 552 | p.expect_end_eventually()?; | ||
| 553 | for port in [51, 52, 53, 55, 56] { | ||
| 554 | relay::shutdown_relay(8000 + port)?; | ||
| 555 | } | ||
| 307 | Ok(()) | 556 | Ok(()) |
| 308 | } | 557 | } |
| 558 | }); | ||
| 559 | |||
| 560 | let _ = join!( | ||
| 561 | r51.listen_until_close(), | ||
| 562 | r52.listen_until_close(), | ||
| 563 | r53.listen_until_close(), | ||
| 564 | r55.listen_until_close(), | ||
| 565 | r56.listen_until_close(), | ||
| 566 | ); | ||
| 567 | cli_tester_handle.join().unwrap()?; | ||
| 568 | |||
| 569 | Ok(get_announcement(&r53.events).clone()) | ||
| 570 | } | ||
| 571 | |||
| 572 | mod errors { | ||
| 573 | use super::*; | ||
| 574 | |||
| 575 | #[tokio::test] | ||
| 576 | #[serial] | ||
| 577 | async fn identifier_change_requires_force() -> Result<()> { | ||
| 578 | let git_repo = prep_git_repo()?; | ||
| 579 | let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( | ||
| 580 | Relay::new( | ||
| 581 | 8051, | ||
| 582 | None, | ||
| 583 | Some(&|relay, client_id, subscription_id, _| -> Result<()> { | ||
| 584 | relay.respond_events( | ||
| 585 | client_id, | ||
| 586 | &subscription_id, | ||
| 587 | &vec![ | ||
| 588 | generate_test_key_1_metadata_event("fred"), | ||
| 589 | generate_test_key_1_relay_list_event(), | ||
| 590 | generate_repo_ref_event(), | ||
| 591 | ], | ||
| 592 | )?; | ||
| 593 | Ok(()) | ||
| 594 | }), | ||
| 595 | ), | ||
| 596 | Relay::new(8052, None, None), | ||
| 597 | Relay::new(8053, None, None), | ||
| 598 | Relay::new(8055, None, None), | ||
| 599 | Relay::new(8056, None, None), | ||
| 600 | ); | ||
| 601 | |||
| 602 | let cli_tester_handle = std::thread::spawn({ | ||
| 603 | let dir = git_repo.dir.clone(); | ||
| 604 | move || -> Result<String> { | ||
| 605 | let args = vec![ | ||
| 606 | "--nsec", | ||
| 607 | TEST_KEY_1_NSEC, | ||
| 608 | "--disable-cli-spinners", | ||
| 609 | "init", | ||
| 610 | "--identifier", | ||
| 611 | "new-id", | ||
| 612 | ]; | ||
| 613 | let mut p = CliTester::new_from_dir(&dir, args); | ||
| 614 | let output = p.expect_end_eventually()?; | ||
| 615 | for port in [51, 52, 53, 55, 56] { | ||
| 616 | relay::shutdown_relay(8000 + port)?; | ||
| 617 | } | ||
| 618 | Ok(output) | ||
| 619 | } | ||
| 620 | }); | ||
| 621 | |||
| 622 | let _ = join!( | ||
| 623 | r51.listen_until_close(), | ||
| 624 | r52.listen_until_close(), | ||
| 625 | r53.listen_until_close(), | ||
| 626 | r55.listen_until_close(), | ||
| 627 | r56.listen_until_close(), | ||
| 628 | ); | ||
| 629 | let output = cli_tester_handle.join().unwrap()?; | ||
| 630 | assert!( | ||
| 631 | output.contains("changing identifier creates a new repository"), | ||
| 632 | "expected identifier change error, got: {output}" | ||
| 633 | ); | ||
| 634 | Ok(()) | ||
| 309 | } | 635 | } |
| 310 | 636 | ||
| 311 | mod tags_as_specified_in_args { | 637 | #[tokio::test] |
| 312 | use super::*; | 638 | #[serial] |
| 639 | async fn bare_no_flags_requires_force() -> Result<()> { | ||
| 640 | let git_repo = prep_git_repo()?; | ||
| 641 | let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( | ||
| 642 | Relay::new( | ||
| 643 | 8051, | ||
| 644 | None, | ||
| 645 | Some(&|relay, client_id, subscription_id, _| -> Result<()> { | ||
| 646 | relay.respond_events( | ||
| 647 | client_id, | ||
| 648 | &subscription_id, | ||
| 649 | &vec![ | ||
| 650 | generate_test_key_1_metadata_event("fred"), | ||
| 651 | generate_test_key_1_relay_list_event(), | ||
| 652 | generate_repo_ref_event(), | ||
| 653 | ], | ||
| 654 | )?; | ||
| 655 | Ok(()) | ||
| 656 | }), | ||
| 657 | ), | ||
| 658 | Relay::new(8052, None, None), | ||
| 659 | Relay::new(8053, None, None), | ||
| 660 | Relay::new(8055, None, None), | ||
| 661 | Relay::new(8056, None, None), | ||
| 662 | ); | ||
| 313 | 663 | ||
| 314 | #[derive(Clone)] | 664 | let cli_tester_handle = std::thread::spawn({ |
| 315 | pub struct TagsAsSpecifiedScenario { | 665 | let dir = git_repo.dir.clone(); |
| 316 | pub event: nostr::Event, | 666 | move || -> Result<String> { |
| 317 | } | 667 | let args = vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; |
| 668 | let mut p = CliTester::new_from_dir(&dir, args); | ||
| 669 | let output = p.expect_end_eventually()?; | ||
| 670 | for port in [51, 52, 53, 55, 56] { | ||
| 671 | relay::shutdown_relay(8000 + port)?; | ||
| 672 | } | ||
| 673 | Ok(output) | ||
| 674 | } | ||
| 675 | }); | ||
| 318 | 676 | ||
| 319 | #[fixture] | 677 | let _ = join!( |
| 320 | async fn scenario() -> TagsAsSpecifiedScenario { | 678 | r51.listen_until_close(), |
| 321 | let (_, _, r53, _r55, _r56, _r57) = | 679 | r52.listen_until_close(), |
| 322 | prep_run_init().await.expect("prep_run_init failed"); | 680 | r53.listen_until_close(), |
| 681 | r55.listen_until_close(), | ||
| 682 | r56.listen_until_close(), | ||
| 683 | ); | ||
| 684 | let output = cli_tester_handle.join().unwrap()?; | ||
| 685 | assert!( | ||
| 686 | output.contains("no arguments specified"), | ||
| 687 | "expected 'no arguments specified' error, got: {output}" | ||
| 688 | ); | ||
| 689 | Ok(()) | ||
| 690 | } | ||
| 691 | } | ||
| 323 | 692 | ||
| 324 | // Extract the GitRepoAnnouncement event (should be same on all relays) | 693 | mod success { |
| 325 | let event = r53 | 694 | use super::*; |
| 326 | .events | 695 | |
| 327 | .iter() | 696 | mod force_refresh { |
| 328 | .find(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) | 697 | use super::*; |
| 329 | .expect("GitRepoAnnouncement event not found") | ||
| 330 | .clone(); | ||
| 331 | 698 | ||
| 332 | TagsAsSpecifiedScenario { event } | 699 | #[fixture] |
| 700 | async fn scenario() -> nostr::Event { | ||
| 701 | run_init(vec!["--force"]).await.expect("init failed") | ||
| 333 | } | 702 | } |
| 334 | 703 | ||
| 335 | #[rstest] | 704 | #[rstest] |
| 336 | #[tokio::test] | 705 | #[tokio::test] |
| 337 | #[serial] | 706 | #[serial] |
| 338 | async fn d_replaceable_event_identifier( | 707 | async fn name_preserved(#[future] scenario: nostr::Event) -> Result<()> { |
| 339 | #[future] scenario: TagsAsSpecifiedScenario, | 708 | let event = scenario.await; |
| 340 | ) -> Result<()> { | 709 | assert_eq!(get_tag_value(&event, "name"), "example name"); |
| 341 | let s = scenario.await; | ||
| 342 | assert!( | ||
| 343 | s.event.tags.iter().any( | ||
| 344 | |t| t.as_slice()[0].eq("d") && t.as_slice()[1].eq("example-identifier") | ||
| 345 | ) | ||
| 346 | ); | ||
| 347 | Ok(()) | 710 | Ok(()) |
| 348 | } | 711 | } |
| 349 | 712 | ||
| 350 | #[rstest] | 713 | #[rstest] |
| 351 | #[tokio::test] | 714 | #[tokio::test] |
| 352 | #[serial] | 715 | #[serial] |
| 353 | async fn earliest_unique_commit_as_reference_with_euc_marker( | 716 | async fn description_preserved(#[future] scenario: nostr::Event) -> Result<()> { |
| 354 | #[future] scenario: TagsAsSpecifiedScenario, | 717 | let event = scenario.await; |
| 355 | ) -> Result<()> { | 718 | assert_eq!(get_tag_value(&event, "description"), "example description"); |
| 356 | let s = scenario.await; | ||
| 357 | assert!(s.event.tags.iter().any(|t| t.as_slice()[0].eq("r") | ||
| 358 | && t.as_slice()[1].eq("9ee507fc4357d7ee16a5d8901bedcd103f23c17d") | ||
| 359 | && t.as_slice()[2].eq("euc"))); | ||
| 360 | Ok(()) | 719 | Ok(()) |
| 361 | } | 720 | } |
| 362 | 721 | ||
| 363 | #[rstest] | 722 | #[rstest] |
| 364 | #[tokio::test] | 723 | #[tokio::test] |
| 365 | #[serial] | 724 | #[serial] |
| 366 | async fn name(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { | 725 | async fn relays_from_my_event(#[future] scenario: nostr::Event) -> Result<()> { |
| 367 | let s = scenario.await; | 726 | let event = scenario.await; |
| 727 | let relays = get_tag_values(&event, "relays"); | ||
| 368 | assert!( | 728 | assert!( |
| 369 | s.event | 729 | relays.contains(&"ws://localhost:8055".to_string()), |
| 370 | .tags | 730 | "relays should include my existing relay: {:?}", |
| 371 | .iter() | 731 | relays |
| 372 | .any(|t| t.as_slice()[0].eq("name") && t.as_slice()[1].eq("example-name")) | ||
| 373 | ); | 732 | ); |
| 374 | Ok(()) | 733 | Ok(()) |
| 375 | } | 734 | } |
| @@ -377,160 +736,472 @@ mod when_repo_not_previously_claimed { | |||
| 377 | #[rstest] | 736 | #[rstest] |
| 378 | #[tokio::test] | 737 | #[tokio::test] |
| 379 | #[serial] | 738 | #[serial] |
| 380 | async fn alt(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { | 739 | async fn maintainers_preserved(#[future] scenario: nostr::Event) -> Result<()> { |
| 381 | let s = scenario.await; | 740 | let event = scenario.await; |
| 382 | assert!(s.event.tags.iter().any(|t| t.as_slice()[0].eq("alt") | 741 | let maintainers = get_tag_values(&event, "maintainers"); |
| 383 | && t.as_slice()[1].eq("git repository: example-name"))); | ||
| 384 | Ok(()) | ||
| 385 | } | ||
| 386 | |||
| 387 | #[rstest] | ||
| 388 | #[tokio::test] | ||
| 389 | #[serial] | ||
| 390 | async fn description(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { | ||
| 391 | let s = scenario.await; | ||
| 392 | assert!( | 742 | assert!( |
| 393 | s.event | 743 | maintainers.contains(&TEST_KEY_1_KEYS.public_key().to_string()), |
| 394 | .tags | 744 | "maintainers should include KEY_1: {:?}", |
| 395 | .iter() | 745 | maintainers |
| 396 | .any(|t| t.as_slice()[0].eq("description") | ||
| 397 | && t.as_slice()[1].eq("example-description")) | ||
| 398 | ); | 746 | ); |
| 399 | Ok(()) | ||
| 400 | } | ||
| 401 | |||
| 402 | #[rstest] | ||
| 403 | #[tokio::test] | ||
| 404 | #[serial] | ||
| 405 | async fn git_server(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { | ||
| 406 | let s = scenario.await; | ||
| 407 | assert!( | 747 | assert!( |
| 408 | s.event.tags.iter().any(|t| t.as_slice()[0].eq("clone") | 748 | maintainers.contains(&TEST_KEY_2_KEYS.public_key().to_string()), |
| 409 | && t.as_slice()[1].eq("https://git.myhosting.com/my-repo.git")) /* todo check it defaults to origin */ | 749 | "maintainers should include KEY_2: {:?}", |
| 750 | maintainers | ||
| 410 | ); | 751 | ); |
| 411 | Ok(()) | 752 | Ok(()) |
| 412 | } | 753 | } |
| 754 | } | ||
| 413 | 755 | ||
| 414 | #[rstest] | 756 | mod name_override { |
| 415 | #[tokio::test] | 757 | use super::*; |
| 416 | #[serial] | 758 | |
| 417 | async fn relays(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { | 759 | #[fixture] |
| 418 | let s = scenario.await; | 760 | async fn scenario() -> nostr::Event { |
| 419 | let relays_tag = s | 761 | run_init(vec!["--name", "New Name"]) |
| 420 | .event | 762 | .await |
| 421 | .tags | 763 | .expect("init failed") |
| 422 | .iter() | ||
| 423 | .find(|t| t.as_slice()[0].eq("relays")) | ||
| 424 | .unwrap() | ||
| 425 | .as_slice(); | ||
| 426 | assert_eq!(relays_tag[1], "ws://localhost:8055",); | ||
| 427 | assert_eq!(relays_tag[2], "ws://localhost:8056",); | ||
| 428 | Ok(()) | ||
| 429 | } | 764 | } |
| 430 | 765 | ||
| 431 | #[rstest] | 766 | #[rstest] |
| 432 | #[tokio::test] | 767 | #[tokio::test] |
| 433 | #[serial] | 768 | #[serial] |
| 434 | async fn web(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { | 769 | async fn name_overridden(#[future] scenario: nostr::Event) -> Result<()> { |
| 435 | let s = scenario.await; | 770 | let event = scenario.await; |
| 436 | let web_tag = s | 771 | assert_eq!(get_tag_value(&event, "name"), "New Name"); |
| 437 | .event | ||
| 438 | .tags | ||
| 439 | .iter() | ||
| 440 | .find(|t| t.as_slice()[0].eq("web")) | ||
| 441 | .unwrap() | ||
| 442 | .as_slice(); | ||
| 443 | assert_eq!(web_tag[1], "https://exampleproject.xyz",); | ||
| 444 | assert_eq!(web_tag[2], "https://gitworkshop.dev/123",); | ||
| 445 | Ok(()) | 772 | Ok(()) |
| 446 | } | 773 | } |
| 447 | 774 | ||
| 448 | #[rstest] | 775 | #[rstest] |
| 449 | #[tokio::test] | 776 | #[tokio::test] |
| 450 | #[serial] | 777 | #[serial] |
| 451 | async fn maintainers(#[future] scenario: TagsAsSpecifiedScenario) -> Result<()> { | 778 | async fn identifier_unchanged(#[future] scenario: nostr::Event) -> Result<()> { |
| 452 | let s = scenario.await; | 779 | let event = scenario.await; |
| 453 | let maintainers_tag = s | 780 | assert_eq!( |
| 454 | .event | 781 | get_tag_value(&event, "d"), |
| 455 | .tags | 782 | "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random" |
| 456 | .iter() | 783 | ); |
| 457 | .find(|t| t.as_slice()[0].eq("maintainers")) | ||
| 458 | .unwrap() | ||
| 459 | .as_slice(); | ||
| 460 | assert_eq!(maintainers_tag[1], TEST_KEY_1_KEYS.public_key().to_string()); | ||
| 461 | Ok(()) | 784 | Ok(()) |
| 462 | } | 785 | } |
| 463 | } | 786 | } |
| 787 | } | ||
| 788 | } | ||
| 464 | 789 | ||
| 465 | mod cli_ouput { | 790 | // --------------------------------------------------------------------------- |
| 466 | use super::*; | 791 | // State D: Existing announcement, not mine, I'm listed as maintainer |
| 792 | // --------------------------------------------------------------------------- | ||
| 467 | 793 | ||
| 468 | #[tokio::test] | 794 | mod state_d_co_maintainer { |
| 469 | #[serial] | 795 | use futures::join; |
| 470 | async fn check_cli_output() -> Result<()> { | 796 | use test_utils::relay::Relay; |
| 471 | let git_repo = prep_git_repo()?; | 797 | |
| 472 | 798 | use super::*; | |
| 473 | // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) | 799 | |
| 474 | let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( | 800 | fn prep_git_repo() -> Result<GitTestRepo> { |
| 475 | Relay::new( | 801 | let test_repo = GitTestRepo::without_repo_in_git_config(); |
| 476 | 8051, | 802 | test_repo.populate()?; |
| 477 | None, | 803 | test_repo.add_remote("origin", "https://localhost:1000")?; |
| 478 | Some(&|relay, client_id, subscription_id, _| -> Result<()> { | 804 | test_repo.set_nostr_repo_coordinate( |
| 479 | relay.respond_events( | 805 | &TEST_KEY_2_KEYS.public_key(), |
| 480 | client_id, | 806 | "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random", |
| 481 | &subscription_id, | 807 | &["ws://localhost:8055", "ws://localhost:8056"], |
| 482 | &vec![ | 808 | ); |
| 483 | generate_test_key_1_metadata_event("fred"), | 809 | Ok(test_repo) |
| 484 | generate_test_key_1_relay_list_event(), | 810 | } |
| 485 | ], | 811 | |
| 486 | )?; | 812 | mod success { |
| 487 | Ok(()) | 813 | use super::*; |
| 488 | }), | 814 | |
| 489 | ), | 815 | #[fixture] |
| 490 | Relay::new(8052, None, None), | 816 | async fn scenario() -> nostr::Event { |
| 491 | Relay::new(8053, None, None), | 817 | let git_repo = prep_git_repo().expect("prep failed"); |
| 492 | Relay::new(8055, None, None), | 818 | let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( |
| 493 | Relay::new(8056, None, None), | 819 | Relay::new( |
| 494 | Relay::new(8057, None, None), | 820 | 8051, |
| 495 | ); | 821 | None, |
| 822 | Some(&|relay, client_id, subscription_id, _| -> Result<()> { | ||
| 823 | relay.respond_events( | ||
| 824 | client_id, | ||
| 825 | &subscription_id, | ||
| 826 | &vec![ | ||
| 827 | generate_test_key_1_metadata_event("fred"), | ||
| 828 | generate_test_key_1_relay_list_event(), | ||
| 829 | generate_test_key_2_metadata_event("carole"), | ||
| 830 | generate_test_key_2_relay_list_event(), | ||
| 831 | generate_repo_ref_event_as_key_2_listing_key_1(), | ||
| 832 | ], | ||
| 833 | )?; | ||
| 834 | Ok(()) | ||
| 835 | }), | ||
| 836 | ), | ||
| 837 | Relay::new(8052, None, None), | ||
| 838 | Relay::new(8053, None, None), | ||
| 839 | Relay::new(8055, None, None), | ||
| 840 | Relay::new(8056, None, None), | ||
| 841 | ); | ||
| 496 | 842 | ||
| 497 | // // check relay had the right number of events | 843 | let cli_tester_handle = std::thread::spawn({ |
| 498 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 844 | let dir = git_repo.dir.clone(); |
| 499 | let mut p = cli_tester_init(&git_repo); | 845 | move || -> Result<()> { |
| 500 | expect_msgs_first(&mut p)?; | 846 | let args = vec![ |
| 501 | relay::expect_send_with_progress( | 847 | "--nsec", |
| 502 | &mut p, | 848 | TEST_KEY_1_NSEC, |
| 503 | vec![ | 849 | "--disable-cli-spinners", |
| 504 | (" [my-relay] [repo-relay] ws://localhost:8055", true, ""), | 850 | "init", |
| 505 | (" [my-relay] ws://localhost:8053", true, ""), | 851 | "--grasp-servers", |
| 506 | (" [repo-relay] ws://localhost:8056", true, ""), | 852 | "ws://localhost:8055", |
| 507 | (" [default] ws://localhost:8051", true, ""), | 853 | ]; |
| 508 | (" [default] ws://localhost:8052", true, ""), | 854 | let mut p = CliTester::new_from_dir(&dir, args); |
| 509 | (" [default] ws://localhost:8057", true, ""), | 855 | p.expect_end_eventually()?; |
| 856 | for port in [51, 52, 53, 55, 56] { | ||
| 857 | relay::shutdown_relay(8000 + port)?; | ||
| 858 | } | ||
| 859 | Ok(()) | ||
| 860 | } | ||
| 861 | }); | ||
| 862 | |||
| 863 | let _ = join!( | ||
| 864 | r51.listen_until_close(), | ||
| 865 | r52.listen_until_close(), | ||
| 866 | r53.listen_until_close(), | ||
| 867 | r55.listen_until_close(), | ||
| 868 | r56.listen_until_close(), | ||
| 869 | ); | ||
| 870 | cli_tester_handle.join().unwrap().expect("cli failed"); | ||
| 871 | |||
| 872 | get_announcement(&r53.events).clone() | ||
| 873 | } | ||
| 874 | |||
| 875 | #[rstest] | ||
| 876 | #[tokio::test] | ||
| 877 | #[serial] | ||
| 878 | async fn name_inherited_from_other_maintainer( | ||
| 879 | #[future] scenario: nostr::Event, | ||
| 880 | ) -> Result<()> { | ||
| 881 | let event = scenario.await; | ||
| 882 | assert_eq!(get_tag_value(&event, "name"), "example name"); | ||
| 883 | Ok(()) | ||
| 884 | } | ||
| 885 | |||
| 886 | #[rstest] | ||
| 887 | #[tokio::test] | ||
| 888 | #[serial] | ||
| 889 | async fn description_inherited_from_other_maintainer( | ||
| 890 | #[future] scenario: nostr::Event, | ||
| 891 | ) -> Result<()> { | ||
| 892 | let event = scenario.await; | ||
| 893 | assert_eq!(get_tag_value(&event, "description"), "example description"); | ||
| 894 | Ok(()) | ||
| 895 | } | ||
| 896 | |||
| 897 | #[rstest] | ||
| 898 | #[tokio::test] | ||
| 899 | #[serial] | ||
| 900 | async fn web_inherited_from_other_maintainer( | ||
| 901 | #[future] scenario: nostr::Event, | ||
| 902 | ) -> Result<()> { | ||
| 903 | let event = scenario.await; | ||
| 904 | let web = get_tag_values(&event, "web"); | ||
| 905 | assert!( | ||
| 906 | web.iter().any(|w| w.contains("exampleproject.xyz")), | ||
| 907 | "web should be inherited from KEY_2's announcement: {:?}", | ||
| 908 | web | ||
| 909 | ); | ||
| 910 | Ok(()) | ||
| 911 | } | ||
| 912 | |||
| 913 | #[rstest] | ||
| 914 | #[tokio::test] | ||
| 915 | #[serial] | ||
| 916 | async fn clone_url_from_my_grasp_server_not_theirs( | ||
| 917 | #[future] scenario: nostr::Event, | ||
| 918 | ) -> Result<()> { | ||
| 919 | let event = scenario.await; | ||
| 920 | let clone_urls = get_tag_values(&event, "clone"); | ||
| 921 | assert!( | ||
| 922 | clone_urls | ||
| 923 | .iter() | ||
| 924 | .any(|u| u.starts_with("http://localhost:8055/")), | ||
| 925 | "clone url should be from my grasp server: {:?}", | ||
| 926 | clone_urls | ||
| 927 | ); | ||
| 928 | assert!( | ||
| 929 | !clone_urls.iter().any(|u| u.contains("123.gitexample.com")), | ||
| 930 | "clone url should NOT contain KEY_2's git server: {:?}", | ||
| 931 | clone_urls | ||
| 932 | ); | ||
| 933 | Ok(()) | ||
| 934 | } | ||
| 935 | |||
| 936 | #[rstest] | ||
| 937 | #[tokio::test] | ||
| 938 | #[serial] | ||
| 939 | async fn relays_from_my_grasp_server(#[future] scenario: nostr::Event) -> Result<()> { | ||
| 940 | let event = scenario.await; | ||
| 941 | let relays = get_tag_values(&event, "relays"); | ||
| 942 | assert!( | ||
| 943 | relays.contains(&"ws://localhost:8055".to_string()), | ||
| 944 | "relays should include my grasp-derived relay: {:?}", | ||
| 945 | relays | ||
| 946 | ); | ||
| 947 | Ok(()) | ||
| 948 | } | ||
| 949 | |||
| 950 | #[rstest] | ||
| 951 | #[tokio::test] | ||
| 952 | #[serial] | ||
| 953 | async fn maintainers_is_me_and_trusted(#[future] scenario: nostr::Event) -> Result<()> { | ||
| 954 | let event = scenario.await; | ||
| 955 | let maintainers = get_tag_values(&event, "maintainers"); | ||
| 956 | assert_eq!( | ||
| 957 | maintainers.len(), | ||
| 958 | 2, | ||
| 959 | "should have exactly 2 maintainers: {:?}", | ||
| 960 | maintainers | ||
| 961 | ); | ||
| 962 | assert!( | ||
| 963 | maintainers.contains(&TEST_KEY_1_KEYS.public_key().to_string()), | ||
| 964 | "maintainers should include KEY_1 (me): {:?}", | ||
| 965 | maintainers | ||
| 966 | ); | ||
| 967 | assert!( | ||
| 968 | maintainers.contains(&TEST_KEY_2_KEYS.public_key().to_string()), | ||
| 969 | "maintainers should include KEY_2 (trusted): {:?}", | ||
| 970 | maintainers | ||
| 971 | ); | ||
| 972 | Ok(()) | ||
| 973 | } | ||
| 974 | } | ||
| 975 | } | ||
| 976 | |||
| 977 | // --------------------------------------------------------------------------- | ||
| 978 | // State E: Existing announcement, not mine, I'm NOT listed as maintainer | ||
| 979 | // --------------------------------------------------------------------------- | ||
| 980 | |||
| 981 | mod state_e_not_listed { | ||
| 982 | use futures::join; | ||
| 983 | use test_utils::relay::Relay; | ||
| 984 | |||
| 985 | use super::*; | ||
| 986 | |||
| 987 | fn prep_git_repo() -> Result<GitTestRepo> { | ||
| 988 | let test_repo = GitTestRepo::without_repo_in_git_config(); | ||
| 989 | test_repo.populate()?; | ||
| 990 | test_repo.add_remote("origin", "https://localhost:1000")?; | ||
| 991 | // Point coordinate to KEY_2 (not the logged-in user) | ||
| 992 | test_repo.set_nostr_repo_coordinate( | ||
| 993 | &TEST_KEY_2_KEYS.public_key(), | ||
| 994 | "9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random", | ||
| 995 | &["ws://localhost:8055", "ws://localhost:8056"], | ||
| 996 | ); | ||
| 997 | Ok(test_repo) | ||
| 998 | } | ||
| 999 | |||
| 1000 | /// Run init with relays that serve KEY_2's announcement NOT listing KEY_1. | ||
| 1001 | async fn run_init_expecting_error(extra_args: Vec<&str>) -> Result<String> { | ||
| 1002 | let git_repo = prep_git_repo()?; | ||
| 1003 | let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( | ||
| 1004 | Relay::new( | ||
| 1005 | 8051, | ||
| 1006 | None, | ||
| 1007 | Some(&|relay, client_id, subscription_id, _| -> Result<()> { | ||
| 1008 | relay.respond_events( | ||
| 1009 | client_id, | ||
| 1010 | &subscription_id, | ||
| 1011 | &vec![ | ||
| 1012 | generate_test_key_1_metadata_event("fred"), | ||
| 1013 | generate_test_key_1_relay_list_event(), | ||
| 1014 | generate_test_key_2_metadata_event("carole"), | ||
| 1015 | generate_test_key_2_relay_list_event(), | ||
| 1016 | generate_repo_ref_event_as_key_2_not_listing_key_1(), | ||
| 510 | ], | 1017 | ], |
| 511 | 1, | ||
| 512 | )?; | 1018 | )?; |
| 1019 | Ok(()) | ||
| 1020 | }), | ||
| 1021 | ), | ||
| 1022 | Relay::new(8052, None, None), | ||
| 1023 | Relay::new(8053, None, None), | ||
| 1024 | Relay::new(8055, None, None), | ||
| 1025 | Relay::new(8056, None, None), | ||
| 1026 | ); | ||
| 1027 | |||
| 1028 | let cli_tester_handle = std::thread::spawn({ | ||
| 1029 | let dir = git_repo.dir.clone(); | ||
| 1030 | let extra_args_owned: Vec<String> = extra_args.iter().map(|s| s.to_string()).collect(); | ||
| 1031 | move || -> Result<String> { | ||
| 1032 | let mut args = vec!["--nsec", TEST_KEY_1_NSEC, "--disable-cli-spinners", "init"]; | ||
| 1033 | let extra_refs: Vec<&str> = extra_args_owned.iter().map(|s| s.as_str()).collect(); | ||
| 1034 | args.extend(extra_refs); | ||
| 1035 | let mut p = CliTester::new_from_dir(&dir, args); | ||
| 1036 | let output = p.expect_end_eventually()?; | ||
| 1037 | for port in [51, 52, 53, 55, 56] { | ||
| 1038 | relay::shutdown_relay(8000 + port)?; | ||
| 1039 | } | ||
| 1040 | Ok(output) | ||
| 1041 | } | ||
| 1042 | }); | ||
| 1043 | |||
| 1044 | let _ = join!( | ||
| 1045 | r51.listen_until_close(), | ||
| 1046 | r52.listen_until_close(), | ||
| 1047 | r53.listen_until_close(), | ||
| 1048 | r55.listen_until_close(), | ||
| 1049 | r56.listen_until_close(), | ||
| 1050 | ); | ||
| 1051 | cli_tester_handle.join().unwrap() | ||
| 1052 | } | ||
| 1053 | |||
| 1054 | mod errors { | ||
| 1055 | use super::*; | ||
| 1056 | |||
| 1057 | #[tokio::test] | ||
| 1058 | #[serial] | ||
| 1059 | async fn bare_no_flags() -> Result<()> { | ||
| 1060 | let output = run_init_expecting_error(vec![]).await?; | ||
| 1061 | assert!( | ||
| 1062 | output.contains("you are not listed as a maintainer"), | ||
| 1063 | "expected not-listed error, got: {output}" | ||
| 1064 | ); | ||
| 1065 | Ok(()) | ||
| 1066 | } | ||
| 1067 | |||
| 1068 | #[tokio::test] | ||
| 1069 | #[serial] | ||
| 1070 | async fn defaults_still_requires_force() -> Result<()> { | ||
| 1071 | let output = run_init_expecting_error(vec!["--defaults"]).await?; | ||
| 1072 | assert!( | ||
| 1073 | output.contains("you are not listed as a maintainer"), | ||
| 1074 | "expected not-listed error even with -d, got: {output}" | ||
| 1075 | ); | ||
| 1076 | Ok(()) | ||
| 1077 | } | ||
| 1078 | } | ||
| 1079 | |||
| 1080 | mod success { | ||
| 1081 | use super::*; | ||
| 1082 | |||
| 1083 | #[fixture] | ||
| 1084 | async fn scenario() -> nostr::Event { | ||
| 1085 | let git_repo = prep_git_repo().expect("prep failed"); | ||
| 1086 | let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( | ||
| 1087 | Relay::new( | ||
| 1088 | 8051, | ||
| 1089 | None, | ||
| 1090 | Some(&|relay, client_id, subscription_id, _| -> Result<()> { | ||
| 1091 | relay.respond_events( | ||
| 1092 | client_id, | ||
| 1093 | &subscription_id, | ||
| 1094 | &vec![ | ||
| 1095 | generate_test_key_1_metadata_event("fred"), | ||
| 1096 | generate_test_key_1_relay_list_event(), | ||
| 1097 | generate_test_key_2_metadata_event("carole"), | ||
| 1098 | generate_test_key_2_relay_list_event(), | ||
| 1099 | generate_repo_ref_event_as_key_2_not_listing_key_1(), | ||
| 1100 | ], | ||
| 1101 | )?; | ||
| 1102 | Ok(()) | ||
| 1103 | }), | ||
| 1104 | ), | ||
| 1105 | Relay::new(8052, None, None), | ||
| 1106 | Relay::new(8053, None, None), | ||
| 1107 | Relay::new(8055, None, None), | ||
| 1108 | Relay::new(8056, None, None), | ||
| 1109 | ); | ||
| 1110 | |||
| 1111 | let cli_tester_handle = std::thread::spawn({ | ||
| 1112 | let dir = git_repo.dir.clone(); | ||
| 1113 | move || -> Result<()> { | ||
| 1114 | let args = vec![ | ||
| 1115 | "--nsec", | ||
| 1116 | TEST_KEY_1_NSEC, | ||
| 1117 | "--disable-cli-spinners", | ||
| 1118 | "init", | ||
| 1119 | "--force", | ||
| 1120 | "--grasp-servers", | ||
| 1121 | "ws://localhost:8055", | ||
| 1122 | ]; | ||
| 1123 | let mut p = CliTester::new_from_dir(&dir, args); | ||
| 513 | p.expect_end_eventually()?; | 1124 | p.expect_end_eventually()?; |
| 514 | for p in [51, 52, 53, 55, 56, 57] { | 1125 | for port in [51, 52, 53, 55, 56] { |
| 515 | relay::shutdown_relay(8000 + p)?; | 1126 | relay::shutdown_relay(8000 + port)?; |
| 516 | } | 1127 | } |
| 517 | Ok(()) | 1128 | Ok(()) |
| 518 | }); | 1129 | } |
| 519 | 1130 | }); | |
| 520 | // launch relay | 1131 | |
| 521 | let _ = join!( | 1132 | let _ = join!( |
| 522 | r51.listen_until_close(), | 1133 | r51.listen_until_close(), |
| 523 | r52.listen_until_close(), | 1134 | r52.listen_until_close(), |
| 524 | r53.listen_until_close(), | 1135 | r53.listen_until_close(), |
| 525 | r55.listen_until_close(), | 1136 | r55.listen_until_close(), |
| 526 | r56.listen_until_close(), | 1137 | r56.listen_until_close(), |
| 527 | r57.listen_until_close(), | 1138 | ); |
| 528 | ); | 1139 | cli_tester_handle.join().unwrap().expect("cli failed"); |
| 529 | cli_tester_handle.join().unwrap()?; | 1140 | |
| 530 | Ok(()) | 1141 | get_announcement(&r53.events).clone() |
| 531 | } | 1142 | } |
| 1143 | |||
| 1144 | #[rstest] | ||
| 1145 | #[tokio::test] | ||
| 1146 | #[serial] | ||
| 1147 | async fn name_inherited_from_other_maintainer( | ||
| 1148 | #[future] scenario: nostr::Event, | ||
| 1149 | ) -> Result<()> { | ||
| 1150 | let event = scenario.await; | ||
| 1151 | assert_eq!(get_tag_value(&event, "name"), "example name"); | ||
| 1152 | Ok(()) | ||
| 1153 | } | ||
| 1154 | |||
| 1155 | #[rstest] | ||
| 1156 | #[tokio::test] | ||
| 1157 | #[serial] | ||
| 1158 | async fn description_inherited_from_other_maintainer( | ||
| 1159 | #[future] scenario: nostr::Event, | ||
| 1160 | ) -> Result<()> { | ||
| 1161 | let event = scenario.await; | ||
| 1162 | assert_eq!(get_tag_value(&event, "description"), "example description"); | ||
| 1163 | Ok(()) | ||
| 1164 | } | ||
| 1165 | |||
| 1166 | #[rstest] | ||
| 1167 | #[tokio::test] | ||
| 1168 | #[serial] | ||
| 1169 | async fn web_inherited_from_other_maintainer( | ||
| 1170 | #[future] scenario: nostr::Event, | ||
| 1171 | ) -> Result<()> { | ||
| 1172 | let event = scenario.await; | ||
| 1173 | let web = get_tag_values(&event, "web"); | ||
| 1174 | assert!( | ||
| 1175 | web.iter().any(|w| w.contains("exampleproject.xyz")), | ||
| 1176 | "web should be inherited from KEY_2's announcement: {:?}", | ||
| 1177 | web | ||
| 1178 | ); | ||
| 1179 | Ok(()) | ||
| 1180 | } | ||
| 1181 | |||
| 1182 | #[rstest] | ||
| 1183 | #[tokio::test] | ||
| 1184 | #[serial] | ||
| 1185 | async fn maintainers_is_me_and_trusted(#[future] scenario: nostr::Event) -> Result<()> { | ||
| 1186 | let event = scenario.await; | ||
| 1187 | let maintainers = get_tag_values(&event, "maintainers"); | ||
| 1188 | assert_eq!( | ||
| 1189 | maintainers.len(), | ||
| 1190 | 2, | ||
| 1191 | "should have exactly 2 maintainers: {:?}", | ||
| 1192 | maintainers | ||
| 1193 | ); | ||
| 1194 | assert!( | ||
| 1195 | maintainers.contains(&TEST_KEY_1_KEYS.public_key().to_string()), | ||
| 1196 | "maintainers should include KEY_1 (me): {:?}", | ||
| 1197 | maintainers | ||
| 1198 | ); | ||
| 1199 | assert!( | ||
| 1200 | maintainers.contains(&TEST_KEY_2_KEYS.public_key().to_string()), | ||
| 1201 | "maintainers should include KEY_2 (trusted): {:?}", | ||
| 1202 | maintainers | ||
| 1203 | ); | ||
| 1204 | Ok(()) | ||
| 532 | } | 1205 | } |
| 533 | } | 1206 | } |
| 534 | // TODO: cli caputuring input | ||
| 535 | } | 1207 | } |
| 536 | // 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 { | |||
| 77 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 77 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 78 | let test_repo = GitTestRepo::without_repo_in_git_config(); | 78 | let test_repo = GitTestRepo::without_repo_in_git_config(); |
| 79 | test_repo.populate()?; | 79 | test_repo.populate()?; |
| 80 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 80 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 81 | p.expect( | 81 | p.expect( |
| 82 | "hint: https://gitworkshop.dev/search lists repositories and their nostr address\r\n", | 82 | "hint: https://gitworkshop.dev/search lists repositories and their nostr address\r\n", |
| 83 | )?; | 83 | )?; |
| @@ -197,7 +197,7 @@ mod when_main_branch_is_uptodate { | |||
| 197 | 197 | ||
| 198 | let test_repo = GitTestRepo::default(); | 198 | let test_repo = GitTestRepo::default(); |
| 199 | test_repo.populate()?; | 199 | test_repo.populate()?; |
| 200 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 200 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 201 | 201 | ||
| 202 | p.expect("fetching updates...\r\n")?; | 202 | p.expect("fetching updates...\r\n")?; |
| 203 | p.expect_eventually("\r\n")?; // some updates listed here | 203 | p.expect_eventually("\r\n")?; // some updates listed here |
| @@ -316,7 +316,7 @@ mod when_main_branch_is_uptodate { | |||
| 316 | 316 | ||
| 317 | let test_repo = GitTestRepo::default(); | 317 | let test_repo = GitTestRepo::default(); |
| 318 | test_repo.populate()?; | 318 | test_repo.populate()?; |
| 319 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 319 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 320 | 320 | ||
| 321 | p.expect("fetching updates...\r\n")?; | 321 | p.expect("fetching updates...\r\n")?; |
| 322 | p.expect_eventually("\r\n")?; // some updates listed here | 322 | p.expect_eventually("\r\n")?; // some updates listed here |
| @@ -438,7 +438,7 @@ mod when_main_branch_is_uptodate { | |||
| 438 | )?; | 438 | )?; |
| 439 | let test_repo = GitTestRepo::default(); | 439 | let test_repo = GitTestRepo::default(); |
| 440 | test_repo.populate()?; | 440 | test_repo.populate()?; |
| 441 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 441 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 442 | 442 | ||
| 443 | p.expect("fetching updates...\r\n")?; | 443 | p.expect("fetching updates...\r\n")?; |
| 444 | p.expect_eventually("\r\n")?; // some updates listed here | 444 | p.expect_eventually("\r\n")?; // some updates listed here |
| @@ -516,7 +516,7 @@ mod when_main_branch_is_uptodate { | |||
| 516 | )?; | 516 | )?; |
| 517 | let test_repo = GitTestRepo::default(); | 517 | let test_repo = GitTestRepo::default(); |
| 518 | test_repo.populate()?; | 518 | test_repo.populate()?; |
| 519 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 519 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 520 | 520 | ||
| 521 | p.expect("fetching updates...\r\n")?; | 521 | p.expect("fetching updates...\r\n")?; |
| 522 | p.expect_eventually("\r\n")?; // some updates listed here | 522 | p.expect_eventually("\r\n")?; // some updates listed here |
| @@ -639,7 +639,7 @@ mod when_main_branch_is_uptodate { | |||
| 639 | let test_repo = GitTestRepo::default(); | 639 | let test_repo = GitTestRepo::default(); |
| 640 | test_repo.populate()?; | 640 | test_repo.populate()?; |
| 641 | // create proposal branch | 641 | // create proposal branch |
| 642 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 642 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 643 | p.expect("fetching updates...\r\n")?; | 643 | p.expect("fetching updates...\r\n")?; |
| 644 | p.expect_eventually("\r\n")?; // some updates listed here | 644 | p.expect_eventually("\r\n")?; // some updates listed here |
| 645 | let mut c = p.expect_choice( | 645 | let mut c = p.expect_choice( |
| @@ -664,7 +664,7 @@ mod when_main_branch_is_uptodate { | |||
| 664 | 664 | ||
| 665 | test_repo.checkout("main")?; | 665 | test_repo.checkout("main")?; |
| 666 | // run test | 666 | // run test |
| 667 | p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 667 | p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 668 | p.expect("fetching updates...\r\n")?; | 668 | p.expect("fetching updates...\r\n")?; |
| 669 | p.expect_eventually("\r\n")?; // some updates listed here | 669 | p.expect_eventually("\r\n")?; // some updates listed here |
| 670 | let mut c = p.expect_choice( | 670 | let mut c = p.expect_choice( |
| @@ -735,7 +735,7 @@ mod when_main_branch_is_uptodate { | |||
| 735 | let test_repo = GitTestRepo::default(); | 735 | let test_repo = GitTestRepo::default(); |
| 736 | test_repo.populate()?; | 736 | test_repo.populate()?; |
| 737 | // create proposal branch | 737 | // create proposal branch |
| 738 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 738 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 739 | p.expect("fetching updates...\r\n")?; | 739 | p.expect("fetching updates...\r\n")?; |
| 740 | p.expect_eventually("\r\n")?; // some updates listed here | 740 | p.expect_eventually("\r\n")?; // some updates listed here |
| 741 | let mut c = p.expect_choice( | 741 | let mut c = p.expect_choice( |
| @@ -760,7 +760,7 @@ mod when_main_branch_is_uptodate { | |||
| 760 | 760 | ||
| 761 | test_repo.checkout("main")?; | 761 | test_repo.checkout("main")?; |
| 762 | // run test | 762 | // run test |
| 763 | p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 763 | p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 764 | p.expect("fetching updates...\r\n")?; | 764 | p.expect("fetching updates...\r\n")?; |
| 765 | p.expect_eventually("\r\n")?; // some updates listed here | 765 | p.expect_eventually("\r\n")?; // some updates listed here |
| 766 | let mut c = p.expect_choice( | 766 | let mut c = p.expect_choice( |
| @@ -850,7 +850,7 @@ mod when_main_branch_is_uptodate { | |||
| 850 | )?; | 850 | )?; |
| 851 | 851 | ||
| 852 | // run test | 852 | // run test |
| 853 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 853 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 854 | p.expect("fetching updates...\r\n")?; | 854 | p.expect("fetching updates...\r\n")?; |
| 855 | p.expect_eventually("\r\n")?; // some updates listed here | 855 | p.expect_eventually("\r\n")?; // some updates listed here |
| 856 | let mut c = p.expect_choice( | 856 | let mut c = p.expect_choice( |
| @@ -926,7 +926,7 @@ mod when_main_branch_is_uptodate { | |||
| 926 | )?; | 926 | )?; |
| 927 | 927 | ||
| 928 | // run test | 928 | // run test |
| 929 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 929 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 930 | p.expect("fetching updates...\r\n")?; | 930 | p.expect("fetching updates...\r\n")?; |
| 931 | p.expect_eventually("\r\n")?; // some updates listed here | 931 | p.expect_eventually("\r\n")?; // some updates listed here |
| 932 | let mut c = p.expect_choice( | 932 | let mut c = p.expect_choice( |
| @@ -1039,7 +1039,7 @@ mod when_main_branch_is_uptodate { | |||
| 1039 | test_repo.checkout("main")?; | 1039 | test_repo.checkout("main")?; |
| 1040 | 1040 | ||
| 1041 | // run test | 1041 | // run test |
| 1042 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 1042 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 1043 | p.expect("fetching updates...\r\n")?; | 1043 | p.expect("fetching updates...\r\n")?; |
| 1044 | p.expect_eventually("\r\n")?; // some updates listed here | 1044 | p.expect_eventually("\r\n")?; // some updates listed here |
| 1045 | let mut c = p.expect_choice( | 1045 | let mut c = p.expect_choice( |
| @@ -1118,7 +1118,7 @@ mod when_main_branch_is_uptodate { | |||
| 1118 | test_repo.checkout("main")?; | 1118 | test_repo.checkout("main")?; |
| 1119 | 1119 | ||
| 1120 | // run test | 1120 | // run test |
| 1121 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 1121 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 1122 | p.expect("fetching updates...\r\n")?; | 1122 | p.expect("fetching updates...\r\n")?; |
| 1123 | p.expect_eventually("\r\n")?; // some updates listed here | 1123 | p.expect_eventually("\r\n")?; // some updates listed here |
| 1124 | let mut c = p.expect_choice( | 1124 | let mut c = p.expect_choice( |
| @@ -1223,7 +1223,7 @@ mod when_main_branch_is_uptodate { | |||
| 1223 | test_repo.checkout("main")?; | 1223 | test_repo.checkout("main")?; |
| 1224 | 1224 | ||
| 1225 | // run test | 1225 | // run test |
| 1226 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 1226 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 1227 | p.expect("fetching updates...\r\n")?; | 1227 | p.expect("fetching updates...\r\n")?; |
| 1228 | p.expect_eventually("\r\n")?; // some updates listed here | 1228 | p.expect_eventually("\r\n")?; // some updates listed here |
| 1229 | let mut c = p.expect_choice( | 1229 | let mut c = p.expect_choice( |
| @@ -1305,7 +1305,7 @@ mod when_main_branch_is_uptodate { | |||
| 1305 | test_repo.checkout("main")?; | 1305 | test_repo.checkout("main")?; |
| 1306 | 1306 | ||
| 1307 | // run test | 1307 | // run test |
| 1308 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 1308 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 1309 | p.expect("fetching updates...\r\n")?; | 1309 | p.expect("fetching updates...\r\n")?; |
| 1310 | p.expect_eventually("\r\n")?; // some updates listed here | 1310 | p.expect_eventually("\r\n")?; // some updates listed here |
| 1311 | let mut c = p.expect_choice( | 1311 | let mut c = p.expect_choice( |
| @@ -1407,7 +1407,7 @@ mod when_main_branch_is_uptodate { | |||
| 1407 | let (originating_repo, test_repo) = create_proposals_with_first_rebased_and_repo_with_latest_main_and_unrebased_proposal()?; | 1407 | let (originating_repo, test_repo) = create_proposals_with_first_rebased_and_repo_with_latest_main_and_unrebased_proposal()?; |
| 1408 | test_repo.checkout("main")?; | 1408 | test_repo.checkout("main")?; |
| 1409 | 1409 | ||
| 1410 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 1410 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 1411 | p.expect("fetching updates...\r\n")?; | 1411 | p.expect("fetching updates...\r\n")?; |
| 1412 | p.expect_eventually("\r\n")?; // some updates listed here | 1412 | p.expect_eventually("\r\n")?; // some updates listed here |
| 1413 | let mut c = p.expect_choice( | 1413 | let mut c = p.expect_choice( |
| @@ -1480,7 +1480,7 @@ mod when_main_branch_is_uptodate { | |||
| 1480 | let (_, test_repo) = create_proposals_with_first_rebased_and_repo_with_latest_main_and_unrebased_proposal()?; | 1480 | let (_, test_repo) = create_proposals_with_first_rebased_and_repo_with_latest_main_and_unrebased_proposal()?; |
| 1481 | test_repo.checkout("main")?; | 1481 | test_repo.checkout("main")?; |
| 1482 | 1482 | ||
| 1483 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); | 1483 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "list"]); |
| 1484 | p.expect("fetching updates...\r\n")?; | 1484 | p.expect("fetching updates...\r\n")?; |
| 1485 | p.expect_eventually("\r\n")?; // some updates listed here | 1485 | p.expect_eventually("\r\n")?; // some updates listed here |
| 1486 | let mut c = p.expect_choice( | 1486 | let mut c = p.expect_choice( |
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) -> | |||
| 38 | 38 | ||
| 39 | fn standard_first_time_login_with_nsec() -> Result<CliTester> { | 39 | fn standard_first_time_login_with_nsec() -> Result<CliTester> { |
| 40 | let test_repo = GitTestRepo::default(); | 40 | let test_repo = GitTestRepo::default(); |
| 41 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login", "--offline"]); | 41 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login", "--offline"]); |
| 42 | 42 | ||
| 43 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; | 43 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; |
| 44 | 44 | ||
| @@ -77,7 +77,8 @@ mod with_relays { | |||
| 77 | 77 | ||
| 78 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 78 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 79 | let test_repo = GitTestRepo::default(); | 79 | let test_repo = GitTestRepo::default(); |
| 80 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); | 80 | let mut p = |
| 81 | CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); | ||
| 81 | 82 | ||
| 82 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; | 83 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; |
| 83 | 84 | ||
| @@ -108,7 +109,8 @@ mod with_relays { | |||
| 108 | 109 | ||
| 109 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 110 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 110 | let test_repo = GitTestRepo::default(); | 111 | let test_repo = GitTestRepo::default(); |
| 111 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); | 112 | let mut p = |
| 113 | CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); | ||
| 112 | 114 | ||
| 113 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; | 115 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; |
| 114 | 116 | ||
| @@ -456,7 +458,8 @@ mod with_relays { | |||
| 456 | 458 | ||
| 457 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 459 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 458 | let test_repo = GitTestRepo::default(); | 460 | let test_repo = GitTestRepo::default(); |
| 459 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); | 461 | let mut p = |
| 462 | CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); | ||
| 460 | 463 | ||
| 461 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; | 464 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; |
| 462 | 465 | ||
| @@ -510,7 +513,8 @@ mod with_relays { | |||
| 510 | 513 | ||
| 511 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 514 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 512 | let test_repo = GitTestRepo::default(); | 515 | let test_repo = GitTestRepo::default(); |
| 513 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); | 516 | let mut p = |
| 517 | CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); | ||
| 514 | 518 | ||
| 515 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; | 519 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; |
| 516 | 520 | ||
| @@ -551,7 +555,7 @@ mod with_relays { | |||
| 551 | 555 | ||
| 552 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 556 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 553 | let test_repo = GitTestRepo::default(); | 557 | let test_repo = GitTestRepo::default(); |
| 554 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); | 558 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); |
| 555 | 559 | ||
| 556 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; | 560 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; |
| 557 | 561 | ||
| @@ -626,7 +630,8 @@ mod with_offline_flag { | |||
| 626 | #[test] | 630 | #[test] |
| 627 | fn succeeds_with_text_logged_in_as_npub() -> Result<()> { | 631 | fn succeeds_with_text_logged_in_as_npub() -> Result<()> { |
| 628 | let test_repo = GitTestRepo::default(); | 632 | let test_repo = GitTestRepo::default(); |
| 629 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login", "--offline"]); | 633 | let mut p = |
| 634 | CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login", "--offline"]); | ||
| 630 | 635 | ||
| 631 | show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; | 636 | show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; |
| 632 | 637 | ||
| @@ -641,7 +646,8 @@ mod with_offline_flag { | |||
| 641 | #[test] | 646 | #[test] |
| 642 | fn succeeds_with_hex_secret_key_in_place_of_nsec() -> Result<()> { | 647 | fn succeeds_with_hex_secret_key_in_place_of_nsec() -> Result<()> { |
| 643 | let test_repo = GitTestRepo::default(); | 648 | let test_repo = GitTestRepo::default(); |
| 644 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login", "--offline"]); | 649 | let mut p = |
| 650 | CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login", "--offline"]); | ||
| 645 | 651 | ||
| 646 | show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; | 652 | show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; |
| 647 | 653 | ||
| @@ -659,8 +665,10 @@ mod with_offline_flag { | |||
| 659 | #[test] | 665 | #[test] |
| 660 | fn prompts_for_nsec_until_valid() -> Result<()> { | 666 | fn prompts_for_nsec_until_valid() -> Result<()> { |
| 661 | let test_repo = GitTestRepo::default(); | 667 | let test_repo = GitTestRepo::default(); |
| 662 | let mut p = | 668 | let mut p = CliTester::new_from_dir( |
| 663 | CliTester::new_from_dir(&test_repo.dir, ["account", "login", "--offline"]); | 669 | &test_repo.dir, |
| 670 | ["-i", "account", "login", "--offline"], | ||
| 671 | ); | ||
| 664 | 672 | ||
| 665 | show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; | 673 | show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; |
| 666 | 674 | ||
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 { | |||
| 75 | let mut r51 = create_relay_51()?; | 75 | let mut r51 = create_relay_51()?; |
| 76 | // // check relay had the right number of events | 76 | // // check relay had the right number of events |
| 77 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 77 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 78 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); | 78 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "send", "HEAD~2"]); |
| 79 | expect_confirm_prompt(&mut p)?; | 79 | expect_confirm_prompt(&mut p)?; |
| 80 | p.exit()?; | 80 | p.exit()?; |
| 81 | relay::shutdown_relay(8051)?; | 81 | relay::shutdown_relay(8051)?; |
| @@ -94,7 +94,7 @@ mod when_commits_behind_ask_to_proceed { | |||
| 94 | let test_repo = prep_test_repo()?; | 94 | let test_repo = prep_test_repo()?; |
| 95 | let mut r51 = create_relay_51()?; | 95 | let mut r51 = create_relay_51()?; |
| 96 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 96 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 97 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); | 97 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "send", "HEAD~2"]); |
| 98 | expect_confirm_prompt(&mut p)?.succeeds_with(Some(false))?; | 98 | expect_confirm_prompt(&mut p)?.succeeds_with(Some(false))?; |
| 99 | p.expect_end_with("Error: aborting so commits can be rebased\r\n")?; | 99 | p.expect_end_with("Error: aborting so commits can be rebased\r\n")?; |
| 100 | relay::shutdown_relay(8051)?; | 100 | relay::shutdown_relay(8051)?; |
| @@ -113,7 +113,7 @@ mod when_commits_behind_ask_to_proceed { | |||
| 113 | let test_repo = prep_test_repo()?; | 113 | let test_repo = prep_test_repo()?; |
| 114 | let mut r51 = create_relay_51()?; | 114 | let mut r51 = create_relay_51()?; |
| 115 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 115 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 116 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); | 116 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "send", "HEAD~2"]); |
| 117 | expect_confirm_prompt(&mut p)?.succeeds_with(Some(true))?; | 117 | expect_confirm_prompt(&mut p)?.succeeds_with(Some(true))?; |
| 118 | p.expect("? include cover letter")?; | 118 | p.expect("? include cover letter")?; |
| 119 | p.exit()?; | 119 | p.exit()?; |
| @@ -1235,6 +1235,7 @@ mod when_range_ommited_prompts_for_selection_defaulting_ahead_of_main { | |||
| 1235 | 1235 | ||
| 1236 | fn cli_tester_create_proposal(git_repo: &GitTestRepo) -> CliTester { | 1236 | fn cli_tester_create_proposal(git_repo: &GitTestRepo) -> CliTester { |
| 1237 | let args = vec![ | 1237 | let args = vec![ |
| 1238 | "-i", | ||
| 1238 | "--nsec", | 1239 | "--nsec", |
| 1239 | TEST_KEY_1_NSEC, | 1240 | TEST_KEY_1_NSEC, |
| 1240 | "--password", | 1241 | "--password", |
| @@ -1943,3 +1944,144 @@ mod in_reply_to_mentions_npub_and_nprofile_which_get_mentioned_in_proposal_root | |||
| 1943 | Ok(()) | 1944 | Ok(()) |
| 1944 | } | 1945 | } |
| 1945 | } | 1946 | } |
| 1947 | |||
| 1948 | mod non_interactive_validation { | ||
| 1949 | use super::*; | ||
| 1950 | |||
| 1951 | #[test] | ||
| 1952 | fn bare_send_errors_with_helpful_message() -> Result<()> { | ||
| 1953 | let test_repo = prep_git_repo()?; | ||
| 1954 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["send"]); | ||
| 1955 | let output = p.expect_end_eventually()?; | ||
| 1956 | assert!(output.contains("ngit send requires additional arguments")); | ||
| 1957 | assert!(output.contains("<SINCE_OR_RANGE>")); | ||
| 1958 | assert!(output.contains("--title")); | ||
| 1959 | assert!(output.contains("--description")); | ||
| 1960 | assert!(output.contains("--defaults")); | ||
| 1961 | assert!(output.contains("--interactive")); | ||
| 1962 | Ok(()) | ||
| 1963 | } | ||
| 1964 | |||
| 1965 | #[test] | ||
| 1966 | fn send_with_range_only_errors() -> Result<()> { | ||
| 1967 | let test_repo = prep_git_repo()?; | ||
| 1968 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); | ||
| 1969 | let output = p.expect_end_eventually()?; | ||
| 1970 | assert!(output.contains("ngit send requires additional arguments")); | ||
| 1971 | assert!(output.contains("--title")); | ||
| 1972 | assert!(output.contains("--description")); | ||
| 1973 | assert!(output.contains("--defaults")); | ||
| 1974 | Ok(()) | ||
| 1975 | } | ||
| 1976 | |||
| 1977 | #[test] | ||
| 1978 | fn send_force_pr_without_title_errors() -> Result<()> { | ||
| 1979 | let test_repo = prep_git_repo()?; | ||
| 1980 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "--force-pr", "HEAD~2"]); | ||
| 1981 | let output = p.expect_end_eventually()?; | ||
| 1982 | assert!(output.contains("ngit send requires additional arguments")); | ||
| 1983 | assert!(output.contains("--title")); | ||
| 1984 | assert!(output.contains("--description")); | ||
| 1985 | assert!(output.contains("--defaults")); | ||
| 1986 | Ok(()) | ||
| 1987 | } | ||
| 1988 | |||
| 1989 | #[test] | ||
| 1990 | fn send_description_without_title_errors() -> Result<()> { | ||
| 1991 | let test_repo = prep_git_repo()?; | ||
| 1992 | let mut p = | ||
| 1993 | CliTester::new_from_dir(&test_repo.dir, ["send", "--description", "Y", "HEAD~2"]); | ||
| 1994 | let output = p.expect_end_eventually()?; | ||
| 1995 | assert!(output.contains("ngit send requires --title when --description is provided")); | ||
| 1996 | assert!(output.contains("--title")); | ||
| 1997 | Ok(()) | ||
| 1998 | } | ||
| 1999 | |||
| 2000 | #[test] | ||
| 2001 | fn send_title_without_description_errors() -> Result<()> { | ||
| 2002 | let test_repo = prep_git_repo()?; | ||
| 2003 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "--title", "X", "HEAD~2"]); | ||
| 2004 | let output = p.expect_end_eventually()?; | ||
| 2005 | assert!(output.contains("ngit send requires --description when --title is provided")); | ||
| 2006 | assert!(output.contains("--description")); | ||
| 2007 | Ok(()) | ||
| 2008 | } | ||
| 2009 | |||
| 2010 | #[tokio::test] | ||
| 2011 | #[serial] | ||
| 2012 | async fn send_defaults_sends_patches_without_cover_letter() -> Result<()> { | ||
| 2013 | let git_repo = prep_git_repo()?; | ||
| 2014 | |||
| 2015 | let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( | ||
| 2016 | Relay::new( | ||
| 2017 | 8051, | ||
| 2018 | None, | ||
| 2019 | Some(&|relay, client_id, subscription_id, _| -> Result<()> { | ||
| 2020 | relay.respond_events( | ||
| 2021 | client_id, | ||
| 2022 | &subscription_id, | ||
| 2023 | &vec![ | ||
| 2024 | generate_test_key_1_metadata_event("fred"), | ||
| 2025 | generate_test_key_1_relay_list_event(), | ||
| 2026 | ], | ||
| 2027 | )?; | ||
| 2028 | Ok(()) | ||
| 2029 | }), | ||
| 2030 | ), | ||
| 2031 | Relay::new(8052, None, None), | ||
| 2032 | Relay::new(8053, None, None), | ||
| 2033 | Relay::new( | ||
| 2034 | 8055, | ||
| 2035 | None, | ||
| 2036 | Some(&|relay, client_id, subscription_id, _| -> Result<()> { | ||
| 2037 | relay.respond_events( | ||
| 2038 | client_id, | ||
| 2039 | &subscription_id, | ||
| 2040 | &vec![generate_repo_ref_event()], | ||
| 2041 | )?; | ||
| 2042 | Ok(()) | ||
| 2043 | }), | ||
| 2044 | ), | ||
| 2045 | Relay::new(8056, None, None), | ||
| 2046 | ); | ||
| 2047 | |||
| 2048 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | ||
| 2049 | let mut p = CliTester::new_from_dir( | ||
| 2050 | &git_repo.dir, | ||
| 2051 | [ | ||
| 2052 | "--nsec", | ||
| 2053 | TEST_KEY_1_NSEC, | ||
| 2054 | "--password", | ||
| 2055 | TEST_PASSWORD, | ||
| 2056 | "--disable-cli-spinners", | ||
| 2057 | "--defaults", | ||
| 2058 | "send", | ||
| 2059 | ], | ||
| 2060 | ); | ||
| 2061 | p.expect_end_eventually()?; | ||
| 2062 | for p in [51, 52, 53, 55, 56] { | ||
| 2063 | relay::shutdown_relay(8000 + p)?; | ||
| 2064 | } | ||
| 2065 | Ok(()) | ||
| 2066 | }); | ||
| 2067 | |||
| 2068 | let _ = join!( | ||
| 2069 | r51.listen_until_close(), | ||
| 2070 | r52.listen_until_close(), | ||
| 2071 | r53.listen_until_close(), | ||
| 2072 | r55.listen_until_close(), | ||
| 2073 | r56.listen_until_close(), | ||
| 2074 | ); | ||
| 2075 | cli_tester_handle.join().unwrap()?; | ||
| 2076 | |||
| 2077 | // verify patches sent without cover letter | ||
| 2078 | for relay in [&r53, &r55, &r56] { | ||
| 2079 | assert_eq!( | ||
| 2080 | relay.events.iter().filter(|e| is_cover_letter(e)).count(), | ||
| 2081 | 0, | ||
| 2082 | ); | ||
| 2083 | assert_eq!(relay.events.iter().filter(|e| is_patch(e)).count(), 2); | ||
| 2084 | } | ||
| 2085 | Ok(()) | ||
| 2086 | } | ||
| 2087 | } | ||