diff options
| -rw-r--r-- | src/bin/ngit/sub_commands/send.rs | 326 | ||||
| -rw-r--r-- | tests/ngit_login.rs | 28 | ||||
| -rw-r--r-- | tests/ngit_send.rs | 148 |
3 files changed, 411 insertions, 91 deletions
diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs index 6ae0cda..325ad89 100644 --- a/src/bin/ngit/sub_commands/send.rs +++ b/src/bin/ngit/sub_commands/send.rs | |||
| @@ -15,6 +15,7 @@ use crate::{ | |||
| 15 | cli::{Cli, extract_signer_cli_arguments}, | 15 | cli::{Cli, extract_signer_cli_arguments}, |
| 16 | cli_interactor::{ | 16 | cli_interactor::{ |
| 17 | Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms, | 17 | Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms, |
| 18 | cli_error, | ||
| 18 | }, | 19 | }, |
| 19 | client::{ | 20 | client::{ |
| 20 | Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, | 21 | Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, |
| @@ -38,9 +39,9 @@ pub struct SubCommandArgs { | |||
| 38 | #[arg(long, action)] | 39 | #[arg(long, action)] |
| 39 | pub(crate) no_cover_letter: bool, | 40 | pub(crate) no_cover_letter: bool, |
| 40 | /// optional cover letter title | 41 | /// optional cover letter title |
| 41 | #[clap(short, long)] | 42 | #[clap(long)] |
| 42 | pub(crate) title: Option<String>, | 43 | pub(crate) title: Option<String>, |
| 43 | #[clap(short, long)] | 44 | #[clap(long)] |
| 44 | /// optional cover letter description | 45 | /// optional cover letter description |
| 45 | pub(crate) description: Option<String>, | 46 | pub(crate) description: Option<String>, |
| 46 | /// publish as Pull Request even if each commit is < 60kb | 47 | /// publish as Pull Request even if each commit is < 60kb |
| @@ -51,6 +52,83 @@ pub struct SubCommandArgs { | |||
| 51 | pub(crate) force_patch: bool, | 52 | pub(crate) force_patch: bool, |
| 52 | } | 53 | } |
| 53 | 54 | ||
| 55 | /// Validates send command arguments for non-interactive mode. | ||
| 56 | /// | ||
| 57 | /// Returns Ok(()) if: | ||
| 58 | /// - Interactive mode is enabled (all validation happens interactively) | ||
| 59 | /// - Updating an existing proposal (`in_reply_to` is non-empty) | ||
| 60 | /// - Using defaults mode (--defaults will fill in gaps) | ||
| 61 | /// - Both title and description are provided | ||
| 62 | /// | ||
| 63 | /// Returns an error if: | ||
| 64 | /// - Description provided without title | ||
| 65 | /// - Title provided without description | ||
| 66 | /// - Missing required arguments in non-interactive mode | ||
| 67 | fn validate_send_args(cli: &Cli, args: &SubCommandArgs) -> Result<()> { | ||
| 68 | // Interactive mode handles all validation interactively | ||
| 69 | if cli.interactive { | ||
| 70 | return Ok(()); | ||
| 71 | } | ||
| 72 | |||
| 73 | // Description requires title | ||
| 74 | if args.description.is_some() && args.title.is_none() { | ||
| 75 | let message = "ngit send requires --title when --description is provided"; | ||
| 76 | let details = vec![("--title <T>", "cover letter title")]; | ||
| 77 | let suggestions = vec![ | ||
| 78 | "ngit send HEAD~2 --title \"My Feature\" --description \"Details\"", | ||
| 79 | "ngit send --interactive", | ||
| 80 | ]; | ||
| 81 | return Err(cli_error(message, &details, &suggestions)); | ||
| 82 | } | ||
| 83 | |||
| 84 | // Title requires description | ||
| 85 | if args.title.is_some() && args.description.is_none() { | ||
| 86 | let message = "ngit send requires --description when --title is provided"; | ||
| 87 | let details = vec![("--description <D>", "cover letter description")]; | ||
| 88 | let suggestions = vec![ | ||
| 89 | "ngit send HEAD~2 --title \"My Feature\" --description \"Details\"", | ||
| 90 | "ngit send --interactive", | ||
| 91 | ]; | ||
| 92 | return Err(cli_error(message, &details, &suggestions)); | ||
| 93 | } | ||
| 94 | |||
| 95 | // Updating existing proposal - no additional validation needed | ||
| 96 | if !args.in_reply_to.is_empty() { | ||
| 97 | return Ok(()); | ||
| 98 | } | ||
| 99 | |||
| 100 | // Defaults mode will fill in gaps | ||
| 101 | if cli.defaults { | ||
| 102 | return Ok(()); | ||
| 103 | } | ||
| 104 | |||
| 105 | // Both title and description provided - all good | ||
| 106 | if args.title.is_some() && args.description.is_some() { | ||
| 107 | return Ok(()); | ||
| 108 | } | ||
| 109 | |||
| 110 | // --no-cover-letter with a range is valid (patches without cover letter) | ||
| 111 | if args.no_cover_letter && !args.since_or_range.is_empty() { | ||
| 112 | return Ok(()); | ||
| 113 | } | ||
| 114 | |||
| 115 | // Missing required arguments for non-interactive mode | ||
| 116 | let message = "ngit send requires additional arguments"; | ||
| 117 | let mut details = vec![]; | ||
| 118 | if args.since_or_range.is_empty() { | ||
| 119 | details.push(("<SINCE_OR_RANGE>", "commits to send (eg. HEAD~2)")); | ||
| 120 | } | ||
| 121 | details.push(("--title <T> --description <D>", "cover letter details")); | ||
| 122 | details.push(("-d, --defaults", "use sensible defaults")); | ||
| 123 | details.push(("--interactive", "prompt for values")); | ||
| 124 | let suggestions = vec![ | ||
| 125 | "ngit send HEAD~2 --title \"My Feature\" --description \"Details\"", | ||
| 126 | "ngit send --defaults", | ||
| 127 | "ngit send --interactive", | ||
| 128 | ]; | ||
| 129 | Err(cli_error(message, &details, &suggestions)) | ||
| 130 | } | ||
| 131 | |||
| 54 | #[allow(clippy::too_many_lines)] | 132 | #[allow(clippy::too_many_lines)] |
| 55 | pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Result<()> { | 133 | pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Result<()> { |
| 56 | let git_repo = Repo::discover().context("failed to find a git repository")?; | 134 | let git_repo = Repo::discover().context("failed to find a git repository")?; |
| @@ -60,6 +138,9 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re | |||
| 60 | .get_main_or_master_branch() | 138 | .get_main_or_master_branch() |
| 61 | .context("the default branches (main or master) do not exist")?; | 139 | .context("the default branches (main or master) do not exist")?; |
| 62 | 140 | ||
| 141 | // Validate arguments early, before any network calls | ||
| 142 | validate_send_args(cli_args, args)?; | ||
| 143 | |||
| 63 | let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); | 144 | let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); |
| 64 | 145 | ||
| 65 | let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; | 146 | let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; |
| @@ -82,14 +163,32 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re | |||
| 82 | 163 | ||
| 83 | let mut commits: Vec<Sha1Hash> = { | 164 | let mut commits: Vec<Sha1Hash> = { |
| 84 | if args.since_or_range.is_empty() { | 165 | if args.since_or_range.is_empty() { |
| 85 | let branch_name = git_repo.get_checked_out_branch_name()?; | 166 | if cli_args.interactive { |
| 86 | let proposed_commits = if branch_name.eq(main_branch_name) { | 167 | let branch_name = git_repo.get_checked_out_branch_name()?; |
| 87 | vec![main_tip] | 168 | let proposed_commits = if branch_name.eq(main_branch_name) { |
| 169 | vec![main_tip] | ||
| 170 | } else { | ||
| 171 | let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?; | ||
| 172 | ahead | ||
| 173 | }; | ||
| 174 | choose_commits(&git_repo, proposed_commits)? | ||
| 88 | } else { | 175 | } else { |
| 89 | let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?; | 176 | // --defaults was validated above, so we know it's set |
| 90 | ahead | 177 | let branch_name = git_repo.get_checked_out_branch_name()?; |
| 91 | }; | 178 | let proposed_commits = if branch_name.eq(main_branch_name) { |
| 92 | choose_commits(&git_repo, proposed_commits)? | 179 | vec![main_tip] |
| 180 | } else { | ||
| 181 | let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?; | ||
| 182 | ahead | ||
| 183 | }; | ||
| 184 | if proposed_commits.len() > 10 && !cli_args.force { | ||
| 185 | bail!( | ||
| 186 | "too many commits ({}). use --force to proceed or specify a range", | ||
| 187 | proposed_commits.len() | ||
| 188 | ); | ||
| 189 | } | ||
| 190 | proposed_commits | ||
| 191 | } | ||
| 93 | } else { | 192 | } else { |
| 94 | git_repo | 193 | git_repo |
| 95 | .parse_starting_commits(&args.since_or_range) | 194 | .parse_starting_commits(&args.since_or_range) |
| @@ -97,6 +196,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re | |||
| 97 | } | 196 | } |
| 98 | }; | 197 | }; |
| 99 | 198 | ||
| 199 | // Check for too many commits with explicit range | ||
| 200 | if commits.len() > 10 && !cli_args.force && !cli_args.interactive { | ||
| 201 | bail!( | ||
| 202 | "too many commits ({}). use --force to proceed or specify a smaller range", | ||
| 203 | commits.len() | ||
| 204 | ); | ||
| 205 | } | ||
| 206 | |||
| 100 | if commits.is_empty() { | 207 | if commits.is_empty() { |
| 101 | bail!("no commits selected"); | 208 | bail!("no commits selected"); |
| 102 | } | 209 | } |
| @@ -115,6 +222,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re | |||
| 115 | git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?; | 222 | git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?; |
| 116 | 223 | ||
| 117 | check_commits_are_suitable_for_proposal( | 224 | check_commits_are_suitable_for_proposal( |
| 225 | cli_args, | ||
| 118 | &first_commit_ahead, | 226 | &first_commit_ahead, |
| 119 | &commits, | 227 | &commits, |
| 120 | &behind, | 228 | &behind, |
| @@ -138,57 +246,92 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re | |||
| 138 | should_be_pr | 246 | should_be_pr |
| 139 | }; | 247 | }; |
| 140 | 248 | ||
| 141 | let title = if as_pr { | 249 | let cover_letter_title_description = if cli_args.interactive { |
| 142 | match &args.title { | 250 | // Interactive flow: prompt for cover letter confirm, title, description |
| 143 | Some(t) => Some(t.clone()), | 251 | let title = if as_pr { |
| 144 | None => { | 252 | match &args.title { |
| 145 | if root_proposal.is_none() { | 253 | Some(t) => Some(t.clone()), |
| 146 | Some( | 254 | None => { |
| 147 | Interactor::default() | 255 | if root_proposal.is_none() { |
| 148 | .input(PromptInputParms::default().with_prompt("title"))? | 256 | Some( |
| 149 | .clone(), | 257 | Interactor::default() |
| 150 | ) | 258 | .input(PromptInputParms::default().with_prompt("title"))? |
| 151 | } else { | 259 | .clone(), |
| 152 | None | 260 | ) |
| 261 | } else { | ||
| 262 | None | ||
| 263 | } | ||
| 153 | } | 264 | } |
| 154 | } | 265 | } |
| 155 | } | 266 | } else if args.no_cover_letter { |
| 156 | } else if args.no_cover_letter { | 267 | None |
| 157 | None | 268 | } else { |
| 158 | } else { | 269 | match &args.title { |
| 159 | match &args.title { | 270 | Some(t) => Some(t.clone()), |
| 160 | Some(t) => Some(t.clone()), | 271 | None => { |
| 161 | None => { | 272 | if Interactor::default().confirm( |
| 162 | if Interactor::default().confirm( | 273 | PromptConfirmParms::default() |
| 163 | PromptConfirmParms::default() | 274 | .with_default(false) |
| 164 | .with_default(false) | 275 | .with_prompt("include cover letter?"), |
| 165 | .with_prompt("include cover letter?"), | 276 | )? { |
| 166 | )? { | 277 | Some( |
| 167 | Some( | 278 | Interactor::default() |
| 168 | Interactor::default() | 279 | .input(PromptInputParms::default().with_prompt("title"))? |
| 169 | .input(PromptInputParms::default().with_prompt("title"))? | 280 | .clone(), |
| 170 | .clone(), | 281 | ) |
| 171 | ) | 282 | } else { |
| 172 | } else { | 283 | None |
| 173 | None | 284 | } |
| 174 | } | 285 | } |
| 175 | } | 286 | } |
| 176 | } | 287 | }; |
| 177 | }; | ||
| 178 | 288 | ||
| 179 | let cover_letter_title_description = if let Some(title) = title { | 289 | if let Some(title) = title { |
| 180 | Some(( | 290 | Some(( |
| 181 | title, | 291 | title, |
| 182 | if let Some(t) = &args.description { | 292 | if let Some(t) = &args.description { |
| 183 | t.clone() | 293 | t.clone() |
| 184 | } else { | 294 | } else { |
| 185 | Interactor::default() | 295 | Interactor::default() |
| 186 | .input(PromptInputParms::default().with_prompt("description"))? | 296 | .input(PromptInputParms::default().with_prompt("description"))? |
| 187 | .clone() | 297 | .clone() |
| 188 | }, | 298 | }, |
| 189 | )) | 299 | )) |
| 300 | } else { | ||
| 301 | None | ||
| 302 | } | ||
| 303 | } else if as_pr { | ||
| 304 | // PR always needs cover letter | ||
| 305 | let title = match &args.title { | ||
| 306 | Some(t) => t.clone(), | ||
| 307 | None if cli_args.defaults => { | ||
| 308 | git_repo.get_commit_message_summary(commits.first().context("no commits")?)? | ||
| 309 | } | ||
| 310 | None => bail!("PR requires --title and --description (or use --defaults)"), | ||
| 311 | }; | ||
| 312 | let description = match &args.description { | ||
| 313 | Some(d) => d.clone(), | ||
| 314 | None if cli_args.defaults => { | ||
| 315 | let commit = commits.first().context("no commits")?; | ||
| 316 | let full_message = git_repo.get_commit_message(commit)?; | ||
| 317 | let summary = git_repo.get_commit_message_summary(commit)?; | ||
| 318 | full_message | ||
| 319 | .strip_prefix(&summary) | ||
| 320 | .unwrap_or(&full_message) | ||
| 321 | .trim() | ||
| 322 | .to_string() | ||
| 323 | } | ||
| 324 | None => bail!("PR requires --title and --description (or use --defaults)"), | ||
| 325 | }; | ||
| 326 | Some((title, description)) | ||
| 190 | } else { | 327 | } else { |
| 191 | None | 328 | // Patch mode |
| 329 | match (&args.title, &args.description) { | ||
| 330 | (Some(t), Some(d)) => Some((t.clone(), d.clone())), | ||
| 331 | (Some(_), None) => bail!("--title requires --description"), | ||
| 332 | (None, Some(_)) => bail!("--description requires --title"), | ||
| 333 | (None, None) => None, // no cover letter | ||
| 334 | } | ||
| 192 | }; | 335 | }; |
| 193 | 336 | ||
| 194 | let (signer, mut user_ref, _) = login::login_or_signup( | 337 | let (signer, mut user_ref, _) = login::login_or_signup( |
| @@ -303,6 +446,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re | |||
| 303 | } | 446 | } |
| 304 | 447 | ||
| 305 | fn check_commits_are_suitable_for_proposal( | 448 | fn check_commits_are_suitable_for_proposal( |
| 449 | cli: &Cli, | ||
| 306 | first_commit_ahead: &[Sha1Hash], | 450 | first_commit_ahead: &[Sha1Hash], |
| 307 | commits: &[Sha1Hash], | 451 | commits: &[Sha1Hash], |
| 308 | behind: &[Sha1Hash], | 452 | behind: &[Sha1Hash], |
| @@ -310,37 +454,63 @@ fn check_commits_are_suitable_for_proposal( | |||
| 310 | main_tip: &Sha1Hash, | 454 | main_tip: &Sha1Hash, |
| 311 | ) -> Result<()> { | 455 | ) -> Result<()> { |
| 312 | // check proposal ahead of origin/main | 456 | // check proposal ahead of origin/main |
| 313 | if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm( | 457 | if first_commit_ahead.len().gt(&1) { |
| 314 | PromptConfirmParms::default() | 458 | if cli.interactive { |
| 315 | .with_prompt( | 459 | if !Interactor::default().confirm( |
| 316 | format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1) | 460 | PromptConfirmParms::default() |
| 317 | ) | 461 | .with_prompt( |
| 318 | .with_default(false) | 462 | format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1) |
| 319 | ).context("failed to get confirmation response from interactor confirm")? { | 463 | ) |
| 320 | bail!("aborting because selected commits were ahead of origin/master"); | 464 | .with_default(false) |
| 465 | ).context("failed to get confirmation response from interactor confirm")? { | ||
| 466 | bail!("aborting ..."); | ||
| 467 | } | ||
| 468 | } else if !cli.force { | ||
| 469 | bail!( | ||
| 470 | "proposal builds on a commit {} ahead of '{}'. use --force to proceed", | ||
| 471 | first_commit_ahead.len() - 1, | ||
| 472 | main_branch_name | ||
| 473 | ); | ||
| 474 | } | ||
| 321 | } | 475 | } |
| 322 | 476 | ||
| 323 | // check if a selected commit is already in origin | 477 | // check if a selected commit is already in origin |
| 324 | if commits.iter().any(|c| c.eq(main_tip)) { | 478 | if commits.iter().any(|c| c.eq(main_tip)) { |
| 325 | if !Interactor::default().confirm( | 479 | if cli.interactive { |
| 326 | PromptConfirmParms::default() | 480 | if !Interactor::default().confirm( |
| 327 | .with_prompt( | 481 | PromptConfirmParms::default() |
| 328 | format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?") | 482 | .with_prompt( |
| 329 | ) | 483 | format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?") |
| 330 | .with_default(false) | 484 | ) |
| 331 | ).context("failed to get confirmation response from interactor confirm")? { | 485 | .with_default(false) |
| 332 | bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'"); | 486 | ).context("failed to get confirmation response from interactor confirm")? { |
| 487 | bail!("aborting ..."); | ||
| 488 | } | ||
| 489 | } else if !cli.force { | ||
| 490 | bail!( | ||
| 491 | "proposal contains commit(s) already in '{main_branch_name}'. use --force to proceed" | ||
| 492 | ); | ||
| 333 | } | 493 | } |
| 334 | } | 494 | } |
| 335 | // check proposal isn't behind origin/main | 495 | // check proposal isn't behind origin/main |
| 336 | else if !behind.is_empty() && !Interactor::default().confirm( | 496 | else if !behind.is_empty() { |
| 337 | PromptConfirmParms::default() | 497 | if cli.interactive { |
| 338 | .with_prompt( | 498 | if !Interactor::default().confirm( |
| 339 | format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len()) | 499 | PromptConfirmParms::default() |
| 340 | ) | 500 | .with_prompt( |
| 341 | .with_default(false) | 501 | format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len()) |
| 342 | ).context("failed to get confirmation response from interactor confirm")? { | 502 | ) |
| 343 | bail!("aborting so commits can be rebased"); | 503 | .with_default(false) |
| 504 | ).context("failed to get confirmation response from interactor confirm")? { | ||
| 505 | bail!("aborting so commits can be rebased"); | ||
| 506 | } | ||
| 507 | } else if !cli.force { | ||
| 508 | bail!( | ||
| 509 | "proposal is {} behind '{}'. rebase first or use --force to proceed", | ||
| 510 | behind.len(), | ||
| 511 | main_branch_name | ||
| 512 | ); | ||
| 513 | } | ||
| 344 | } | 514 | } |
| 345 | Ok(()) | 515 | Ok(()) |
| 346 | } | 516 | } |
diff --git a/tests/ngit_login.rs b/tests/ngit_login.rs index 31c6edf..0d397ae 100644 --- a/tests/ngit_login.rs +++ b/tests/ngit_login.rs | |||
| @@ -38,7 +38,7 @@ fn first_time_login_choices_succeeds_with_nsec(p: &mut CliTester, nsec: &str) -> | |||
| 38 | 38 | ||
| 39 | fn standard_first_time_login_with_nsec() -> Result<CliTester> { | 39 | fn standard_first_time_login_with_nsec() -> Result<CliTester> { |
| 40 | let test_repo = GitTestRepo::default(); | 40 | let test_repo = GitTestRepo::default(); |
| 41 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login", "--offline"]); | 41 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login", "--offline"]); |
| 42 | 42 | ||
| 43 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; | 43 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; |
| 44 | 44 | ||
| @@ -77,7 +77,8 @@ mod with_relays { | |||
| 77 | 77 | ||
| 78 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 78 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 79 | let test_repo = GitTestRepo::default(); | 79 | let test_repo = GitTestRepo::default(); |
| 80 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); | 80 | let mut p = |
| 81 | CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); | ||
| 81 | 82 | ||
| 82 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; | 83 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; |
| 83 | 84 | ||
| @@ -108,7 +109,8 @@ mod with_relays { | |||
| 108 | 109 | ||
| 109 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 110 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 110 | let test_repo = GitTestRepo::default(); | 111 | let test_repo = GitTestRepo::default(); |
| 111 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); | 112 | let mut p = |
| 113 | CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); | ||
| 112 | 114 | ||
| 113 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; | 115 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; |
| 114 | 116 | ||
| @@ -456,7 +458,8 @@ mod with_relays { | |||
| 456 | 458 | ||
| 457 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 459 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 458 | let test_repo = GitTestRepo::default(); | 460 | let test_repo = GitTestRepo::default(); |
| 459 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); | 461 | let mut p = |
| 462 | CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); | ||
| 460 | 463 | ||
| 461 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; | 464 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; |
| 462 | 465 | ||
| @@ -510,7 +513,8 @@ mod with_relays { | |||
| 510 | 513 | ||
| 511 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 514 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 512 | let test_repo = GitTestRepo::default(); | 515 | let test_repo = GitTestRepo::default(); |
| 513 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); | 516 | let mut p = |
| 517 | CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); | ||
| 514 | 518 | ||
| 515 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; | 519 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; |
| 516 | 520 | ||
| @@ -551,7 +555,7 @@ mod with_relays { | |||
| 551 | 555 | ||
| 552 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 556 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 553 | let test_repo = GitTestRepo::default(); | 557 | let test_repo = GitTestRepo::default(); |
| 554 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login"]); | 558 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login"]); |
| 555 | 559 | ||
| 556 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; | 560 | first_time_login_choices_succeeds_with_nsec(&mut p, TEST_KEY_1_NSEC)?; |
| 557 | 561 | ||
| @@ -626,7 +630,8 @@ mod with_offline_flag { | |||
| 626 | #[test] | 630 | #[test] |
| 627 | fn succeeds_with_text_logged_in_as_npub() -> Result<()> { | 631 | fn succeeds_with_text_logged_in_as_npub() -> Result<()> { |
| 628 | let test_repo = GitTestRepo::default(); | 632 | let test_repo = GitTestRepo::default(); |
| 629 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login", "--offline"]); | 633 | let mut p = |
| 634 | CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login", "--offline"]); | ||
| 630 | 635 | ||
| 631 | show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; | 636 | show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; |
| 632 | 637 | ||
| @@ -641,7 +646,8 @@ mod with_offline_flag { | |||
| 641 | #[test] | 646 | #[test] |
| 642 | fn succeeds_with_hex_secret_key_in_place_of_nsec() -> Result<()> { | 647 | fn succeeds_with_hex_secret_key_in_place_of_nsec() -> Result<()> { |
| 643 | let test_repo = GitTestRepo::default(); | 648 | let test_repo = GitTestRepo::default(); |
| 644 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["account", "login", "--offline"]); | 649 | let mut p = |
| 650 | CliTester::new_from_dir(&test_repo.dir, ["-i", "account", "login", "--offline"]); | ||
| 645 | 651 | ||
| 646 | show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; | 652 | show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; |
| 647 | 653 | ||
| @@ -659,8 +665,10 @@ mod with_offline_flag { | |||
| 659 | #[test] | 665 | #[test] |
| 660 | fn prompts_for_nsec_until_valid() -> Result<()> { | 666 | fn prompts_for_nsec_until_valid() -> Result<()> { |
| 661 | let test_repo = GitTestRepo::default(); | 667 | let test_repo = GitTestRepo::default(); |
| 662 | let mut p = | 668 | let mut p = CliTester::new_from_dir( |
| 663 | CliTester::new_from_dir(&test_repo.dir, ["account", "login", "--offline"]); | 669 | &test_repo.dir, |
| 670 | ["-i", "account", "login", "--offline"], | ||
| 671 | ); | ||
| 664 | 672 | ||
| 665 | show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; | 673 | show_first_time_login_choices(&mut p)?.succeeds_with(0, false, Some(0))?; |
| 666 | 674 | ||
diff --git a/tests/ngit_send.rs b/tests/ngit_send.rs index 2ae858a..7946aef 100644 --- a/tests/ngit_send.rs +++ b/tests/ngit_send.rs | |||
| @@ -75,7 +75,7 @@ mod when_commits_behind_ask_to_proceed { | |||
| 75 | let mut r51 = create_relay_51()?; | 75 | let mut r51 = create_relay_51()?; |
| 76 | // // check relay had the right number of events | 76 | // // check relay had the right number of events |
| 77 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 77 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 78 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); | 78 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "send", "HEAD~2"]); |
| 79 | expect_confirm_prompt(&mut p)?; | 79 | expect_confirm_prompt(&mut p)?; |
| 80 | p.exit()?; | 80 | p.exit()?; |
| 81 | relay::shutdown_relay(8051)?; | 81 | relay::shutdown_relay(8051)?; |
| @@ -94,7 +94,7 @@ mod when_commits_behind_ask_to_proceed { | |||
| 94 | let test_repo = prep_test_repo()?; | 94 | let test_repo = prep_test_repo()?; |
| 95 | let mut r51 = create_relay_51()?; | 95 | let mut r51 = create_relay_51()?; |
| 96 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 96 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 97 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); | 97 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "send", "HEAD~2"]); |
| 98 | expect_confirm_prompt(&mut p)?.succeeds_with(Some(false))?; | 98 | expect_confirm_prompt(&mut p)?.succeeds_with(Some(false))?; |
| 99 | p.expect_end_with("Error: aborting so commits can be rebased\r\n")?; | 99 | p.expect_end_with("Error: aborting so commits can be rebased\r\n")?; |
| 100 | relay::shutdown_relay(8051)?; | 100 | relay::shutdown_relay(8051)?; |
| @@ -113,7 +113,7 @@ mod when_commits_behind_ask_to_proceed { | |||
| 113 | let test_repo = prep_test_repo()?; | 113 | let test_repo = prep_test_repo()?; |
| 114 | let mut r51 = create_relay_51()?; | 114 | let mut r51 = create_relay_51()?; |
| 115 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | 115 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { |
| 116 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); | 116 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["-i", "send", "HEAD~2"]); |
| 117 | expect_confirm_prompt(&mut p)?.succeeds_with(Some(true))?; | 117 | expect_confirm_prompt(&mut p)?.succeeds_with(Some(true))?; |
| 118 | p.expect("? include cover letter")?; | 118 | p.expect("? include cover letter")?; |
| 119 | p.exit()?; | 119 | p.exit()?; |
| @@ -1235,6 +1235,7 @@ mod when_range_ommited_prompts_for_selection_defaulting_ahead_of_main { | |||
| 1235 | 1235 | ||
| 1236 | fn cli_tester_create_proposal(git_repo: &GitTestRepo) -> CliTester { | 1236 | fn cli_tester_create_proposal(git_repo: &GitTestRepo) -> CliTester { |
| 1237 | let args = vec![ | 1237 | let args = vec![ |
| 1238 | "-i", | ||
| 1238 | "--nsec", | 1239 | "--nsec", |
| 1239 | TEST_KEY_1_NSEC, | 1240 | TEST_KEY_1_NSEC, |
| 1240 | "--password", | 1241 | "--password", |
| @@ -1943,3 +1944,144 @@ mod in_reply_to_mentions_npub_and_nprofile_which_get_mentioned_in_proposal_root | |||
| 1943 | Ok(()) | 1944 | Ok(()) |
| 1944 | } | 1945 | } |
| 1945 | } | 1946 | } |
| 1947 | |||
| 1948 | mod non_interactive_validation { | ||
| 1949 | use super::*; | ||
| 1950 | |||
| 1951 | #[test] | ||
| 1952 | fn bare_send_errors_with_helpful_message() -> Result<()> { | ||
| 1953 | let test_repo = prep_git_repo()?; | ||
| 1954 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["send"]); | ||
| 1955 | let output = p.expect_end_eventually()?; | ||
| 1956 | assert!(output.contains("ngit send requires additional arguments")); | ||
| 1957 | assert!(output.contains("<SINCE_OR_RANGE>")); | ||
| 1958 | assert!(output.contains("--title")); | ||
| 1959 | assert!(output.contains("--description")); | ||
| 1960 | assert!(output.contains("--defaults")); | ||
| 1961 | assert!(output.contains("--interactive")); | ||
| 1962 | Ok(()) | ||
| 1963 | } | ||
| 1964 | |||
| 1965 | #[test] | ||
| 1966 | fn send_with_range_only_errors() -> Result<()> { | ||
| 1967 | let test_repo = prep_git_repo()?; | ||
| 1968 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); | ||
| 1969 | let output = p.expect_end_eventually()?; | ||
| 1970 | assert!(output.contains("ngit send requires additional arguments")); | ||
| 1971 | assert!(output.contains("--title")); | ||
| 1972 | assert!(output.contains("--description")); | ||
| 1973 | assert!(output.contains("--defaults")); | ||
| 1974 | Ok(()) | ||
| 1975 | } | ||
| 1976 | |||
| 1977 | #[test] | ||
| 1978 | fn send_force_pr_without_title_errors() -> Result<()> { | ||
| 1979 | let test_repo = prep_git_repo()?; | ||
| 1980 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "--force-pr", "HEAD~2"]); | ||
| 1981 | let output = p.expect_end_eventually()?; | ||
| 1982 | assert!(output.contains("ngit send requires additional arguments")); | ||
| 1983 | assert!(output.contains("--title")); | ||
| 1984 | assert!(output.contains("--description")); | ||
| 1985 | assert!(output.contains("--defaults")); | ||
| 1986 | Ok(()) | ||
| 1987 | } | ||
| 1988 | |||
| 1989 | #[test] | ||
| 1990 | fn send_description_without_title_errors() -> Result<()> { | ||
| 1991 | let test_repo = prep_git_repo()?; | ||
| 1992 | let mut p = | ||
| 1993 | CliTester::new_from_dir(&test_repo.dir, ["send", "--description", "Y", "HEAD~2"]); | ||
| 1994 | let output = p.expect_end_eventually()?; | ||
| 1995 | assert!(output.contains("ngit send requires --title when --description is provided")); | ||
| 1996 | assert!(output.contains("--title")); | ||
| 1997 | Ok(()) | ||
| 1998 | } | ||
| 1999 | |||
| 2000 | #[test] | ||
| 2001 | fn send_title_without_description_errors() -> Result<()> { | ||
| 2002 | let test_repo = prep_git_repo()?; | ||
| 2003 | let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "--title", "X", "HEAD~2"]); | ||
| 2004 | let output = p.expect_end_eventually()?; | ||
| 2005 | assert!(output.contains("ngit send requires --description when --title is provided")); | ||
| 2006 | assert!(output.contains("--description")); | ||
| 2007 | Ok(()) | ||
| 2008 | } | ||
| 2009 | |||
| 2010 | #[tokio::test] | ||
| 2011 | #[serial] | ||
| 2012 | async fn send_defaults_sends_patches_without_cover_letter() -> Result<()> { | ||
| 2013 | let git_repo = prep_git_repo()?; | ||
| 2014 | |||
| 2015 | let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( | ||
| 2016 | Relay::new( | ||
| 2017 | 8051, | ||
| 2018 | None, | ||
| 2019 | Some(&|relay, client_id, subscription_id, _| -> Result<()> { | ||
| 2020 | relay.respond_events( | ||
| 2021 | client_id, | ||
| 2022 | &subscription_id, | ||
| 2023 | &vec![ | ||
| 2024 | generate_test_key_1_metadata_event("fred"), | ||
| 2025 | generate_test_key_1_relay_list_event(), | ||
| 2026 | ], | ||
| 2027 | )?; | ||
| 2028 | Ok(()) | ||
| 2029 | }), | ||
| 2030 | ), | ||
| 2031 | Relay::new(8052, None, None), | ||
| 2032 | Relay::new(8053, None, None), | ||
| 2033 | Relay::new( | ||
| 2034 | 8055, | ||
| 2035 | None, | ||
| 2036 | Some(&|relay, client_id, subscription_id, _| -> Result<()> { | ||
| 2037 | relay.respond_events( | ||
| 2038 | client_id, | ||
| 2039 | &subscription_id, | ||
| 2040 | &vec![generate_repo_ref_event()], | ||
| 2041 | )?; | ||
| 2042 | Ok(()) | ||
| 2043 | }), | ||
| 2044 | ), | ||
| 2045 | Relay::new(8056, None, None), | ||
| 2046 | ); | ||
| 2047 | |||
| 2048 | let cli_tester_handle = std::thread::spawn(move || -> Result<()> { | ||
| 2049 | let mut p = CliTester::new_from_dir( | ||
| 2050 | &git_repo.dir, | ||
| 2051 | [ | ||
| 2052 | "--nsec", | ||
| 2053 | TEST_KEY_1_NSEC, | ||
| 2054 | "--password", | ||
| 2055 | TEST_PASSWORD, | ||
| 2056 | "--disable-cli-spinners", | ||
| 2057 | "--defaults", | ||
| 2058 | "send", | ||
| 2059 | ], | ||
| 2060 | ); | ||
| 2061 | p.expect_end_eventually()?; | ||
| 2062 | for p in [51, 52, 53, 55, 56] { | ||
| 2063 | relay::shutdown_relay(8000 + p)?; | ||
| 2064 | } | ||
| 2065 | Ok(()) | ||
| 2066 | }); | ||
| 2067 | |||
| 2068 | let _ = join!( | ||
| 2069 | r51.listen_until_close(), | ||
| 2070 | r52.listen_until_close(), | ||
| 2071 | r53.listen_until_close(), | ||
| 2072 | r55.listen_until_close(), | ||
| 2073 | r56.listen_until_close(), | ||
| 2074 | ); | ||
| 2075 | cli_tester_handle.join().unwrap()?; | ||
| 2076 | |||
| 2077 | // verify patches sent without cover letter | ||
| 2078 | for relay in [&r53, &r55, &r56] { | ||
| 2079 | assert_eq!( | ||
| 2080 | relay.events.iter().filter(|e| is_cover_letter(e)).count(), | ||
| 2081 | 0, | ||
| 2082 | ); | ||
| 2083 | assert_eq!(relay.events.iter().filter(|e| is_patch(e)).count(), 2); | ||
| 2084 | } | ||
| 2085 | Ok(()) | ||
| 2086 | } | ||
| 2087 | } | ||