From 949c6459aa7683453a7160423b689ceadb08954b Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 4 Sep 2024 08:04:48 +0100 Subject: refactor: organise into lib and bin structure the make the code more readable this commit just moves the files, the next commit should fix the imports --- src/sub_commands/fetch.rs | 44 -- src/sub_commands/init.rs | 385 ------------- src/sub_commands/list.rs | 906 ------------------------------ src/sub_commands/login.rs | 52 -- src/sub_commands/mod.rs | 7 - src/sub_commands/pull.rs | 209 ------- src/sub_commands/push.rs | 223 -------- src/sub_commands/send.rs | 1363 --------------------------------------------- 8 files changed, 3189 deletions(-) delete mode 100644 src/sub_commands/fetch.rs delete mode 100644 src/sub_commands/init.rs delete mode 100644 src/sub_commands/list.rs delete mode 100644 src/sub_commands/login.rs delete mode 100644 src/sub_commands/mod.rs delete mode 100644 src/sub_commands/pull.rs delete mode 100644 src/sub_commands/push.rs delete mode 100644 src/sub_commands/send.rs (limited to 'src/sub_commands') diff --git a/src/sub_commands/fetch.rs b/src/sub_commands/fetch.rs deleted file mode 100644 index b1e83c5..0000000 --- a/src/sub_commands/fetch.rs +++ /dev/null @@ -1,44 +0,0 @@ -use std::collections::HashSet; - -use anyhow::{Context, Result}; -use clap; -use nostr::nips::nip01::Coordinate; - -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{ - cli::Cli, - client::{fetching_with_report, Connect}, - git::{Repo, RepoActions}, - repo_ref::get_repo_coordinates, -}; - -#[derive(clap::Args)] -pub struct SubCommandArgs { - /// address pointer to repo announcement - #[arg(long, action)] - repo: Vec, -} - -pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { - let _ = args; - let git_repo = Repo::discover().context("cannot find a git repository")?; - #[cfg(not(test))] - let client = Client::default(); - #[cfg(test)] - let client = ::default(); - let repo_coordinates = if command_args.repo.is_empty() { - get_repo_coordinates(&git_repo, &client).await? - } else { - let mut repo_coordinates = HashSet::new(); - for repo in &command_args.repo { - repo_coordinates.insert(Coordinate::parse(repo.clone())?); - } - repo_coordinates - }; - fetching_with_report(git_repo.get_path()?, &client, &repo_coordinates).await?; - client.disconnect().await?; - Ok(()) -} diff --git a/src/sub_commands/init.rs b/src/sub_commands/init.rs deleted file mode 100644 index 5b7e03d..0000000 --- a/src/sub_commands/init.rs +++ /dev/null @@ -1,385 +0,0 @@ -use std::collections::HashMap; - -use anyhow::{Context, Result}; -use nostr::{nips::nip01::Coordinate, FromBech32, PublicKey, ToBech32}; -use nostr_sdk::Kind; - -use super::send::send_events; -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{ - cli::Cli, - cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, - client::{fetching_with_report, get_repo_ref_from_cache, Connect}, - git::{convert_clone_url_to_https, Repo, RepoActions}, - login, - repo_ref::{ - extract_pks, get_repo_config_from_yaml, save_repo_config_to_yaml, - try_and_get_repo_coordinates, RepoRef, - }, -}; - -#[derive(Debug, clap::Args)] -pub struct SubCommandArgs { - #[clap(short, long)] - /// name of repository - title: Option, - #[clap(short, long)] - /// optional description - description: Option, - #[clap(long)] - /// git server url users can clone from - clone_url: Vec, - #[clap(short, long, value_parser, num_args = 1..)] - /// homepage - web: Vec, - #[clap(short, long, value_parser, num_args = 1..)] - /// relays contributors push patches and comments to - relays: Vec, - #[clap(short, long, value_parser, num_args = 1..)] - /// npubs of other maintainers - other_maintainers: Vec, - #[clap(long)] - /// usually root commit but will be more recent commit for forks - earliest_unique_commit: Option, - #[clap(short, long)] - /// shortname with no spaces or special characters - identifier: Option, -} - -#[allow(clippy::too_many_lines)] -pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { - let git_repo = Repo::discover().context("cannot find a git repository")?; - let git_repo_path = git_repo.get_path()?; - - let root_commit = git_repo - .get_root_commit() - .context("failed to get root commit of the repository")?; - - // TODO: check for empty repo - // TODO: check for existing maintaiers file - - #[cfg(not(test))] - let mut client = Client::default(); - #[cfg(test)] - let mut client = ::default(); - - let repo_coordinates = if let Ok(repo_coordinates) = - try_and_get_repo_coordinates(&git_repo, &client, false).await - { - Some(repo_coordinates) - } else { - None - }; - - let repo_ref = if let Some(repo_coordinates) = repo_coordinates { - fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; - Some(get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?) - } else { - None - }; - - let (signer, user_ref) = login::launch( - &git_repo, - &cli_args.bunker_uri, - &cli_args.bunker_app_key, - &cli_args.nsec, - &cli_args.password, - Some(&client), - false, - false, - ) - .await?; - - let repo_config_result = get_repo_config_from_yaml(&git_repo); - // TODO: check for other claims - - let name = match &args.title { - Some(t) => t.clone(), - None => Interactor::default().input( - PromptInputParms::default() - .with_prompt("name") - .with_default(if let Some(repo_ref) = &repo_ref { - repo_ref.name.clone() - } else { - String::new() - }), - )?, - }; - - let identifier = match &args.identifier { - Some(t) => t.clone(), - None => Interactor::default().input( - PromptInputParms::default() - .with_prompt("identifier") - .with_default(if let Some(repo_ref) = &repo_ref { - repo_ref.identifier.clone() - } else { - let fallback = name - .clone() - .replace(' ', "-") - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c.eq(&'/') { - c - } else { - '-' - } - }) - .collect(); - if let Ok(config) = &repo_config_result { - if let Some(identifier) = &config.identifier { - identifier.to_string() - } else { - fallback - } - } else { - fallback - } - }), - )?, - }; - - let description = match &args.description { - Some(t) => t.clone(), - None => Interactor::default().input( - PromptInputParms::default() - .with_prompt("description") - .with_default(if let Some(repo_ref) = &repo_ref { - repo_ref.description.clone() - } else { - String::new() - }), - )?, - }; - - let git_server = if args.clone_url.is_empty() { - Interactor::default() - .input( - PromptInputParms::default() - .with_prompt("clone url (for fetch)") - .with_default(if let Some(repo_ref) = &repo_ref { - repo_ref.git_server.clone().join(" ") - } else if let Ok(url) = git_repo.get_origin_url() { - if let Ok(fetch_url) = convert_clone_url_to_https(&url) { - fetch_url - } else { - // local repo or custom protocol - url - } - } else { - String::new() - }), - )? - .split(' ') - .map(std::string::ToString::to_string) - .collect() - } else { - args.clone_url.clone() - }; - - let web: Vec = if args.web.is_empty() { - Interactor::default() - .input( - PromptInputParms::default() - .with_prompt("web") - .optional() - .with_default(if let Some(repo_ref) = &repo_ref { - repo_ref.web.clone().join(" ") - } else { - format!("https://gitworkshop.dev/repo/{}", &identifier) - }), - )? - .split(' ') - .map(std::string::ToString::to_string) - .collect() - } else { - args.web.clone() - }; - - let maintainers: Vec = { - let mut dont_ask = !args.other_maintainers.is_empty(); - let mut maintainers_string = if !args.other_maintainers.is_empty() { - [args.other_maintainers.clone()].concat().join(" ") - } else if repo_ref.is_none() && repo_config_result.is_err() { - signer.public_key().await?.to_bech32()? - } else { - let maintainers = if let Ok(config) = &repo_config_result { - config.maintainers.clone() - } else if let Some(repo_ref) = &repo_ref { - repo_ref - .maintainers - .clone() - .iter() - .map(|k| k.to_bech32().unwrap()) - .collect() - } else { - //unreachable - vec![signer.public_key().await?.to_bech32()?] - }; - // add current user if not present - if maintainers.iter().any(|m| { - if let Ok(m_pubkey) = PublicKey::from_bech32(m) { - user_ref.public_key.eq(&m_pubkey) - } else { - false - } - }) { - maintainers.join(" ") - } else { - [maintainers, vec![signer.public_key().await?.to_bech32()?]] - .concat() - .join(" ") - } - }; - 'outer: loop { - if !dont_ask { - println!("{}", &maintainers_string); - maintainers_string = Interactor::default().input( - PromptInputParms::default() - .with_prompt("maintainers") - .with_default(maintainers_string), - )?; - } - let mut maintainers: Vec = vec![]; - for m in maintainers_string.split(' ') { - if let Ok(m_pubkey) = PublicKey::from_bech32(m) { - maintainers.push(m_pubkey); - } else { - println!("not a valid set of npubs seperated by a space"); - dont_ask = false; - continue 'outer; - } - } - // add current user incase removed - if !maintainers.iter().any(|m| user_ref.public_key.eq(m)) { - maintainers.push(signer.public_key().await?); - } - break maintainers; - } - }; - - // TODO: check if relays are free to post to so contributors can submit patches - // TODO: recommend some reliable free ones - let relays: Vec = if args.relays.is_empty() { - Interactor::default() - .input( - PromptInputParms::default() - .with_prompt("relays") - .with_default(if let Ok(config) = &repo_config_result { - config.relays.clone().join(" ") - } else if let Some(repo_ref) = &repo_ref { - repo_ref.relays.clone().join(" ") - } else { - user_ref.relays.write().join(" ") - }), - )? - .split(' ') - .map(std::string::ToString::to_string) - .collect() - } else { - args.relays.clone() - }; - - let earliest_unique_commit = match &args.earliest_unique_commit { - Some(t) => t.clone(), - None => { - let mut earliest_unique_commit = if let Some(repo_ref) = &repo_ref { - repo_ref.root_commit.clone() - } else { - root_commit.to_string() - }; - loop { - earliest_unique_commit = Interactor::default().input( - PromptInputParms::default() - .with_prompt("earliest unique commit") - .with_default(earliest_unique_commit.clone()), - )?; - if let Ok(exists) = git_repo.does_commit_exist(&earliest_unique_commit) { - if exists { - break earliest_unique_commit; - } - println!("commit does not exist on current repository"); - } else { - println!("commit id not formatted correctly"); - } - if earliest_unique_commit.len().ne(&40) { - println!("commit id must be 40 characters long"); - } - } - } - }; - - println!("publishing repostory reference..."); - - let repo_ref = RepoRef { - identifier: identifier.clone(), - name, - description, - root_commit: earliest_unique_commit, - git_server, - web, - relays: relays.clone(), - maintainers: maintainers.clone(), - events: HashMap::new(), - }; - let repo_event = repo_ref.to_event(&signer).await?; - - client.set_signer(signer).await; - - send_events( - &client, - git_repo_path, - vec![repo_event], - user_ref.relays.write(), - relays.clone(), - !cli_args.disable_cli_spinners, - false, - ) - .await?; - - git_repo.save_git_config_item( - "nostr.repo", - &Coordinate { - kind: Kind::GitRepoAnnouncement, - public_key: user_ref.public_key, - identifier: identifier.clone(), - relays: vec![], - } - .to_bech32()?, - false, - )?; - - // if yaml file doesnt exist or needs updating - if match &repo_config_result { - Ok(config) => { - ! as Clone>::clone(&config.identifier) - .unwrap_or_default() - .eq(&identifier) - || !extract_pks(config.maintainers.clone())?.eq(&maintainers) - || !config.relays.eq(&relays) - } - Err(_) => true, - } { - save_repo_config_to_yaml( - &git_repo, - identifier.clone(), - maintainers.clone(), - relays.clone(), - )?; - println!( - "maintainers.yaml {}. commit and push.", - if repo_config_result.is_err() { - "created" - } else { - "updated" - } - ); - println!( - "this optional file helps in identifying who the maintainers are over time through the commit history" - ); - } - Ok(()) -} diff --git a/src/sub_commands/list.rs b/src/sub_commands/list.rs deleted file mode 100644 index ac1f4ab..0000000 --- a/src/sub_commands/list.rs +++ /dev/null @@ -1,906 +0,0 @@ -use std::{collections::HashSet, io::Write, ops::Add, path::Path}; - -use anyhow::{bail, Context, Result}; -use nostr::nips::nip01::Coordinate; -use nostr_sdk::{Kind, PublicKey}; - -use super::send::event_is_patch_set_root; -#[cfg(test)] -use crate::client::MockConnect; -#[cfg(not(test))] -use crate::client::{Client, Connect}; -use crate::{ - cli_interactor::{Interactor, InteractorPrompt, PromptChoiceParms, PromptConfirmParms}, - client::{fetching_with_report, get_events_from_cache, get_repo_ref_from_cache}, - git::{str_to_sha1, Repo, RepoActions}, - repo_ref::{get_repo_coordinates, RepoRef}, - sub_commands::send::{ - commit_msg_from_patch_oneliner, event_is_cover_letter, event_is_revision_root, - event_to_cover_letter, patch_supports_commit_ids, - }, -}; - -#[allow(clippy::too_many_lines)] -pub async fn launch() -> Result<()> { - let git_repo = Repo::discover().context("cannot find a git repository")?; - let git_repo_path = git_repo.get_path()?; - - // TODO: check for empty repo - // TODO: check for existing maintaiers file - // TODO: check for other claims - - #[cfg(not(test))] - let client = Client::default(); - #[cfg(test)] - let client = ::default(); - - let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; - - fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; - - let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?; - - let proposals_and_revisions: Vec = - get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?; - if proposals_and_revisions.is_empty() { - println!("no proposals found... create one? try `ngit send`"); - return Ok(()); - } - - let statuses: Vec = { - let mut statuses = get_events_from_cache( - git_repo_path, - vec![ - nostr::Filter::default() - .kinds(status_kinds().clone()) - .events(proposals_and_revisions.iter().map(nostr::Event::id)), - ], - ) - .await?; - statuses.sort_by_key(|e| e.created_at); - statuses.reverse(); - statuses - }; - - let mut open_proposals: Vec<&nostr::Event> = vec![]; - let mut draft_proposals: Vec<&nostr::Event> = vec![]; - let mut closed_proposals: Vec<&nostr::Event> = vec![]; - let mut applied_proposals: Vec<&nostr::Event> = vec![]; - - let proposals: Vec = proposals_and_revisions - .iter() - .filter(|e| !event_is_revision_root(e)) - .cloned() - .collect(); - - for proposal in &proposals { - let status = if let Some(e) = statuses - .iter() - .filter(|e| { - status_kinds().contains(&e.kind()) - && e.tags() - .iter() - .any(|t| t.as_vec()[1].eq(&proposal.id.to_string())) - }) - .collect::>() - .first() - { - e.kind() - } else { - Kind::GitStatusOpen - }; - if status.eq(&Kind::GitStatusOpen) { - open_proposals.push(proposal); - } else if status.eq(&Kind::GitStatusClosed) { - closed_proposals.push(proposal); - } else if status.eq(&Kind::GitStatusDraft) { - draft_proposals.push(proposal); - } else if status.eq(&Kind::GitStatusApplied) { - applied_proposals.push(proposal); - } - } - - let mut selected_status = Kind::GitStatusOpen; - - loop { - let proposals_for_status = if selected_status == Kind::GitStatusOpen { - &open_proposals - } else if selected_status == Kind::GitStatusDraft { - &draft_proposals - } else if selected_status == Kind::GitStatusClosed { - &closed_proposals - } else if selected_status == Kind::GitStatusApplied { - &applied_proposals - } else { - &open_proposals - }; - - let prompt = if proposals.len().eq(&open_proposals.len()) { - "all proposals" - } else if selected_status == Kind::GitStatusOpen { - if open_proposals.is_empty() { - "proposals menu" - } else { - "open proposals" - } - } else if selected_status == Kind::GitStatusDraft { - "draft proposals" - } else if selected_status == Kind::GitStatusClosed { - "closed proposals" - } else { - "applied proposals" - }; - - let mut choices: Vec = proposals_for_status - .iter() - .map(|e| { - if let Ok(cl) = event_to_cover_letter(e) { - cl.title - } else if let Ok(msg) = tag_value(e, "description") { - msg.split('\n').collect::>()[0].to_string() - } else { - e.id.to_string() - } - }) - .collect(); - - if !selected_status.eq(&Kind::GitStatusOpen) && open_proposals.len().gt(&0) { - choices.push(format!("({}) Open proposals...", open_proposals.len())); - } - if !selected_status.eq(&Kind::GitStatusDraft) && draft_proposals.len().gt(&0) { - choices.push(format!("({}) Draft proposals...", draft_proposals.len())); - } - if !selected_status.eq(&Kind::GitStatusClosed) && closed_proposals.len().gt(&0) { - choices.push(format!("({}) Closed proposals...", closed_proposals.len())); - } - if !selected_status.eq(&Kind::GitStatusApplied) && applied_proposals.len().gt(&0) { - choices.push(format!( - "({}) Applied proposals...", - applied_proposals.len() - )); - } - - let selected_index = Interactor::default().choice( - PromptChoiceParms::default() - .with_prompt(prompt) - .with_choices(choices.clone()), - )?; - - if (selected_index + 1).gt(&proposals_for_status.len()) { - if choices[selected_index].contains("Open") { - selected_status = Kind::GitStatusOpen; - } else if choices[selected_index].contains("Draft") { - selected_status = Kind::GitStatusDraft; - } else if choices[selected_index].contains("Closed") { - selected_status = Kind::GitStatusClosed; - } else if choices[selected_index].contains("Applied") { - selected_status = Kind::GitStatusApplied; - } - continue; - } - - let cover_letter = event_to_cover_letter(proposals_for_status[selected_index]) - .context("cannot extract proposal details from proposal root event")?; - - let commits_events: Vec = get_all_proposal_patch_events_from_cache( - git_repo_path, - &repo_ref, - &proposals_for_status[selected_index].id(), - ) - .await?; - - let Ok(most_recent_proposal_patch_chain) = - get_most_recent_patch_with_ancestors(commits_events.clone()) - else { - if Interactor::default().confirm( - PromptConfirmParms::default() - .with_default(true) - .with_prompt( - "cannot find any patches on this proposal. choose another proposal?", - ), - )? { - continue; - } - return Ok(()); - }; - // for commit in &most_recent_proposal_patch_chain { - // println!("recent_event: {:?}", commit.as_json()); - // } - - let binding_patch_text_ref = format!("{} commits", most_recent_proposal_patch_chain.len()); - let patch_text_ref = if most_recent_proposal_patch_chain.len().gt(&1) { - binding_patch_text_ref.as_str() - } else { - "1 commit" - }; - - let no_support_for_patches_as_branch = most_recent_proposal_patch_chain - .iter() - .any(|event| !patch_supports_commit_ids(event)); - - if no_support_for_patches_as_branch { - println!("{patch_text_ref}"); - return match Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec![ - "learn why 'patch only' proposals can't be checked out".to_string(), - format!("apply to current branch with `git am`"), - format!("download to ./patches"), - "back".to_string(), - ]), - )? { - 0 => { - println!("Some proposals are posted as 'patch only'\n"); - println!( - "they are not anchored against a particular state of the code base like a standard proposal or a GitHub Pull Request can be\n" - ); - println!( - "they are designed to reviewed by studying the diff (in a tool like gitworkshop.dev) and if acceptable by a maintainer, applied to the latest version of master with any conflicts resolved as the do so\n" - ); - println!( - "this has proven to be a smoother workflow for large scale projects with a high frequency of changes, even when patches are exchanged via email\n" - ); - println!( - "by default ngit posts proposals that support both the branch and patch model so either workflow can be used" - ); - Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec!["back".to_string()]), - )?; - continue; - } - 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), - 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), - 3 => continue, - _ => { - bail!("unexpected choice") - } - }; - } - - let branch_exists = git_repo - .get_local_branch_names() - .context("gitlib2 will not show a list of local branch names")? - .iter() - .any(|n| n.eq(&cover_letter.get_branch_name().unwrap())); - - let checked_out_proposal_branch = git_repo - .get_checked_out_branch_name()? - .eq(&cover_letter.get_branch_name()?); - - let proposal_base_commit = str_to_sha1(&tag_value( - most_recent_proposal_patch_chain.last().context( - "there should be at least one patch as we have already checked for this", - )?, - "parent-commit", - )?) - .context("cannot get valid parent commit id from patch")?; - - let (main_branch_name, master_tip) = git_repo.get_main_or_master_branch()?; - - if !git_repo.does_commit_exist(&proposal_base_commit.to_string())? { - println!("your '{main_branch_name}' branch may not be up-to-date."); - println!("the proposal parent commit doesnt exist in your local repository."); - return match Interactor::default().choice(PromptChoiceParms::default().with_default(0).with_choices( - vec![ - format!( - "manually run `git pull` on '{main_branch_name}' and select proposal again" - ), - format!("apply to current branch with `git am`"), - format!("download to ./patches"), - "back".to_string(), - ], - ))? { - 0 | 3 => continue, - 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), - 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), - _ => { - bail!("unexpected choice") - } - }; - } - - let proposal_tip = str_to_sha1( - &get_commit_id_from_patch(most_recent_proposal_patch_chain.first().context( - "there should be at least one patch as we have already checked for this", - )?) - .context("cannot get valid commit_id from patch")?, - ) - .context("cannot get valid commit_id from patch")?; - - let (_, proposal_behind_main) = - git_repo.get_commits_ahead_behind(&master_tip, &proposal_base_commit)?; - - // branch doesnt exist - if !branch_exists { - return match Interactor::default() - .choice(PromptChoiceParms::default().with_default(0).with_choices(vec![ - format!( - "create and checkout proposal branch ({} ahead {} behind '{main_branch_name}')", - most_recent_proposal_patch_chain.len(), - proposal_behind_main.len(), - ), - format!("apply to current branch with `git am`"), - format!("download to ./patches"), - "back".to_string(), - ]))? { - 0 => { - check_clean(&git_repo)?; - let _ = git_repo - .apply_patch_chain( - &cover_letter.get_branch_name()?, - most_recent_proposal_patch_chain, - ) - .context("cannot apply patch chain")?; - - println!( - "checked out proposal as '{}' branch", - cover_letter.get_branch_name()? - ); - Ok(()) - } - 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), - 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), - 3 => continue, - _ => { - bail!("unexpected choice") - } - }; - } - - let local_branch_tip = git_repo.get_tip_of_branch(&cover_letter.get_branch_name()?)?; - - // up-to-date - if proposal_tip.eq(&local_branch_tip) { - if checked_out_proposal_branch { - println!("branch checked out and up-to-date"); - return match Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec!["exit".to_string(), "back".to_string()]), - )? { - 0 => Ok(()), - 1 => continue, - _ => { - bail!("unexpected choice") - } - }; - } - - return match Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec![ - format!( - "checkout proposal branch ({} ahead {} behind '{main_branch_name}')", - most_recent_proposal_patch_chain.len(), - proposal_behind_main.len(), - ), - format!("apply to current branch with `git am`"), - format!("download to ./patches"), - "back".to_string(), - ]), - )? { - 0 => { - check_clean(&git_repo)?; - git_repo.checkout(&cover_letter.get_branch_name()?)?; - println!( - "checked out proposal as '{}' branch", - cover_letter.get_branch_name()? - ); - Ok(()) - } - 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), - 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), - 3 => continue, - _ => { - bail!("unexpected choice") - } - }; - } - - let (local_ahead_of_main, local_beind_main) = - git_repo.get_commits_ahead_behind(&master_tip, &local_branch_tip)?; - - // new appendments to proposal - if let Some(index) = most_recent_proposal_patch_chain.iter().position(|patch| { - get_commit_id_from_patch(patch) - .unwrap_or_default() - .eq(&local_branch_tip.to_string()) - }) { - return match Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec![ - format!("checkout proposal branch and apply {} appendments", &index,), - format!("apply to current branch with `git am`"), - format!("download to ./patches"), - "back".to_string(), - ]), - )? { - 0 => { - check_clean(&git_repo)?; - git_repo.checkout(&cover_letter.get_branch_name()?)?; - let _ = git_repo - .apply_patch_chain( - &cover_letter.get_branch_name()?, - most_recent_proposal_patch_chain, - ) - .context("cannot apply patch chain")?; - println!( - "checked out proposal branch and applied {} appendments ({} ahead {} behind '{main_branch_name}')", - &index, - local_ahead_of_main.len().add(&index), - local_beind_main.len(), - ); - Ok(()) - } - 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), - 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), - 3 => continue, - _ => { - bail!("unexpected choice") - } - }; - } - - // new proposal revision / rebase - // tip of local in proposal history (new, amended or rebased version but no - // local changes) - if commits_events.iter().any(|patch| { - get_commit_id_from_patch(patch) - .unwrap_or_default() - .eq(&local_branch_tip.to_string()) - }) { - println!( - "updated proposal available ({} ahead {} behind '{main_branch_name}'). existing version is {} ahead {} behind '{main_branch_name}'", - most_recent_proposal_patch_chain.len(), - proposal_behind_main.len(), - local_ahead_of_main.len(), - local_beind_main.len(), - ); - return match Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec![ - format!("checkout and overwrite existing proposal branch"), - format!("checkout existing outdated proposal branch"), - format!("apply to current branch with `git am`"), - format!("download to ./patches"), - "back".to_string(), - ]), - )? { - 0 => { - check_clean(&git_repo)?; - git_repo.create_branch_at_commit( - &cover_letter.get_branch_name()?, - &proposal_base_commit.to_string(), - )?; - git_repo.checkout(&cover_letter.get_branch_name()?)?; - let chain_length = most_recent_proposal_patch_chain.len(); - let _ = git_repo - .apply_patch_chain( - &cover_letter.get_branch_name()?, - most_recent_proposal_patch_chain, - ) - .context("cannot apply patch chain")?; - println!( - "checked out new version of proposal ({} ahead {} behind '{main_branch_name}'), replacing old version ({} ahead {} behind '{main_branch_name}')", - chain_length, - proposal_behind_main.len(), - local_ahead_of_main.len(), - local_beind_main.len(), - ); - Ok(()) - } - 1 => { - check_clean(&git_repo)?; - git_repo.checkout(&cover_letter.get_branch_name()?)?; - println!( - "checked out old proposal in existing branch ({} ahead {} behind '{main_branch_name}')", - local_ahead_of_main.len(), - local_beind_main.len(), - ); - Ok(()) - } - 2 => launch_git_am_with_patches(most_recent_proposal_patch_chain), - 3 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), - 4 => continue, - _ => { - bail!("unexpected choice") - } - }; - } - // tip of proposal in branch in history (local appendments made to up-to-date - // proposal) - else if git_repo.ancestor_of(&local_branch_tip, &proposal_tip)? { - let (local_ahead_of_proposal, _) = git_repo - .get_commits_ahead_behind(&proposal_tip, &local_branch_tip) - .context("cannot get commits ahead behind for propsal_top and local_branch_tip")?; - - println!( - "local proposal branch exists with {} unpublished commits on top of the most up-to-date version of the proposal ({} ahead {} behind '{main_branch_name}')", - local_ahead_of_proposal.len(), - local_ahead_of_main.len(), - proposal_behind_main.len(), - ); - return match Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec![ - format!( - "checkout proposal branch with {} unpublished commits", - local_ahead_of_proposal.len(), - ), - "back".to_string(), - ]), - )? { - 0 => { - git_repo.checkout(&cover_letter.get_branch_name()?)?; - println!( - "checked out proposal branch with {} unpublished commits ({} ahead {} behind '{main_branch_name}')", - local_ahead_of_proposal.len(), - local_ahead_of_main.len(), - proposal_behind_main.len(), - ); - Ok(()) - } - 1 => continue, - _ => { - bail!("unexpected choice") - } - }; - } - - println!("you have an amended/rebase version the proposal that is unpublished"); - // user probably has a unpublished amended or rebase version of the latest - // proposal version - // if tip of proposal commits exist (were once part of branch but have been - // amended and git clean up job hasn't removed them) - if git_repo.does_commit_exist(&proposal_tip.to_string())? { - println!( - "you have previously applied the latest version of the proposal ({} ahead {} behind '{main_branch_name}') but your local proposal branch has amended or rebased it ({} ahead {} behind '{main_branch_name}')", - most_recent_proposal_patch_chain.len(), - proposal_behind_main.len(), - local_ahead_of_main.len(), - local_beind_main.len(), - ); - } - // user probably has a unpublished amended or rebase version of an older - // proposal version - else { - println!( - "your local proposal branch ({} ahead {} behind '{main_branch_name}') has conflicting changes with the latest published proposal ({} ahead {} behind '{main_branch_name}')", - local_ahead_of_main.len(), - local_beind_main.len(), - most_recent_proposal_patch_chain.len(), - proposal_behind_main.len(), - ); - - println!( - "its likely that you have rebased / amended an old proposal version because git has no record of the latest proposal commit." - ); - println!( - "it is possible that you have been working off the latest version and git has delete this commit as part of a clean up" - ); - } - println!("to view the latest proposal but retain your changes:"); - println!(" 1) create a new branch off the tip commit of this one to store your changes"); - println!(" 2) run `ngit list` and checkout the latest published version of this proposal"); - - println!("if you are confident in your changes consider running `ngit push --force`"); - - return match Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec![ - format!("checkout local branch with unpublished changes"), - format!("discard unpublished changes and checkout new revision",), - format!("apply to current branch with `git am`"), - format!("download to ./patches"), - "back".to_string(), - ]), - )? { - 0 => { - check_clean(&git_repo)?; - git_repo.checkout(&cover_letter.get_branch_name()?)?; - println!( - "checked out old proposal in existing branch ({} ahead {} behind '{main_branch_name}')", - local_ahead_of_main.len(), - local_beind_main.len(), - ); - Ok(()) - } - 1 => { - check_clean(&git_repo)?; - git_repo.create_branch_at_commit( - &cover_letter.get_branch_name()?, - &proposal_base_commit.to_string(), - )?; - let chain_length = most_recent_proposal_patch_chain.len(); - let _ = git_repo - .apply_patch_chain( - &cover_letter.get_branch_name()?, - most_recent_proposal_patch_chain, - ) - .context("cannot apply patch chain")?; - - git_repo.checkout(&cover_letter.get_branch_name()?)?; - println!( - "checked out latest version of proposal ({} ahead {} behind '{main_branch_name}'), replacing unpublished version ({} ahead {} behind '{main_branch_name}')", - chain_length, - proposal_behind_main.len(), - local_ahead_of_main.len(), - local_beind_main.len(), - ); - Ok(()) - } - 2 => launch_git_am_with_patches(most_recent_proposal_patch_chain), - 3 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), - 4 => continue, - _ => { - bail!("unexpected choice") - } - }; - } -} - -fn launch_git_am_with_patches(mut patches: Vec) -> Result<()> { - println!("applying to current branch with `git am`"); - // TODO: add PATCH x/n to appended patches - patches.reverse(); - - let mut am = std::process::Command::new("git") - .arg("am") - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .spawn() - .context("failed to spawn git am")?; - - let stdin = am - .stdin - .as_mut() - .context("git am process failed to take stdin")?; - - for patch in patches { - stdin - .write(format!("{}\n\n", patch.content).as_bytes()) - .context("failed to write patch content into git am stdin buffer")?; - } - stdin.flush()?; - let output = am - .wait_with_output() - .context("failed to read git am stdout")?; - print!("{:?}", output.stdout); - Ok(()) -} - -fn event_id_extra_shorthand(event: &nostr::Event) -> String { - event.id.to_string()[..5].to_string() -} - -fn save_patches_to_dir(mut patches: Vec, git_repo: &Repo) -> Result<()> { - // TODO: add PATCH x/n to appended patches - patches.reverse(); - let path = git_repo.get_path()?.join("patches"); - std::fs::create_dir_all(&path)?; - let id = event_id_extra_shorthand( - patches - .first() - .context("there must be at least one patch to save")?, - ); - for (i, patch) in patches.iter().enumerate() { - let path = path.join(format!( - "{}-{:0>4}-{}.patch", - &id, - i.add(&1), - commit_msg_from_patch_oneliner(patch)? - )); - let mut file = std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(path) - .context("open new patch file with write and truncate options")?; - file.write_all(patch.content().as_bytes())?; - file.write_all("\n\n".as_bytes())?; - file.flush()?; - } - println!("created {} patch files in ./patches/{id}-*", patches.len()); - Ok(()) -} - -fn check_clean(git_repo: &Repo) -> Result<()> { - if git_repo.has_outstanding_changes()? { - bail!( - "cannot pull proposal branch when repository is not clean. discard or stash (un)staged changes and try again." - ); - } - Ok(()) -} - -pub fn tag_value(event: &nostr::Event, tag_name: &str) -> Result { - Ok(event - .tags - .iter() - .find(|t| t.as_vec()[0].eq(tag_name)) - .context(format!("tag '{tag_name}'not present"))? - .as_vec()[1] - .clone()) -} - -pub fn get_commit_id_from_patch(event: &nostr::Event) -> Result { - let value = tag_value(event, "commit"); - - if value.is_ok() { - value - } else if event.content.starts_with("From ") && event.content.len().gt(&45) { - Ok(event.content[5..45].to_string()) - } else { - bail!("event is not a patch") - } -} - -fn get_event_parent_id(event: &nostr::Event) -> Result { - Ok(if let Some(reply_tag) = event - .tags - .iter() - .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("reply")) - { - reply_tag - } else { - event - .tags - .iter() - .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("root")) - .context("no reply or root e tag present".to_string())? - } - .as_vec()[1] - .clone()) -} - -pub fn get_most_recent_patch_with_ancestors( - mut patches: Vec, -) -> Result> { - patches.sort_by_key(|e| e.created_at); - - let youngest_patch = patches.last().context("no patches found")?; - - let patches_with_youngest_created_at: Vec<&nostr::Event> = patches - .iter() - .filter(|p| p.created_at.eq(&youngest_patch.created_at)) - .collect(); - - let mut res = vec![]; - - let mut event_id_to_search = patches_with_youngest_created_at - .clone() - .iter() - .find(|p| { - !patches_with_youngest_created_at.iter().any(|p2| { - if let Ok(reply_to) = get_event_parent_id(p2) { - reply_to.eq(&p.id.to_string()) - } else { - false - } - }) - }) - .context("cannot find patches_with_youngest_created_at")? - .id - .to_string(); - - while let Some(event) = patches - .iter() - .find(|e| e.id.to_string().eq(&event_id_to_search)) - { - res.push(event.clone()); - if event_is_patch_set_root(event) { - break; - } - event_id_to_search = get_event_parent_id(event).unwrap_or_default(); - } - Ok(res) -} - -pub fn status_kinds() -> Vec { - vec![ - nostr::Kind::GitStatusOpen, - nostr::Kind::GitStatusApplied, - nostr::Kind::GitStatusClosed, - nostr::Kind::GitStatusDraft, - ] -} - -pub async fn get_proposals_and_revisions_from_cache( - git_repo_path: &Path, - repo_coordinates: HashSet, -) -> Result> { - let mut proposals = get_events_from_cache( - git_repo_path, - vec![ - nostr::Filter::default() - .kind(nostr::Kind::GitPatch) - .custom_tag( - nostr::SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), - repo_coordinates - .iter() - .map(std::string::ToString::to_string) - .collect::>(), - ), - ], - ) - .await? - .iter() - .filter(|e| event_is_patch_set_root(e)) - .cloned() - .collect::>(); - proposals.sort_by_key(|e| e.created_at); - proposals.reverse(); - Ok(proposals) -} - -pub async fn get_all_proposal_patch_events_from_cache( - git_repo_path: &Path, - repo_ref: &RepoRef, - proposal_id: &nostr::EventId, -) -> Result> { - let mut commit_events = get_events_from_cache( - git_repo_path, - vec![ - nostr::Filter::default() - .kind(nostr::Kind::GitPatch) - .event(*proposal_id), - nostr::Filter::default() - .kind(nostr::Kind::GitPatch) - .id(*proposal_id), - ], - ) - .await?; - - let permissioned_users: HashSet = [ - repo_ref.maintainers.clone(), - vec![ - commit_events - .iter() - .find(|e| e.id().eq(proposal_id)) - .context("proposal not in cache")? - .author(), - ], - ] - .concat() - .iter() - .copied() - .collect(); - commit_events.retain(|e| permissioned_users.contains(&e.author())); - - let revision_roots: HashSet = commit_events - .iter() - .filter(|e| event_is_revision_root(e)) - .map(nostr::Event::id) - .collect(); - - if !revision_roots.is_empty() { - for event in get_events_from_cache( - git_repo_path, - vec![ - nostr::Filter::default() - .kind(nostr::Kind::GitPatch) - .events(revision_roots) - .authors(permissioned_users.clone()), - ], - ) - .await? - { - commit_events.push(event); - } - } - - Ok(commit_events - .iter() - .filter(|e| !event_is_cover_letter(e) && permissioned_users.contains(&e.author())) - .cloned() - .collect()) -} diff --git a/src/sub_commands/login.rs b/src/sub_commands/login.rs deleted file mode 100644 index 8a3788f..0000000 --- a/src/sub_commands/login.rs +++ /dev/null @@ -1,52 +0,0 @@ -use anyhow::{Context, Result}; -use clap; - -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{cli::Cli, client::Connect, git::Repo, login}; - -#[derive(clap::Args)] -pub struct SubCommandArgs { - /// don't fetch user metadata and relay list from relays - #[arg(long, action)] - offline: bool, -} - -pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { - let git_repo = Repo::discover().context("cannot find a git repository")?; - if command_args.offline { - login::launch( - &git_repo, - &args.bunker_uri, - &args.bunker_app_key, - &args.nsec, - &args.password, - None, - true, - false, - ) - .await?; - Ok(()) - } else { - #[cfg(not(test))] - let client = Client::default(); - #[cfg(test)] - let client = ::default(); - - login::launch( - &git_repo, - &args.bunker_uri, - &args.bunker_app_key, - &args.nsec, - &args.password, - Some(&client), - true, - false, - ) - .await?; - client.disconnect().await?; - Ok(()) - } -} diff --git a/src/sub_commands/mod.rs b/src/sub_commands/mod.rs deleted file mode 100644 index 29a60f9..0000000 --- a/src/sub_commands/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod fetch; -pub mod init; -pub mod list; -pub mod login; -pub mod pull; -pub mod push; -pub mod send; diff --git a/src/sub_commands/pull.rs b/src/sub_commands/pull.rs deleted file mode 100644 index e33a744..0000000 --- a/src/sub_commands/pull.rs +++ /dev/null @@ -1,209 +0,0 @@ -use anyhow::{bail, Context, Result}; - -use super::{ - list::{ - get_all_proposal_patch_events_from_cache, get_commit_id_from_patch, - get_proposals_and_revisions_from_cache, tag_value, - }, - send::event_to_cover_letter, -}; -#[cfg(test)] -use crate::client::MockConnect; -#[cfg(not(test))] -use crate::client::{Client, Connect}; -use crate::{ - client::{fetching_with_report, get_repo_ref_from_cache}, - git::{str_to_sha1, Repo, RepoActions}, - repo_ref::get_repo_coordinates, - sub_commands::{list::get_most_recent_patch_with_ancestors, send::event_is_revision_root}, -}; - -#[allow(clippy::too_many_lines)] -pub async fn launch() -> Result<()> { - let git_repo = Repo::discover().context("cannot find a git repository")?; - let git_repo_path = git_repo.get_path()?; - - let (main_or_master_branch_name, _) = git_repo - .get_main_or_master_branch() - .context("no main or master branch")?; - - let branch_name = git_repo - .get_checked_out_branch_name() - .context("cannot get checked out branch name")?; - - if branch_name == main_or_master_branch_name { - bail!("checkout a branch associated with a proposal first") - } - #[cfg(not(test))] - let client = Client::default(); - #[cfg(test)] - let client = ::default(); - - let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; - - fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; - - let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?; - - let proposal_root_event = - get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()) - .await? - .iter() - .find(|e| { - event_to_cover_letter(e) - .is_ok_and(|cl| cl.get_branch_name().is_ok_and(|s| s.eq(&branch_name))) - && !event_is_revision_root(e) - }) - .context("cannot find proposal that matches the current branch name")? - .clone(); - let commit_events = get_all_proposal_patch_events_from_cache( - git_repo_path, - &repo_ref, - &proposal_root_event.id(), - ) - .await?; - - let most_recent_proposal_patch_chain = - get_most_recent_patch_with_ancestors(commit_events.clone()) - .context("cannot get most recent patch for proposal")?; - - let local_branch_tip = git_repo.get_tip_of_branch(&branch_name)?; - - let (main_branch_name, master_tip) = git_repo.get_main_or_master_branch()?; - - let (local_ahead_of_main, local_beind_main) = - git_repo.get_commits_ahead_behind(&master_tip, &local_branch_tip)?; - - let proposal_base_commit = str_to_sha1(&tag_value( - most_recent_proposal_patch_chain - .last() - .context("there should be at least one patch as we have already checked for this")?, - "parent-commit", - )?) - .context("cannot get valid parent commit id from patch")?; - - let (_, proposal_behind_main) = - git_repo.get_commits_ahead_behind(&master_tip, &proposal_base_commit)?; - - let proposal_tip = - str_to_sha1( - &get_commit_id_from_patch(most_recent_proposal_patch_chain.first().context( - "there should be at least one patch as we have already checked for this", - )?) - .context("cannot get valid commit_id from patch")?, - ) - .context("cannot get valid commit_id from patch")?; - - // if uptodate - if proposal_tip.eq(&local_branch_tip) { - println!("branch already up-to-date"); - } - // if new appendments - else if most_recent_proposal_patch_chain.iter().any(|patch| { - get_commit_id_from_patch(patch) - .unwrap_or_default() - .eq(&local_branch_tip.to_string()) - }) { - check_clean(&git_repo)?; - let applied = git_repo - .apply_patch_chain(&branch_name, most_recent_proposal_patch_chain) - .context("cannot apply patch chain")?; - println!("applied {} new commits", applied.len(),); - } - // if parent commit doesnt exist - else if !git_repo.does_commit_exist(&proposal_base_commit.to_string())? { - println!( - "a new version of the proposal has a prant commit that doesnt exist in your local repository." - ); - println!("your '{main_branch_name}' branch may not be up-to-date."); - println!("manually run `git pull` on '{main_branch_name}' and try again"); - } - // if new revision and no local changes (tip of local in proposal history) - else if commit_events.iter().any(|patch| { - get_commit_id_from_patch(patch) - .unwrap_or_default() - .eq(&local_branch_tip.to_string()) - }) { - check_clean(&git_repo)?; - - git_repo.create_branch_at_commit(&branch_name, &proposal_base_commit.to_string())?; - let applied = git_repo - .apply_patch_chain(&branch_name, most_recent_proposal_patch_chain) - .context("cannot apply patch chain")?; - - println!( - "pulled new version of proposal ({} ahead {} behind '{main_branch_name}'), replacing old version ({} ahead {} behind '{main_branch_name}')", - applied.len(), - proposal_behind_main.len(), - local_ahead_of_main.len(), - local_beind_main.len(), - ); - } - // if tip of proposal in branch in history (local appendments made to up-to-date - // proposal) - else if git_repo.ancestor_of(&local_branch_tip, &proposal_tip)? { - let (local_ahead_of_proposal, _) = git_repo - .get_commits_ahead_behind(&proposal_tip, &local_branch_tip) - .context("cannot get commits ahead behind for propsal_top and local_branch_tip")?; - println!( - "local proposal branch exists with {} unpublished commits on top of the most up-to-date version of the proposal", - local_ahead_of_proposal.len() - ); - } else { - println!("you have an amended/rebase version the proposal that is unpublished"); - // user probably has a unpublished amended or rebase version of the latest - // proposal version - // if tip of proposal commits exist (were once part of branch but have been - // amended and git clean up job hasn't removed them) - if git_repo.does_commit_exist(&proposal_tip.to_string())? { - println!( - "you have previously applied the latest version of the proposal ({} ahead {} behind '{main_branch_name}') but your local proposal branch has amended or rebased it ({} ahead {} behind '{main_branch_name}')", - most_recent_proposal_patch_chain.len(), - proposal_behind_main.len(), - local_ahead_of_main.len(), - local_beind_main.len(), - ); - } - // user probably has a unpublished amended or rebase version of an older - // proposal version - else { - println!( - "your local proposal branch ({} ahead {} behind '{main_branch_name}') has conflicting changes with the latest published proposal ({} ahead {} behind '{main_branch_name}')", - local_ahead_of_main.len(), - local_beind_main.len(), - most_recent_proposal_patch_chain.len(), - proposal_behind_main.len(), - ); - - println!( - "its likely that you have rebased / amended an old proposal version because git has no record of the latest proposal commit." - ); - println!( - "it is possible that you have been working off the latest version and git has delete this commit as part of a clean up" - ); - } - println!("to view the latest proposal but retain your changes:"); - println!(" 1) create a new branch off the tip commit of this one to store your changes"); - println!(" 2) run `ngit list` and checkout the latest published version of this proposal"); - - println!("if you are confident in your changes consider running `ngit push --force`"); - - // TODO: this copy could be refined further based on this: - // - amended commits in the proposal - // - if local_base eq proposal base - // - amended an older version of proposal - // - if local_base is behind proposal_base - // - rebased the proposal - // - if local_base is ahead of proposal_base - } - Ok(()) -} - -fn check_clean(git_repo: &Repo) -> Result<()> { - if git_repo.has_outstanding_changes()? { - bail!( - "cannot pull proposal branch when repository is not clean. discard or stash (un)staged changes and try again." - ); - } - Ok(()) -} diff --git a/src/sub_commands/push.rs b/src/sub_commands/push.rs deleted file mode 100644 index 7a82c7a..0000000 --- a/src/sub_commands/push.rs +++ /dev/null @@ -1,223 +0,0 @@ -use anyhow::{bail, Context, Result}; - -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{ - cli::Cli, - client::{fetching_with_report, get_repo_ref_from_cache, Connect}, - git::{str_to_sha1, Repo, RepoActions}, - login, - repo_ref::get_repo_coordinates, - sub_commands::{ - self, - list::{ - get_all_proposal_patch_events_from_cache, get_commit_id_from_patch, - get_most_recent_patch_with_ancestors, get_proposals_and_revisions_from_cache, - tag_value, - }, - send::{ - event_is_revision_root, event_to_cover_letter, generate_patch_event, - identify_ahead_behind, send_events, - }, - }, -}; - -#[derive(Debug, clap::Args)] -pub struct SubCommandArgs { - #[arg(long, action)] - /// send proposal revision from checked out proposal branch - force: bool, -} - -#[allow(clippy::too_many_lines)] -pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { - let git_repo = Repo::discover().context("cannot find a git repository")?; - let git_repo_path = git_repo.get_path()?; - - let (main_or_master_branch_name, _) = git_repo - .get_main_or_master_branch() - .context("no main or master branch")?; - - let root_commit = git_repo - .get_root_commit() - .context("failed to get root commit of the repository")?; - - let branch_name = git_repo - .get_checked_out_branch_name() - .context("cannot get checked out branch name")?; - - if branch_name == main_or_master_branch_name { - bail!("checkout a branch associated with a proposal first") - } - #[cfg(not(test))] - let mut client = Client::default(); - #[cfg(test)] - let mut client = ::default(); - - let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; - - fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; - - let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?; - - let proposal_root_event = - get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()) - .await? - .iter() - .find(|e| { - event_to_cover_letter(e) - .is_ok_and(|cl| cl.get_branch_name().is_ok_and(|s| s.eq(&branch_name))) - && !event_is_revision_root(e) - }) - .context("cannot find proposal that matches the current branch name")? - .clone(); - - let commit_events = get_all_proposal_patch_events_from_cache( - git_repo_path, - &repo_ref, - &proposal_root_event.id(), - ) - .await?; - - let most_recent_proposal_patch_chain = get_most_recent_patch_with_ancestors(commit_events) - .context("cannot get most recent patch for proposal")?; - - let branch_tip = git_repo.get_tip_of_branch(&branch_name)?; - - let most_recent_patch_commit_id = str_to_sha1( - &get_commit_id_from_patch( - most_recent_proposal_patch_chain - .first() - .context("no patches found")?, - ) - .context("latest patch event doesnt have a commit tag")?, - ) - .context("latest patch event commit tag isn't a valid SHA1 hash")?; - - let proposal_base_commit_id = str_to_sha1( - &tag_value( - most_recent_proposal_patch_chain - .last() - .context("no patches found")?, - "parent-commit", - ) - .context("patch is incorrectly formatted")?, - ) - .context("latest patch event parent-commit tag isn't a valid SHA1 hash")?; - - if most_recent_patch_commit_id.eq(&branch_tip) { - bail!("proposal already up-to-date with local branch"); - } - - if args.force { - println!("preparing to force push proposal revision..."); - sub_commands::send::launch( - cli_args, - &sub_commands::send::SubCommandArgs { - // if not ahead of master prompt, otherwise assume proposal revision is all commits - // ahead - since_or_range: if let Ok((_, _, ahead, _)) = - identify_ahead_behind(&git_repo, &None, &None) - { - if ahead.is_empty() { - String::new() - } else { - format!("HEAD~{}", ahead.len()) - } - } else { - String::new() - }, - in_reply_to: vec![proposal_root_event.id.to_string()], - title: None, - description: None, - no_cover_letter: true, - }, - true, - ) - .await?; - println!("force pushed proposal revision"); - return Ok(()); - } - - if most_recent_proposal_patch_chain.iter().any(|e| { - let c = tag_value(e, "parent-commit").unwrap_or_default(); - c.eq(&branch_tip.to_string()) - }) { - bail!("proposal is ahead of local branch"); - } - - let Ok((ahead, behind)) = git_repo - .get_commits_ahead_behind(&most_recent_patch_commit_id, &branch_tip) - .context("the latest patch in proposal doesnt share an ancestor with your branch.") - else { - if git_repo.ancestor_of(&proposal_base_commit_id, &branch_tip)? { - bail!("local unpublished proposal ammendments. consider force pushing."); - } - bail!("local unpublished proposal has been rebased. consider force pushing"); - }; - - if !behind.is_empty() { - bail!( - "your local proposal branch is {} behind patches on nostr. consider rebasing or force pushing", - behind.len() - ) - } - - println!( - "{} commits ahead. preparing to create creating patch events.", - ahead.len() - ); - - let (signer, user_ref) = login::launch( - &git_repo, - &cli_args.bunker_uri, - &cli_args.bunker_app_key, - &cli_args.nsec, - &cli_args.password, - Some(&client), - false, - false, - ) - .await?; - - let mut patch_events: Vec = vec![]; - for commit in &ahead { - patch_events.push( - generate_patch_event( - &git_repo, - &root_commit, - commit, - Some(proposal_root_event.id), - &signer, - &repo_ref, - patch_events.last().map(nostr::Event::id), - None, - None, - &None, - &[], - ) - .await - .context("cannot make patch event from commit")?, - ); - } - println!("pushing {} commits", ahead.len()); - - client.set_signer(signer).await; - - send_events( - &client, - git_repo_path, - patch_events, - user_ref.relays.write(), - repo_ref.relays.clone(), - !cli_args.disable_cli_spinners, - false, - ) - .await?; - - println!("pushed {} commits", ahead.len()); - - Ok(()) -} diff --git a/src/sub_commands/send.rs b/src/sub_commands/send.rs deleted file mode 100644 index 3c4df9d..0000000 --- a/src/sub_commands/send.rs +++ /dev/null @@ -1,1363 +0,0 @@ -use std::{path::Path, str::FromStr, time::Duration}; - -use anyhow::{bail, Context, Result}; -use console::Style; -use futures::future::join_all; -use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; -use nostr::{ - nips::{ - nip01::Coordinate, - nip10::Marker, - nip19::{Nip19, Nip19Event}, - }, - EventBuilder, FromBech32, Tag, TagKind, ToBech32, UncheckedUrl, -}; -use nostr_sdk::{hashes::sha1::Hash as Sha1Hash, Kind, NostrSigner, TagStandard}; - -use super::list::tag_value; -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{ - cli::Cli, - cli_interactor::{ - Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms, - }, - client::{ - fetching_with_report, get_events_from_cache, get_repo_ref_from_cache, sign_event, Connect, - }, - git::{Repo, RepoActions}, - login, - repo_ref::{get_repo_coordinates, RepoRef}, -}; - -#[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(short, long)] - pub(crate) title: Option, - #[clap(short, long)] - /// optional cover letter description - pub(crate) description: Option, -} - -#[allow(clippy::too_many_lines)] -pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Result<()> { - let git_repo = Repo::discover().context("cannot 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")?; - - #[cfg(not(test))] - let mut client = Client::default(); - #[cfg(test)] - let mut client = ::default(); - - let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; - - if !no_fetch { - fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; - } - - let (root_proposal_id, mention_tags) = - get_root_proposal_id_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_id.is_some() { - println!("creating proposal revision for: {root_ref}"); - } - } - - let mut commits: Vec = { - if args.since_or_range.is_empty() { - 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 { - git_repo - .parse_starting_commits(&args.since_or_range) - .context("cannot parse specified starting commit or range")? - } - }; - - 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 proposal ahead of origin/main - if first_commit_ahead.len().gt(&1) && !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 because selected commits were ahead of origin/master"); - } - - // check if a selected commit is already in origin - if commits.iter().any(|c| c.eq(&main_tip)) { - 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 as proposal contains commit(s) already in '{main_branch_name}'"); - } - } - // check proposal isn't behind origin/main - else if !behind.is_empty() && !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"); - } - - let title = 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 - } - } - } - }; - - let cover_letter_title_description = if let Some(title) = title { - Some(( - title, - if let Some(t) = &args.description { - t.clone() - } else { - Interactor::default() - .input(PromptInputParms::default().with_prompt("cover letter description"))? - .clone() - }, - )) - } else { - None - }; - let (signer, user_ref) = login::launch( - &git_repo, - &cli_args.bunker_uri, - &cli_args.bunker_app_key, - &cli_args.nsec, - &cli_args.password, - Some(&client), - false, - false, - ) - .await?; - - client.set_signer(signer.clone()).await; - - let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?; - - // oldest first - commits.reverse(); - - let events = generate_cover_letter_and_patch_events( - cover_letter_title_description.clone(), - &git_repo, - &commits, - &signer, - &repo_ref, - &root_proposal_id, - &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" - } - ); - - send_events( - &client, - git_repo_path, - events.clone(), - user_ref.relays.write(), - repo_ref.relays.clone(), - !cli_args.disable_cli_spinners, - false, - ) - .await?; - - if root_proposal_id.is_none() { - if let Some(event) = events.first() { - let event_bech32 = if let Some(relay) = repo_ref.relays.first() { - Nip19Event::new(event.id(), vec![relay]).to_bech32()? - } else { - event.id().to_bech32()? - }; - println!( - "{}", - dim.apply_to(format!( - "view in gitworkshop.dev: https://gitworkshop.dev/repo/{}/proposal/{}", - repo_ref.coordinate_with_hint().to_bech32()?, - &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(()) -} - -#[allow(clippy::module_name_repetitions)] -#[allow(clippy::too_many_lines)] -pub async fn send_events( - #[cfg(test)] client: &crate::client::MockConnect, - #[cfg(not(test))] client: &Client, - git_repo_path: &Path, - events: Vec, - my_write_relays: Vec, - repo_read_relays: Vec, - animate: bool, - silent: bool, -) -> Result<()> { - let fallback = [ - client.get_fallback_relays().clone(), - if events - .iter() - .any(|e| e.kind().eq(&Kind::GitRepoAnnouncement)) - { - client.get_blaster_relays().clone() - } else { - vec![] - }, - ] - .concat(); - let mut relays: Vec<&String> = vec![]; - - let all = &[ - repo_read_relays.clone(), - my_write_relays.clone(), - fallback.clone(), - ] - .concat(); - // add duplicates first - for r in &repo_read_relays { - let r_clean = remove_trailing_slash(r); - if !my_write_relays - .iter() - .filter(|x| r_clean.eq(&remove_trailing_slash(x))) - .count() - > 1 - && !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) - { - relays.push(r); - } - } - - for r in all { - let r_clean = remove_trailing_slash(r); - if !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) { - relays.push(r); - } - } - - let m = if silent { - MultiProgress::with_draw_target(ProgressDrawTarget::hidden()) - } else { - MultiProgress::new() - }; - let pb_style = ProgressStyle::with_template(if animate { - " {spinner} {prefix} {bar} {pos}/{len} {msg}" - } else { - " - {prefix} {bar} {pos}/{len} {msg}" - })? - .progress_chars("##-"); - - let pb_after_style = - |symbol| ProgressStyle::with_template(format!(" {symbol} {}", "{prefix} {msg}",).as_str()); - let pb_after_style_succeeded = pb_after_style(if animate { - console::style("✔".to_string()) - .for_stderr() - .green() - .to_string() - } else { - "y".to_string() - })?; - - let pb_after_style_failed = pb_after_style(if animate { - console::style("✘".to_string()) - .for_stderr() - .red() - .to_string() - } else { - "x".to_string() - })?; - - #[allow(clippy::borrow_deref_ref)] - join_all(relays.iter().map(|&relay| async { - let relay_clean = remove_trailing_slash(&*relay); - let details = format!( - "{}{}{} {}", - if my_write_relays - .iter() - .any(|r| relay_clean.eq(&remove_trailing_slash(r))) - { - " [my-relay]" - } else { - "" - }, - if repo_read_relays - .iter() - .any(|r| relay_clean.eq(&remove_trailing_slash(r))) - { - " [repo-relay]" - } else { - "" - }, - if fallback - .iter() - .any(|r| relay_clean.eq(&remove_trailing_slash(r))) - { - " [default]" - } else { - "" - }, - relay_clean, - ); - let pb = m.add( - ProgressBar::new(events.len() as u64) - .with_prefix(details.to_string()) - .with_style(pb_style.clone()), - ); - if animate { - pb.enable_steady_tick(Duration::from_millis(300)); - } - pb.inc(0); // need to make pb display intially - let mut failed = false; - for event in &events { - match client - .send_event_to(git_repo_path, relay.as_str(), event.clone()) - .await - { - Ok(_) => pb.inc(1), - Err(e) => { - pb.set_style(pb_after_style_failed.clone()); - pb.finish_with_message( - console::style( - e.to_string() - .replace("relay pool error:", "error:") - .replace("event not published: ", "error: "), - ) - .for_stderr() - .red() - .to_string(), - ); - failed = true; - break; - } - }; - } - if !failed { - pb.set_style(pb_after_style_succeeded.clone()); - pb.finish_with_message(""); - } - })) - .await; - Ok(()) -} - -fn remove_trailing_slash(s: &String) -> String { - match s.as_str().strip_suffix('/') { - Some(s) => s, - None => s, - } - .to_string() -} - -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_id_and_mentions_from_in_reply_to( - git_repo_path: &Path, - in_reply_to: &[String], -) -> Result<(Option, Vec)> { - let root_proposal_id = if let Some(first) = in_reply_to.first() { - match event_tag_from_nip19_or_hex(first, "in-reply-to", Marker::Root, true, false)? - .as_standardized() - { - Some(nostr_sdk::TagStandard::Event { - event_id, - relay_url: _, - marker: _, - public_key: _, - }) => { - let events = - get_events_from_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) { - Some(event_id.to_string()) - } 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_id.is_none() { - mention_tags.push( - event_tag_from_nip19_or_hex(reply_to, "in-reply-to", Marker::Mention, true, false) - .context(format!( - "{reply_to} in 'in-reply-to' not a valid nostr reference" - ))?, - ); - } - } - - Ok((root_proposal_id, mention_tags)) -} - -#[allow(clippy::too_many_lines)] -pub async fn generate_cover_letter_and_patch_events( - cover_letter_title_description: Option<(String, String)>, - git_repo: &Repo, - commits: &[Sha1Hash], - signer: &NostrSigner, - repo_ref: &RepoRef, - root_proposal_id: &Option, - mentions: &[nostr::Tag], -) -> Result> { - let root_commit = git_repo - .get_root_commit() - .context("failed to get root commit of the repository")?; - - let mut events = vec![]; - - if let Some((title, description)) = cover_letter_title_description { - events.push(sign_event(EventBuilder::new( - nostr::event::Kind::GitPatch, - format!( - "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}", - commits.last().unwrap(), - commits.len() - ), - [ - repo_ref.maintainers.iter().map(|m| Tag::coordinate(Coordinate { - kind: nostr::Kind::GitRepoAnnouncement, - public_key: *m, - identifier: repo_ref.identifier.to_string(), - relays: repo_ref.relays.clone(), - })).collect::>(), - vec![ - Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))), - Tag::hashtag("cover-letter"), - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), - vec![format!("git patch cover letter: {}", title.clone())], - ), - ], - if let Some(event_ref) = root_proposal_id.clone() { - vec![ - Tag::hashtag("root"), - Tag::hashtag("revision-root"), - // TODO check if id is for a root proposal (perhaps its for an issue?) - event_tag_from_nip19_or_hex(&event_ref,"proposal",Marker::Reply, false, false)?, - ] - } else { - vec![ - Tag::hashtag("root"), - ] - }, - mentions.to_vec(), - // this is not strictly needed but makes for prettier branch names - // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding - // a change like this, or the removal of this tag will require the actual branch name to be tracked - // so pulling and pushing still work - if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { - if !branch_name.eq("main") - && !branch_name.eq("master") - && !branch_name.eq("origin/main") - && !branch_name.eq("origin/master") - { - vec![ - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), - vec![if let Some(branch_name) = branch_name.strip_prefix("pr/") { - branch_name.to_string() - } else { - branch_name - }], - ), - ] - } - else { vec![] } - } else { - vec![] - }, - repo_ref.maintainers - .iter() - .map(|pk| Tag::public_key(*pk)) - .collect(), - ].concat(), - ), signer).await - .context("failed to create cover-letter event")?); - } - - for (i, commit) in commits.iter().enumerate() { - events.push( - generate_patch_event( - git_repo, - &root_commit, - commit, - events.first().map(|event| event.id), - signer, - repo_ref, - events.last().map(nostr::Event::id), - if events.is_empty() && commits.len().eq(&1) { - None - } else { - Some(((i + 1).try_into()?, commits.len().try_into()?)) - }, - if events.is_empty() { - if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { - if !branch_name.eq("main") - && !branch_name.eq("master") - && !branch_name.eq("origin/main") - && !branch_name.eq("origin/master") - { - Some(if let Some(branch_name) = branch_name.strip_prefix("pr/") { - branch_name.to_string() - } else { - branch_name - }) - } else { - None - } - } else { - None - } - } else { - None - }, - root_proposal_id, - if events.is_empty() { mentions } else { &[] }, - ) - .await - .context("failed to generate patch event")?, - ); - } - Ok(events) -} - -fn event_tag_from_nip19_or_hex( - reference: &str, - reference_name: &str, - marker: Marker, - allow_npub_reference: bool, - prompt_for_correction: bool, -) -> Result { - let mut bech32 = reference.to_string(); - loop { - if bech32.is_empty() { - bech32 = Interactor::default().input( - PromptInputParms::default().with_prompt(&format!("{reference_name} reference")), - )?; - } - if let Ok(nip19) = Nip19::from_bech32(bech32.clone()) { - match nip19 { - Nip19::Event(n) => { - break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: n.event_id, - relay_url: n.relays.first().map(UncheckedUrl::new), - marker: Some(marker), - public_key: None, - })); - } - Nip19::EventId(id) => { - break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: id, - relay_url: None, - marker: Some(marker), - public_key: None, - })); - } - Nip19::Coordinate(coordinate) => { - break Ok(Tag::coordinate(coordinate)); - } - Nip19::Profile(profile) => { - if allow_npub_reference { - break Ok(Tag::public_key(profile.public_key)); - } - } - Nip19::Pubkey(public_key) => { - if allow_npub_reference { - break Ok(Tag::public_key(public_key)); - } - } - _ => {} - } - } - if let Ok(id) = nostr::EventId::from_str(&bech32) { - break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: id, - relay_url: None, - marker: Some(marker), - public_key: None, - })); - } - if prompt_for_correction { - println!("not a valid {reference_name} event reference"); - } else { - bail!(format!("not a valid {reference_name} event reference")); - } - - bech32 = String::new(); - } -} - -pub struct CoverLetter { - pub title: String, - pub description: String, - pub branch_name: String, - pub event_id: Option, -} - -impl CoverLetter { - pub fn get_branch_name(&self) -> Result { - Ok(format!( - "pr/{}({})", - self.branch_name, - &self - .event_id - .context("proposal root event_id must be know to get it's branch name")? - .to_hex() - .as_str()[..8], - )) - } -} -pub fn event_is_cover_letter(event: &nostr::Event) -> bool { - // TODO: look for Subject:[ PATCH 0/n ] but watch out for: - // [PATCH v1 0/n ] or - // [PATCH subsystem v2 0/n ] - event.kind.eq(&Kind::GitPatch) - && event.tags().iter().any(|t| t.as_vec()[1].eq("root")) - && event - .tags() - .iter() - .any(|t| t.as_vec()[1].eq("cover-letter")) -} - -pub fn commit_msg_from_patch(patch: &nostr::Event) -> Result { - if let Ok(msg) = tag_value(patch, "description") { - Ok(msg) - } else { - let start_index = patch - .content - .find("] ") - .context("event is not formatted as a patch or cover letter")? - + 2; - let end_index = patch.content[start_index..] - .find("\ndiff --git") - .unwrap_or(patch.content.len()); - Ok(patch.content[start_index..end_index].to_string()) - } -} - -pub fn commit_msg_from_patch_oneliner(patch: &nostr::Event) -> Result { - Ok(commit_msg_from_patch(patch)? - .split('\n') - .collect::>()[0] - .to_string()) -} - -pub fn event_to_cover_letter(event: &nostr::Event) -> Result { - if !event_is_patch_set_root(event) { - bail!("event is not a patch set root event (root patch or cover letter)") - } - - let title = commit_msg_from_patch_oneliner(event)?; - let full = commit_msg_from_patch(event)?; - let description = full[title.len()..].trim().to_string(); - - Ok(CoverLetter { - title: title.clone(), - description, - // TODO should this be prefixed by format!("{}-"e.id.to_string()[..5]?) - branch_name: if let Ok(name) = match tag_value(event, "branch-name") { - Ok(name) => { - if !name.eq("main") && !name.eq("master") { - Ok(name) - } else { - Err(()) - } - } - _ => Err(()), - } { - name - } else { - let s = title - .replace(' ', "-") - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c.eq(&'/') { - c - } else { - '-' - } - }) - .collect(); - s - }, - event_id: Some(event.id()), - }) -} - -pub fn event_is_patch_set_root(event: &nostr::Event) -> bool { - event.kind.eq(&Kind::GitPatch) && event.tags().iter().any(|t| t.as_vec()[1].eq("root")) -} - -pub fn event_is_revision_root(event: &nostr::Event) -> bool { - event.kind.eq(&Kind::GitPatch) - && event - .tags() - .iter() - .any(|t| t.as_vec()[1].eq("revision-root")) -} - -pub fn patch_supports_commit_ids(event: &nostr::Event) -> bool { - event.kind.eq(&Kind::GitPatch) - && event - .tags() - .iter() - .any(|t| t.as_vec()[0].eq("commit-pgp-sig")) -} - -#[allow(clippy::too_many_arguments)] -#[allow(clippy::too_many_lines)] -pub async fn generate_patch_event( - git_repo: &Repo, - root_commit: &Sha1Hash, - commit: &Sha1Hash, - thread_event_id: Option, - signer: &nostr_sdk::NostrSigner, - repo_ref: &RepoRef, - parent_patch_event_id: Option, - series_count: Option<(u64, u64)>, - branch_name: Option, - root_proposal_id: &Option, - mentions: &[nostr::Tag], -) -> Result { - let commit_parent = git_repo - .get_commit_parent(commit) - .context("failed to get parent commit")?; - let relay_hint = repo_ref.relays.first().map(nostr::UncheckedUrl::from); - - sign_event( - EventBuilder::new( - nostr::event::Kind::GitPatch, - git_repo - .make_patch_from_commit(commit, &series_count) - .context(format!("cannot make patch for commit {commit}"))?, - [ - repo_ref - .maintainers - .iter() - .map(|m| { - Tag::coordinate(Coordinate { - kind: nostr::Kind::GitRepoAnnouncement, - public_key: *m, - identifier: repo_ref.identifier.to_string(), - relays: repo_ref.relays.clone(), - }) - }) - .collect::>(), - vec![ - Tag::from_standardized(TagStandard::Reference(root_commit.to_string())), - // commit id reference is a trade-off. its now - // unclear which one is the root commit id but it - // enables easier location of code comments againt - // code that makes it into the main branch, assuming - // the commit id is correct - Tag::from_standardized(TagStandard::Reference(commit.to_string())), - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("alt")), - vec![format!( - "git patch: {}", - git_repo - .get_commit_message_summary(commit) - .unwrap_or_default() - )], - ), - ], - if let Some(thread_event_id) = thread_event_id { - vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: thread_event_id, - relay_url: relay_hint.clone(), - marker: Some(Marker::Root), - public_key: None, - })] - } else if let Some(event_ref) = root_proposal_id.clone() { - vec![ - Tag::hashtag("root"), - Tag::hashtag("revision-root"), - // TODO check if id is for a root proposal (perhaps its for an issue?) - event_tag_from_nip19_or_hex( - &event_ref, - "proposal", - Marker::Reply, - false, - false, - )?, - ] - } else { - vec![Tag::hashtag("root")] - }, - mentions.to_vec(), - if let Some(id) = parent_patch_event_id { - vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: id, - relay_url: relay_hint.clone(), - marker: Some(Marker::Reply), - public_key: None, - })] - } else { - vec![] - }, - // see comment on branch names in cover letter event creation - if let Some(branch_name) = branch_name { - if thread_event_id.is_none() { - vec![Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), - vec![branch_name.to_string()], - )] - } else { - vec![] - } - } else { - vec![] - }, - // whilst it is in nip34 draft to tag the maintainers - // I'm not sure it is a good idea because if they are - // interested in all patches then their specialised - // client should subscribe to patches tagged with the - // repo reference. maintainers of large repos will not - // be interested in every patch. - repo_ref - .maintainers - .iter() - .map(|pk| Tag::public_key(*pk)) - .collect(), - vec![ - // a fallback is now in place to extract this from the patch - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("commit")), - vec![commit.to_string()], - ), - // this is required as patches cannot be relied upon to include the 'base - // commit' - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")), - vec![commit_parent.to_string()], - ), - // this is required to ensure the commit id matches - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")), - vec![ - git_repo - .extract_commit_pgp_signature(commit) - .unwrap_or_default(), - ], - ), - // removing description tag will not cause anything to break - Tag::from_standardized(nostr_sdk::TagStandard::Description( - git_repo.get_commit_message(commit)?.to_string(), - )), - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("author")), - git_repo.get_commit_author(commit)?, - ), - // this is required to ensure the commit id matches - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("committer")), - git_repo.get_commit_comitter(commit)?, - ), - ], - ] - .concat(), - ), - signer, - ) - .await - .context("failed to sign event") -} -// TODO -// - find profile -// - file relays -// - find repo events -// - - -/** - * returns `(from_branch,to_branch,ahead,behind)` - */ -pub fn identify_ahead_behind( - git_repo: &Repo, - from_branch: &Option, - to_branch: &Option, -) -> Result<(String, String, Vec, Vec)> { - let (from_branch, from_tip) = match from_branch { - Some(name) => ( - name.to_string(), - git_repo - .get_tip_of_branch(name) - .context(format!("cannot find from_branch '{name}'"))?, - ), - None => ( - if let Ok(name) = git_repo.get_checked_out_branch_name() { - name - } else { - "head".to_string() - }, - git_repo - .get_head_commit() - .context("failed to get head commit") - .context( - "checkout a commit or specify a from_branch. head does not reveal a commit", - )?, - ), - }; - - let (to_branch, to_tip) = match to_branch { - Some(name) => ( - name.to_string(), - git_repo - .get_tip_of_branch(name) - .context(format!("cannot find to_branch '{name}'"))?, - ), - None => { - let (name, commit) = git_repo - .get_main_or_master_branch() - .context("the default branches (main or master) do not exist")?; - (name.to_string(), commit) - } - }; - - match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) { - Err(e) => { - if e.to_string().contains("is not an ancestor of") { - return Err(e).context(format!( - "'{from_branch}' is not branched from '{to_branch}'" - )); - } - Err(e).context(format!( - "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'" - )) - } - Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)), - } -} - -#[cfg(test)] -mod tests { - use test_utils::git::GitTestRepo; - - use super::*; - mod identify_ahead_behind { - - use super::*; - use crate::git::oid_to_sha1; - - #[test] - fn when_from_branch_doesnt_exist_return_error() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - let branch_name = "doesnt_exist"; - assert_eq!( - identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None) - .unwrap_err() - .to_string(), - format!("cannot find from_branch '{}'", &branch_name), - ); - Ok(()) - } - - #[test] - fn when_to_branch_doesnt_exist_return_error() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - let branch_name = "doesnt_exist"; - assert_eq!( - identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string())) - .unwrap_err() - .to_string(), - format!("cannot find to_branch '{}'", &branch_name), - ); - Ok(()) - } - - #[test] - fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> { - let test_repo = GitTestRepo::new("notmain")?; - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - - assert_eq!( - identify_ahead_behind(&git_repo, &None, &None) - .unwrap_err() - .to_string(), - "the default branches (main or master) do not exist", - ); - Ok(()) - } - - #[test] - fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - // create feature branch with 1 commit ahead - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let head_oid = test_repo.stage_and_commit("add t3.md")?; - - // make feature branch 1 commit behind - test_repo.checkout("main")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let main_oid = test_repo.stage_and_commit("add t4.md")?; - - let (from_branch, to_branch, ahead, behind) = - identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?; - - assert_eq!(from_branch, "feature"); - assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]); - assert_eq!(to_branch, "main"); - assert_eq!(behind, vec![oid_to_sha1(&main_oid)]); - Ok(()) - } - - #[test] - fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - // create dev branch with 1 commit ahead - test_repo.create_branch("dev")?; - test_repo.checkout("dev")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let dev_oid_first = test_repo.stage_and_commit("add t3.md")?; - - // create feature branch with 1 commit ahead of dev - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let feature_oid = test_repo.stage_and_commit("add t4.md")?; - - // make feature branch 1 behind - test_repo.checkout("dev")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let dev_oid = test_repo.stage_and_commit("add t3.md")?; - - let (from_branch, to_branch, ahead, behind) = identify_ahead_behind( - &git_repo, - &Some("feature".to_string()), - &Some("dev".to_string()), - )?; - - assert_eq!(from_branch, "feature"); - assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]); - assert_eq!(to_branch, "dev"); - assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]); - - let (from_branch, to_branch, ahead, behind) = - identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?; - - assert_eq!(from_branch, "feature"); - assert_eq!( - ahead, - vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)] - ); - assert_eq!(to_branch, "main"); - assert_eq!(behind, vec![]); - - Ok(()) - } - } - - mod event_to_cover_letter { - use super::*; - - fn generate_cover_letter(title: &str, description: &str) -> Result { - Ok(nostr::event::EventBuilder::new( - nostr::event::Kind::GitPatch, - format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"), - [ - Tag::hashtag("cover-letter"), - Tag::hashtag("root"), - ], - ) - .to_event(&nostr::Keys::generate())?) - } - - #[test] - fn basic_title() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter("the title", "description here")?)? - .title, - "the title", - ); - Ok(()) - } - - #[test] - fn basic_description() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter("the title", "description here")?)? - .description, - "description here", - ); - Ok(()) - } - - #[test] - fn description_trimmed() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter( - "the title", - " \n \ndescription here\n\n " - )?)? - .description, - "description here", - ); - Ok(()) - } - - #[test] - fn multi_line_description() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter( - "the title", - "description here\n\nmore here\nmore" - )?)? - .description, - "description here\n\nmore here\nmore", - ); - Ok(()) - } - - #[test] - fn new_lines_in_title_forms_part_of_description() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter( - "the title\nwith new line", - "description here\n\nmore here\nmore" - )?)? - .title, - "the title", - ); - assert_eq!( - event_to_cover_letter(&generate_cover_letter( - "the title\nwith new line", - "description here\n\nmore here\nmore" - )?)? - .description, - "with new line\n\ndescription here\n\nmore here\nmore", - ); - Ok(()) - } - - mod blank_description { - use super::*; - - #[test] - fn title_correct() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter("the title", "")?)?.title, - "the title", - ); - Ok(()) - } - - #[test] - fn description_is_empty_string() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter("the title", "")?)?.description, - "", - ); - Ok(()) - } - } - } -} -- cgit v1.2.3