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(Clone, Debug, clap::Args)]
pub struct SubCommandArgs {
#[arg(default_value = "")]
pub since_or_range: String,
#[arg(long, num_args = 0.., value_delimiter = ' ')]
pub in_reply_to: Vec<String>,
#[arg(long)]
pub no_cover_letter: bool,
#[arg(long)]
pub title: Option<String>,
#[arg(long)]
pub description: Option<String>,
#[arg(long)]
pub force_pr: bool,
#[arg(long)]
pub force_patch: bool,
#[arg(long = "push-option", short = 'o', num_args = 0..)]
pub push_options: Vec<String>,
}
fn validate_send_args(cli: &Cli, args: &SubCommandArgs) -> Result<()> {
if cli.interactive {
return Ok(());
}
if args.description.is_some() && args.title.is_none() {
let message = "ngit send requires --title when --description is provided";
let details = vec![("--title <T>", "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));
}
if args.title.is_some() && args.description.is_none() {
let message = "ngit send requires --description when --title is provided";
let details = vec![("--description <D>", "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));
}
if !args.in_reply_to.is_empty() {
return Ok(());
}
if cli.defaults {
return Ok(());
}
if args.title.is_some() && args.description.is_some() {
return Ok(());
}
if args.no_cover_letter && !args.since_or_range.is_empty() {
return Ok(());
}
let message = "ngit send requires additional arguments";
let mut details = vec![];
if args.since_or_range.is_empty() {
details.push(("<SINCE_OR_RANGE>", "commits to send (eg. HEAD~2)"));
}
details.push(("--title <T> --description <D>", "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_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<Sha1Hash> = {
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 {
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")?
}
};
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::<String>()),
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 if args.no_cover_letter {
false
} else {
should_be_pr
};
let cover_letter_title_description = if cli_args.interactive {
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 {
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 {
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, }
};
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;
commits.reverse();
let events = if as_pr {
let tip = commits.last().context("no commits")?; 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,
))
);
}
}
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<()> {
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"
);
}
}
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<Sha1Hash>) -> Result<Vec<Sha1Hash>> {
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<String> {
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::<Vec<String>>()
.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::<String>(),),
))
}
async fn get_root_proposal_and_mentions_from_in_reply_to(
git_repo_path: &Path,
in_reply_to: &[String],
) -> Result<(Option<Event>, Vec<nostr::Tag>)> {
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))
}