upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-10 13:10:18 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-10 13:10:18 +0000
commit1e7aeb4d7972d29c6586df18128a8a4f7667845a (patch)
tree0f7e5fcaa5a005aeec7ae2d9f35b2c473ef8f785
parentd2412565334f48bd31e57d29d7959c24258ccd98 (diff)
parentaae452697d152694a8f163219f707356e84b420b (diff)
Make ngit non-interactive by default
Implements non-interactive mode as the default behavior for ngit. Users must now use -i flag for interactive prompts, or provide all required arguments explicitly. Adds -d flag for sensible defaults and -f flag for force operations. Changes: - CLI interactor infrastructure supports non-interactive mode - Global flags: -i (interactive), --defaults (use defaults), -f (force) - ngit init: requires --name or --identifier, supports --defaults - ngit account: new signup command, login supports non-interactive - ngit send: validates required fields, supports --defaults - git-remote-nostr: fixed to prevent interactive prompts during push - Comprehensive test coverage: 234 unit tests + integration tests
-rw-r--r--README.md2
-rw-r--r--docs/architecture/maintainer-model.md84
-rw-r--r--src/bin/git_remote_nostr/push.rs17
-rw-r--r--src/bin/ngit/cli.rs12
-rw-r--r--src/bin/ngit/main.rs26
-rw-r--r--src/bin/ngit/sub_commands/create.rs71
-rw-r--r--src/bin/ngit/sub_commands/init.rs1759
-rw-r--r--src/bin/ngit/sub_commands/login.rs56
-rw-r--r--src/bin/ngit/sub_commands/mod.rs1
-rw-r--r--src/bin/ngit/sub_commands/send.rs326
-rw-r--r--src/lib/cli_interactor.rs148
-rw-r--r--src/lib/client.rs14
-rw-r--r--src/lib/login/fresh.rs179
-rw-r--r--test_utils/src/git.rs24
-rw-r--r--test_utils/src/lib.rs78
-rw-r--r--tests/ngit_init.rs1419
-rw-r--r--tests/ngit_list.rs34
-rw-r--r--tests/ngit_login.rs28
-rw-r--r--tests/ngit_send.rs148
19 files changed, 3312 insertions, 1114 deletions
diff --git a/README.md b/README.md
index 3d172a2..074cde3 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ for code collaboration, nostr is used for:
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
44a 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. 44a 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
46eg self-hosted, github, codeberg, etc. 46eg 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
3How ngit handles multi-maintainer repositories: coordinate discovery, maintainer sets, and the distinction between shared metadata and personal infrastructure.
4
5## Coordinate Discovery
6
7A **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
9ngit discovers the coordinate locally from (in priority order):
10
111. `nostr://` git remotes
122. `nostr.repo` git config
133. `maintainers.yaml`
14
15No network access is required to find the coordinate. The coordinate may exist without a corresponding announcement event on relays.
16
17## Maintainer Set
18
19Each 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
21Each maintainer independently decides who they list. Adding someone to your maintainers tag is an invitation to co-maintain.
22
23## Consuming vs Publishing
24
25The key architectural distinction is between **consuming** repository data (fetching, cloning, listing) and **publishing** it (`ngit init`).
26
27### Consuming: Union Across Maintainers
28
29When 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
33When publishing via `ngit init`, fields are sourced differently depending on their type:
34
35#### Shared Metadata
36
37Sourced from the **latest event** (by `created_at`) across the maintainer set:
38
39- `name`
40- `description`
41- `web`
42- `hashtags`
43
44Rationale: these are shared identity. If any maintainer updates the project name, all subsequent re-announcements should pick it up.
45
46#### Infrastructure (Personal)
47
48Each 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
56Grasp-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
60Sourced from **my own announcement only**. Each maintainer independently decides who they list.
61
62If I don't have an existing announcement (first time co-maintaining), the default is `[me, trusted_maintainer]`.
63
64#### Earliest Unique Commit
65
66Cascade: 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
70From the existing coordinate. Cannot change without `--force` (changing it creates a new repository).
71
72## Init States
73
74When `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
84See `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)]
176async fn create_and_publish_events_and_proposals( 177async 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)]
14pub struct Cli { 15pub 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
37pub const CUSTOMISE_TEMPLATE: &str = r" 47pub 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
5use anyhow::Result;
6use clap::Parser; 5use clap::Parser;
7use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands}; 6use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands};
8 7
9mod cli; 8mod cli;
10use ngit::{ 9use 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::{
16mod sub_commands; 16mod sub_commands;
17 17
18#[tokio::main] 18#[tokio::main]
19async fn main() -> Result<()> { 19async 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 @@
1use anyhow::{Context, Result};
2use clap::Parser;
3use ngit::client::Params;
4use nostr_sdk::ToBech32;
5
6use crate::{
7 cli::Cli,
8 client::{Client, Connect},
9 git::Repo,
10 login::fresh::signup_non_interactive,
11};
12
13#[derive(Parser)]
14pub 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
28pub 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;
13use ngit::{ 14use 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
52enum 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
74impl 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
106struct 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.
121fn 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).
131fn 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.
140fn 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.
153fn 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.
163fn 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
168fn 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
175fn 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
188fn 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.
213fn 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.
241fn 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.
298fn 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
373fn 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.
398fn 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)]
48pub struct SubCommandArgs { 458pub 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)] 491impl SubCommandArgs {
79pub 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
510fn 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(); 530fn 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)]
606fn 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.
1139fn 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)]
1213async 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)]
1469pub 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
977fn format_grasp_server_url_as_clone_url( 1560fn 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
28pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { 28pub 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)]
59async fn logout(git_repo: Option<&Repo>, local_only: bool) -> Result<(bool, bool)> { 78async 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 @@
1pub mod create;
1pub mod export_keys; 2pub mod export_keys;
2pub mod init; 3pub mod init;
3pub mod list; 4pub 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
67fn 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)]
55pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Result<()> { 133pub 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
305fn check_commits_are_suitable_for_proposal( 448fn 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 @@
1use anyhow::{Context, Result}; 1use std::fmt;
2
3use anyhow::{Context, Result, bail};
4use console::Style;
2use dialoguer::{ 5use 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)]
8use mockall::*; 11use 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)]
19pub struct CliError;
20
21impl 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
28impl 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`.
40pub 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)]
11pub struct Interactor { 81pub struct Interactor {
12 theme: ColorfulTheme, 82 theme: ColorfulTheme,
83 non_interactive: bool,
84}
85
86impl 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}
23impl InteractorPrompt for Interactor { 110impl 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.
76pub struct PromptInputParms { 215pub 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
83impl Default for PromptInputParms { 223impl 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
114pub struct PromptPasswordParms { 260pub 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
712pub 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
681async fn signup( 814async 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
287impl Drop for GitTestRepo { 311impl 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).
214pub 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).
223pub 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.
231fn 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)
268pub 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
212pub fn get_pretend_proposal_root_event() -> nostr::Event { 288pub 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 @@
1use anyhow::Result; 1use anyhow::Result;
2use nostr::Event;
2use nostr_sdk::Kind; 3use nostr_sdk::Kind;
3use rstest::*; 4use rstest::*;
4use serial_test::serial; 5use serial_test::serial;
5use test_utils::{git::GitTestRepo, *}; 6use test_utils::{git::GitTestRepo, *};
6 7
7fn 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(()) 13fn 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").
21fn 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
15fn 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", 32fn 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
44mod when_repo_not_previously_claimed { 41// ---------------------------------------------------------------------------
42// State A: Fresh (no coordinate)
43// ---------------------------------------------------------------------------
44
45mod 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}; 304mod 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
506mod 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] 794mod 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
981mod 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
39fn standard_first_time_login_with_nsec() -> Result<CliTester> { 39fn 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
1948mod 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}