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 12:52:26 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-10 13:03:39 +0000
commit3383477386916e82a19fa1e9c4d95b232ba0a40e (patch)
tree90347a4d2a5d09d218b534a945ced243bedd8a0d
parent09bb21462ac5571cace5a7e71103156772a499fe (diff)
feat: update ngit send for non-interactive mode
Rewrite ngit send to support non-interactive mode: - Add validation for required arguments (title/description) - Add --force flag to bypass commit suitability checks - Add --no-cover-letter flag to skip cover letter - Improve error messages for missing required fields - Update title/description/cover-letter logic for non-interactive mode - Add comprehensive tests for non-interactive behavior
-rw-r--r--src/bin/ngit/sub_commands/send.rs326
-rw-r--r--tests/ngit_login.rs28
-rw-r--r--tests/ngit_send.rs148
3 files changed, 411 insertions, 91 deletions
diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs
index 6ae0cda..325ad89 100644
--- a/src/bin/ngit/sub_commands/send.rs
+++ b/src/bin/ngit/sub_commands/send.rs
@@ -15,6 +15,7 @@ use crate::{
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/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}