upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
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 /src
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
Diffstat (limited to 'src')
-rw-r--r--src/bin/ngit/sub_commands/send.rs326
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
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}