use std::path::Path; use anyhow::{Context, Result, bail}; use console::Style; use ngit::{ client::{Params, send_events}, git_events::{EventRefType, KIND_PULL_REQUEST, generate_cover_letter_and_patch_events}, push::select_servers_push_refs_and_generate_pr_or_pr_update_event, utils::proposal_tip_is_pr_or_pr_update, }; use nostr::{ToBech32, event::Event, nips::nip19::Nip19Event}; use nostr_sdk::hashes::sha1::Hash as Sha1Hash; use crate::{ cli::{Cli, extract_signer_cli_arguments}, cli_interactor::{ Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms, cli_error, }, client::{ Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, }, git::{Repo, RepoActions, identify_ahead_behind}, git_events::{event_is_patch_set_root, event_tag_from_nip19_or_hex}, login, repo_ref::get_repo_coordinates_when_remote_unknown, }; #[derive(Debug, clap::Args)] pub struct SubCommandArgs { #[arg(default_value = "")] /// commits to send as proposal; like in `git format-patch` eg. HEAD~2 pub(crate) since_or_range: String, #[clap(long, value_parser, num_args = 0.., value_delimiter = ' ')] /// references to an existing proposal for which this is a new /// version and/or events / npubs to tag as mentions pub(crate) in_reply_to: Vec, /// don't prompt for a cover letter #[arg(long, action)] pub(crate) no_cover_letter: bool, /// optional cover letter title #[clap(long)] pub(crate) title: Option, #[clap(long)] /// optional cover letter description pub(crate) description: Option, /// publish as Pull Request even if each commit is < 60kb #[arg(long, action)] pub(crate) force_pr: bool, /// publish as Patches even if they may be > 60kb #[arg(long, action)] pub(crate) force_patch: bool, #[clap(long = "push-option", short = 'o', value_parser, num_args = 0..)] /// git push options to pass to the git server (eg. -o secret-scanning.skip) pub(crate) push_options: Vec, } /// Validates send command arguments for non-interactive mode. /// /// Returns Ok(()) if: /// - Interactive mode is enabled (all validation happens interactively) /// - Updating an existing proposal (`in_reply_to` is non-empty) /// - Using defaults mode (--defaults will fill in gaps) /// - Both title and description are provided /// /// Returns an error if: /// - Description provided without title /// - Title provided without description /// - Missing required arguments in non-interactive mode fn validate_send_args(cli: &Cli, args: &SubCommandArgs) -> Result<()> { // Interactive mode handles all validation interactively if cli.interactive { return Ok(()); } // Description requires title if args.description.is_some() && args.title.is_none() { let message = "ngit send requires --title when --description is provided"; let details = vec![("--title ", "cover letter title")]; let suggestions = vec![ "ngit send HEAD~2 --title \"My Feature\" --description \"Details\"", "ngit send --interactive", ]; return Err(cli_error(message, &details, &suggestions)); } // Title requires description if args.title.is_some() && args.description.is_none() { let message = "ngit send requires --description when --title is provided"; let details = vec![("--description ", "cover letter description")]; let suggestions = vec![ "ngit send HEAD~2 --title \"My Feature\" --description \"Details\"", "ngit send --interactive", ]; return Err(cli_error(message, &details, &suggestions)); } // Updating existing proposal - no additional validation needed if !args.in_reply_to.is_empty() { return Ok(()); } // Defaults mode will fill in gaps if cli.defaults { return Ok(()); } // Both title and description provided - all good if args.title.is_some() && args.description.is_some() { return Ok(()); } // --no-cover-letter with a range is valid (patches without cover letter) if args.no_cover_letter && !args.since_or_range.is_empty() { return Ok(()); } // Missing required arguments for non-interactive mode let message = "ngit send requires additional arguments"; let mut details = vec![]; if args.since_or_range.is_empty() { details.push(("", "commits to send (eg. HEAD~2)")); } details.push(("--title --description ", "cover letter details")); details.push(("-d, --defaults", "use sensible defaults")); details.push(("--interactive", "prompt for values")); let suggestions = vec![ "ngit send HEAD~2 --title \"My Feature\" --description \"Details\"", "ngit send --defaults", "ngit send --interactive", ]; Err(cli_error(message, &details, &suggestions)) } #[allow(clippy::too_many_lines)] pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Result<()> { let git_repo = Repo::discover().context("failed to find a git repository")?; let git_repo_path = git_repo.get_path()?; let (main_branch_name, main_tip) = git_repo .get_main_or_master_branch() .context("the default branches (main or master) do not exist")?; // Validate arguments early, before any network calls validate_send_args(cli_args, args)?; let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; if !no_fetch { fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; } let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?; let (root_proposal, mention_tags) = get_root_proposal_and_mentions_from_in_reply_to(git_repo.get_path()?, &args.in_reply_to) .await?; if let Some(root_ref) = args.in_reply_to.first() { if root_proposal.is_some() { println!("creating proposal revision for: {root_ref}"); } } let mut commits: Vec = { if args.since_or_range.is_empty() { if cli_args.interactive { let branch_name = git_repo.get_checked_out_branch_name()?; let proposed_commits = if branch_name.eq(main_branch_name) { vec![main_tip] } else { let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?; ahead }; choose_commits(&git_repo, proposed_commits)? } else { // --defaults was validated above, so we know it's set let branch_name = git_repo.get_checked_out_branch_name()?; let proposed_commits = if branch_name.eq(main_branch_name) { vec![main_tip] } else { let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?; ahead }; if proposed_commits.len() > 10 && !cli_args.force { bail!( "too many commits ({}). use --force to proceed or specify a range", proposed_commits.len() ); } proposed_commits } } else { git_repo .parse_starting_commits(&args.since_or_range) .context("failed to parse specified starting commit or range")? } }; // Check for too many commits with explicit range if commits.len() > 10 && !cli_args.force && !cli_args.interactive { bail!( "too many commits ({}). use --force to proceed or specify a smaller range", commits.len() ); } if commits.is_empty() { bail!("no commits selected"); } println!("creating proposal from {} commits:", commits.len()); let dim = Style::new().color256(247); for commit in &commits { println!( "{} {}", dim.apply_to(commit.to_string().chars().take(7).collect::()), git_repo.get_commit_message_summary(commit)? ); } let (first_commit_ahead, behind) = git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?; check_commits_are_suitable_for_proposal( cli_args, &first_commit_ahead, &commits, &behind, main_branch_name, &main_tip, )?; let should_be_pr = { if let Some(root_proposal) = &root_proposal { proposal_tip_is_pr_or_pr_update(git_repo_path, &repo_ref, &root_proposal.id).await? } else { false } } || git_repo.are_commits_too_big_for_patches(&commits); let as_pr = if args.force_patch { false } else if args.force_pr { true } else { should_be_pr }; let cover_letter_title_description = if cli_args.interactive { // Interactive flow: prompt for cover letter confirm, title, description let title = if as_pr { match &args.title { Some(t) => Some(t.clone()), None => { if root_proposal.is_none() { Some( Interactor::default() .input(PromptInputParms::default().with_prompt("title"))? .clone(), ) } else { None } } } } else if args.no_cover_letter { None } else { match &args.title { Some(t) => Some(t.clone()), None => { if Interactor::default().confirm( PromptConfirmParms::default() .with_default(false) .with_prompt("include cover letter?"), )? { Some( Interactor::default() .input(PromptInputParms::default().with_prompt("title"))? .clone(), ) } else { None } } } }; if let Some(title) = title { Some(( title, if let Some(t) = &args.description { t.clone() } else { Interactor::default() .input(PromptInputParms::default().with_prompt("description"))? .clone() }, )) } else { None } } else if as_pr { // PR always needs cover letter let title = match &args.title { Some(t) => t.clone(), None if cli_args.defaults => { git_repo.get_commit_message_summary(commits.first().context("no commits")?)? } None => bail!("PR requires --title and --description (or use --defaults)"), }; let description = match &args.description { Some(d) => d.clone(), None if cli_args.defaults => { let commit = commits.first().context("no commits")?; let full_message = git_repo.get_commit_message(commit)?; let summary = git_repo.get_commit_message_summary(commit)?; full_message .strip_prefix(&summary) .unwrap_or(&full_message) .trim() .to_string() } None => bail!("PR requires --title and --description (or use --defaults)"), }; Some((title, description)) } else { // Patch mode match (&args.title, &args.description) { (Some(t), Some(d)) => Some((t.clone(), d.clone())), (Some(_), None) => bail!("--title requires --description"), (None, Some(_)) => bail!("--description requires --title"), (None, None) => None, // no cover letter } }; let (signer, mut user_ref, _) = login::login_or_signup( &Some(&git_repo), &extract_signer_cli_arguments(cli_args).unwrap_or(None), &cli_args.password, Some(&client), true, ) .await?; client.set_signer(signer.clone()).await; // oldest first commits.reverse(); let events = if as_pr { let tip = commits.last().context("no commits")?; // commits has been reversed to oldest first let first_commit = commits.first().context("no commits")?; { let push_options_refs: Vec<&str> = args.push_options.iter().map(String::as_str).collect(); select_servers_push_refs_and_generate_pr_or_pr_update_event( &client, &git_repo, &repo_ref, tip, first_commit, git_repo.get_commit_parent(first_commit).ok().as_ref(), &mut user_ref, root_proposal.as_ref(), &cover_letter_title_description, &signer, true, &console::Term::stdout(), &push_options_refs, ) .await? } } else { let events = generate_cover_letter_and_patch_events( cover_letter_title_description.clone(), &git_repo, &commits, &signer, &repo_ref, &root_proposal.as_ref().map(|e| e.id.to_string()), &mention_tags, ) .await?; println!( "posting {} patch{} {} a covering letter...", if cover_letter_title_description.is_none() { events.len() } else { events.len() - 1 }, if cover_letter_title_description.is_none() && events.len().eq(&1) || cover_letter_title_description.is_some() && events.len().eq(&2) { "" } else { "es" }, if cover_letter_title_description.is_none() { "without" } else { "with" } ); events }; let _ = send_events( &client, Some(git_repo_path), events.clone(), user_ref.relays.write(), repo_ref.relays.clone(), !cli_args.disable_cli_spinners, false, ) .await?; if root_proposal.is_none() { if let Some(event) = events.first() { let event_bech32 = if let Some(relay) = repo_ref.relays.first() { Nip19Event { event_id: event.id, relays: vec![relay.clone()], author: None, kind: None, } .to_bech32()? } else { event.id.to_bech32()? }; println!( "{}", dim.apply_to(format!( "view in gitworkshop.dev: https://gitworkshop.dev/{}", &event_bech32, )) ); println!( "{}", dim.apply_to(format!( "view in another client: https://njump.me/{}", &event_bech32, )) ); } } // TODO check if there is already a similarly named Ok(()) } fn check_commits_are_suitable_for_proposal( cli: &Cli, first_commit_ahead: &[Sha1Hash], commits: &[Sha1Hash], behind: &[Sha1Hash], main_branch_name: &str, main_tip: &Sha1Hash, ) -> Result<()> { // check proposal ahead of origin/main if first_commit_ahead.len().gt(&1) { if cli.interactive { if !Interactor::default().confirm( PromptConfirmParms::default() .with_prompt( format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1) ) .with_default(false) ).context("failed to get confirmation response from interactor confirm")? { bail!("aborting ..."); } } else if !cli.force { bail!( "proposal builds on a commit {} ahead of '{}'. use --force to proceed", first_commit_ahead.len() - 1, main_branch_name ); } } // check if a selected commit is already in origin if commits.iter().any(|c| c.eq(main_tip)) { if cli.interactive { if !Interactor::default().confirm( PromptConfirmParms::default() .with_prompt( format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?") ) .with_default(false) ).context("failed to get confirmation response from interactor confirm")? { bail!("aborting ..."); } } else if !cli.force { bail!( "proposal contains commit(s) already in '{main_branch_name}'. use --force to proceed" ); } } // check proposal isn't behind origin/main else if !behind.is_empty() { if cli.interactive { if !Interactor::default().confirm( PromptConfirmParms::default() .with_prompt( format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len()) ) .with_default(false) ).context("failed to get confirmation response from interactor confirm")? { bail!("aborting so commits can be rebased"); } } else if !cli.force { bail!( "proposal is {} behind '{}'. rebase first or use --force to proceed", behind.len(), main_branch_name ); } } Ok(()) } fn choose_commits(git_repo: &Repo, proposed_commits: Vec) -> Result> { let mut proposed_commits = if proposed_commits.len().gt(&10) { vec![] } else { proposed_commits }; let tip_of_head = git_repo.get_tip_of_branch(&git_repo.get_checked_out_branch_name()?)?; let most_recent_commit = proposed_commits.first().unwrap_or(&tip_of_head); let mut last_15_commits = vec![*most_recent_commit]; while last_15_commits.len().lt(&15) { if let Ok(parent_commit) = git_repo.get_commit_parent(last_15_commits.last().unwrap()) { last_15_commits.push(parent_commit); } else { break; } } let term = console::Term::stderr(); let mut printed_error_line = false; let selected_commits = 'outer: loop { let selected = Interactor::default().multi_choice( PromptMultiChoiceParms::default() .with_prompt("select commits for proposal") .dont_report() .with_choices( last_15_commits .iter() .map(|h| summarise_commit_for_selection(git_repo, h).unwrap()) .collect(), ) .with_defaults( last_15_commits .iter() .map(|h| proposed_commits.iter().any(|c| c.eq(h))) .collect(), ), )?; proposed_commits = selected.iter().map(|i| last_15_commits[*i]).collect(); if printed_error_line { term.clear_last_lines(1)?; } if proposed_commits.is_empty() { term.write_line("no commits selected")?; printed_error_line = true; continue; } for (i, selected_i) in selected.iter().enumerate() { if i.gt(&0) && selected_i.ne(&(selected[i - 1] + 1)) { term.write_line("commits must be consecutive. try again.")?; printed_error_line = true; continue 'outer; } } break proposed_commits; }; Ok(selected_commits) } fn summarise_commit_for_selection(git_repo: &Repo, commit: &Sha1Hash) -> Result { let references = git_repo.get_refs(commit)?; let dim = Style::new().color256(247); let prefix = format!("({})", git_repo.get_commit_author(commit)?[0],); let references_string = if references.is_empty() { String::new() } else { format!( " {}", references .iter() .map(|r| format!("[{r}]")) .collect::>() .join(" ") ) }; Ok(format!( "{} {}{} {}", dim.apply_to(prefix), git_repo.get_commit_message_summary(commit)?, Style::new().magenta().apply_to(references_string), dim.apply_to(commit.to_string().chars().take(7).collect::(),), )) } async fn get_root_proposal_and_mentions_from_in_reply_to( git_repo_path: &Path, in_reply_to: &[String], ) -> Result<(Option, Vec)> { let root_proposal = if let Some(first) = in_reply_to.first() { match event_tag_from_nip19_or_hex(first, "in-reply-to", EventRefType::Root, true, false)? .as_standardized() { Some(nostr_sdk::TagStandard::Event { event_id, relay_url: _, marker: _, public_key: _, uppercase: false, }) => { let events = get_events_from_local_cache( git_repo_path, vec![nostr::Filter::new().id(*event_id)], ) .await?; if let Some(first) = events.iter().find(|e| e.id.eq(event_id)) { if event_is_patch_set_root(first) || first.kind.eq(&KIND_PULL_REQUEST) { Some(first.clone()) } else { None } } else { None } } _ => None, } } else { return Ok((None, vec![])); }; let mut mention_tags = vec![]; for (i, reply_to) in in_reply_to.iter().enumerate() { if i.ne(&0) || root_proposal.is_none() { mention_tags.push( event_tag_from_nip19_or_hex( reply_to, "in-reply-to", EventRefType::Quote, true, false, ) .context(format!( "{reply_to} in 'in-reply-to' not a valid nostr reference" ))?, ); } } Ok((root_proposal, mention_tags)) } // TODO // - find profile // - file relays // - find repo events // -