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/bin/git_remote_nostr/main.rs | 1897 ++++++++++++++++++++++++++++++++++++ src/bin/ngit/cli.rs | 44 + src/bin/ngit/main.rs | 26 + src/bin/ngit/sub_commands/fetch.rs | 44 + src/bin/ngit/sub_commands/init.rs | 385 ++++++++ src/bin/ngit/sub_commands/list.rs | 906 +++++++++++++++++ src/bin/ngit/sub_commands/login.rs | 52 + src/bin/ngit/sub_commands/mod.rs | 7 + src/bin/ngit/sub_commands/pull.rs | 209 ++++ src/bin/ngit/sub_commands/push.rs | 223 +++++ src/bin/ngit/sub_commands/send.rs | 1363 ++++++++++++++++++++++++++ 11 files changed, 5156 insertions(+) create mode 100644 src/bin/git_remote_nostr/main.rs create mode 100644 src/bin/ngit/cli.rs create mode 100644 src/bin/ngit/main.rs create mode 100644 src/bin/ngit/sub_commands/fetch.rs create mode 100644 src/bin/ngit/sub_commands/init.rs create mode 100644 src/bin/ngit/sub_commands/list.rs create mode 100644 src/bin/ngit/sub_commands/login.rs create mode 100644 src/bin/ngit/sub_commands/mod.rs create mode 100644 src/bin/ngit/sub_commands/pull.rs create mode 100644 src/bin/ngit/sub_commands/push.rs create mode 100644 src/bin/ngit/sub_commands/send.rs (limited to 'src/bin') diff --git a/src/bin/git_remote_nostr/main.rs b/src/bin/git_remote_nostr/main.rs new file mode 100644 index 0000000..a5244bf --- /dev/null +++ b/src/bin/git_remote_nostr/main.rs @@ -0,0 +1,1897 @@ +#![cfg_attr(not(test), warn(clippy::pedantic))] +#![allow(clippy::large_futures)] +// better solution to dead_code error on multiple binaries than https://stackoverflow.com/a/66196291 +#![allow(dead_code)] +#![cfg_attr(not(test), warn(clippy::expect_used))] + +use core::str; +use std::{ + collections::{HashMap, HashSet}, + env, + io::{self, Stdin}, + path::{Path, PathBuf}, +}; + +use anyhow::{anyhow, bail, Context, Result}; +use auth_git2::GitAuthenticator; +use client::{ + consolidate_fetch_reports, get_events_from_cache, get_repo_ref_from_cache, + get_state_from_cache, sign_event, Connect, STATE_KIND, +}; +use console::Term; +use git::{sha1_to_oid, NostrUrlDecoded, RepoActions}; +use git2::{Oid, Repository}; +use nostr::nips::{nip01::Coordinate, nip10::Marker}; +use nostr_sdk::{ + hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, EventId, Kind, PublicKey, Tag, Url, +}; +use nostr_signer::NostrSigner; +use repo_ref::RepoRef; +use repo_state::RepoState; +use sub_commands::{ + 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, status_kinds, + tag_value, + }, + send::{ + event_is_revision_root, event_to_cover_letter, generate_cover_letter_and_patch_events, + generate_patch_event, send_events, + }, +}; + +#[cfg(not(test))] +use crate::client::Client; +#[cfg(test)] +use crate::client::MockConnect; +use crate::git::Repo; + +mod cli; +mod cli_interactor; +mod client; +mod config; +mod git; +mod key_handling; +mod login; +mod repo_ref; +mod repo_state; +mod sub_commands; + +#[tokio::main] +async fn main() -> Result<()> { + let args = env::args(); + let args = args.skip(1).take(2).collect::>(); + + let ([_, nostr_remote_url] | [nostr_remote_url]) = args.as_slice() else { + bail!("invalid arguments - no url"); + }; + if env::args().nth(1).as_deref() == Some("--version") { + const VERSION: &str = env!("CARGO_PKG_VERSION"); + println!("v{VERSION}"); + return Ok(()); + } + + let git_repo = Repo::from_path(&PathBuf::from( + std::env::var("GIT_DIR").context("git should set GIT_DIR when remote helper is called")?, + ))?; + let git_repo_path = git_repo.get_path()?; + + #[cfg(not(test))] + let client = Client::default(); + #[cfg(test)] + let client = ::default(); + + let decoded_nostr_url = + NostrUrlDecoded::from_str(nostr_remote_url).context("invalid nostr url")?; + + fetching_with_report_for_helper(git_repo_path, &client, &decoded_nostr_url.coordinates).await?; + + let repo_ref = get_repo_ref_from_cache(git_repo_path, &decoded_nostr_url.coordinates).await?; + + let stdin = io::stdin(); + let mut line = String::new(); + + let mut list_outputs = None; + loop { + let tokens = read_line(&stdin, &mut line)?; + + match tokens.as_slice() { + ["capabilities"] => { + println!("option"); + println!("push"); + println!("fetch"); + println!(); + } + ["option", "verbosity"] => { + println!("ok"); + } + ["option", ..] => { + println!("unsupported"); + } + ["fetch", oid, refstr] => { + fetch(&git_repo, &repo_ref, &stdin, oid, refstr).await?; + } + ["push", refspec] => { + push( + &git_repo, + &repo_ref, + nostr_remote_url, + &stdin, + refspec, + &client, + list_outputs.clone(), + ) + .await?; + } + ["list"] => { + list_outputs = Some(list(&git_repo, &repo_ref, false).await?); + } + ["list", "for-push"] => { + list_outputs = Some(list(&git_repo, &repo_ref, true).await?); + } + [] => { + return Ok(()); + } + _ => { + bail!(format!("unknown command: {}", line.trim().to_owned())); + } + } + } +} + +/// Read one line from stdin, and split it into tokens. +pub(crate) fn read_line<'a>(stdin: &io::Stdin, line: &'a mut String) -> io::Result> { + line.clear(); + + let read = stdin.read_line(line)?; + if read == 0 { + return Ok(vec![]); + } + let line = line.trim(); + let tokens = line.split(' ').filter(|t| !t.is_empty()).collect(); + + Ok(tokens) +} + +async fn fetching_with_report_for_helper( + git_repo_path: &Path, + #[cfg(test)] client: &crate::client::MockConnect, + #[cfg(not(test))] client: &Client, + repo_coordinates: &HashSet, +) -> Result<()> { + let term = console::Term::stderr(); + term.write_line("nostr: fetching...")?; + let (relay_reports, progress_reporter) = client + .fetch_all(git_repo_path, repo_coordinates, &HashSet::new()) + .await?; + if !relay_reports.iter().any(std::result::Result::is_err) { + let _ = progress_reporter.clear(); + term.clear_last_lines(1)?; + } + let report = consolidate_fetch_reports(relay_reports); + if report.to_string().is_empty() { + term.write_line("nostr: no updates")?; + } else { + term.write_line(&format!("nostr updates: {report}"))?; + } + Ok(()) +} + +async fn list( + git_repo: &Repo, + repo_ref: &RepoRef, + for_push: bool, +) -> Result>> { + let nostr_state = + if let Ok(nostr_state) = get_state_from_cache(git_repo.get_path()?, repo_ref).await { + Some(nostr_state) + } else { + None + }; + + let term = console::Term::stderr(); + + let remote_states = list_from_remotes(&term, git_repo, &repo_ref.git_server)?; + + let mut state = if let Some(nostr_state) = nostr_state { + for (name, value) in &nostr_state.state { + for (url, remote_state) in &remote_states { + let remote_name = get_short_git_server_name(git_repo, url); + if let Some(remote_value) = remote_state.get(name) { + if value.ne(remote_value) { + term.write_line( + format!( + "WARNING: {remote_name} {name} is {} nostr ", + if let Ok((ahead, behind)) = + get_ahead_behind(git_repo, value, remote_value) + { + format!("{} ahead {} behind", ahead.len(), behind.len()) + } else { + "out of sync with".to_string() + } + ) + .as_str(), + )?; + } + } else { + term.write_line( + format!("WARNING: {remote_name} {name} is missing but tracked on nostr") + .as_str(), + )?; + } + } + } + nostr_state.state + } else { + repo_ref + .git_server + .iter() + .filter_map(|server| remote_states.get(server)) + .cloned() + .collect::>>() + .first() + .context("failed to get refs from git server")? + .clone() + }; + + state.retain(|k, _| !k.starts_with("refs/heads/pr/")); + + let open_proposals = get_open_proposals(git_repo, repo_ref).await?; + let current_user = get_curent_user(git_repo)?; + for (_, (proposal, patches)) in open_proposals { + if let Ok(cl) = event_to_cover_letter(&proposal) { + if let Ok(mut branch_name) = cl.get_branch_name() { + branch_name = if let Some(public_key) = current_user { + if proposal.author().eq(&public_key) { + cl.branch_name.to_string() + } else { + branch_name + } + } else { + branch_name + }; + if let Some(patch) = patches.first() { + // TODO this isn't resilient because the commit id stated may not be correct + // we will need to check whether the commit id exists in the repo or apply the + // proposal and each patch to check + if let Ok(commit_id) = get_commit_id_from_patch(patch) { + state.insert(format!("refs/heads/{branch_name}"), commit_id); + } + } + } + } + } + + // TODO 'for push' should we check with the git servers to see if any of them + // allow push from the user? + for (name, value) in state { + if value.starts_with("ref: ") { + if !for_push { + println!("{} {name}", value.replace("ref: ", "@")); + } + } else { + println!("{value} {name}"); + } + } + + println!(); + Ok(remote_states) +} + +fn list_from_remotes( + term: &console::Term, + git_repo: &Repo, + git_servers: &Vec, +) -> Result>> { + let mut remote_states = HashMap::new(); + for url in git_servers { + let short_name = get_short_git_server_name(git_repo, url); + term.write_line(format!("fetching refs list: {short_name}...").as_str())?; + match list_from_remote(git_repo, url) { + Ok(remote_state) => { + remote_states.insert(url.clone(), remote_state); + } + Err(error1) => { + if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(url) { + match list_from_remote(git_repo, &alternative_url) { + Ok(remote_state) => { + remote_states.insert(url.clone(), remote_state); + } + Err(error2) => { + term.write_line( + format!("WARNING: {short_name} failed to list refs error: {error1}\r\nand alternative protocol {alternative_url}: {error2}").as_str(), + )?; + } + } + } else { + term.write_line( + format!("WARNING: {short_name} failed to list refs error: {error1}",) + .as_str(), + )?; + } + } + } + term.clear_last_lines(1)?; + } + Ok(remote_states) +} + +fn switch_clone_url_between_ssh_and_https(url: &str) -> Result { + if url.starts_with("https://") { + // Convert HTTPS to git@ syntax + let parts: Vec<&str> = url.trim_start_matches("https://").split('/').collect(); + if parts.len() >= 2 { + // Construct the git@ URL + Ok(format!("git@{}:{}", parts[0], parts[1..].join("/"))) + } else { + // If the format is unexpected, return an error + bail!("Invalid HTTPS URL format: {}", url); + } + } else if url.starts_with("ssh://") { + // Convert SSH to git@ syntax + let parts: Vec<&str> = url.trim_start_matches("ssh://").split('/').collect(); + if parts.len() >= 2 { + // Construct the git@ URL + Ok(format!("git@{}:{}", parts[0], parts[1..].join("/"))) + } else { + // If the format is unexpected, return an error + bail!("Invalid SSH URL format: {}", url); + } + } else if url.starts_with("git@") { + // Convert git@ syntax to HTTPS + let parts: Vec<&str> = url.split(':').collect(); + if parts.len() == 2 { + // Construct the HTTPS URL + Ok(format!( + "https://{}/{}", + parts[0].trim_end_matches('@'), + parts[1] + )) + } else { + // If the format is unexpected, return an error + bail!("Invalid git@ URL format: {}", url); + } + } else { + // If the URL is neither HTTPS, SSH, nor git@, return an error + bail!("Unsupported URL protocol: {}", url); + } +} + +fn list_from_remote( + git_repo: &Repo, + git_server_remote_url: &str, +) -> Result> { + let git_config = git_repo.git_repo.config()?; + + let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_remote_url)?; + // authentication may be required + let auth = GitAuthenticator::default(); + let mut remote_callbacks = git2::RemoteCallbacks::new(); + remote_callbacks.credentials(auth.credentials(&git_config)); + git_server_remote.connect_auth(git2::Direction::Fetch, Some(remote_callbacks), None)?; + let mut state = HashMap::new(); + for head in git_server_remote.list()? { + if let Some(symbolic_reference) = head.symref_target() { + state.insert( + head.name().to_string(), + format!("ref: {symbolic_reference}"), + ); + } else { + state.insert(head.name().to_string(), head.oid().to_string()); + } + } + git_server_remote.disconnect()?; + Ok(state) +} + +fn get_ahead_behind( + git_repo: &Repo, + base_ref_or_oid: &str, + latest_ref_or_oid: &str, +) -> Result<(Vec, Vec)> { + let base = git_repo.get_commit_or_tip_of_reference(base_ref_or_oid)?; + let latest = git_repo.get_commit_or_tip_of_reference(latest_ref_or_oid)?; + git_repo.get_commits_ahead_behind(&base, &latest) +} + +async fn get_open_proposals( + git_repo: &Repo, + repo_ref: &RepoRef, +) -> Result)>> { + let git_repo_path = git_repo.get_path()?; + let proposals: Vec = + get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()) + .await? + .iter() + .filter(|e| !event_is_revision_root(e)) + .cloned() + .collect(); + + let statuses: Vec = { + let mut statuses = get_events_from_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .kinds(status_kinds().clone()) + .events(proposals.iter().map(nostr::Event::id)), + ], + ) + .await?; + statuses.sort_by_key(|e| e.created_at); + statuses.reverse(); + statuses + }; + let mut open_proposals = HashMap::new(); + + 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) { + if let Ok(commits_events) = + get_all_proposal_patch_events_from_cache(git_repo_path, repo_ref, &proposal.id) + .await + { + if let Ok(most_recent_proposal_patch_chain) = + get_most_recent_patch_with_ancestors(commits_events.clone()) + { + open_proposals + .insert(proposal.id(), (proposal, most_recent_proposal_patch_chain)); + } + } + } + } + Ok(open_proposals) +} + +fn get_curent_user(git_repo: &Repo) -> Result> { + Ok( + if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? { + if let Ok(public_key) = PublicKey::parse(npub) { + Some(public_key) + } else { + None + } + } else { + None + }, + ) +} + +async fn get_all_proposals( + git_repo: &Repo, + repo_ref: &RepoRef, +) -> Result)>> { + let git_repo_path = git_repo.get_path()?; + let proposals: Vec = + get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()) + .await? + .iter() + .filter(|e| !event_is_revision_root(e)) + .cloned() + .collect(); + + let mut all_proposals = HashMap::new(); + + for proposal in proposals { + if let Ok(commits_events) = + get_all_proposal_patch_events_from_cache(git_repo_path, repo_ref, &proposal.id).await + { + if let Ok(most_recent_proposal_patch_chain) = + get_most_recent_patch_with_ancestors(commits_events.clone()) + { + all_proposals.insert(proposal.id(), (proposal, most_recent_proposal_patch_chain)); + } + } + } + Ok(all_proposals) +} + +async fn fetch( + git_repo: &Repo, + repo_ref: &RepoRef, + stdin: &Stdin, + oid: &str, + refstr: &str, +) -> Result<()> { + let mut fetch_batch = get_oids_from_fetch_batch(stdin, oid, refstr)?; + + let oids_from_git_servers = fetch_batch + .iter() + .filter(|(refstr, _)| !refstr.contains("refs/heads/pr/")) + .map(|(_, oid)| oid.clone()) + .collect::>(); + + let mut errors = HashMap::new(); + let term = console::Term::stderr(); + + for git_server_url in &repo_ref.git_server { + let term = console::Term::stderr(); + let short_name = get_short_git_server_name(git_repo, git_server_url); + term.write_line(format!("fetching from {short_name}...").as_str())?; + let res = fetch_from_git_server(&git_repo.git_repo, &oids_from_git_servers, git_server_url); + term.clear_last_lines(1)?; + if let Err(error1) = res { + if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(git_server_url) { + let res2 = fetch_from_git_server( + &git_repo.git_repo, + &oids_from_git_servers, + &alternative_url, + ); + if let Err(error2) = res2 { + term.write_line( + format!( + "WARNING: failed to fetch from {short_name} error:{error1}\r\nand using alternative protocol {alternative_url}: {error2}" + ).as_str() + )?; + errors.insert( + short_name.to_string(), + anyhow!( + "{error1} and using alternative protocol {alternative_url}: {error2}" + ), + ); + } else { + break; + } + } else { + term.write_line( + format!("WARNING: failed to fetch from {short_name} error:{error1}").as_str(), + )?; + errors.insert(short_name.to_string(), error1); + } + } else { + break; + } + } + + if oids_from_git_servers + .iter() + .any(|oid| !git_repo.does_commit_exist(oid).unwrap()) + && !errors.is_empty() + { + bail!( + "failed to fetch objects in nostr state event from:\r\n{}", + errors + .iter() + .map(|(url, error)| format!("{url}: {error}")) + .collect::>() + .join("\r\n") + ); + } + + fetch_batch.retain(|refstr, _| refstr.contains("refs/heads/pr/")); + + if !fetch_batch.is_empty() { + let open_proposals = get_open_proposals(git_repo, repo_ref).await?; + + let current_user = get_curent_user(git_repo)?; + + for (refstr, oid) in fetch_batch { + if let Some((_, (_, patches))) = + find_proposal_and_patches_by_branch_name(&refstr, &open_proposals, ¤t_user) + { + if !git_repo.does_commit_exist(&oid)? { + let mut patches_ancestor_first = patches.clone(); + patches_ancestor_first.reverse(); + if git_repo.does_commit_exist(&tag_value( + patches_ancestor_first.first().unwrap(), + "parent-commit", + )?)? { + for patch in &patches_ancestor_first { + git_repo.create_commit_from_patch(patch)?; + } + } else { + term.write_line( + format!("WARNING: cannot find parent commit for {refstr}").as_str(), + )?; + } + } + } else { + term.write_line(format!("WARNING: cannot find proposal for {refstr}").as_str())?; + } + } + } + + term.flush()?; + println!(); + Ok(()) +} + +fn find_proposal_and_patches_by_branch_name<'a>( + refstr: &'a str, + open_proposals: &'a HashMap)>, + current_user: &Option, +) -> Option<(&'a EventId, &'a (Event, Vec))> { + open_proposals.iter().find(|(_, (proposal, _))| { + if let Ok(cl) = event_to_cover_letter(proposal) { + if let Ok(mut branch_name) = cl.get_branch_name() { + branch_name = if let Some(public_key) = current_user { + if proposal.author().eq(public_key) { + cl.branch_name.to_string() + } else { + branch_name + } + } else { + branch_name + }; + branch_name.eq(&refstr.replace("refs/heads/", "")) + } else { + false + } + } else { + false + } + }) +} + +fn fetch_from_git_server( + git_repo: &Repository, + oids: &[String], + git_server_url: &str, +) -> Result<()> { + let git_config = git_repo.config()?; + + let mut git_server_remote = git_repo.remote_anonymous(git_server_url)?; + // authentication may be required (and will be requird if clone url is ssh) + let auth = GitAuthenticator::default(); + let mut fetch_options = git2::FetchOptions::new(); + let mut remote_callbacks = git2::RemoteCallbacks::new(); + remote_callbacks.credentials(auth.credentials(&git_config)); + fetch_options.remote_callbacks(remote_callbacks); + git_server_remote.download(oids, Some(&mut fetch_options))?; + git_server_remote.disconnect()?; + Ok(()) +} + +#[allow(clippy::too_many_lines)] +async fn push( + git_repo: &Repo, + repo_ref: &RepoRef, + nostr_remote_url: &str, + stdin: &Stdin, + initial_refspec: &str, + #[cfg(test)] client: &crate::client::MockConnect, + #[cfg(not(test))] client: &Client, + list_outputs: Option>>, +) -> Result<()> { + let refspecs = get_refspecs_from_push_batch(stdin, initial_refspec)?; + + let proposal_refspecs = refspecs + .iter() + .filter(|r| r.contains("refs/heads/pr/")) + .cloned() + .collect::>(); + + let mut git_server_refspecs = refspecs + .iter() + .filter(|r| !r.contains("refs/heads/pr/")) + .cloned() + .collect::>(); + + let term = console::Term::stderr(); + + let list_outputs = match list_outputs { + Some(outputs) => outputs, + _ => list_from_remotes(&term, git_repo, &repo_ref.git_server)?, + }; + + let nostr_state = get_state_from_cache(git_repo.get_path()?, repo_ref).await; + + let existing_state = { + // if no state events - create from first git server listed + if let Ok(nostr_state) = &nostr_state { + nostr_state.state.clone() + } else if let Some(url) = repo_ref + .git_server + .iter() + .find(|&url| list_outputs.contains_key(url)) + { + list_outputs.get(url).unwrap().to_owned() + } else { + bail!( + "cannot connect to git servers: {}", + repo_ref.git_server.join(" ") + ); + } + }; + + let (rejected_refspecs, remote_refspecs) = create_rejected_refspecs_and_remotes_refspecs( + &term, + git_repo, + &git_server_refspecs, + &existing_state, + &list_outputs, + )?; + + git_server_refspecs.retain(|refspec| { + if let Some(rejected) = rejected_refspecs.get(&refspec.to_string()) { + let (_, to) = refspec_to_from_to(refspec).unwrap(); + println!("error {to} {} out of sync with nostr", rejected.join(" ")); + false + } else { + true + } + }); + + let mut events = vec![]; + + if git_server_refspecs.is_empty() && proposal_refspecs.is_empty() { + // all refspecs rejected + println!(); + return Ok(()); + } + + let (signer, user_ref) = login::launch( + git_repo, + &None, + &None, + &None, + &None, + Some(client), + false, + true, + ) + .await?; + + if !repo_ref.maintainers.contains(&user_ref.public_key) { + for refspec in &git_server_refspecs { + let (_, to) = refspec_to_from_to(refspec).unwrap(); + println!( + "error {to} your nostr account {} isn't listed as a maintainer of the repo", + user_ref.metadata.name + ); + } + git_server_refspecs.clear(); + if proposal_refspecs.is_empty() { + println!(); + return Ok(()); + } + } + + if !git_server_refspecs.is_empty() { + let new_state = generate_updated_state(git_repo, &existing_state, &git_server_refspecs)?; + + let new_repo_state = + RepoState::build(repo_ref.identifier.clone(), new_state, &signer).await?; + + events.push(new_repo_state.event); + + for event in get_merged_status_events( + &term, + repo_ref, + git_repo, + nostr_remote_url, + &signer, + &git_server_refspecs, + ) + .await? + { + events.push(event); + } + } + + let mut rejected_proposal_refspecs = vec![]; + if !proposal_refspecs.is_empty() { + let all_proposals = get_all_proposals(git_repo, repo_ref).await?; + let current_user = get_curent_user(git_repo)?; + + for refspec in &proposal_refspecs { + let (from, to) = refspec_to_from_to(refspec).unwrap(); + let tip_of_pushed_branch = git_repo.get_commit_or_tip_of_reference(from)?; + + if let Some((_, (proposal, patches))) = + find_proposal_and_patches_by_branch_name(to, &all_proposals, ¤t_user) + { + if [repo_ref.maintainers.clone(), vec![proposal.author()]] + .concat() + .contains(&user_ref.public_key) + { + if refspec.starts_with('+') { + // force push + let (_, main_tip) = git_repo.get_main_or_master_branch()?; + let (mut ahead, _) = + git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?; + ahead.reverse(); + for patch in generate_cover_letter_and_patch_events( + None, + git_repo, + &ahead, + &signer, + repo_ref, + &Some(proposal.id().to_string()), + &[], + ) + .await? + { + events.push(patch); + } + } else { + // fast forward push + let tip_patch = patches.first().unwrap(); + let tip_of_proposal = get_commit_id_from_patch(tip_patch)?; + let tip_of_proposal_commit = + git_repo.get_commit_or_tip_of_reference(&tip_of_proposal)?; + + let (mut ahead, behind) = git_repo.get_commits_ahead_behind( + &tip_of_proposal_commit, + &tip_of_pushed_branch, + )?; + if behind.is_empty() { + let thread_id = if let Ok(root_event_id) = get_event_root(tip_patch) { + root_event_id + } else { + // tip patch is the root proposal + tip_patch.id() + }; + let mut parent_patch = tip_patch.clone(); + ahead.reverse(); + for (i, commit) in ahead.iter().enumerate() { + let new_patch = generate_patch_event( + git_repo, + &git_repo.get_root_commit()?, + commit, + Some(thread_id), + &signer, + repo_ref, + Some(parent_patch.id()), + Some(( + (patches.len() + i + 1).try_into().unwrap(), + (patches.len() + ahead.len()).try_into().unwrap(), + )), + None, + &None, + &[], + ) + .await + .context("cannot make patch event from commit")?; + events.push(new_patch.clone()); + parent_patch = new_patch; + } + } else { + // we shouldn't get here + term.write_line( + format!( + "WARNING: failed to push {from} as nostr proposal. Try and force push ", + ) + .as_str(), + ) + .unwrap(); + println!( + "error {to} cannot fastforward as newer patches found on proposal" + ); + rejected_proposal_refspecs.push(refspec.to_string()); + } + } + } else { + println!( + "error {to} permission denied. you are not the proposal author or a repo maintainer" + ); + rejected_proposal_refspecs.push(refspec.to_string()); + } + } else { + // TODO new proposal / couldn't find exisiting proposal + let (_, main_tip) = git_repo.get_main_or_master_branch()?; + let (mut ahead, _) = + git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?; + ahead.reverse(); + for patch in generate_cover_letter_and_patch_events( + None, + git_repo, + &ahead, + &signer, + repo_ref, + &None, + &[], + ) + .await? + { + events.push(patch); + } + } + } + } + + // TODO check whether tip of each branch pushed is on at least one git server + // before broadcasting the nostr state + if !events.is_empty() { + send_events( + client, + git_repo.get_path()?, + events, + user_ref.relays.write(), + repo_ref.relays.clone(), + false, + true, + ) + .await?; + } + + for refspec in &[git_server_refspecs.clone(), proposal_refspecs.clone()].concat() { + if rejected_proposal_refspecs.contains(refspec) { + continue; + } + let (_, to) = refspec_to_from_to(refspec)?; + println!("ok {to}"); + update_remote_refs_pushed(&git_repo.git_repo, refspec, nostr_remote_url) + .context("could not update remote_ref locally")?; + } + + // TODO make async - check gitlib2 callbacks work async + for (git_server_url, remote_refspecs) in remote_refspecs { + let remote_refspecs = remote_refspecs + .iter() + .filter(|refspec| git_server_refspecs.contains(refspec)) + .cloned() + .collect::>(); + if !refspecs.is_empty() + && push_to_remote(git_repo, &git_server_url, &remote_refspecs, &term).is_err() + { + if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(&git_server_url) { + if push_to_remote(git_repo, &alternative_url, &remote_refspecs, &term).is_err() { + // errors get printed as part of callback + // TODO prevent 2 warning messages and instead use one + // to say it didnt work over either https or ssh + } else { + term.write_line( + format!("but succeed over alterantive protocol {alternative_url}",) + .as_str(), + )?; + } + } + } + } + println!(); + Ok(()) +} + +fn push_to_remote( + git_repo: &Repo, + git_server_url: &str, + remote_refspecs: &[String], + term: &Term, +) -> Result<()> { + let git_config = git_repo.git_repo.config()?; + let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_url)?; + let auth = GitAuthenticator::default(); + let mut push_options = git2::PushOptions::new(); + let mut remote_callbacks = git2::RemoteCallbacks::new(); + remote_callbacks.credentials(auth.credentials(&git_config)); + remote_callbacks.push_update_reference(|name, error| { + if let Some(error) = error { + term.write_line( + format!( + "WARNING: {} failed to push {name} error: {error}", + get_short_git_server_name(git_repo, git_server_url), + ) + .as_str(), + ) + .unwrap(); + } + Ok(()) + }); + push_options.remote_callbacks(remote_callbacks); + git_server_remote.push(remote_refspecs, Some(&mut push_options))?; + let _ = git_server_remote.disconnect(); + Ok(()) +} + +fn get_event_root(event: &nostr::Event) -> Result { + Ok(EventId::parse( + event + .tags() + .iter() + .find(|t| t.is_root()) + .context("no thread root in event")? + .as_vec() + .get(1) + .unwrap(), + )?) +} + +type HashMapUrlRefspecs = HashMap>; + +#[allow(clippy::too_many_lines)] +fn create_rejected_refspecs_and_remotes_refspecs( + term: &console::Term, + git_repo: &Repo, + refspecs: &Vec, + nostr_state: &HashMap, + list_outputs: &HashMap>, +) -> Result<(HashMapUrlRefspecs, HashMapUrlRefspecs)> { + let mut refspecs_for_remotes = HashMap::new(); + + let mut rejected_refspecs: HashMapUrlRefspecs = HashMap::new(); + + for (url, remote_state) in list_outputs { + let short_name = get_short_git_server_name(git_repo, url); + let mut refspecs_for_remote = vec![]; + for refspec in refspecs { + let (from, to) = refspec_to_from_to(refspec)?; + let nostr_value = nostr_state.get(to); + let remote_value = remote_state.get(to); + if from.is_empty() { + if remote_value.is_some() { + // delete remote branch + refspecs_for_remote.push(refspec.clone()); + } + continue; + } + let from_tip = git_repo.get_commit_or_tip_of_reference(from)?; + if let Some(nostr_value) = nostr_value { + if let Some(remote_value) = remote_value { + if nostr_value.eq(remote_value) { + // in sync - existing branch at same state + let is_remote_tip_ancestor_of_commit = if let Ok(remote_value_tip) = + git_repo.get_commit_or_tip_of_reference(remote_value) + { + if let Ok((_, behind)) = + git_repo.get_commits_ahead_behind(&remote_value_tip, &from_tip) + { + behind.is_empty() + } else { + false + } + } else { + false + }; + if is_remote_tip_ancestor_of_commit { + refspecs_for_remote.push(refspec.clone()); + } else { + // this is a force push so we need to force push to git server too + if refspec.starts_with('+') { + refspecs_for_remote.push(refspec.clone()); + } else { + refspecs_for_remote.push(format!("+{refspec}")); + } + } + } else if let Ok(remote_value_tip) = + git_repo.get_commit_or_tip_of_reference(remote_value) + { + if from_tip.eq(&remote_value_tip) { + // remote already at correct state + term.write_line( + format!("{short_name} {to} already up-to-date").as_str(), + )?; + } + let (ahead_of_local, behind_local) = + git_repo.get_commits_ahead_behind(&from_tip, &remote_value_tip)?; + if ahead_of_local.is_empty() { + // can soft push + refspecs_for_remote.push(refspec.clone()); + } else { + // cant soft push + let (ahead_of_nostr, behind_nostr) = git_repo + .get_commits_ahead_behind( + &git_repo.get_commit_or_tip_of_reference(nostr_value)?, + &remote_value_tip, + )?; + if ahead_of_nostr.is_empty() { + // ancestor of nostr and we are force pushing anyway... + refspecs_for_remote.push(refspec.clone()); + } else { + rejected_refspecs + .entry(refspec.to_string()) + .and_modify(|a| a.push(url.to_string())) + .or_insert(vec![url.to_string()]); + term.write_line( + format!( + "ERROR: {short_name} {to} conflicts with nostr ({} ahead {} behind) and local ({} ahead {} behind). either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote", + ahead_of_nostr.len(), + behind_nostr.len(), + ahead_of_local.len(), + behind_local.len(), + ).as_str(), + )?; + } + }; + } else { + // remote_value oid is not present locally + // TODO can we download the remote reference? + + // cant soft push + rejected_refspecs + .entry(refspec.to_string()) + .and_modify(|a| a.push(url.to_string())) + .or_insert(vec![url.to_string()]); + term.write_line( + format!("ERROR: {short_name} {to} conflicts with nostr and is not an ancestor of local branch. either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote").as_str(), + )?; + } + } else { + // existing nostr branch not on remote + // report - creating new branch + term.write_line( + format!( + "{short_name} {to} doesn't exist and will be added as a new branch" + ) + .as_str(), + )?; + refspecs_for_remote.push(refspec.clone()); + } + } else if let Some(remote_value) = remote_value { + // new to nostr but on remote + if let Ok(remote_value_tip) = git_repo.get_commit_or_tip_of_reference(remote_value) + { + let (ahead, behind) = + git_repo.get_commits_ahead_behind(&from_tip, &remote_value_tip)?; + if behind.is_empty() { + // can soft push + refspecs_for_remote.push(refspec.clone()); + } else { + // cant soft push + rejected_refspecs + .entry(refspec.to_string()) + .and_modify(|a| a.push(url.to_string())) + .or_insert(vec![url.to_string()]); + term.write_line( + format!( + "ERROR: {short_name} already contains {to} {} ahead and {} behind local branch. either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote", + ahead.len(), + behind.len(), + ).as_str(), + )?; + } + } else { + // havn't fetched oid from remote + // TODO fetch oid from remote + // cant soft push + rejected_refspecs + .entry(refspec.to_string()) + .and_modify(|a| a.push(url.to_string())) + .or_insert(vec![url.to_string()]); + term.write_line( + format!("ERROR: {short_name} already contains {to} at {remote_value} which is not an ancestor of local branch. either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote").as_str(), + )?; + } + } else { + // in sync - new branch + refspecs_for_remote.push(refspec.clone()); + } + } + if !refspecs_for_remote.is_empty() { + refspecs_for_remotes.insert(url.to_string(), refspecs_for_remote); + } + } + + // remove rejected refspecs so they dont get pushed to some remotes + let mut remotes_refspecs_without_rejected = HashMap::new(); + for (url, value) in &refspecs_for_remotes { + remotes_refspecs_without_rejected.insert( + url.to_string(), + value + .iter() + .filter(|refspec| !rejected_refspecs.contains_key(*refspec)) + .cloned() + .collect(), + ); + } + Ok((rejected_refspecs, remotes_refspecs_without_rejected)) +} + +fn generate_updated_state( + git_repo: &Repo, + existing_state: &HashMap, + refspecs: &Vec, +) -> Result> { + let mut new_state = existing_state.clone(); + + for refspec in refspecs { + let (from, to) = refspec_to_from_to(refspec)?; + if from.is_empty() { + // delete + new_state.remove(to); + if to.contains("refs/tags") { + new_state.remove(&format!("{to}{}", "^{}")); + } + } else if to.contains("refs/tags") { + new_state.insert( + format!("{to}{}", "^{}"), + git_repo + .get_commit_or_tip_of_reference(from) + .unwrap() + .to_string(), + ); + new_state.insert( + to.to_string(), + git_repo + .git_repo + .find_reference(to) + .unwrap() + .peel(git2::ObjectType::Tag) + .unwrap() + .id() + .to_string(), + ); + } else { + // add or update + new_state.insert( + to.to_string(), + git_repo + .get_commit_or_tip_of_reference(from) + .unwrap() + .to_string(), + ); + } + } + Ok(new_state) +} + +async fn get_merged_status_events( + term: &console::Term, + repo_ref: &RepoRef, + git_repo: &Repo, + remote_nostr_url: &str, + signer: &NostrSigner, + refspecs_to_git_server: &Vec, +) -> Result> { + let mut events = vec![]; + for refspec in refspecs_to_git_server { + let (from, to) = refspec_to_from_to(refspec)?; + if to.eq("refs/heads/main") || to.eq("refs/heads/master") { + let tip_of_pushed_branch = git_repo.get_commit_or_tip_of_reference(from)?; + let Ok(tip_of_remote_branch) = git_repo.get_commit_or_tip_of_reference( + &refspec_remote_ref_name(&git_repo.git_repo, refspec, remote_nostr_url)?, + ) else { + // branch not on remote + continue; + }; + let (ahead, _) = + git_repo.get_commits_ahead_behind(&tip_of_remote_branch, &tip_of_pushed_branch)?; + for commit_hash in ahead { + let commit = git_repo.git_repo.find_commit(sha1_to_oid(&commit_hash)?)?; + if commit.parent_count() > 1 { + // merge commit + for parent in commit.parents() { + // lookup parent id + let commit_events = get_events_from_cache( + git_repo.get_path()?, + vec![ + nostr::Filter::default() + .kind(nostr::Kind::GitPatch) + .reference(parent.id().to_string()), + ], + ) + .await?; + if let Some(commit_event) = commit_events.iter().find(|e| { + e.tags.iter().any(|t| { + t.as_vec()[0].eq("commit") + && t.as_vec()[1].eq(&parent.id().to_string()) + }) + }) { + let (proposal_id, revision_id) = + get_proposal_and_revision_root_from_patch(git_repo, commit_event) + .await?; + term.write_line( + format!( + "merge commit {}: create nostr proposal status event", + &commit.id().to_string()[..7], + ) + .as_str(), + )?; + + events.push( + create_merge_status( + signer, + repo_ref, + &get_event_from_cache_by_id(git_repo, &proposal_id).await?, + &if let Some(revision_id) = revision_id { + Some( + get_event_from_cache_by_id(git_repo, &revision_id) + .await?, + ) + } else { + None + }, + &commit_hash, + commit_event.id(), + ) + .await?, + ); + } + } + } + } + } + } + Ok(events) +} + +async fn get_event_from_cache_by_id(git_repo: &Repo, event_id: &EventId) -> Result { + Ok(get_events_from_cache( + git_repo.get_path()?, + vec![nostr::Filter::default().id(*event_id)], + ) + .await? + .first() + .context("cannot find event in cache")? + .clone()) +} + +async fn create_merge_status( + signer: &NostrSigner, + repo_ref: &RepoRef, + proposal: &Event, + revision: &Option, + merge_commit: &Sha1Hash, + merged_patch: EventId, +) -> Result { + let mut public_keys = repo_ref + .maintainers + .iter() + .copied() + .collect::>(); + public_keys.insert(proposal.author()); + if let Some(revision) = revision { + public_keys.insert(revision.author()); + } + sign_event( + EventBuilder::new( + nostr::event::Kind::GitStatusApplied, + String::new(), + [ + vec![ + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), + vec!["git proposal merged / applied".to_string()], + ), + Tag::from_standardized(nostr::TagStandard::Event { + event_id: proposal.id(), + relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new), + marker: Some(Marker::Root), + public_key: None, + }), + Tag::from_standardized(nostr::TagStandard::Event { + event_id: merged_patch, + relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new), + marker: Some(Marker::Mention), + public_key: None, + }), + ], + if let Some(revision) = revision { + vec![Tag::from_standardized(nostr::TagStandard::Event { + event_id: revision.id(), + relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new), + marker: Some(Marker::Root), + public_key: None, + })] + } else { + vec![] + }, + public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(), + repo_ref + .coordinates() + .iter() + .map(|c| Tag::coordinate(c.clone())) + .collect::>(), + vec![ + Tag::from_standardized(nostr::TagStandard::Reference( + repo_ref.root_commit.to_string(), + )), + Tag::from_standardized(nostr::TagStandard::Reference(format!( + "{merge_commit}" + ))), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("merge-commit-id")), + vec![format!("{merge_commit}")], + ), + ], + ] + .concat(), + ), + signer, + ) + .await +} + +async fn get_proposal_and_revision_root_from_patch( + git_repo: &Repo, + patch: &Event, +) -> Result<(EventId, Option)> { + let proposal_or_revision = if patch.tags.iter().any(|t| t.as_vec()[1].eq("root")) { + patch.clone() + } else { + let proposal_or_revision_id = EventId::parse( + if let Some(t) = patch.tags.iter().find(|t| t.is_root()) { + t.clone() + } else if let Some(t) = patch.tags.iter().find(|t| t.is_reply()) { + t.clone() + } else { + Tag::event(patch.id()) + } + .as_vec()[1] + .clone(), + )?; + + get_events_from_cache( + git_repo.get_path()?, + vec![nostr::Filter::default().id(proposal_or_revision_id)], + ) + .await? + .first() + .unwrap() + .clone() + }; + + if !proposal_or_revision.kind().eq(&Kind::GitPatch) { + bail!("thread root is not a git patch"); + } + + if proposal_or_revision + .tags + .iter() + .any(|t| t.as_vec()[1].eq("revision-root")) + { + Ok(( + EventId::parse( + proposal_or_revision + .tags + .iter() + .find(|t| t.is_reply()) + .unwrap() + .as_vec()[1] + .clone(), + )?, + Some(proposal_or_revision.id()), + )) + } else { + Ok((proposal_or_revision.id(), None)) + } +} + +fn update_remote_refs_pushed( + git_repo: &Repository, + refspec: &str, + nostr_remote_url: &str, +) -> Result<()> { + let (from, _) = refspec_to_from_to(refspec)?; + + let target_ref_name = refspec_remote_ref_name(git_repo, refspec, nostr_remote_url)?; + + if from.is_empty() { + if let Ok(mut remote_ref) = git_repo.find_reference(&target_ref_name) { + remote_ref.delete()?; + } + } else { + let commit = reference_to_commit(git_repo, from) + .context(format!("cannot get commit of reference {from}"))?; + if let Ok(mut remote_ref) = git_repo.find_reference(&target_ref_name) { + remote_ref.set_target(commit, "updated by nostr remote helper")?; + } else { + git_repo.reference( + &target_ref_name, + commit, + false, + "created by nostr remote helper", + )?; + } + } + Ok(()) +} + +fn refspec_to_from_to(refspec: &str) -> Result<(&str, &str)> { + if !refspec.contains(':') { + bail!( + "refspec should contain a colon (:) but consists of: {}", + refspec + ); + } + let parts = refspec.split(':').collect::>(); + Ok(( + if parts.first().unwrap().starts_with('+') { + &parts.first().unwrap()[1..] + } else { + parts.first().unwrap() + }, + parts.get(1).unwrap(), + )) +} + +fn refspec_remote_ref_name( + git_repo: &Repository, + refspec: &str, + nostr_remote_url: &str, +) -> Result { + let (_, to) = refspec_to_from_to(refspec)?; + let nostr_remote = git_repo + .find_remote(&get_remote_name_by_url(git_repo, nostr_remote_url)?) + .context("we should have just located this remote")?; + Ok(format!( + "refs/remotes/{}/{}", + nostr_remote.name().context("remote should have a name")?, + to.replace("refs/heads/", ""), /* TODO only replace if it begins with this + * TODO what about tags? */ + )) +} + +fn reference_to_commit(git_repo: &Repository, reference: &str) -> Result { + Ok(git_repo + .find_reference(reference) + .context(format!("cannot find reference: {reference}"))? + .peel_to_commit() + .context(format!("cannot get commit from reference: {reference}"))? + .id()) +} + +// this maybe a commit id or a ref: pointer +fn reference_to_ref_value(git_repo: &Repository, reference: &str) -> Result { + let reference_obj = git_repo + .find_reference(reference) + .context(format!("cannot find reference: {reference}"))?; + if let Some(symref) = reference_obj.symbolic_target() { + Ok(symref.to_string()) + } else { + Ok(reference_obj + .peel_to_commit() + .context(format!("cannot get commit from reference: {reference}"))? + .id() + .to_string()) + } +} + +fn get_remote_name_by_url(git_repo: &Repository, url: &str) -> Result { + let remotes = git_repo.remotes()?; + Ok(remotes + .iter() + .find(|r| { + if let Some(name) = r { + if let Some(remote_url) = git_repo.find_remote(name).unwrap().url() { + url == remote_url + } else { + false + } + } else { + false + } + }) + .context("could not find remote with matching url")? + .context("remote with matching url must be named")? + .to_string()) +} + +fn get_short_git_server_name(git_repo: &Repo, url: &str) -> std::string::String { + if let Ok(name) = get_remote_name_by_url(&git_repo.git_repo, url) { + return name; + } + if let Ok(url) = Url::parse(url) { + if let Some(domain) = url.domain() { + return domain.to_string(); + } + } + url.to_string() +} + +fn get_oids_from_fetch_batch( + stdin: &Stdin, + initial_oid: &str, + initial_refstr: &str, +) -> Result> { + let mut line = String::new(); + let mut batch = HashMap::new(); + batch.insert(initial_refstr.to_string(), initial_oid.to_string()); + loop { + let tokens = read_line(stdin, &mut line)?; + match tokens.as_slice() { + ["fetch", oid, refstr] => { + batch.insert((*refstr).to_string(), (*oid).to_string()); + } + [] => break, + _ => bail!( + "after a `fetch` command we are only expecting another fetch or an empty line" + ), + } + } + Ok(batch) +} + +fn get_refspecs_from_push_batch(stdin: &Stdin, initial_refspec: &str) -> Result> { + let mut line = String::new(); + let mut refspecs = vec![initial_refspec.to_string()]; + loop { + let tokens = read_line(stdin, &mut line)?; + match tokens.as_slice() { + ["push", spec] => { + refspecs.push((*spec).to_string()); + } + [] => break, + _ => { + bail!("after a `push` command we are only expecting another push or an empty line") + } + } + } + Ok(refspecs) +} + +impl RepoState { + pub async fn build( + identifier: String, + state: HashMap, + signer: &NostrSigner, + ) -> Result { + let mut tags = vec![Tag::identifier(identifier.clone())]; + for (name, value) in &state { + tags.push(Tag::custom( + nostr_sdk::TagKind::Custom(name.into()), + vec![value.clone()], + )); + } + let event = sign_event(EventBuilder::new(STATE_KIND, "", tags), signer).await?; + Ok(RepoState { + identifier, + state, + event, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod nostr_git_url_paramemters_from_str { + use git::ServerProtocol; + use nostr_sdk::PublicKey; + + use super::*; + + fn get_model_coordinate(relays: bool) -> Coordinate { + Coordinate { + identifier: "ngit".to_string(), + public_key: PublicKey::parse( + "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", + ) + .unwrap(), + kind: nostr_sdk::Kind::GitRepoAnnouncement, + relays: if relays { + vec!["wss://nos.lol/".to_string()] + } else { + vec![] + }, + } + } + + #[test] + fn from_naddr() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://naddr1qqzxuemfwsqs6amnwvaz7tmwdaejumr0dspzpgqgmmc409hm4xsdd74sf68a2uyf9pwel4g9mfdg8l5244t6x4jdqvzqqqrhnym0k2qj" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([Coordinate { + identifier: "ngit".to_string(), + public_key: PublicKey::parse( + "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", + ) + .unwrap(), + kind: nostr_sdk::Kind::GitRepoAnnouncement, + relays: vec!["wss://nos.lol".to_string()], // wont add the slash + }]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + mod from_npub_slash_identifier { + use super::*; + + #[test] + fn without_relay() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(false)]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + + mod with_url_parameters { + + use super::*; + + #[test] + fn with_relay_without_scheme_defaults_to_wss() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay=nos.lol" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(true)]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + + #[test] + fn with_encoded_relay() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str(&format!( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay={}", + urlencoding::encode("wss://nos.lol") + ))?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(true)]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + #[test] + fn with_multiple_encoded_relays() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str(&format!( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay={}&relay1={}", + urlencoding::encode("wss://nos.lol"), + urlencoding::encode("wss://relay.damus.io"), + ))?, + NostrUrlDecoded { + coordinates: HashSet::from([Coordinate { + identifier: "ngit".to_string(), + public_key: PublicKey::parse( + "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", + ) + .unwrap(), + kind: nostr_sdk::Kind::GitRepoAnnouncement, + relays: vec![ + "wss://nos.lol/".to_string(), + "wss://relay.damus.io/".to_string(), + ], + }]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + + #[test] + fn with_server_protocol() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?protocol=ssh" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(false)]), + protocol: Some(ServerProtocol::Ssh), + user: None, + }, + ); + Ok(()) + } + #[test] + fn with_server_protocol_and_user() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?protocol=ssh&user=fred" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(false)]), + protocol: Some(ServerProtocol::Ssh), + user: Some("fred".to_string()), + }, + ); + Ok(()) + } + } + mod with_parameters_embedded_with_slashes { + use super::*; + + #[test] + fn with_relay_without_scheme_defaults_to_wss() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/nos.lol/ngit" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(true)]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + + #[test] + fn with_encoded_relay() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str(&format!( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/{}/ngit", + urlencoding::encode("wss://nos.lol") + ))?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(true)]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + #[test] + fn with_multiple_encoded_relays() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str(&format!( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/{}/{}/ngit", + urlencoding::encode("wss://nos.lol"), + urlencoding::encode("wss://relay.damus.io"), + ))?, + NostrUrlDecoded { + coordinates: HashSet::from([Coordinate { + identifier: "ngit".to_string(), + public_key: PublicKey::parse( + "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", + ) + .unwrap(), + kind: nostr_sdk::Kind::GitRepoAnnouncement, + relays: vec![ + "wss://nos.lol/".to_string(), + "wss://relay.damus.io/".to_string(), + ], + }]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + + #[test] + fn with_server_protocol() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(false)]), + protocol: Some(ServerProtocol::Ssh), + user: None, + }, + ); + Ok(()) + } + #[test] + fn with_server_protocol_and_user() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://fred@ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(false)]), + protocol: Some(ServerProtocol::Ssh), + user: Some("fred".to_string()), + }, + ); + Ok(()) + } + } + } + } + + mod refspec_to_from_to { + use super::*; + + #[test] + fn trailing_plus_stripped() { + let (from, _) = refspec_to_from_to("+testing:testingb").unwrap(); + assert_eq!(from, "testing"); + } + } +} diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs new file mode 100644 index 0000000..d0f934e --- /dev/null +++ b/src/bin/ngit/cli.rs @@ -0,0 +1,44 @@ +use clap::{Parser, Subcommand}; + +use crate::sub_commands; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, + /// remote signer address + #[arg(long, global = true)] + pub bunker_uri: Option, + /// remote signer app secret key + #[arg(long, global = true)] + pub bunker_app_key: Option, + /// nsec or hex private key + #[arg(short, long, global = true)] + pub nsec: Option, + /// password to decrypt nsec + #[arg(short, long, global = true)] + pub password: Option, + /// disable spinner animations + #[arg(long, action)] + pub disable_cli_spinners: bool, +} + +#[derive(Subcommand)] +pub enum Commands { + /// update cache with latest updates from nostr + Fetch(sub_commands::fetch::SubCommandArgs), + /// signal you are this repo's maintainer accepting proposals via nostr + Init(sub_commands::init::SubCommandArgs), + /// issue commits as a proposal + Send(sub_commands::send::SubCommandArgs), + /// list proposals; checkout, apply or download selected + List, + /// send proposal revision + Push(sub_commands::push::SubCommandArgs), + /// fetch and apply new proposal commits / revisions linked to branch + Pull, + /// run with --nsec flag to change npub + Login(sub_commands::login::SubCommandArgs), +} diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs new file mode 100644 index 0000000..97e5981 --- /dev/null +++ b/src/bin/ngit/main.rs @@ -0,0 +1,26 @@ +#![cfg_attr(not(test), warn(clippy::pedantic))] +#![allow(clippy::large_futures)] +#![cfg_attr(not(test), warn(clippy::expect_used))] + +use anyhow::Result; +use clap::Parser; +use cli::{Cli, Commands}; + +mod cli; +use ngit::*; + +mod sub_commands; + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + match &cli.command { + Commands::Fetch(args) => sub_commands::fetch::launch(&cli, args).await, + Commands::Login(args) => sub_commands::login::launch(&cli, args).await, + Commands::Init(args) => sub_commands::init::launch(&cli, args).await, + Commands::Send(args) => sub_commands::send::launch(&cli, args, false).await, + Commands::List => sub_commands::list::launch().await, + Commands::Pull => sub_commands::pull::launch().await, + Commands::Push(args) => sub_commands::push::launch(&cli, args).await, + } +} diff --git a/src/bin/ngit/sub_commands/fetch.rs b/src/bin/ngit/sub_commands/fetch.rs new file mode 100644 index 0000000..b1e83c5 --- /dev/null +++ b/src/bin/ngit/sub_commands/fetch.rs @@ -0,0 +1,44 @@ +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/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs new file mode 100644 index 0000000..5b7e03d --- /dev/null +++ b/src/bin/ngit/sub_commands/init.rs @@ -0,0 +1,385 @@ +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/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs new file mode 100644 index 0000000..ac1f4ab --- /dev/null +++ b/src/bin/ngit/sub_commands/list.rs @@ -0,0 +1,906 @@ +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/bin/ngit/sub_commands/login.rs b/src/bin/ngit/sub_commands/login.rs new file mode 100644 index 0000000..8a3788f --- /dev/null +++ b/src/bin/ngit/sub_commands/login.rs @@ -0,0 +1,52 @@ +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/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs new file mode 100644 index 0000000..29a60f9 --- /dev/null +++ b/src/bin/ngit/sub_commands/mod.rs @@ -0,0 +1,7 @@ +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/bin/ngit/sub_commands/pull.rs b/src/bin/ngit/sub_commands/pull.rs new file mode 100644 index 0000000..e33a744 --- /dev/null +++ b/src/bin/ngit/sub_commands/pull.rs @@ -0,0 +1,209 @@ +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/bin/ngit/sub_commands/push.rs b/src/bin/ngit/sub_commands/push.rs new file mode 100644 index 0000000..7a82c7a --- /dev/null +++ b/src/bin/ngit/sub_commands/push.rs @@ -0,0 +1,223 @@ +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/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs new file mode 100644 index 0000000..3c4df9d --- /dev/null +++ b/src/bin/ngit/sub_commands/send.rs @@ -0,0 +1,1363 @@ +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