diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-10 12:52:26 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-10 13:03:39 +0000 |
| commit | 3383477386916e82a19fa1e9c4d95b232ba0a40e (patch) | |
| tree | 90347a4d2a5d09d218b534a945ced243bedd8a0d /src/bin/ngit/sub_commands | |
| parent | 09bb21462ac5571cace5a7e71103156772a499fe (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
Diffstat (limited to 'src/bin/ngit/sub_commands')
| -rw-r--r-- | src/bin/ngit/sub_commands/send.rs | 326 |
1 files changed, 248 insertions, 78 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 | } |