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 +++++++++++++++++++ src/cli.rs | 44 - src/cli_interactor.rs | 186 --- src/client.rs | 1480 --------------------- src/config.rs | 47 - src/git.rs | 2566 ------------------------------------ src/git_remote_helper.rs | 1897 -------------------------- src/key_handling/encryption.rs | 105 -- src/key_handling/mod.rs | 1 - src/lib/cli_interactor.rs | 186 +++ src/lib/client.rs | 1480 +++++++++++++++++++++ src/lib/git/mod.rs | 2566 ++++++++++++++++++++++++++++++++++++ src/lib/login/key_encryption.rs | 105 ++ src/lib/login/mod.rs | 695 ++++++++++ src/lib/login/user.rs | 47 + src/lib/mod.rs | 16 + src/lib/repo_ref.rs | 700 ++++++++++ src/lib/repo_state.rs | 40 + src/login.rs | 695 ---------- src/main.rs | 32 - src/repo_ref.rs | 700 ---------- src/repo_state.rs | 40 - src/sub_commands/fetch.rs | 44 - src/sub_commands/init.rs | 385 ------ src/sub_commands/list.rs | 906 ------------- src/sub_commands/login.rs | 52 - src/sub_commands/mod.rs | 7 - src/sub_commands/pull.rs | 209 --- src/sub_commands/push.rs | 223 ---- src/sub_commands/send.rs | 1363 ------------------- 40 files changed, 10991 insertions(+), 10982 deletions(-) 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 delete mode 100644 src/cli.rs delete mode 100644 src/cli_interactor.rs delete mode 100644 src/client.rs delete mode 100644 src/config.rs delete mode 100644 src/git.rs delete mode 100644 src/git_remote_helper.rs delete mode 100644 src/key_handling/encryption.rs delete mode 100644 src/key_handling/mod.rs create mode 100644 src/lib/cli_interactor.rs create mode 100644 src/lib/client.rs create mode 100644 src/lib/git/mod.rs create mode 100644 src/lib/login/key_encryption.rs create mode 100644 src/lib/login/mod.rs create mode 100644 src/lib/login/user.rs create mode 100644 src/lib/mod.rs create mode 100644 src/lib/repo_ref.rs create mode 100644 src/lib/repo_state.rs delete mode 100644 src/login.rs delete mode 100644 src/main.rs delete mode 100644 src/repo_ref.rs delete mode 100644 src/repo_state.rs delete mode 100644 src/sub_commands/fetch.rs delete mode 100644 src/sub_commands/init.rs delete mode 100644 src/sub_commands/list.rs delete mode 100644 src/sub_commands/login.rs delete mode 100644 src/sub_commands/mod.rs delete mode 100644 src/sub_commands/pull.rs delete mode 100644 src/sub_commands/push.rs delete mode 100644 src/sub_commands/send.rs (limited to 'src') 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(()) + } + } + } +} diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index d0f934e..0000000 --- a/src/cli.rs +++ /dev/null @@ -1,44 +0,0 @@ -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/cli_interactor.rs b/src/cli_interactor.rs deleted file mode 100644 index 4cf6357..0000000 --- a/src/cli_interactor.rs +++ /dev/null @@ -1,186 +0,0 @@ -use anyhow::{Context, Result}; -use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password}; -#[cfg(test)] -use mockall::*; - -#[derive(Default)] -pub struct Interactor { - theme: ColorfulTheme, -} - -#[cfg_attr(test, automock)] -pub trait InteractorPrompt { - fn input(&self, parms: PromptInputParms) -> Result; - fn password(&self, parms: PromptPasswordParms) -> Result; - fn confirm(&self, params: PromptConfirmParms) -> Result; - fn choice(&self, params: PromptChoiceParms) -> Result; - fn multi_choice(&self, params: PromptMultiChoiceParms) -> Result>; -} -impl InteractorPrompt for Interactor { - fn input(&self, parms: PromptInputParms) -> Result { - let mut input = Input::with_theme(&self.theme); - input.with_prompt(parms.prompt).allow_empty(parms.optional); - if !parms.default.is_empty() { - input.default(parms.default); - } - Ok(input.interact_text()?) - } - fn password(&self, parms: PromptPasswordParms) -> Result { - let mut p = Password::with_theme(&self.theme); - p.with_prompt(parms.prompt); - if parms.confirm { - p.with_confirmation("confirm password", "passwords didnt match..."); - } - let pass: String = p.interact()?; - Ok(pass) - } - fn confirm(&self, params: PromptConfirmParms) -> Result { - let confirm: bool = Confirm::with_theme(&self.theme) - .with_prompt(params.prompt) - .default(params.default) - .interact()?; - Ok(confirm) - } - fn choice(&self, parms: PromptChoiceParms) -> Result { - let mut choice = dialoguer::Select::with_theme(&self.theme); - choice - .with_prompt(parms.prompt) - .report(parms.report) - .items(&parms.choices); - if let Some(default) = parms.default { - if std::env::var("NGITTEST").is_err() { - choice.default(default); - } - } - choice.interact().context("failed to get choice") - } - fn multi_choice(&self, parms: PromptMultiChoiceParms) -> Result> { - // the colorful theme is not very clear so falling back to default - let mut choice = dialoguer::MultiSelect::default(); - choice - .with_prompt(parms.prompt) - .report(parms.report) - .items(&parms.choices); - if let Some(defaults) = parms.defaults { - choice.defaults(&defaults); - } - choice.interact().context("failed to get choice") - } -} - -#[derive(Default)] -pub struct PromptInputParms { - pub prompt: String, - pub default: String, - pub optional: bool, -} - -impl PromptInputParms { - pub fn with_prompt>(mut self, prompt: S) -> Self { - self.prompt = prompt.into(); - self - } - pub fn with_default>(mut self, default: S) -> Self { - self.default = default.into(); - self - } - pub fn optional(mut self) -> Self { - self.optional = true; - self - } -} - -#[derive(Default)] -pub struct PromptPasswordParms { - pub prompt: String, - pub confirm: bool, -} - -impl PromptPasswordParms { - pub fn with_prompt>(mut self, prompt: S) -> Self { - self.prompt = prompt.into(); - self - } - pub const fn with_confirm(mut self) -> Self { - self.confirm = true; - self - } -} - -#[derive(Default)] -pub struct PromptConfirmParms { - pub prompt: String, - pub default: bool, -} - -impl PromptConfirmParms { - pub fn with_prompt>(mut self, prompt: S) -> Self { - self.prompt = prompt.into(); - self - } - pub fn with_default(mut self, default: bool) -> Self { - self.default = default; - self - } -} - -#[derive(Default)] -pub struct PromptChoiceParms { - pub prompt: String, - pub choices: Vec, - pub default: Option, - pub report: bool, -} - -impl PromptChoiceParms { - pub fn with_prompt>(mut self, prompt: S) -> Self { - self.prompt = prompt.into(); - self.report = true; - self - } - - // pub fn dont_report(mut self) -> Self { - // self.report = false; - // self - // } - pub fn with_choices(mut self, choices: Vec) -> Self { - self.choices = choices; - self - } - - pub fn with_default(mut self, index: usize) -> Self { - self.default = Some(index); - self - } -} - -#[derive(Default)] -pub struct PromptMultiChoiceParms { - pub prompt: String, - pub choices: Vec, - pub defaults: Option>, - pub report: bool, -} - -impl PromptMultiChoiceParms { - pub fn with_prompt>(mut self, prompt: S) -> Self { - self.prompt = prompt.into(); - self.report = true; - self - } - - pub fn dont_report(mut self) -> Self { - self.report = false; - self - } - - pub fn with_choices(mut self, choices: Vec) -> Self { - self.choices = choices; - self - } - - pub fn with_defaults(mut self, defaults: Vec) -> Self { - self.defaults = Some(defaults); - self - } -} diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index abde217..0000000 --- a/src/client.rs +++ /dev/null @@ -1,1480 +0,0 @@ -// have you considered - -// TO USE ASYNC - -// in traits (required for mocking unit tests) -// https://rust-lang.github.io/async-book/07_workarounds/05_async_in_traits.html -// https://github.com/dtolnay/async-trait -// see https://blog.rust-lang.org/inside-rust/2022/11/17/async-fn-in-trait-nightly.html -// I think we can use the async-trait crate and switch to the native feature -// which is currently in nightly. alternatively we can use nightly as it looks -// certain that the implementation is going to make it to stable but we don't -// want to inadvertlty use other features of nightly that might be removed. -use std::{ - collections::{HashMap, HashSet}, - fmt::{Display, Write}, - fs::create_dir_all, - path::Path, - time::Duration, -}; - -use anyhow::{bail, Context, Result}; -use async_trait::async_trait; -use console::Style; -use futures::stream::{self, StreamExt}; -use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle}; -#[cfg(test)] -use mockall::*; -use nostr::{nips::nip01::Coordinate, Event}; -use nostr_database::{NostrDatabase, Order}; -use nostr_sdk::{ - prelude::RelayLimits, EventBuilder, EventId, Kind, NostrSigner, Options, PublicKey, - SingleLetterTag, Timestamp, Url, -}; -use nostr_sqlite::SQLiteDatabase; - -use crate::{ - config::get_dirs, - login::{get_logged_in_user, get_user_ref_from_cache}, - repo_ref::RepoRef, - repo_state::RepoState, - sub_commands::{ - list::status_kinds, - send::{event_is_patch_set_root, event_is_revision_root}, - }, -}; - -#[allow(clippy::struct_field_names)] -pub struct Client { - client: nostr_sdk::Client, - fallback_relays: Vec, - more_fallback_relays: Vec, - blaster_relays: Vec, -} - -#[cfg_attr(test, automock)] -#[async_trait] -pub trait Connect { - fn default() -> Self; - fn new(opts: Params) -> Self; - async fn set_signer(&mut self, signer: NostrSigner); - async fn connect(&self, relay_url: &Url) -> Result<()>; - async fn disconnect(&self) -> Result<()>; - fn get_fallback_relays(&self) -> &Vec; - fn get_more_fallback_relays(&self) -> &Vec; - fn get_blaster_relays(&self) -> &Vec; - async fn send_event_to( - &self, - git_repo_path: &Path, - url: &str, - event: nostr::event::Event, - ) -> Result; - async fn get_events( - &self, - relays: Vec, - filters: Vec, - ) -> Result>; - async fn get_events_per_relay( - &self, - relays: Vec, - filters: Vec, - progress_reporter: MultiProgress, - ) -> Result<(Vec>>, MultiProgress)>; - async fn fetch_all( - &self, - git_repo_path: &Path, - repo_coordinates: &HashSet, - user_profiles: &HashSet, - ) -> Result<(Vec>, MultiProgress)>; - async fn fetch_all_from_relay( - &self, - git_repo_path: &Path, - request: FetchRequest, - pb: &Option, - ) -> Result; -} - -#[async_trait] -impl Connect for Client { - fn default() -> Self { - let fallback_relays: Vec = if std::env::var("NGITTEST").is_ok() { - vec![ - "ws://localhost:8051".to_string(), - "ws://localhost:8052".to_string(), - ] - } else { - vec![ - "wss://relay.damus.io".to_string(), /* free, good reliability, have been known - * to delete all messages */ - "wss://nos.lol".to_string(), - "wss://relay.nostr.band".to_string(), - ] - }; - - let more_fallback_relays: Vec = if std::env::var("NGITTEST").is_ok() { - vec![ - "ws://localhost:8055".to_string(), - "ws://localhost:8056".to_string(), - ] - } else { - vec![ - "wss://purplerelay.com".to_string(), // free but reliability not tested - "wss://purplepages.es".to_string(), // for profile events but unreliable - "wss://relayable.org".to_string(), // free but not always reliable - ] - }; - - let blaster_relays: Vec = if std::env::var("NGITTEST").is_ok() { - vec!["ws://localhost:8057".to_string()] - } else { - vec!["wss://nostr.mutinywallet.com".to_string()] - }; - Client { - client: nostr_sdk::ClientBuilder::new() - .opts(Options::new().relay_limits(RelayLimits::disable())) - .build(), - fallback_relays, - more_fallback_relays, - blaster_relays, - } - } - fn new(opts: Params) -> Self { - Client { - client: nostr_sdk::ClientBuilder::new() - .opts(Options::new().relay_limits(RelayLimits::disable())) - .signer(&opts.keys.unwrap_or(nostr::Keys::generate())) - // .database( - // SQLiteDatabase::open(get_dirs()?.cache_dir().join("nostr-cache.sqlite")). - // await?, ) - .build(), - fallback_relays: opts.fallback_relays, - more_fallback_relays: opts.more_fallback_relays, - blaster_relays: opts.blaster_relays, - } - } - - async fn set_signer(&mut self, signer: NostrSigner) { - self.client.set_signer(Some(signer)).await; - } - - async fn connect(&self, relay_url: &Url) -> Result<()> { - self.client - .add_relay(relay_url) - .await - .context("cannot add relay")?; - - let relay = self.client.relay(relay_url).await?; - - if !relay.is_connected().await { - #[allow(clippy::large_futures)] - relay - .connect(Some(std::time::Duration::from_secs(CONNECTION_TIMEOUT))) - .await; - } - - if !relay.is_connected().await { - bail!("connection timeout"); - } - Ok(()) - } - - async fn disconnect(&self) -> Result<()> { - self.client.disconnect().await?; - Ok(()) - } - - fn get_fallback_relays(&self) -> &Vec { - &self.fallback_relays - } - - fn get_more_fallback_relays(&self) -> &Vec { - &self.more_fallback_relays - } - - fn get_blaster_relays(&self) -> &Vec { - &self.blaster_relays - } - - async fn send_event_to( - &self, - git_repo_path: &Path, - url: &str, - event: Event, - ) -> Result { - self.client.add_relay(url).await?; - #[allow(clippy::large_futures)] - self.client.connect_relay(url).await?; - let res = self.client.send_event_to(vec![url], event.clone()).await?; - if let Some(err) = res.failed.get(&Url::parse(url)?) { - bail!(if let Some(err) = err { - err.to_string() - } else { - "error: unknown".to_string() - }); - } - save_event_in_cache(git_repo_path, &event).await?; - if event.kind().eq(&Kind::GitRepoAnnouncement) { - save_event_in_global_cache(git_repo_path, &event).await?; - } - Ok(event.id()) - } - - async fn get_events( - &self, - relays: Vec, - filters: Vec, - ) -> Result> { - let (relay_results, _) = self - .get_events_per_relay( - relays.iter().map(|r| Url::parse(r).unwrap()).collect(), - filters, - MultiProgress::new(), - ) - .await?; - Ok(get_dedup_events(relay_results)) - } - - async fn get_events_per_relay( - &self, - relays: Vec, - filters: Vec, - progress_reporter: MultiProgress, - ) -> Result<(Vec>>, MultiProgress)> { - // add relays - for relay in &relays { - self.client - .add_relay(relay.as_str()) - .await - .context("cannot add relay")?; - } - - let relays_map = self.client.relays().await; - - let futures: Vec<_> = relays - .clone() - .iter() - // don't look for events on blaster - .filter(|r| !r.as_str().contains("nostr.mutinywallet.com")) - .map(|r| (relays_map.get(r).unwrap(), filters.clone())) - .map(|(relay, filters)| async { - let pb = if std::env::var("NGITTEST").is_err() { - let pb = progress_reporter.add( - ProgressBar::new(1) - .with_prefix(format!("{: <11}{}", "connecting", relay.url())) - .with_style(pb_style()?), - ); - pb.enable_steady_tick(Duration::from_millis(300)); - Some(pb) - } else { - None - }; - #[allow(clippy::large_futures)] - match get_events_of(relay, filters, &pb).await { - Err(error) => { - if let Some(pb) = pb { - pb.set_style(pb_after_style(false)); - pb.set_prefix(format!("{: <11}{}", "error", relay.url())); - pb.finish_with_message( - console::style( - error.to_string().replace("relay pool error:", "error:"), - ) - .for_stderr() - .red() - .to_string(), - ); - } - Err(error) - } - Ok(res) => { - if let Some(pb) = pb { - pb.set_style(pb_after_style(true)); - pb.set_prefix(format!( - "{: <11}{}", - format!("{} events", res.len()), - relay.url() - )); - pb.finish_with_message(""); - } - Ok(res) - } - } - }) - .collect(); - - let relay_results: Vec>> = - stream::iter(futures).buffer_unordered(15).collect().await; - - Ok((relay_results, progress_reporter)) - } - - #[allow(clippy::too_many_lines)] - async fn fetch_all( - &self, - git_repo_path: &Path, - repo_coordinates: &HashSet, - user_profiles: &HashSet, - ) -> Result<(Vec>, MultiProgress)> { - let fallback_relays = &self - .fallback_relays - .iter() - .filter_map(|r| Url::parse(r).ok()) - .collect::>(); - - let mut request = create_relays_request( - git_repo_path, - repo_coordinates, - user_profiles, - fallback_relays.clone(), - ) - .await?; - - let progress_reporter = MultiProgress::new(); - - let mut processed_relays = HashSet::new(); - - let mut relay_reports: Vec> = vec![]; - - loop { - let relays = request - .repo_relays - .union(&request.user_relays_for_profiles) - // don't look for events on blaster - .filter(|&r| !r.as_str().contains("nostr.mutinywallet.com")) - .cloned() - .collect::>() - .difference(&processed_relays) - .cloned() - .collect::>(); - if relays.is_empty() { - break; - } - let profile_relays_only = request - .user_relays_for_profiles - .difference(&request.repo_relays) - .collect::>(); - for relay in &request.repo_relays { - self.client - .add_relay(relay.as_str()) - .await - .context("cannot add relay")?; - } - - let dim = Style::new().color256(247); - - let futures: Vec<_> = relays - .iter() - .map(|r| { - if profile_relays_only.contains(r) { - // if relay isn't a repo relay, just filter for user profile - FetchRequest { - selected_relay: Some(r.to_owned()), - repo_coordinates_without_relays: vec![], - proposals: HashSet::new(), - missing_contributor_profiles: request - .missing_contributor_profiles - .union( - &request - .profiles_to_fetch_from_user_relays - .clone() - .into_keys() - .collect(), - ) - .copied() - .collect(), - ..request.clone() - } - } else { - FetchRequest { - selected_relay: Some(r.to_owned()), - ..request.clone() - } - } - }) - .map(|request| async { - let relay_column_width = request.relay_column_width; - - let relay_url = request - .selected_relay - .clone() - .context("fetch_all_from_relay called without a relay")?; - - let pb = if std::env::var("NGITTEST").is_err() { - let pb = progress_reporter.add( - ProgressBar::new(1) - .with_prefix( - dim.apply_to(format!( - "{: { - if let Some(pb) = pb { - pb.set_style(pb_after_style(false)); - pb.set_prefix( - dim.apply_to(format!("{: Ok(res), - } - }) - .collect(); - - for report in stream::iter(futures) - .buffer_unordered(15) - .collect::>>() - .await - { - relay_reports.push(report); - } - processed_relays.extend(relays.clone()); - - if let Ok(repo_ref) = get_repo_ref_from_cache(git_repo_path, repo_coordinates).await { - request.repo_relays = repo_ref - .relays - .iter() - .filter_map(|r| Url::parse(r).ok()) - .collect(); - } - - request.user_relays_for_profiles = { - let mut set = HashSet::new(); - for user in &request - .profiles_to_fetch_from_user_relays - .clone() - .into_keys() - .collect::>() - { - if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, user).await { - for r in user_ref.relays.write() { - if let Ok(url) = Url::parse(&r) { - set.insert(url); - } - } - } - } - set - }; - } - Ok((relay_reports, progress_reporter)) - } - - async fn fetch_all_from_relay( - &self, - git_repo_path: &Path, - request: FetchRequest, - pb: &Option, - ) -> Result { - let mut fresh_coordinates: HashSet = HashSet::new(); - for (c, _) in request.repo_coordinates_without_relays.clone() { - fresh_coordinates.insert(c); - } - let mut fresh_proposal_roots = request.proposals.clone(); - let mut fresh_profiles: HashSet = request - .missing_contributor_profiles - .union( - &request - .profiles_to_fetch_from_user_relays - .clone() - .into_keys() - .collect(), - ) - .copied() - .collect(); - - let mut report = FetchReport::default(); - - let relay_url = request - .selected_relay - .clone() - .context("fetch_all_from_relay called without a relay")?; - - let relay_column_width = request.relay_column_width; - - self.connect(&relay_url).await?; - - let dim = Style::new().color256(247); - - loop { - let filters = - get_fetch_filters(&fresh_coordinates, &fresh_proposal_roots, &fresh_profiles); - - if let Some(pb) = &pb { - pb.set_prefix( - dim.apply_to(format!( - "{: = get_events_of(&relay, filters.clone(), &None) - .await? - .iter() - // don't process events that don't match filters - .filter(|e| filters.iter().any(|f| f.match_event(e))) - .cloned() - .collect(); - // TODO: try reconcile - - process_fetched_events( - events, - &request, - git_repo_path, - &mut fresh_coordinates, - &mut fresh_proposal_roots, - &mut fresh_profiles, - &mut report, - ) - .await?; - - if fresh_coordinates.is_empty() - && fresh_proposal_roots.is_empty() - && fresh_profiles.is_empty() - { - break; - } - } - if let Some(pb) = pb { - pb.set_style(pb_after_style(true)); - pb.set_prefix( - dim.apply_to(format!( - "{: , - pb: &Option, -) -> Result> { - // relay.reconcile(filter, opts).await?; - - if !relay.is_connected().await { - #[allow(clippy::large_futures)] - relay - .connect(Some(std::time::Duration::from_secs(CONNECTION_TIMEOUT))) - .await; - } - - if !relay.is_connected().await { - bail!("connection timeout"); - } else if let Some(pb) = pb { - pb.set_prefix(format!("connected {}", relay.url())); - } - let events = relay - .get_events_of( - filters, - // 20 is nostr_sdk default - std::time::Duration::from_secs(GET_EVENTS_TIMEOUT), - nostr_sdk::FilterOptions::ExitOnEOSE, - ) - .await?; - Ok(events) -} - -#[derive(Default)] -pub struct Params { - pub keys: Option, - pub fallback_relays: Vec, - pub more_fallback_relays: Vec, - pub blaster_relays: Vec, -} - -fn get_dedup_events(relay_results: Vec>>) -> Vec { - let mut dedup_events: Vec = vec![]; - for events in relay_results.into_iter().flatten() { - for event in events { - if !dedup_events.iter().any(|e| event.id.eq(&e.id)) { - dedup_events.push(event); - } - } - } - dedup_events -} - -pub async fn sign_event(event_builder: EventBuilder, signer: &NostrSigner) -> Result { - if signer.r#type().eq(&nostr_signer::NostrSignerType::NIP46) { - let term = console::Term::stderr(); - term.write_line("signing event with remote signer...")?; - let event = signer - .sign_event_builder(event_builder) - .await - .context("failed to sign event")?; - term.clear_last_lines(1)?; - Ok(event) - } else { - signer - .sign_event_builder(event_builder) - .await - .context("failed to sign event") - } -} - -pub async fn fetch_public_key(signer: &NostrSigner) -> Result { - let term = console::Term::stderr(); - term.write_line("fetching npub from remote signer...")?; - let public_key = signer - .public_key() - .await - .context("failed to get npub from remote signer")?; - term.clear_last_lines(1)?; - Ok(public_key) -} - -fn pb_style() -> Result { - Ok( - ProgressStyle::with_template(" {spinner} {prefix} {msg} {timeout_in}")?.with_key( - "timeout_in", - |state: &ProgressState, w: &mut dyn Write| { - if state.elapsed().as_secs() > 3 && state.elapsed().as_secs() < GET_EVENTS_TIMEOUT { - let dim = Style::new().color256(247); - write!( - w, - "{}", - dim.apply_to(format!( - "timeout in {:.1}s", - GET_EVENTS_TIMEOUT - state.elapsed().as_secs() - )) - ) - .unwrap(); - } - }, - ), - ) -} - -fn pb_after_style(succeed: bool) -> indicatif::ProgressStyle { - ProgressStyle::with_template( - format!( - " {} {}", - if succeed { - console::style("✔".to_string()) - .for_stderr() - .green() - .to_string() - } else { - console::style("✘".to_string()) - .for_stderr() - .red() - .to_string() - }, - "{prefix} {msg}", - ) - .as_str(), - ) - .unwrap() -} - -async fn get_local_cache_database(git_repo_path: &Path) -> Result { - SQLiteDatabase::open(git_repo_path.join(".git/nostr-cache.sqlite")) - .await - .context("cannot open or create nostr cache database at .git/nostr-cache.sqlite") -} - -async fn get_global_cache_database(git_repo_path: &Path) -> Result { - SQLiteDatabase::open(if std::env::var("NGITTEST").is_err() { - create_dir_all(get_dirs()?.cache_dir()).context(format!( - "cannot create cache directory in: {:?}", - get_dirs()?.cache_dir() - ))?; - get_dirs()?.cache_dir().join("nostr-cache.sqlite") - } else { - git_repo_path.join(".git/test-global-cache.sqlite") - }) - .await - .context("cannot open ngit global nostr cache database") -} - -pub async fn get_events_from_cache( - git_repo_path: &Path, - filters: Vec, -) -> Result> { - get_local_cache_database(git_repo_path) - .await? - .query(filters.clone(), Order::Asc) - .await - .context( - "cannot execute query on opened git repo nostr cache database .git/nostr-cache.sqlite", - ) -} - -pub async fn get_event_from_global_cache( - git_repo_path: &Path, - filters: Vec, -) -> Result> { - get_global_cache_database(git_repo_path) - .await? - .query(filters.clone(), Order::Asc) - .await - .context("cannot execute query on opened ngit nostr cache database") -} - -pub async fn save_event_in_cache(git_repo_path: &Path, event: &nostr::Event) -> Result { - get_local_cache_database(git_repo_path) - .await? - .save_event(event) - .await - .context("cannot save event in local cache") -} - -pub async fn save_event_in_global_cache( - git_repo_path: &Path, - event: &nostr::Event, -) -> Result { - get_global_cache_database(git_repo_path) - .await? - .save_event(event) - .await - .context("cannot save event in local cache") -} - -pub async fn get_repo_ref_from_cache( - git_repo_path: &Path, - repo_coordinates: &HashSet, -) -> Result { - let mut maintainers = HashSet::new(); - let mut new_coordinate: bool; - - for c in repo_coordinates { - maintainers.insert(c.public_key); - } - let mut repo_events = vec![]; - loop { - new_coordinate = false; - let repo_events_filter = get_filter_repo_events(repo_coordinates); - - let events = [ - get_event_from_global_cache(git_repo_path, vec![repo_events_filter.clone()]).await?, - get_events_from_cache(git_repo_path, vec![repo_events_filter]).await?, - ] - .concat(); - for e in events { - if let Ok(repo_ref) = RepoRef::try_from(e.clone()) { - for m in repo_ref.maintainers { - if maintainers.insert(m) { - new_coordinate = true; - } - } - repo_events.push(e); - } - } - if !new_coordinate { - break; - } - } - repo_events.sort_by_key(|e| e.created_at); - let repo_ref = RepoRef::try_from( - repo_events - .first() - .context("no repo events at specified coordinates")? - .clone(), - )?; - - let mut events: HashMap = HashMap::new(); - for m in &maintainers { - if let Some(e) = repo_events.iter().find(|e| e.author().eq(m)) { - events.insert( - Coordinate { - kind: e.kind, - identifier: e.identifier().unwrap().to_string(), - public_key: e.author(), - relays: vec![], - }, - e.clone(), - ); - } - } - - Ok(RepoRef { - // use all maintainers from all events found, not just maintainers in the most - // recent event - maintainers: maintainers.iter().copied().collect::>(), - events, - ..repo_ref - }) -} - -pub async fn get_state_from_cache(git_repo_path: &Path, repo_ref: &RepoRef) -> Result { - RepoState::try_from( - get_events_from_cache( - git_repo_path, - vec![get_filter_state_events(&repo_ref.coordinates())], - ) - .await?, - ) -} - -#[allow(clippy::too_many_lines)] -async fn create_relays_request( - git_repo_path: &Path, - repo_coordinates: &HashSet, - user_profiles: &HashSet, - fallback_relays: HashSet, -) -> Result { - let repo_ref = get_repo_ref_from_cache(git_repo_path, repo_coordinates).await; - - let repo_coordinates = { - // add coordinates of users listed in maintainers to explicitly specified - // coodinates - let mut repo_coordinates = repo_coordinates.clone(); - if let Ok(repo_ref) = &repo_ref { - for c in repo_ref.coordinates() { - if !repo_coordinates - .iter() - .any(|e| e.identifier.eq(&c.identifier) && e.public_key.eq(&c.public_key)) - { - repo_coordinates.insert(c); - } - } - } - repo_coordinates - }; - - let repo_coordinates_without_relays = { - let mut set = HashSet::new(); - for c in &repo_coordinates { - set.insert(Coordinate { - kind: c.kind, - identifier: c.identifier.clone(), - public_key: c.public_key, - relays: vec![], - }); - } - set - }; - - let mut proposals: HashSet = HashSet::new(); - let mut missing_contributor_profiles: HashSet = HashSet::new(); - let mut contributors: HashSet = HashSet::new(); - - if !repo_coordinates_without_relays.is_empty() { - if let Ok(repo_ref) = &repo_ref { - for m in &repo_ref.maintainers { - contributors.insert(m.to_owned()); - } - } - - for event in &get_events_from_cache( - git_repo_path, - vec![ - nostr::Filter::default() - .kinds(vec![Kind::GitPatch]) - .custom_tag( - SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), - repo_coordinates_without_relays - .iter() - .map(std::string::ToString::to_string) - .collect::>(), - ), - ], - ) - .await? - { - if event_is_patch_set_root(event) || event_is_revision_root(event) { - proposals.insert(event.id()); - contributors.insert(event.author()); - } - } - - let profile_events = get_event_from_global_cache( - git_repo_path, - vec![get_filter_contributor_profiles(contributors.clone())], - ) - .await?; - for c in &contributors { - if let Some(event) = profile_events - .iter() - .find(|e| e.kind() == Kind::Metadata && e.author().eq(c)) - { - save_event_in_cache(git_repo_path, event).await?; - } else { - missing_contributor_profiles.insert(c.to_owned()); - } - } - } - - let profiles_to_fetch_from_user_relays = { - let mut user_profiles = user_profiles.clone(); - if let Ok(Some(current_user)) = get_logged_in_user(git_repo_path).await { - user_profiles.insert(current_user); - } - let mut map: HashMap = HashMap::new(); - for public_key in &user_profiles { - if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { - map.insert( - public_key.to_owned(), - (user_ref.metadata.created_at, user_ref.relays.created_at), - ); - } else { - map.insert( - public_key.to_owned(), - (Timestamp::from(0), Timestamp::from(0)), - ); - } - } - map - }; - - let user_relays_for_profiles = { - let mut set = HashSet::new(); - for user in &profiles_to_fetch_from_user_relays - .clone() - .into_keys() - .collect::>() - { - if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, user).await { - for r in user_ref.relays.write() { - if let Ok(url) = Url::parse(&r) { - set.insert(url); - } - } - } else { - missing_contributor_profiles.insert(user.to_owned()); - } - } - set - }; - - let existing_events: HashSet = { - let mut existing_events: HashSet = HashSet::new(); - for filter in get_fetch_filters( - &repo_coordinates_without_relays, - &proposals, - &missing_contributor_profiles - .union( - &profiles_to_fetch_from_user_relays - .clone() - .into_keys() - .collect(), - ) - .copied() - .collect(), - ) { - for (id, _) in get_local_cache_database(git_repo_path) - .await? - .negentropy_items(filter) - .await? - { - existing_events.insert(id); - } - } - existing_events - }; - - let relays = { - let mut relays = fallback_relays; - if let Ok(repo_ref) = &repo_ref { - for r in &repo_ref.relays { - if let Ok(url) = Url::parse(r) { - relays.insert(url); - } - } - } - for c in repo_coordinates { - for r in &c.relays { - if let Ok(url) = Url::parse(r) { - relays.insert(url); - } - } - } - relays - }; - - let relay_column_width = relays - .union(&user_relays_for_profiles) - .reduce(|a, r| { - if r.to_string() - .chars() - .count() - .gt(&a.to_string().chars().count()) - { - r - } else { - a - } - }) - .unwrap() - .to_string() - .chars() - .count() - + 2; - - Ok(FetchRequest { - selected_relay: None, - repo_relays: relays, - relay_column_width, - repo_coordinates_without_relays: if let Ok(repo_ref) = &repo_ref { - repo_ref.coordinates_with_timestamps() - } else { - repo_coordinates_without_relays - .iter() - .map(|c| (c.clone(), None)) - .collect() - }, - state: if let Ok(repo_ref) = &repo_ref { - if let Ok(existing_state) = get_state_from_cache(git_repo_path, repo_ref).await { - Some((existing_state.event.created_at, existing_state.event.id)) - } else { - None - } - } else { - None - }, - proposals, - contributors, - missing_contributor_profiles, - existing_events, - profiles_to_fetch_from_user_relays, - user_relays_for_profiles, - }) -} - -#[allow(clippy::too_many_lines)] -async fn process_fetched_events( - events: Vec, - request: &FetchRequest, - git_repo_path: &Path, - fresh_coordinates: &mut HashSet, - fresh_proposal_roots: &mut HashSet, - fresh_profiles: &mut HashSet, - report: &mut FetchReport, -) -> Result<()> { - for event in &events { - if !request.existing_events.contains(&event.id) { - save_event_in_cache(git_repo_path, event).await?; - if event.kind().eq(&Kind::GitRepoAnnouncement) { - save_event_in_global_cache(git_repo_path, event).await?; - let new_coordinate = !request - .repo_coordinates_without_relays - .iter() - .map(|(c, _)| c.clone()) - .any(|c| { - c.identifier.eq(event.identifier().unwrap()) - && c.public_key.eq(&event.pubkey) - }); - let update_to_existing = !new_coordinate - && request - .repo_coordinates_without_relays - .iter() - .any(|(c, t)| { - c.identifier.eq(event.identifier().unwrap()) - && c.public_key.eq(&event.pubkey) - && if let Some(t) = t { - event.created_at.gt(t) - } else { - true - } - }); - if update_to_existing { - report.updated_repo_announcements.push(( - Coordinate { - kind: event.kind(), - public_key: event.author(), - identifier: event.identifier().unwrap().to_owned(), - relays: vec![], - }, - event.created_at, - )); - } - // if contains new maintainer - if let Ok(repo_ref) = &RepoRef::try_from(event.clone()) { - for m in &repo_ref.maintainers { - if !request - .repo_coordinates_without_relays // prexisting maintainers - .iter() - .map(|(c, _)| c.clone()) - .collect::>() - .union(&report.repo_coordinates_without_relays) // already added maintainers - .any(|c| c.identifier.eq(&repo_ref.identifier) && m.eq(&c.public_key)) - { - let c = Coordinate { - kind: event.kind(), - public_key: *m, - identifier: repo_ref.identifier.clone(), - relays: vec![], - }; - fresh_coordinates.insert(c.clone()); - report.repo_coordinates_without_relays.insert(c); - - if !request.contributors.contains(m) - && !request - .profiles_to_fetch_from_user_relays - .clone() - .into_keys() - .collect::>() - .contains(m) - && !fresh_profiles.contains(m) - { - fresh_profiles.insert(m.to_owned()); - } - } - } - } - } else if event.kind().eq(&STATE_KIND) { - let existing_state = if report.updated_state.is_some() { - report.updated_state - } else { - request.state - }; - if let Some((timestamp, id)) = existing_state { - if event.created_at.gt(×tamp) - || (event.created_at.eq(×tamp) && event.id.gt(&id)) - { - report.updated_state = Some((event.created_at, event.id)); - } - } - } else if event_is_patch_set_root(event) { - fresh_proposal_roots.insert(event.id); - report.proposals.insert(event.id); - if !request.contributors.contains(&event.author()) - && !fresh_profiles.contains(&event.author()) - { - fresh_profiles.insert(event.author()); - } - } else if [Kind::RelayList, Kind::Metadata].contains(&event.kind()) { - if request - .missing_contributor_profiles - .contains(&event.author()) - { - report.contributor_profiles.insert(event.author()); - } else if let Some((_, (metadata_timestamp, relay_list_timestamp))) = request - .profiles_to_fetch_from_user_relays - .get_key_value(&event.author()) - { - if (Kind::Metadata.eq(&event.kind()) - && event.created_at().gt(metadata_timestamp)) - || (Kind::RelayList.eq(&event.kind()) - && event.created_at().gt(relay_list_timestamp)) - { - report.profile_updates.insert(event.author()); - } - } - save_event_in_global_cache(git_repo_path, event).await?; - } - } - } - for event in &events { - if !request.existing_events.contains(&event.id) - && !event.event_ids().any(|id| report.proposals.contains(id)) - { - if event.kind().eq(&Kind::GitPatch) && !event_is_patch_set_root(event) { - report.commits.insert(event.id); - } else if status_kinds().contains(&event.kind()) { - report.statuses.insert(event.id); - } - } - } - Ok(()) -} - -pub fn consolidate_fetch_reports(reports: Vec>) -> FetchReport { - let mut report = FetchReport::default(); - for relay_report in reports.into_iter().flatten() { - for c in relay_report.repo_coordinates_without_relays { - if !report - .repo_coordinates_without_relays - .iter() - .any(|e| e.eq(&c)) - { - report.repo_coordinates_without_relays.insert(c); - } - } - for (r, t) in relay_report.updated_repo_announcements { - if let Some(i) = report - .updated_repo_announcements - .iter() - .position(|(e, _)| e.eq(&r)) - { - let (_, existing_t) = &report.updated_repo_announcements[i]; - if t.gt(existing_t) { - report.updated_repo_announcements[i] = (r, t); - } - } else { - report.updated_repo_announcements.push((r, t)); - } - } - if let Some((timestamp, id)) = relay_report.updated_state { - if let Some((existing_timestamp, existing_id)) = report.updated_state { - if timestamp.gt(&existing_timestamp) - || (timestamp.eq(&existing_timestamp) && id.gt(&existing_id)) - { - report.updated_state = Some((timestamp, id)); - } - } else { - report.updated_state = Some((timestamp, id)); - } - } - for c in relay_report.proposals { - report.proposals.insert(c); - } - for c in relay_report.commits { - report.commits.insert(c); - } - for c in relay_report.statuses { - report.statuses.insert(c); - } - for c in relay_report.contributor_profiles { - report.contributor_profiles.insert(c); - } - for c in relay_report.profile_updates { - report.profile_updates.insert(c); - } - } - report -} -pub fn get_fetch_filters( - repo_coordinates: &HashSet, - proposal_ids: &HashSet, - required_profiles: &HashSet, -) -> Vec { - [ - if repo_coordinates.is_empty() { - vec![] - } else { - vec![ - get_filter_state_events(repo_coordinates), - get_filter_repo_events(repo_coordinates), - nostr::Filter::default() - .kinds(vec![Kind::GitPatch, Kind::EventDeletion]) - .custom_tag( - SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), - repo_coordinates - .iter() - .map(std::string::ToString::to_string) - .collect::>(), - ), - ] - }, - if proposal_ids.is_empty() { - vec![] - } else { - vec![ - nostr::Filter::default() - .events(proposal_ids.clone()) - .kinds([vec![Kind::GitPatch, Kind::EventDeletion], status_kinds()].concat()), - ] - }, - if required_profiles.is_empty() { - vec![] - } else { - vec![get_filter_contributor_profiles(required_profiles.clone())] - }, - ] - .concat() -} - -pub fn get_filter_repo_events(repo_coordinates: &HashSet) -> nostr::Filter { - nostr::Filter::default() - .kind(Kind::GitRepoAnnouncement) - .identifiers( - repo_coordinates - .iter() - .map(|c| c.identifier.clone()) - .collect::>(), - ) - .authors( - repo_coordinates - .iter() - .map(|c| c.public_key) - .collect::>(), - ) -} - -pub static STATE_KIND: nostr::Kind = Kind::Custom(30618); -pub fn get_filter_state_events(repo_coordinates: &HashSet) -> nostr::Filter { - nostr::Filter::default() - .kind(STATE_KIND) - .identifiers( - repo_coordinates - .iter() - .map(|c| c.identifier.clone()) - .collect::>(), - ) - .authors( - repo_coordinates - .iter() - .map(|c| c.public_key) - .collect::>(), - ) -} - -pub fn get_filter_contributor_profiles(contributors: HashSet) -> nostr::Filter { - nostr::Filter::default() - .kinds(vec![Kind::Metadata, Kind::RelayList]) - .authors(contributors) -} - -#[derive(Default)] -pub struct FetchReport { - repo_coordinates_without_relays: HashSet, - updated_repo_announcements: Vec<(Coordinate, Timestamp)>, - updated_state: Option<(Timestamp, EventId)>, - proposals: HashSet, - /// commits against existing propoals - commits: HashSet, - statuses: HashSet, - contributor_profiles: HashSet, - profile_updates: HashSet, -} - -impl Display for FetchReport { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // report: "1 new maintainer, 1 announcement, 1 proposal, 3 commits, 2 statuses" - let mut display_items: Vec = vec![]; - if !self.repo_coordinates_without_relays.is_empty() { - display_items.push(format!( - "{} new maintainer{}", - self.repo_coordinates_without_relays.len(), - if self.repo_coordinates_without_relays.len() > 1 { - "s" - } else { - "" - }, - )); - } - if !self.updated_repo_announcements.is_empty() { - display_items.push(format!( - "{} announcement update{}", - self.updated_repo_announcements.len(), - if self.updated_repo_announcements.len() > 1 { - "s" - } else { - "" - }, - )); - } - if self.updated_state.is_some() { - display_items.push("new state".to_string()); - } - if !self.proposals.is_empty() { - display_items.push(format!( - "{} proposal{}", - self.proposals.len(), - if self.proposals.len() > 1 { "s" } else { "" }, - )); - } - if !self.commits.is_empty() { - display_items.push(format!( - "{} commit{}", - self.commits.len(), - if self.commits.len() > 1 { "s" } else { "" }, - )); - } - if !self.statuses.is_empty() { - display_items.push(format!( - "{} status{}", - self.statuses.len(), - if self.statuses.len() > 1 { "es" } else { "" }, - )); - } - if !self.contributor_profiles.is_empty() { - display_items.push(format!( - "{} user profile{}", - self.contributor_profiles.len(), - if self.contributor_profiles.len() > 1 { - "s" - } else { - "" - }, - )); - } - if !self.profile_updates.is_empty() { - display_items.push(format!( - "{} profile update{}", - self.profile_updates.len(), - if self.profile_updates.len() > 1 { - "s" - } else { - "" - }, - )); - } - write!(f, "{}", display_items.join(", ")) - } -} - -#[derive(Default, Clone)] -pub struct FetchRequest { - repo_relays: HashSet, - selected_relay: Option, - relay_column_width: usize, - repo_coordinates_without_relays: Vec<(Coordinate, Option)>, - state: Option<(Timestamp, EventId)>, - proposals: HashSet, - contributors: HashSet, - missing_contributor_profiles: HashSet, - existing_events: HashSet, - profiles_to_fetch_from_user_relays: HashMap, - user_relays_for_profiles: HashSet, -} - -pub async fn fetching_with_report( - 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("fetching updates...")?; - 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(); - } - let report = consolidate_fetch_reports(relay_reports); - if report.to_string().is_empty() { - println!("no updates"); - } else { - println!("updates: {report}"); - } - Ok(report) -} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 547fe7e..0000000 --- a/src/config.rs +++ /dev/null @@ -1,47 +0,0 @@ -use anyhow::{anyhow, Result}; -use directories::ProjectDirs; -use nostr::PublicKey; -use nostr_sdk::Timestamp; -use serde::{self, Deserialize, Serialize}; - -pub fn get_dirs() -> Result { - ProjectDirs::from("", "", "ngit").ok_or(anyhow!( - "should find operating system home directories with rust-directories crate" - )) -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -pub struct UserRef { - pub public_key: PublicKey, - pub metadata: UserMetadata, - pub relays: UserRelays, -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -pub struct UserMetadata { - pub name: String, - pub created_at: Timestamp, -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -pub struct UserRelays { - pub relays: Vec, - pub created_at: Timestamp, -} - -impl UserRelays { - pub fn write(&self) -> Vec { - self.relays - .iter() - .filter(|r| r.write) - .map(|r| r.url.clone()) - .collect() - } -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -pub struct UserRelayRef { - pub url: String, - pub read: bool, - pub write: bool, -} diff --git a/src/git.rs b/src/git.rs deleted file mode 100644 index 5919667..0000000 --- a/src/git.rs +++ /dev/null @@ -1,2566 +0,0 @@ -use std::{ - collections::HashSet, - env::current_dir, - path::{Path, PathBuf}, -}; - -use anyhow::{bail, Context, Result}; -use git2::{DiffOptions, Oid, Revwalk}; -use nostr::nips::nip01::Coordinate; -use nostr_sdk::{ - hashes::{sha1::Hash as Sha1Hash, Hash}, - PublicKey, Url, -}; - -use crate::sub_commands::list::{get_commit_id_from_patch, tag_value}; - -pub struct Repo { - pub git_repo: git2::Repository, -} - -impl Repo { - pub fn discover() -> Result { - Ok(Self { - git_repo: git2::Repository::discover(current_dir()?)?, - }) - } - pub fn from_path(path: &PathBuf) -> Result { - Ok(Self { - git_repo: git2::Repository::open(path)?, - }) - } -} - -// pub type CommitId = [u8; 7]; -// pub type Sha1 = [u8; 20]; - -pub trait RepoActions { - fn get_path(&self) -> Result<&Path>; - fn get_origin_url(&self) -> Result; - fn get_remote_branch_names(&self) -> Result>; - fn get_local_branch_names(&self) -> Result>; - fn get_origin_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)>; - fn get_local_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)>; - fn get_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)>; - fn get_checked_out_branch_name(&self) -> Result; - fn get_tip_of_branch(&self, branch_name: &str) -> Result; - fn get_commit_or_tip_of_reference(&self, reference: &str) -> Result; - fn get_root_commit(&self) -> Result; - fn does_commit_exist(&self, commit: &str) -> Result; - fn get_head_commit(&self) -> Result; - fn get_commit_parent(&self, commit: &Sha1Hash) -> Result; - fn get_commit_message(&self, commit: &Sha1Hash) -> Result; - fn get_commit_message_summary(&self, commit: &Sha1Hash) -> Result; - #[allow(clippy::doc_link_with_quotes)] - /// returns vector ["name", "email", "unixtime", "offset"] - /// eg ["joe bloggs", "joe@pm.me", "12176","-300"] - fn get_commit_author(&self, commit: &Sha1Hash) -> Result>; - #[allow(clippy::doc_link_with_quotes)] - /// returns vector ["name", "email", "unixtime", "offset"] - /// eg ["joe bloggs", "joe@pm.me", "12176","-300"] - fn get_commit_comitter(&self, commit: &Sha1Hash) -> Result>; - fn get_commits_ahead_behind( - &self, - base_commit: &Sha1Hash, - latest_commit: &Sha1Hash, - ) -> Result<(Vec, Vec)>; - fn get_refs(&self, commit: &Sha1Hash) -> Result>; - // including (un)staged changes and (un)tracked files - fn has_outstanding_changes(&self) -> Result; - fn make_patch_from_commit( - &self, - commit: &Sha1Hash, - series_count: &Option<(u64, u64)>, - ) -> Result; - fn extract_commit_pgp_signature(&self, commit: &Sha1Hash) -> Result; - fn checkout(&self, ref_name: &str) -> Result; - fn create_branch_at_commit(&self, branch_name: &str, commit: &str) -> Result<()>; - fn apply_patch_chain( - &self, - branch_name: &str, - patch_and_ancestors: Vec, - ) -> Result>; - fn create_commit_from_patch(&self, patch: &nostr::Event) -> Result; - fn parse_starting_commits(&self, starting_commits: &str) -> Result>; - fn ancestor_of(&self, decendant: &Sha1Hash, ancestor: &Sha1Hash) -> Result; - fn get_git_config_item(&self, item: &str, global: Option) -> Result>; - fn save_git_config_item(&self, item: &str, value: &str, global: bool) -> Result<()>; - fn remove_git_config_item(&self, item: &str, global: bool) -> Result; -} - -impl RepoActions for Repo { - fn get_path(&self) -> Result<&Path> { - self.git_repo - .path() - .parent() - .context("cannot find repositiory path as .git has no parent") - } - - fn get_origin_url(&self) -> Result { - Ok(self - .git_repo - .find_remote("origin") - .context("cannot find origin")? - .url() - .context("cannot find origin url")? - .to_string()) - } - - fn get_origin_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)> { - let main_branch_name = { - let remote_branches = self - .get_remote_branch_names() - .context("cannot find any local branches")?; - if remote_branches.contains(&"origin/main".to_string()) { - "origin/main" - } else if remote_branches.contains(&"origin/master".to_string()) { - "origin/master" - } else { - bail!("no main or master branch locally in this git repository to initiate from",) - } - }; - - let tip = self - .get_tip_of_branch(main_branch_name) - .context(format!( - "branch {main_branch_name} was listed as a remote branch but cannot get its tip commit id", - ))?; - - Ok((main_branch_name, tip)) - } - - fn get_local_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)> { - let main_branch_name = { - let local_branches = self - .get_local_branch_names() - .context("cannot find any local branches")?; - if local_branches.contains(&"main".to_string()) { - "main" - } else if local_branches.contains(&"master".to_string()) { - "master" - } else { - bail!("no main or master branch locally in this git repository to initiate from",) - } - }; - - let tip = self - .get_tip_of_branch(main_branch_name) - .context(format!( - "branch {main_branch_name} was listed as a local branch but cannot get its tip commit id", - ))?; - - Ok((main_branch_name, tip)) - } - - fn get_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)> { - if let Ok(main_tuple) = self - .get_origin_main_or_master_branch() - .context("the default branches (main or master) do not exist") - { - Ok(main_tuple) - } else { - self.get_local_main_or_master_branch() - .context("the default branches (main or master) do not exist") - } - } - - fn get_local_branch_names(&self) -> Result> { - let local_branches = self - .git_repo - .branches(Some(git2::BranchType::Local)) - .context("getting GitRepo branches should not error even for a blank repository")?; - - let mut branch_names = vec![]; - - for iter in local_branches { - let branch = iter?.0; - if let Some(name) = branch.name()? { - branch_names.push(name.to_string()); - } - } - Ok(branch_names) - } - - fn get_remote_branch_names(&self) -> Result> { - let remote_branches = self - .git_repo - .branches(Some(git2::BranchType::Remote)) - .context("getting GitRepo branches should not error even for a blank repository")?; - - let mut branch_names = vec![]; - - for iter in remote_branches { - let branch = iter?.0; - if let Some(name) = branch.name()? { - branch_names.push(name.to_string()); - } - } - Ok(branch_names) - } - - fn get_checked_out_branch_name(&self) -> Result { - Ok(self - .git_repo - .head()? - .shorthand() - .context("an object without a shorthand is checked out")? - .to_string()) - } - - fn get_tip_of_branch(&self, branch_name: &str) -> Result { - let branch = if let Ok(branch) = self - .git_repo - .find_branch(branch_name, git2::BranchType::Local) - .context(format!("cannot find local branch {branch_name}")) - { - branch - } else { - self.git_repo - .find_branch(branch_name, git2::BranchType::Remote) - .context(format!("cannot find local or remote branch {branch_name}"))? - }; - Ok(oid_to_sha1(&branch.into_reference().peel_to_commit()?.id())) - } - - fn get_commit_or_tip_of_reference(&self, sha1_or_reference: &str) -> Result { - let oid = { - if let Ok(oid) = Oid::from_str(sha1_or_reference) { - self.git_repo.find_commit(oid)?; - oid - } else { - self.git_repo - .find_reference(sha1_or_reference)? - .peel_to_commit()? - .id() - } - }; - Ok(oid_to_sha1(&oid)) - } - - fn get_root_commit(&self) -> Result { - let mut revwalk = self - .git_repo - .revwalk() - .context("revwalk should be created from git repo")?; - revwalk - .push(sha1_to_oid(&self.get_head_commit()?)?) - .context("revwalk should accept tip oid")?; - Ok(oid_to_sha1( - &revwalk - .last() - .context("revwalk from tip should be at least contain the tip oid")? - .context("revwalk iter from branch tip should not result in an error")?, - )) - } - - fn does_commit_exist(&self, commit: &str) -> Result { - if self.git_repo.find_commit(Oid::from_str(commit)?).is_ok() { - Ok(true) - } else { - Ok(false) - } - } - - fn get_head_commit(&self) -> Result { - let head = self - .git_repo - .head() - .context("failed to get git repo head")?; - let oid = head.peel_to_commit()?.id(); - Ok(oid_to_sha1(&oid)) - } - - fn get_commit_parent(&self, commit: &Sha1Hash) -> Result { - let parent_oid = self - .git_repo - .find_commit(sha1_to_oid(commit)?) - .context(format!("could not find commit {commit}"))? - .parent_id(0) - .context(format!("could not find parent of commit {commit}"))?; - Ok(oid_to_sha1(&parent_oid)) - } - - fn get_commit_message(&self, commit: &Sha1Hash) -> Result { - Ok(self - .git_repo - .find_commit(sha1_to_oid(commit)?) - .context(format!("could not find commit {commit}"))? - .message_raw() - .context("commit message has unusual characters in (not valid utf-8)")? - .to_string()) - } - - fn get_commit_message_summary(&self, commit: &Sha1Hash) -> Result { - Ok(self - .git_repo - .find_commit(sha1_to_oid(commit)?) - .context(format!("could not find commit {commit}"))? - .message_raw() - .context("commit message has unusual characters in (not valid utf-8)")? - .split('\r') - .collect::>()[0] - .split('\n') - .collect::>()[0] - .to_string() - .trim() - .to_string()) - } - - fn get_commit_author(&self, commit: &Sha1Hash) -> Result> { - let commit = self - .git_repo - .find_commit(sha1_to_oid(commit)?) - .context(format!("could not find commit {commit}"))?; - let sig = commit.author(); - Ok(git_sig_to_tag_vec(&sig)) - } - - fn get_commit_comitter(&self, commit: &Sha1Hash) -> Result> { - let commit = self - .git_repo - .find_commit(sha1_to_oid(commit)?) - .context(format!("could not find commit {commit}"))?; - let sig = commit.committer(); - Ok(git_sig_to_tag_vec(&sig)) - } - - fn get_refs(&self, commit: &Sha1Hash) -> Result> { - Ok(self - .git_repo - .references()? - .filter(|r| { - if let Ok(r) = r { - if let Ok(ref_tip) = r.peel_to_commit() { - ref_tip.id().to_string().eq(&commit.to_string()) - } else { - false - } - } else { - false - } - }) - .map(|r| r.unwrap().shorthand().unwrap().to_string()) - .collect::>()) - } - - fn make_patch_from_commit( - &self, - commit: &Sha1Hash, - series_count: &Option<(u64, u64)>, - ) -> Result { - let c = self - .git_repo - .find_commit(Oid::from_bytes(commit.as_byte_array()).context(format!( - "failed to convert commit_id format for {}", - &commit - ))?) - .context(format!("failed to find commit {}", &commit))?; - let mut options = git2::EmailCreateOptions::default(); - if let Some((n, total)) = series_count { - options.subject_prefix(format!("PATCH {n}/{total}")); - } - let patch = git2::Email::from_commit(&c, &mut options) - .context(format!("failed to create patch from commit {}", &commit))?; - - Ok(std::str::from_utf8(patch.as_slice()) - .context("patch content could not be converted to a utf8 string")? - .to_owned()) - } - - fn extract_commit_pgp_signature(&self, commit: &Sha1Hash) -> Result { - let oid = Oid::from_bytes(commit.as_byte_array()).context(format!( - "failed to convert commit_id format for {}", - &commit - ))?; - - let (sign, _data) = self - .git_repo - .extract_signature(&oid, None) - .context("failed to extract signature - perhaps there is no signature?")?; - - Ok(std::str::from_utf8(&sign) - .context("commit signature cannot be converted to a utf8 string")? - .to_owned()) - } - - // including (un)staged changes and (un)tracked files - fn has_outstanding_changes(&self) -> Result { - let diff = self.git_repo.diff_tree_to_workdir_with_index( - Some(&self.git_repo.head()?.peel_to_tree()?), - Some(DiffOptions::new().include_untracked(true)), - )?; - - Ok(diff.deltas().len().gt(&0)) - } - - fn get_commits_ahead_behind( - &self, - base_commit: &Sha1Hash, - latest_commit: &Sha1Hash, - ) -> Result<(Vec, Vec)> { - let mut ahead: Vec = vec![]; - let mut behind: Vec = vec![]; - - let get_revwalk = |commit: &Sha1Hash| -> Result { - let mut revwalk = self - .git_repo - .revwalk() - .context("revwalk should be created from git repo")?; - revwalk - .push(sha1_to_oid(commit)?) - .context("revwalk should accept commit oid")?; - Ok(revwalk) - }; - - // scan through the base commit ancestory until a common ancestor is found - let most_recent_shared_commit = match get_revwalk(base_commit) - .context("failed to get revwalk for base_commit")? - .find(|base_res| { - let base_oid = base_res.as_ref().unwrap(); - - if get_revwalk(latest_commit) - .unwrap() - .any(|latest_res| base_oid.eq(latest_res.as_ref().unwrap())) - { - true - } else { - // add commits not found in latest ancestory to 'behind' vector - behind.push(oid_to_sha1(base_oid)); - false - } - }) { - None => { - bail!(format!( - "{} is not an ancestor of {}", - latest_commit, base_commit - )); - } - Some(res) => res.context("revwalk failed to reveal commit")?, - }; - - // scan through the latest commits until shared commit is reached - get_revwalk(latest_commit) - .context("failed to get revwalk for latest_commit")? - .any(|latest_res| { - let latest_oid = latest_res.as_ref().unwrap(); - if latest_oid.eq(&most_recent_shared_commit) { - true - } else { - // add commits not found in base to 'ahead' vector - ahead.push(oid_to_sha1(latest_oid)); - false - } - }); - Ok((ahead, behind)) - } - - fn checkout(&self, ref_name: &str) -> Result { - let (object, reference) = self.git_repo.revparse_ext(ref_name)?; - - self.git_repo.checkout_tree(&object, None)?; - - match reference { - // gref is an actual reference like branches or tags - Some(gref) => self.git_repo.set_head(gref.name().unwrap()), - // this is a commit, not a reference - None => self.git_repo.set_head_detached(object.id()), - }?; - let oid = self.git_repo.head()?.peel_to_commit()?.id(); - - Ok(oid_to_sha1(&oid)) - } - - fn create_branch_at_commit(&self, branch_name: &str, commit: &str) -> Result<()> { - let branch_checkedout = self.get_checked_out_branch_name()?.eq(branch_name); - if branch_checkedout { - let (name, _) = self.get_main_or_master_branch()?; - self.checkout(name)?; - } - - self.git_repo - .branch( - branch_name, - &self.git_repo.find_commit(Oid::from_str(commit)?)?, - true, - ) - .context("branch could not be created")?; - - if branch_checkedout { - self.checkout(branch_name)?; - } - Ok(()) - } - /* returns patches applied */ - fn apply_patch_chain( - &self, - branch_name: &str, - patch_and_ancestors: Vec, - ) -> Result> { - let branch_tip_result = self.get_tip_of_branch(branch_name); - - // filter out existing ancestors in branch - let mut patches_to_apply: Vec = patch_and_ancestors - .into_iter() - .filter(|e| { - let commit_id = get_commit_id_from_patch(e).unwrap(); - if let Ok(branch_tip) = branch_tip_result { - !branch_tip.to_string().eq(&commit_id) - && !self - .ancestor_of(&branch_tip, &str_to_sha1(&commit_id).unwrap()) - .unwrap() - } else { - true - } - }) - .collect(); - - let parent_commit_id = tag_value( - if let Ok(last_patch) = patches_to_apply.last().context("no patches") { - last_patch - } else { - self.checkout(branch_name) - .context("no patches and so cannot create a proposal branch")?; - return Ok(vec![]); - }, - "parent-commit", - )?; - - // check patches can be applied - if !self.does_commit_exist(&parent_commit_id)? { - bail!("cannot find parent commit ({parent_commit_id}). run git pull and try again.") - } - - // checkout branch - self.create_branch_at_commit(branch_name, &parent_commit_id)?; - self.checkout(branch_name)?; - - // apply commits - patches_to_apply.reverse(); - - for patch in &patches_to_apply { - let commit_id = get_commit_id_from_patch(patch)?; - // only create new commits - otherwise make them the tip - if !self.does_commit_exist(&commit_id)? { - self.create_commit_from_patch(patch)?; - } - self.create_branch_at_commit(branch_name, &commit_id)?; - self.checkout(branch_name)?; - } - Ok(patches_to_apply) - } - fn create_commit_from_patch(&self, patch: &nostr::Event) -> Result { - let commit_id = get_commit_id_from_patch(patch)?; - if self.does_commit_exist(&commit_id)? { - return Ok(Oid::from_str(&commit_id)?); - } - let parent_commit_id = tag_value(patch, "parent-commit")?; - - let parent_commit = self - .git_repo - .find_commit(Oid::from_str(&parent_commit_id)?) - .context("parrent commit doesnt exist")?; - let parent_tree = parent_commit.tree()?; - - // let mut apply_opts = git2::ApplyOptions::new(); - // apply_opts.check(false); - let mut existing_index = self.git_repo.index()?; - let mut index = self.git_repo.apply_to_tree( - &parent_tree, - &git2::Diff::from_buffer(patch.content.as_bytes())?, - // Some(&mut apply_opts), - None, - )?; - let tree = self - .git_repo - .find_tree(index.write_tree_to(&self.git_repo)?)?; - - let pgp_sig = if let Ok(pgp_sig) = tag_value(patch, "commit-pgp-sig") { - if pgp_sig.is_empty() { - None - } else { - Some(pgp_sig) - } - } else { - None - }; - - let commit_buff = self.git_repo.commit_create_buffer( - &extract_sig_from_patch_tags(&patch.tags, "author")?, - &extract_sig_from_patch_tags(&patch.tags, "committer")?, - tag_value(patch, "description")?.as_str(), - &tree, - &[&parent_commit], - )?; - - let mut applied_oid = self - .git_repo - .commit_signed( - commit_buff.as_str().unwrap(), - pgp_sig.unwrap_or(String::new()).as_str(), - None, - ) - .context("failed to create signed commit")?; - - // I beleive this was added to address a bug where commit author / committer - // were identical when in a scenario when they should be different but I dont - // think we have a test case for it. surely we should be using the - // extract_sig_from_patch_tags outputs to address this? - if !applied_oid.to_string().eq(&commit_id) { - let commit = self.git_repo.find_commit(applied_oid)?; - applied_oid = commit - .amend( - None, - Some(&commit.author()), - Some(&commit.committer()), - None, - None, - None, - ) - .context("cannot amend commit to produce new oid")?; - } - if !applied_oid.to_string().eq(&commit_id) { - bail!( - "when applied the patch commit id ({}) doesn't match the one specified in the event tag ({})", - applied_oid.to_string(), - get_commit_id_from_patch(patch)?, - ); - } - self.git_repo.set_index(&mut existing_index)?; - Ok(applied_oid) - } - fn parse_starting_commits(&self, starting_commits: &str) -> Result> { - let revspec = self - .git_repo - .revparse(starting_commits) - .context("specified value not in a valid format")?; - if revspec.mode().is_no_single() { - let (ahead, _) = self - .get_commits_ahead_behind( - &oid_to_sha1( - &revspec - .from() - .context("cannot get starting commit from specified value")? - .id(), - ), - &self - .get_head_commit() - .context("cannot get head commit with gitlib2")?, - ) - .context("specified commit is not an ancestor of current head")?; - Ok(ahead) - } else if revspec.mode().is_range() { - let (ahead, _) = self - .get_commits_ahead_behind( - &oid_to_sha1( - &revspec - .from() - .context("cannot get starting commit of range from specified value")? - .id(), - ), - &oid_to_sha1( - &revspec - .to() - .context("cannot get end of range commit from specified value")? - .id(), - ), - ) - .context("specified commit is not an ancestor of current head")?; - Ok(ahead) - } else { - bail!("specified value not in a supported format") - } - } - - fn ancestor_of(&self, decendant: &Sha1Hash, ancestor: &Sha1Hash) -> Result { - if let Ok(res) = self - .git_repo - .graph_descendant_of(sha1_to_oid(decendant)?, sha1_to_oid(ancestor)?) - .context("could not run graph_descendant_of in gitlib2") - { - Ok(res) - } else { - Ok(false) - } - } - - /// setting global to None will suppliment local config with global items - /// not in local - fn get_git_config_item(&self, item: &str, global: Option) -> Result> { - let just_global = if let Some(just_global) = global { - just_global - } else { - false - }; - match if just_global { - self.git_repo - .config() - .context("cannot open git config")? - .open_global() - .context("cannot open global git config")? - } else { - self.git_repo.config().context("cannot open git config")? - } - .get_entry(item) - { - Ok(item) => { - if let Some(global) = global { - if item.level().eq(&git2::ConfigLevel::Local) { - if global { - bail!("only local repository login available") - } - } else if !global { - bail!("only global repository login available") - } - } - Ok(Some( - item.value() - .context("cannot find git config item")? - .to_string(), - )) - } - Err(_) => Ok(None), - } - } - - fn save_git_config_item(&self, item: &str, value: &str, global: bool) -> Result<()> { - if global { - self.git_repo - .config() - .context("cannot open git config")? - .open_global() - .context("cannot open global git config")? - } else { - self.git_repo.config().context("cannot open git config")? - } - .set_str(item, value) - .context(format!( - "cannot set {} git config item {}", - if global { "global" } else { "local" }, - item - ))?; - Ok(()) - } - - /// returns false if item doesn't exist - fn remove_git_config_item(&self, item: &str, global: bool) -> Result { - if self.get_git_config_item(item, Some(global))?.is_none() { - Ok(false) - } else { - if global { - self.git_repo - .config() - .context("cannot open git config")? - .open_global() - .context("cannot open global git config")? - } else { - self.git_repo.config().context("cannot open git config")? - } - .remove(item) - .context("cannot remove existing git config item")?; - Ok(true) - } - } -} - -fn oid_to_u8_20_bytes(oid: &Oid) -> [u8; 20] { - let b = oid.as_bytes(); - [ - b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13], - b[14], b[15], b[16], b[17], b[18], b[19], - ] -} - -// fn oid_to_shorthand_string(oid: Oid) -> Result { -// let binding = oid.to_string(); -// let b = binding.as_bytes(); -// String::from_utf8(vec![b[0], b[1], b[2], b[3], b[4], b[5], b[6]]) -// .context("oid should always start with 7 u8 btyes of utf8") -// } - -// fn oid_to_sha1_string(oid: Oid) -> Result { -// let b = oid.as_bytes(); -// String::from_utf8(vec![ -// b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], -// b[11], b[12], b[13], b[14], b[15], b[16], b[17], b[18], b[19], -// ]) -// .context("oid should contain 20 u8 btyes of utf8") -// } - -// git2 Oid object to Sha1Hash -pub fn oid_to_sha1(oid: &Oid) -> Sha1Hash { - Sha1Hash::from_byte_array(oid_to_u8_20_bytes(oid)) -} - -/// `Sha1Hash` to git2 `Oid` object -pub fn sha1_to_oid(hash: &Sha1Hash) -> Result { - Oid::from_bytes(hash.as_byte_array()).context("Sha1Hash bytes failed to produce a valid Oid") -} - -pub fn str_to_sha1(s: &str) -> Result { - Ok(oid_to_sha1( - &Oid::from_str(s).context("string is not a sha1 hash")?, - )) -} - -fn git_sig_to_tag_vec(sig: &git2::Signature) -> Vec { - vec![ - sig.name().unwrap_or("").to_string(), - sig.email().unwrap_or("").to_string(), - format!("{}", sig.when().seconds()), - format!("{}", sig.when().offset_minutes()), - ] -} - -fn extract_sig_from_patch_tags<'a>( - tags: &'a [nostr::Tag], - tag_name: &str, -) -> Result> { - let v = tags - .iter() - .find(|t| t.as_vec()[0].eq(tag_name)) - .context(format!("tag '{tag_name}' not present in patch"))? - .as_vec(); - if v.len() != 5 { - bail!("tag '{tag_name}' is incorrectly formatted") - } - git2::Signature::new( - v[1].as_str(), - v[2].as_str(), - &git2::Time::new( - v[3].parse().context("tag time is incorrectly formatted")?, - v[4].parse() - .context("tag time offset is incorrectly formatted")?, - ), - ) - .context("failed to create git signature") -} - -#[derive(Debug, PartialEq)] -pub enum ServerProtocol { - Ssh, - Https, - Http, - Git, -} - -#[derive(Debug, PartialEq)] -pub struct NostrUrlDecoded { - pub coordinates: HashSet, - pub protocol: Option, - pub user: Option, -} - -static INCORRECT_NOSTR_URL_FORMAT_ERROR: &str = "incorrect nostr git url format. try nostr://naddr123 or nostr://npub123/my-repo or nostr://ssh/npub123/relay.damus.io/my-repo"; - -impl NostrUrlDecoded { - pub fn from_str(url: &str) -> Result { - let mut coordinates = HashSet::new(); - let mut protocol = None; - let mut user = None; - let mut relays = vec![]; - - if !url.starts_with("nostr://") { - bail!("nostr git url must start with nostr://"); - } - // process get url parameters if present - for (name, value) in Url::parse(url)?.query_pairs() { - if name.contains("relay") { - let mut decoded = urlencoding::decode(&value) - .context("could not parse relays in nostr git url")? - .to_string(); - if !decoded.starts_with("ws://") && !decoded.starts_with("wss://") { - decoded = format!("wss://{decoded}"); - } - let url = - Url::parse(&decoded).context("could not parse relays in nostr git url")?; - relays.push(url.to_string()); - } else if name == "protocol" { - protocol = match value.as_ref() { - "ssh" => Some(ServerProtocol::Ssh), - "https" => Some(ServerProtocol::Https), - "http" => Some(ServerProtocol::Http), - "git" => Some(ServerProtocol::Git), - _ => None, - }; - } else if name == "user" { - user = Some(value.to_string()); - } - } - - let mut parts: Vec<&str> = url[8..] - .split('?') - .next() - .unwrap_or("") - .split('/') - .collect(); - - // extract optional protocol - if protocol.is_none() { - let part = parts.first().context(INCORRECT_NOSTR_URL_FORMAT_ERROR)?; - let protocol_str = if let Some(at_index) = part.find('@') { - user = Some(part[..at_index].to_string()); - &part[at_index + 1..] - } else { - part - }; - protocol = match protocol_str { - "ssh" => Some(ServerProtocol::Ssh), - "https" => Some(ServerProtocol::Https), - "http" => Some(ServerProtocol::Http), - "git" => Some(ServerProtocol::Git), - _ => protocol, - }; - if protocol.is_some() { - parts.remove(0); - } - } - // extract naddr npub//identifer - let part = parts.first().context(INCORRECT_NOSTR_URL_FORMAT_ERROR)?; - // naddr used - if let Ok(coordinate) = Coordinate::parse(part) { - if coordinate.kind.eq(&nostr_sdk::Kind::GitRepoAnnouncement) { - coordinates.insert(coordinate); - } else { - bail!("naddr doesnt point to a git repository announcement"); - } - // npub//identifer used - } else if let Ok(public_key) = PublicKey::parse(part) { - parts.remove(0); - let identifier = parts - .pop() - .context("nostr url must have an identifier eg. nostr://npub123/repo-identifier")? - .to_string(); - for relay in parts { - let mut decoded = urlencoding::decode(relay) - .context("could not parse relays in nostr git url")? - .to_string(); - if !decoded.starts_with("ws://") && !decoded.starts_with("wss://") { - decoded = format!("wss://{decoded}"); - } - let url = - Url::parse(&decoded).context("could not parse relays in nostr git url")?; - relays.push(url.to_string()); - } - coordinates.insert(Coordinate { - identifier, - public_key, - kind: nostr_sdk::Kind::GitRepoAnnouncement, - relays, - }); - } else { - bail!(INCORRECT_NOSTR_URL_FORMAT_ERROR); - } - - Ok(Self { - coordinates, - protocol, - user, - }) - } -} - -/** produce error when using local repo or custom protocols */ -pub fn convert_clone_url_to_https(url: &str) -> Result { - // Strip credentials if present - let stripped_url = strip_credentials(url); - - // Check if the URL is already in HTTPS format - if stripped_url.starts_with("https://") { - return Ok(stripped_url); - } - // Convert http:// to https:// - else if stripped_url.starts_with("http://") { - return Ok(stripped_url.replace("http://", "https://")); - } - // Check if the URL starts with SSH - else if stripped_url.starts_with("ssh://") { - // Convert SSH to HTTPS - let parts: Vec<&str> = stripped_url - .trim_start_matches("ssh://") - .split('/') - .collect(); - if parts.len() >= 2 { - // Construct the HTTPS URL - return Ok(format!("https://{}/{}", parts[0], parts[1..].join("/"))); - } - bail!("Invalid SSH URL format: {}", url); - } - // Convert ftp:// to https:// - else if stripped_url.starts_with("ftp://") { - return Ok(stripped_url.replace("ftp://", "https://")); - } - // Convert git:// to https:// - else if stripped_url.starts_with("git://") { - return Ok(stripped_url.replace("git://", "https://")); - } - - // If the URL is neither HTTPS, SSH, nor git@, return an error - bail!("Unsupported URL protocol: {}", url); -} - -// Function to strip username and password from the URL -fn strip_credentials(url: &str) -> String { - if let Some(pos) = url.find("://") { - let (protocol, rest) = url.split_at(pos + 3); // Split at "://" - let rest_parts: Vec<&str> = rest.split('@').collect(); - if rest_parts.len() > 1 { - // If there are credentials, return the URL without them - return format!("{}{}", protocol, rest_parts[1]); - } - } else if let Some(at_pos) = url.find('@') { - // Handle user@host:path format - let (_, rest) = url.split_at(at_pos); - // This is a git@ syntax - let host_and_repo = &rest[1..]; // Skip the ':' - return format!("ssh://{}", host_and_repo.replace(':', "/")); - } - url.to_string() // Return the original URL if no credentials are found -} - -#[cfg(test)] -mod tests { - use std::fs; - - use test_utils::{generate_repo_ref_event, git::GitTestRepo}; - - use super::*; - - mod git_config_item_local { - use super::*; - - #[test] - fn save_git_config_item_returns_ok() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.save_git_config_item("test.item", "testvalue", false)?; - Ok(()) - } - - #[test] - fn get_git_config_item_returns_item_just_saved() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.save_git_config_item("test.item", "testvalue", false)?; - assert_eq!( - git_repo - .get_git_config_item("test.item", Some(false))? - .unwrap(), - "testvalue", - ); - Ok(()) - } - - #[test] - fn get_git_config_item_returns_none_if_not_present() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - assert_eq!( - git_repo.get_git_config_item("test.item", Some(false))?, - None - ); - Ok(()) - } - - #[test] - fn get_git_config_item_empty_string_returns_empty_string_instead_of_none() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.save_git_config_item("test.item", "", false)?; - assert_eq!( - git_repo.get_git_config_item("test.item", Some(false))?, - Some("".to_string()), - ); - Ok(()) - } - - #[test] - fn remove_local_git_config_item() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.save_git_config_item("test.item", "testvalue", false)?; - assert!(git_repo.remove_git_config_item("test.item", false)?); - assert_eq!( - git_repo.get_git_config_item("test.item", Some(false))?, - None, - ); - Ok(()) - } - - #[test] - fn remove_git_config_item_returns_false_if_item_wasnt_set() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - assert!(!(git_repo.remove_git_config_item("test.item", false)?)); - Ok(()) - } - } - - #[test] - fn get_commit_parent() -> Result<()> { - let test_repo = GitTestRepo::default(); - let parent_oid = test_repo.populate()?; - std::fs::write(test_repo.dir.join("t100.md"), "some content")?; - let child_oid = test_repo.stage_and_commit("add t100.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - assert_eq!( - // Sha1Hash::from_byte_array("bla".to_string().as_bytes()), - oid_to_sha1(&parent_oid), - git_repo.get_commit_parent(&oid_to_sha1(&child_oid))?, - ); - Ok(()) - } - - mod get_commit_message { - use super::*; - fn run(message: &str) -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - std::fs::write(test_repo.dir.join("t100.md"), "some content")?; - let oid = test_repo.stage_and_commit(message)?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - assert_eq!(message, git_repo.get_commit_message(&oid_to_sha1(&oid))?,); - Ok(()) - } - #[test] - fn one_liner() -> Result<()> { - run("add t100.md") - } - - #[test] - fn multiline() -> Result<()> { - run("add t100.md\r\nanother line\r\nthird line") - } - - #[test] - fn trailing_newlines() -> Result<()> { - run("add t100.md\r\n\r\n\r\n\r\n\r\n\r\n") - } - - #[test] - fn unicode_characters() -> Result<()> { - run("add t100.md ❤️") - } - } - - mod get_commit_message_summary { - use super::*; - fn run(message: &str, summary: &str) -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - std::fs::write(test_repo.dir.join("t100.md"), "some content")?; - let oid = test_repo.stage_and_commit(message)?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - assert_eq!( - summary, - git_repo.get_commit_message_summary(&oid_to_sha1(&oid))?, - ); - Ok(()) - } - #[test] - fn one_liner() -> Result<()> { - run("add t100.md", "add t100.md") - } - - #[test] - fn multiline() -> Result<()> { - run("add t100.md\r\nanother line\r\nthird line", "add t100.md") - } - - #[test] - fn trailing_newlines() -> Result<()> { - run("add t100.md\r\n\r\n\r\n\r\n\r\n\r\n", "add t100.md") - } - - #[test] - fn unicode_characters() -> Result<()> { - run("add t100.md ❤️", "add t100.md ❤️") - } - } - - mod get_commit_author { - use super::*; - - static NAME: &str = "carole"; - static EMAIL: &str = "carole@pm.me"; - - fn prep(time: &git2::Time) -> Result> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - fs::write(test_repo.dir.join("x1.md"), "some content")?; - let oid = test_repo.stage_and_commit_custom_signature( - "add x1.md", - Some(&git2::Signature::new(NAME, EMAIL, time)?), - None, - )?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.get_commit_author(&oid_to_sha1(&oid)) - } - - #[test] - fn name() -> Result<()> { - let res = prep(&git2::Time::new(5000, 0))?; - assert_eq!(NAME, res[0]); - Ok(()) - } - - #[test] - fn email() -> Result<()> { - let res = prep(&git2::Time::new(5000, 0))?; - assert_eq!(EMAIL, res[1]); - Ok(()) - } - - mod time { - use super::*; - - #[test] - fn no_offset() -> Result<()> { - let res = prep(&git2::Time::new(5000, 0))?; - assert_eq!("5000", res[2]); - assert_eq!("0", res[3]); - Ok(()) - } - #[test] - fn positive_offset() -> Result<()> { - let res = prep(&git2::Time::new(5000, 300))?; - assert_eq!("5000", res[2]); - assert_eq!("300", res[3]); - Ok(()) - } - #[test] - fn negative_offset() -> Result<()> { - let res = prep(&git2::Time::new(5000, -300))?; - assert_eq!("5000", res[2]); - assert_eq!("-300", res[3]); - Ok(()) - } - } - - mod extract_sig_from_patch_tags { - use super::*; - - fn test(time: git2::Time) -> Result<()> { - assert_eq!( - extract_sig_from_patch_tags( - &[nostr::Tag::custom( - nostr::TagKind::Custom("author".to_string().into()), - prep(&time)?, - )], - "author", - )? - .to_string(), - git2::Signature::new(NAME, EMAIL, &time)?.to_string(), - ); - Ok(()) - } - - #[test] - fn no_offset() -> Result<()> { - test(git2::Time::new(5000, 0)) - } - - #[test] - fn positive_offset() -> Result<()> { - test(git2::Time::new(5000, 300)) - } - - #[test] - fn negative_offset() -> Result<()> { - test(git2::Time::new(5000, -300)) - } - } - } - - mod get_commit_comitter { - use super::*; - - static NAME: &str = "carole"; - static EMAIL: &str = "carole@pm.me"; - - fn prep(time: &git2::Time) -> Result> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - fs::write(test_repo.dir.join("x1.md"), "some content")?; - let oid = test_repo.stage_and_commit_custom_signature( - "add x1.md", - None, - Some(&git2::Signature::new(NAME, EMAIL, time)?), - )?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.get_commit_comitter(&oid_to_sha1(&oid)) - } - - #[test] - fn name() -> Result<()> { - let res = prep(&git2::Time::new(5000, 0))?; - assert_eq!(NAME, res[0]); - Ok(()) - } - - #[test] - fn email() -> Result<()> { - let res = prep(&git2::Time::new(5000, 0))?; - assert_eq!(EMAIL, res[1]); - Ok(()) - } - } - - mod does_commit_exist { - use super::*; - - #[test] - fn existing_commits_results_in_true() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - - assert!(git_repo.does_commit_exist("431b84edc0d2fa118d63faa3c2db9c73d630a5ae")?); - Ok(()) - } - - #[test] - fn correctly_formatted_hash_that_doesnt_correspond_to_an_existing_commit_results_in_false() - -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - - assert!(!git_repo.does_commit_exist("000004edc0d2fa118d63faa3c2db9c73d630a5ae")?); - Ok(()) - } - - #[test] - fn incorrectly_formatted_hash_that_doesnt_correspond_to_an_existing_commit_results_in_error() - -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - - assert!(git_repo.does_commit_exist("00").is_ok()); - Ok(()) - } - } - - mod make_patch_from_commit { - use super::*; - #[test] - fn simple_patch_matches_string() -> Result<()> { - let test_repo = GitTestRepo::default(); - let oid = test_repo.populate()?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - assert_eq!( - "\ - From 431b84edc0d2fa118d63faa3c2db9c73d630a5ae Mon Sep 17 00:00:00 2001\n\ - From: Joe Bloggs \n\ - Date: Thu, 1 Jan 1970 00:00:00 +0000\n\ - Subject: [PATCH] add t2.md\n\ - \n\ - ---\n \ - t2.md | 1 +\n \ - 1 file changed, 1 insertion(+)\n \ - create mode 100644 t2.md\n\ - \n\ - diff --git a/t2.md b/t2.md\n\ - new file mode 100644\n\ - index 0000000..a66525d\n\ - --- /dev/null\n\ - +++ b/t2.md\n\ - @@ -0,0 +1 @@\n\ - +some content1\n\\ \ - No newline at end of file\n\ - --\n\ - libgit2 1.7.2\n\ - \n\ - ", - git_repo.make_patch_from_commit(&oid_to_sha1(&oid), &None)?, - ); - Ok(()) - } - - #[test] - fn series_count() -> Result<()> { - let test_repo = GitTestRepo::default(); - let oid = test_repo.populate()?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - assert_eq!( - "\ - From 431b84edc0d2fa118d63faa3c2db9c73d630a5ae Mon Sep 17 00:00:00 2001\n\ - From: Joe Bloggs \n\ - Date: Thu, 1 Jan 1970 00:00:00 +0000\n\ - Subject: [PATCH 3/5] add t2.md\n\ - \n\ - ---\n \ - t2.md | 1 +\n \ - 1 file changed, 1 insertion(+)\n \ - create mode 100644 t2.md\n\ - \n\ - diff --git a/t2.md b/t2.md\n\ - new file mode 100644\n\ - index 0000000..a66525d\n\ - --- /dev/null\n\ - +++ b/t2.md\n\ - @@ -0,0 +1 @@\n\ - +some content1\n\\ \ - No newline at end of file\n\ - --\n\ - libgit2 1.7.2\n\ - \n\ - ", - git_repo.make_patch_from_commit(&oid_to_sha1(&oid), &Some((3, 5)))?, - ); - Ok(()) - } - } - - mod get_main_or_master_branch { - - use super::*; - - #[test] - fn return_origin_main_if_exists() -> Result<()> { - let test_origin_repo = GitTestRepo::new("main")?; - let main_origin_oid = test_origin_repo.populate()?; - - let test_repo = GitTestRepo::new("main")?; - test_repo.populate()?; - test_repo.add_remote("origin", test_origin_repo.dir.to_str().unwrap())?; - test_repo - .git_repo - .find_remote("origin")? - .fetch(&["main"], None, None)?; - - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - test_repo.stage_and_commit("add t3.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - let (name, commit_hash) = git_repo.get_main_or_master_branch()?; - assert_eq!(name, "origin/main"); - assert_eq!(commit_hash, oid_to_sha1(&main_origin_oid)); - Ok(()) - } - - mod returns_main { - use super::*; - #[test] - fn when_it_exists() -> Result<()> { - let test_repo = GitTestRepo::new("main")?; - let main_oid = test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - let (name, commit_hash) = git_repo.get_main_or_master_branch()?; - assert_eq!(name, "main"); - assert_eq!(commit_hash, oid_to_sha1(&main_oid)); - Ok(()) - } - - #[test] - fn when_it_exists_and_other_branch_checkedout() -> Result<()> { - let test_repo = GitTestRepo::new("main")?; - let main_oid = test_repo.populate()?; - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let feature_oid = test_repo.stage_and_commit("add t3.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - let (name, commit_hash) = git_repo.get_main_or_master_branch()?; - assert_eq!(name, "main"); - assert_eq!(commit_hash, oid_to_sha1(&main_oid)); - assert_ne!(commit_hash, oid_to_sha1(&feature_oid)); - Ok(()) - } - - #[test] - fn when_exists_even_if_master_is_checkedout() -> Result<()> { - let test_repo = GitTestRepo::new("main")?; - let main_oid = test_repo.populate()?; - test_repo.create_branch("master")?; - test_repo.checkout("master")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let master_oid = test_repo.stage_and_commit("add t3.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - let (name, commit_hash) = git_repo.get_main_or_master_branch()?; - assert_eq!(name, "main"); - assert_eq!(commit_hash, oid_to_sha1(&main_oid)); - assert_ne!(commit_hash, oid_to_sha1(&master_oid)); - Ok(()) - } - } - - #[test] - fn returns_master_if_exists_and_main_doesnt() -> Result<()> { - let test_repo = GitTestRepo::new("master")?; - let master_oid = test_repo.populate()?; - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let feature_oid = test_repo.stage_and_commit("add t3.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - let (name, commit_hash) = git_repo.get_main_or_master_branch()?; - assert_eq!(name, "master"); - assert_eq!(commit_hash, oid_to_sha1(&master_oid)); - assert_ne!(commit_hash, oid_to_sha1(&feature_oid)); - Ok(()) - } - #[test] - fn returns_error_if_no_main_or_master() -> Result<()> { - let test_repo = GitTestRepo::new("feature")?; - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - assert!(git_repo.get_main_or_master_branch().is_err()); - Ok(()) - } - } - - mod get_origin_url { - use super::*; - - #[test] - fn returns_origin_url() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.add_remote("origin", "https://localhost:1000")?; - let git_repo = Repo::from_path(&test_repo.dir)?; - assert_eq!(git_repo.get_origin_url()?, "https://localhost:1000"); - Ok(()) - } - } - mod get_checked_out_branch_name { - use super::*; - - #[test] - fn returns_checked_out_branch_name() -> Result<()> { - let test_repo = GitTestRepo::default(); - let _ = test_repo.populate()?; - // create feature branch - test_repo.create_branch("example-feature")?; - test_repo.checkout("example-feature")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - assert_eq!( - git_repo.get_checked_out_branch_name()?, - "example-feature".to_string() - ); - Ok(()) - } - } - - mod get_commits_ahead_behind { - use super::*; - mod returns_main { - use super::*; - - #[test] - fn when_on_same_commit_return_empty() -> Result<()> { - let test_repo = GitTestRepo::default(); - let oid = test_repo.populate()?; - // create feature branch - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - let (ahead, behind) = - git_repo.get_commits_ahead_behind(&oid_to_sha1(&oid), &oid_to_sha1(&oid))?; - assert_eq!(ahead, vec![]); - assert_eq!(behind, vec![]); - Ok(()) - } - - #[test] - fn when_2_commit_behind() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - // create feature branch - test_repo.create_branch("feature")?; - let feature_oid = test_repo.checkout("feature")?; - // checkout main and add 2 commits - test_repo.checkout("main")?; - std::fs::write(test_repo.dir.join("t5.md"), "some content")?; - let behind_1_oid = test_repo.stage_and_commit("add t5.md")?; - std::fs::write(test_repo.dir.join("t6.md"), "some content")?; - let behind_2_oid = test_repo.stage_and_commit("add t6.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - let (ahead, behind) = git_repo.get_commits_ahead_behind( - &oid_to_sha1(&behind_2_oid), - &oid_to_sha1(&feature_oid), - )?; - assert_eq!(ahead, vec![]); - assert_eq!( - behind, - vec![oid_to_sha1(&behind_2_oid), oid_to_sha1(&behind_1_oid),], - ); - Ok(()) - } - - #[test] - fn when_2_commit_ahead() -> Result<()> { - let test_repo = GitTestRepo::default(); - let main_oid = test_repo.populate()?; - // create feature branch and add 2 commits - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - let (ahead, behind) = git_repo.get_commits_ahead_behind( - &oid_to_sha1(&main_oid), - &oid_to_sha1(&ahead_2_oid), - )?; - assert_eq!( - ahead, - vec![oid_to_sha1(&ahead_2_oid), oid_to_sha1(&ahead_1_oid),], - ); - assert_eq!(behind, vec![]); - Ok(()) - } - - #[test] - fn when_2_commit_ahead_and_2_commits_behind() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - // create feature branch and add 2 commits - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; - // checkout main and add 2 commits - test_repo.checkout("main")?; - std::fs::write(test_repo.dir.join("t5.md"), "some content")?; - let behind_1_oid = test_repo.stage_and_commit("add t5.md")?; - std::fs::write(test_repo.dir.join("t6.md"), "some content")?; - let behind_2_oid = test_repo.stage_and_commit("add t6.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - let (ahead, behind) = git_repo.get_commits_ahead_behind( - &oid_to_sha1(&behind_2_oid), - &oid_to_sha1(&ahead_2_oid), - )?; - assert_eq!( - ahead, - vec![oid_to_sha1(&ahead_2_oid), oid_to_sha1(&ahead_1_oid)], - ); - assert_eq!( - behind, - vec![oid_to_sha1(&behind_2_oid), oid_to_sha1(&behind_1_oid)], - ); - Ok(()) - } - } - } - - mod create_branch_at_commit { - use super::*; - #[test] - fn doesnt_error() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - // create feature branch and add 2 commits - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - test_repo.stage_and_commit("add t4.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - let branch_name = "test-name-1"; - git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; - - Ok(()) - } - - #[test] - fn branch_gets_created() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - // create feature branch and add 2 commits - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - test_repo.stage_and_commit("add t4.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - let branch_name = "test-name-1"; - git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; - - assert!(test_repo.checkout(branch_name).is_ok()); - Ok(()) - } - - #[test] - fn branch_created_with_correct_commit() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - // create feature branch and add 2 commits - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - test_repo.stage_and_commit("add t4.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - let branch_name = "test-name-1"; - git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; - - assert_eq!(test_repo.checkout(branch_name)?, ahead_1_oid); - Ok(()) - } - - mod when_branch_already_exists { - use super::*; - - #[test] - fn when_new_tip_specified_it_is_updated() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - // create feature branch and add 2 commits - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - let branch_name = "test-name-1"; - git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; - - git_repo.create_branch_at_commit(branch_name, &ahead_2_oid.to_string())?; - assert_eq!(test_repo.checkout(branch_name)?, ahead_2_oid); - Ok(()) - } - - #[test] - fn when_same_tip_is_specified_it_doesnt_error() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - // create feature branch and add 2 commits - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - test_repo.stage_and_commit("add t4.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - let branch_name = "test-name-1"; - git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; - - git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; - assert_eq!(test_repo.checkout(branch_name)?, ahead_1_oid); - Ok(()) - } - - #[test] - fn when_branch_is_checkedout_new_tip_specified_it_is_updated() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - // create feature branch and add 2 commits - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - let branch_name = "test-name-1"; - git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; - test_repo.checkout(branch_name)?; - git_repo.create_branch_at_commit(branch_name, &ahead_2_oid.to_string())?; - test_repo.checkout("main")?; - - assert_eq!(test_repo.checkout(branch_name)?, ahead_2_oid); - Ok(()) - } - } - } - - mod create_commit_from_patch { - - use test_utils::TEST_KEY_1_SIGNER; - - use super::*; - use crate::{repo_ref::RepoRef, sub_commands::send::generate_patch_event}; - - async fn generate_patch_from_head_commit(test_repo: &GitTestRepo) -> Result { - let original_oid = test_repo.git_repo.head()?.peel_to_commit()?.id(); - let git_repo = Repo::from_path(&test_repo.dir)?; - generate_patch_event( - &git_repo, - &git_repo.get_root_commit()?, - &oid_to_sha1(&original_oid), - Some(nostr::EventId::all_zeros()), - &TEST_KEY_1_SIGNER, - &RepoRef::try_from(generate_repo_ref_event()).unwrap(), - None, - None, - None, - &None, - &[], - ) - .await - } - fn test_patch_applies_to_repository(patch_event: nostr::Event) -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - println!("{:?}", &patch_event); - git_repo.create_commit_from_patch(&patch_event)?; - let commit_id = tag_value(&patch_event, "commit")?; - // does commit with id exist? - assert!(git_repo.does_commit_exist(&commit_id)?); - Ok(()) - } - - mod patch_created_as_commit_with_matching_id { - use test_utils::git::joe_signature; - - use super::*; - - #[tokio::test] - async fn simple_signature_author_committer_same_as_git_user_0_unixtime_no_pgp_signature() - -> Result<()> { - let source_repo = GitTestRepo::default(); - source_repo.populate()?; - fs::write(source_repo.dir.join("x1.md"), "some content")?; - source_repo.stage_and_commit("add x1.md")?; - - test_patch_applies_to_repository( - generate_patch_from_head_commit(&source_repo).await?, - ) - } - - #[tokio::test] - async fn signature_with_specific_author_time() -> Result<()> { - let source_repo = GitTestRepo::default(); - source_repo.populate()?; - fs::write(source_repo.dir.join("x1.md"), "some content")?; - source_repo.stage_and_commit_custom_signature( - "add x1.md", - Some(&git2::Signature::new( - joe_signature().name().unwrap(), - joe_signature().email().unwrap(), - &git2::Time::new(5000, 0), - )?), - None, - )?; - - test_patch_applies_to_repository( - generate_patch_from_head_commit(&source_repo).await?, - ) - } - - #[tokio::test] - async fn author_name_and_email_not_current_git_user() -> Result<()> { - let source_repo = GitTestRepo::default(); - source_repo.populate()?; - fs::write(source_repo.dir.join("x1.md"), "some content")?; - source_repo.stage_and_commit_custom_signature( - "add x1.md", - Some(&git2::Signature::new( - "carole", - "carole@pm.me", - &git2::Time::new(0, 0), - )?), - None, - )?; - - test_patch_applies_to_repository( - generate_patch_from_head_commit(&source_repo).await?, - ) - } - - #[tokio::test] - async fn comiiter_name_and_email_not_current_git_user_or_author() -> Result<()> { - let source_repo = GitTestRepo::default(); - source_repo.populate()?; - fs::write(source_repo.dir.join("x1.md"), "some content")?; - source_repo.stage_and_commit_custom_signature( - "add x1.md", - Some(&git2::Signature::new( - "carole", - "carole@pm.me", - &git2::Time::new(0, 0), - )?), - Some(&git2::Signature::new( - "bob", - "bob@pm.me", - &git2::Time::new(0, 0), - )?), - )?; - - test_patch_applies_to_repository( - generate_patch_from_head_commit(&source_repo).await?, - ) - } - - // TODO: pgp signature - - #[tokio::test] - async fn unique_author_and_commiter_details() -> Result<()> { - let source_repo = GitTestRepo::default(); - source_repo.populate()?; - fs::write(source_repo.dir.join("x1.md"), "some content")?; - source_repo.stage_and_commit_custom_signature( - "add x1.md", - Some(&git2::Signature::new( - "carole", - "carole@pm.me", - &git2::Time::new(5000, 0), - )?), - Some(&git2::Signature::new( - "bob", - "bob@pm.me", - &git2::Time::new(1000, 0), - )?), - )?; - - test_patch_applies_to_repository( - generate_patch_from_head_commit(&source_repo).await?, - ) - } - } - } - - mod apply_patch_chain { - use test_utils::TEST_KEY_1_SIGNER; - - use super::*; - use crate::{ - repo_ref::RepoRef, sub_commands::send::generate_cover_letter_and_patch_events, - }; - - static BRANCH_NAME: &str = "add-example-feature"; - // returns original_repo, cover_letter_event, patch_events - async fn generate_test_repo_and_events() - -> Result<(GitTestRepo, nostr::Event, Vec)> { - let original_repo = GitTestRepo::default(); - let oid3 = original_repo.populate_with_test_branch()?; - let oid2 = original_repo.git_repo.find_commit(oid3)?.parent_id(0)?; - let oid1 = original_repo.git_repo.find_commit(oid2)?.parent_id(0)?; - // TODO: generate cover_letter and patch events - let git_repo = Repo::from_path(&original_repo.dir)?; - - let mut events = generate_cover_letter_and_patch_events( - Some(("test".to_string(), "test".to_string())), - &git_repo, - &[oid_to_sha1(&oid1), oid_to_sha1(&oid2), oid_to_sha1(&oid3)], - &TEST_KEY_1_SIGNER, - &RepoRef::try_from(generate_repo_ref_event()).unwrap(), - &None, - &[], - ) - .await?; - - events.reverse(); - - Ok((original_repo, events.pop().unwrap(), events)) - } - - mod when_branch_and_commits_dont_exist { - use super::*; - - mod when_branch_root_is_tip_of_main { - use super::*; - - #[tokio::test] - async fn branch_gets_created_with_name_specified_in_proposal() -> Result<()> { - let (_, _, patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert!( - git_repo - .get_local_branch_names()? - .contains(&BRANCH_NAME.to_string()) - ); - Ok(()) - } - - #[tokio::test] - async fn branch_checked_out() -> Result<()> { - let (_, _, patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert_eq!( - git_repo.get_checked_out_branch_name()?, - BRANCH_NAME.to_string(), - ); - Ok(()) - } - - #[tokio::test] - async fn patches_get_created_as_commits() -> Result<()> { - let (original_repo, _, patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert_eq!( - test_repo.git_repo.head()?.peel_to_commit()?.id(), - original_repo.git_repo.head()?.peel_to_commit()?.id(), - ); - Ok(()) - } - - #[tokio::test] - async fn branch_tip_is_most_recent_patch() -> Result<()> { - let (original_repo, _, patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert_eq!( - git_repo.get_tip_of_branch(BRANCH_NAME)?, - oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),), - ); - Ok(()) - } - - #[tokio::test] - async fn previously_checked_out_branch_tip_does_not_change() -> Result<()> { - let (_, _, patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let existing_branch = test_repo.get_checked_out_branch_name()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - let previous_tip_of_existing_branch = - git_repo.get_tip_of_branch(existing_branch.as_str())?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert_eq!( - previous_tip_of_existing_branch, - git_repo.get_tip_of_branch(existing_branch.as_str())?, - ); - Ok(()) - } - - #[tokio::test] - async fn returns_all_patches_applied() -> Result<()> { - let (_, _, patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert_eq!(res.len(), 3); - Ok(()) - } - } - - mod when_branch_root_is_tip_behind_main { - use super::*; - - #[tokio::test] - async fn branch_gets_created_with_name_specified_in_proposal() -> Result<()> { - let (_, _, patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - std::fs::write(test_repo.dir.join("m3.md"), "some content")?; - test_repo.stage_and_commit("add m3.md")?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert!( - git_repo - .get_local_branch_names()? - .contains(&BRANCH_NAME.to_string()) - ); - Ok(()) - } - - #[tokio::test] - async fn branch_checked_out() -> Result<()> { - let (_, _, patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - std::fs::write(test_repo.dir.join("m3.md"), "some content")?; - test_repo.stage_and_commit("add m3.md")?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert_eq!( - git_repo.get_checked_out_branch_name()?, - BRANCH_NAME.to_string(), - ); - Ok(()) - } - - #[tokio::test] - async fn branch_tip_is_most_recent_patch() -> Result<()> { - let (original_repo, _, patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - std::fs::write(test_repo.dir.join("m3.md"), "some content")?; - test_repo.stage_and_commit("add m3.md")?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert_eq!( - git_repo.get_tip_of_branch(BRANCH_NAME)?, - oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),), - ); - Ok(()) - } - - #[tokio::test] - async fn previously_checked_out_branch_tip_does_not_change() -> Result<()> { - let (_, _, patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - std::fs::write(test_repo.dir.join("m3.md"), "some content")?; - test_repo.stage_and_commit("add m3.md")?; - let existing_branch = test_repo.get_checked_out_branch_name()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - let previous_tip_of_existing_branch = - git_repo.get_tip_of_branch(existing_branch.as_str())?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert_eq!( - previous_tip_of_existing_branch, - git_repo.get_tip_of_branch(existing_branch.as_str())?, - ); - Ok(()) - } - - #[tokio::test] - async fn returns_all_patches_applied() -> Result<()> { - let (_, _, patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert_eq!(res.len(), 3); - Ok(()) - } - } - - // TODO when_proposal_root_is_tip_ahead_of_main_and_doesnt_exist - } - - mod when_branch_and_first_commits_exists { - use super::*; - - mod when_branch_already_checked_out { - use super::*; - - #[tokio::test] - async fn branch_tip_is_most_recent_patch() -> Result<()> { - let (original_repo, _, mut patch_events) = - generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - - assert_eq!( - git_repo.get_tip_of_branch(BRANCH_NAME)?, - oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),), - ); - Ok(()) - } - - #[tokio::test] - async fn returns_all_patches_applied() -> Result<()> { - let (_, _, mut patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; - let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert_eq!(res.len(), 2); - Ok(()) - } - } - mod when_branch_not_checked_out { - use super::*; - - #[tokio::test] - async fn branch_tip_is_most_recent_patch() -> Result<()> { - let (original_repo, _, mut patch_events) = - generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; - git_repo.checkout("main")?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - - assert_eq!( - git_repo.get_tip_of_branch(BRANCH_NAME)?, - oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),), - ); - Ok(()) - } - - #[tokio::test] - async fn branch_checked_out() -> Result<()> { - let (_, _, mut patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; - git_repo.checkout("main")?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - - assert_eq!( - git_repo.get_checked_out_branch_name()?, - BRANCH_NAME.to_string(), - ); - Ok(()) - } - - #[tokio::test] - async fn returns_all_patches_applied() -> Result<()> { - let (_, _, mut patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; - git_repo.checkout("main")?; - let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert_eq!(res.len(), 2); - Ok(()) - } - } - // TODO when branch ahead (rebased or user commits) - } - mod when_branch_exists_and_is_up_to_date { - use super::*; - - mod when_branch_already_checked_out { - use super::*; - - #[tokio::test] - async fn returns_all_patches_applied_0() -> Result<()> { - let (_, _, patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events.clone())?; - let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert_eq!(res.len(), 0); - Ok(()) - } - } - mod when_branch_not_checked_out { - use super::*; - - #[tokio::test] - async fn branch_checked_out() -> Result<()> { - let (_, _, patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events.clone())?; - git_repo.checkout("main")?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - - assert_eq!( - git_repo.get_checked_out_branch_name()?, - BRANCH_NAME.to_string(), - ); - Ok(()) - } - - #[tokio::test] - async fn returns_all_patches_applied_0() -> Result<()> { - let (_, _, patch_events) = generate_test_repo_and_events().await?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let git_repo = Repo::from_path(&test_repo.dir)?; - git_repo.apply_patch_chain(BRANCH_NAME, patch_events.clone())?; - git_repo.checkout("main")?; - let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; - assert_eq!(res.len(), 0); - Ok(()) - } - } - } - } - mod parse_starting_commits { - use super::*; - - mod head_1_returns_latest_commit { - use super::*; - - #[test] - fn when_on_main_and_other_commits_are_more_recent_on_feature_branch() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - test_repo.populate_with_test_branch()?; - test_repo.checkout("main")?; - - assert_eq!( - git_repo.parse_starting_commits("HEAD~1")?, - vec![str_to_sha1("431b84edc0d2fa118d63faa3c2db9c73d630a5ae")?], - ); - Ok(()) - } - - #[test] - fn when_checked_out_branch_ahead_of_main() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - test_repo.populate_with_test_branch()?; - - assert_eq!( - git_repo.parse_starting_commits("HEAD~1")?, - vec![str_to_sha1("82ff2bcc9aa94d1bd8faee723d4c8cc190d6061c")?], - ); - Ok(()) - } - } - mod head_2_returns_latest_2_commits_youngest_first { - use super::*; - - #[test] - fn when_on_main_and_other_commits_are_more_recent_on_feature_branch() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - test_repo.populate_with_test_branch()?; - test_repo.checkout("main")?; - - assert_eq!( - git_repo.parse_starting_commits("HEAD~2")?, - vec![ - str_to_sha1("431b84edc0d2fa118d63faa3c2db9c73d630a5ae")?, - str_to_sha1("af474d8d271490e5c635aad337abdc050034b16a")?, - ], - ); - Ok(()) - } - } - mod head_3_returns_latest_3_commits_youngest_first { - use super::*; - - #[test] - fn when_checked_out_branch_ahead_of_main() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - test_repo.populate_with_test_branch()?; - - assert_eq!( - git_repo.parse_starting_commits("HEAD~3")?, - vec![ - str_to_sha1("82ff2bcc9aa94d1bd8faee723d4c8cc190d6061c")?, - str_to_sha1("a23e6b05aaeb7d1471b4a838b51f337d5644eeb0")?, - str_to_sha1("7ab82116068982671a8111f27dc10599172334b2")?, - ], - ); - Ok(()) - } - } - mod range_of_3_commits_not_in_branch_history_returns_3_commits_youngest_first { - use super::*; - - #[test] - fn when_checked_out_branch_ahead_of_main() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - test_repo.populate_with_test_branch()?; - test_repo.checkout("main")?; - - assert_eq!( - git_repo.parse_starting_commits("af474d8..a23e6b0")?, - vec![ - str_to_sha1("a23e6b05aaeb7d1471b4a838b51f337d5644eeb0")?, - str_to_sha1("7ab82116068982671a8111f27dc10599172334b2")?, - str_to_sha1("431b84edc0d2fa118d63faa3c2db9c73d630a5ae")?, - ], - ); - Ok(()) - } - } - } - mod ancestor_of { - use super::*; - - #[test] - fn deep_ancestor_returns_true() -> Result<()> { - let test_repo = GitTestRepo::default(); - let from_main_in_feature_history = test_repo.populate()?; - - // create feature branch and add 2 commits - test_repo.create_branch("feature")?; - - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - assert!(git_repo.ancestor_of( - &oid_to_sha1(&ahead_2_oid), - &oid_to_sha1(&from_main_in_feature_history) - )?); - Ok(()) - } - - #[test] - fn commit_parent_returns_true() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - - // create feature branch and add 2 commits - test_repo.create_branch("feature")?; - - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - assert!(git_repo.ancestor_of(&oid_to_sha1(&ahead_2_oid), &oid_to_sha1(&ahead_1_oid))?); - Ok(()) - } - - #[test] - fn same_commit_returns_false() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - - // create feature branch and add 2 commits - test_repo.create_branch("feature")?; - - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - assert!(!git_repo.ancestor_of(&oid_to_sha1(&ahead_2_oid), &oid_to_sha1(&ahead_2_oid))?); - Ok(()) - } - - #[test] - fn commit_not_in_history_returns_false() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - - // create feature branch and add 2 commits - test_repo.create_branch("feature")?; - - // create commit not in feature history - std::fs::write(test_repo.dir.join("notfeature.md"), "some content")?; - let on_main_after_feature = test_repo.stage_and_commit("add notfeature.md")?; - - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; - - let git_repo = Repo::from_path(&test_repo.dir)?; - - assert!(!git_repo.ancestor_of( - &oid_to_sha1(&ahead_2_oid), - &oid_to_sha1(&on_main_after_feature) - )?); - Ok(()) - } - } - mod convert_clone_url_to_https { - use super::*; - - #[test] - fn test_https_url() { - let url = "https://github.com/user/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://github.com/user/repo.git"); - } - - #[test] - fn test_http_url() { - let url = "http://github.com/user/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://github.com/user/repo.git"); - } - - #[test] - fn test_http_url_with_credentials() { - let url = "http://username:password@github.com/user/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://github.com/user/repo.git"); - } - - #[test] - fn test_git_at_url() { - let url = "git@github.com:user/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://github.com/user/repo.git"); - } - - #[test] - fn test_user_at_url() { - let url = "user1@github.com:user/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://github.com/user/repo.git"); - } - - #[test] - fn test_ssh_url() { - let url = "ssh://github.com/user/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://github.com/user/repo.git"); - } - - #[test] - fn test_ftp_url() { - let url = "ftp://example.com/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://example.com/repo.git"); - } - - #[test] - fn test_git_protocol_url() { - let url = "git://example.com/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://example.com/repo.git"); - } - - #[test] - fn test_invalid_url() { - let url = "unsupported://example.com/repo.git"; - let result = convert_clone_url_to_https(url); - assert!(result.is_err()); - } - } -} diff --git a/src/git_remote_helper.rs b/src/git_remote_helper.rs deleted file mode 100644 index a5244bf..0000000 --- a/src/git_remote_helper.rs +++ /dev/null @@ -1,1897 +0,0 @@ -#![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/key_handling/encryption.rs b/src/key_handling/encryption.rs deleted file mode 100644 index 3841d50..0000000 --- a/src/key_handling/encryption.rs +++ /dev/null @@ -1,105 +0,0 @@ -use anyhow::Result; -use nostr::{prelude::*, Keys}; - -pub fn encrypt_key(keys: &Keys, password: &str) -> Result { - let log2_rounds: u8 = if password.len() > 20 { - // we have enough of entropy - no need to spend CPU time adding much more - 1 - } else { - println!("this may take a few seconds..."); - // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait - 15 - }; - Ok(nostr::nips::nip49::EncryptedSecretKey::new( - keys.secret_key()?, - password, - log2_rounds, - KeySecurity::Medium, - )? - .to_bech32()?) -} - -pub fn decrypt_key(encrypted_key: &str, password: &str) -> Result { - let encrypted_key = nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted_key)?; - // to request that log_n gets exposed - if encrypted_key.log_n() > 14 { - println!("this may take a few seconds..."); - } - Ok(nostr::Keys::new(encrypted_key.to_secret_key(password)?)) -} - -#[cfg(test)] -mod tests { - use test_utils::*; - - use super::*; - - #[test] - fn encrypt_key_produces_string_prefixed_with() -> Result<()> { - let s = encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?; - assert!(s.starts_with("ncryptsec")); - Ok(()) - } - - #[test] - // ensures password encryption hasn't changed - fn decrypts_with_strong_password_from_reference_string() -> Result<()> { - let decrypted_key = decrypt_key(TEST_KEY_1_ENCRYPTED, TEST_PASSWORD)?; - - assert_eq!( - format!( - "{}", - TEST_KEY_1_KEYS.secret_key().unwrap().to_bech32().unwrap() - ), - format!( - "{}", - decrypted_key.secret_key().unwrap().to_bech32().unwrap() - ), - ); - Ok(()) - } - - #[test] - // ensures password encryption hasn't changed - fn decrypts_with_weak_password_from_reference_string() -> Result<()> { - let decrypted_key = decrypt_key(TEST_KEY_1_ENCRYPTED_WEAK, TEST_WEAK_PASSWORD)?; - - assert_eq!( - format!( - "{}", - TEST_KEY_1_KEYS.secret_key().unwrap().to_bech32().unwrap() - ), - format!( - "{}", - decrypted_key.secret_key().unwrap().to_bech32().unwrap() - ), - ); - Ok(()) - } - - #[test] - fn decrypts_key_encrypted_using_encrypt_key() -> Result<()> { - let key = nostr::Keys::generate(); - let s = encrypt_key(&key, TEST_PASSWORD)?; - let newkey = decrypt_key(s.as_str(), TEST_PASSWORD)?; - - assert_eq!( - format!("{}", key.secret_key().unwrap().to_bech32().unwrap()), - format!("{}", newkey.secret_key().unwrap().to_bech32().unwrap()), - ); - Ok(()) - } - - #[test] - fn decrypt_key_successfully_decrypts_key_encrypted_using_encrypt_key() -> Result<()> { - let key = nostr::Keys::generate(); - let s = encrypt_key(&key, TEST_PASSWORD)?; - let newkey = decrypt_key(s.as_str(), TEST_PASSWORD)?; - - assert_eq!( - format!("{}", key.secret_key().unwrap().to_bech32().unwrap()), - format!("{}", newkey.secret_key().unwrap().to_bech32().unwrap()), - ); - Ok(()) - } -} diff --git a/src/key_handling/mod.rs b/src/key_handling/mod.rs deleted file mode 100644 index 81c4253..0000000 --- a/src/key_handling/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod encryption; diff --git a/src/lib/cli_interactor.rs b/src/lib/cli_interactor.rs new file mode 100644 index 0000000..4cf6357 --- /dev/null +++ b/src/lib/cli_interactor.rs @@ -0,0 +1,186 @@ +use anyhow::{Context, Result}; +use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password}; +#[cfg(test)] +use mockall::*; + +#[derive(Default)] +pub struct Interactor { + theme: ColorfulTheme, +} + +#[cfg_attr(test, automock)] +pub trait InteractorPrompt { + fn input(&self, parms: PromptInputParms) -> Result; + fn password(&self, parms: PromptPasswordParms) -> Result; + fn confirm(&self, params: PromptConfirmParms) -> Result; + fn choice(&self, params: PromptChoiceParms) -> Result; + fn multi_choice(&self, params: PromptMultiChoiceParms) -> Result>; +} +impl InteractorPrompt for Interactor { + fn input(&self, parms: PromptInputParms) -> Result { + let mut input = Input::with_theme(&self.theme); + input.with_prompt(parms.prompt).allow_empty(parms.optional); + if !parms.default.is_empty() { + input.default(parms.default); + } + Ok(input.interact_text()?) + } + fn password(&self, parms: PromptPasswordParms) -> Result { + let mut p = Password::with_theme(&self.theme); + p.with_prompt(parms.prompt); + if parms.confirm { + p.with_confirmation("confirm password", "passwords didnt match..."); + } + let pass: String = p.interact()?; + Ok(pass) + } + fn confirm(&self, params: PromptConfirmParms) -> Result { + let confirm: bool = Confirm::with_theme(&self.theme) + .with_prompt(params.prompt) + .default(params.default) + .interact()?; + Ok(confirm) + } + fn choice(&self, parms: PromptChoiceParms) -> Result { + let mut choice = dialoguer::Select::with_theme(&self.theme); + choice + .with_prompt(parms.prompt) + .report(parms.report) + .items(&parms.choices); + if let Some(default) = parms.default { + if std::env::var("NGITTEST").is_err() { + choice.default(default); + } + } + choice.interact().context("failed to get choice") + } + fn multi_choice(&self, parms: PromptMultiChoiceParms) -> Result> { + // the colorful theme is not very clear so falling back to default + let mut choice = dialoguer::MultiSelect::default(); + choice + .with_prompt(parms.prompt) + .report(parms.report) + .items(&parms.choices); + if let Some(defaults) = parms.defaults { + choice.defaults(&defaults); + } + choice.interact().context("failed to get choice") + } +} + +#[derive(Default)] +pub struct PromptInputParms { + pub prompt: String, + pub default: String, + pub optional: bool, +} + +impl PromptInputParms { + pub fn with_prompt>(mut self, prompt: S) -> Self { + self.prompt = prompt.into(); + self + } + pub fn with_default>(mut self, default: S) -> Self { + self.default = default.into(); + self + } + pub fn optional(mut self) -> Self { + self.optional = true; + self + } +} + +#[derive(Default)] +pub struct PromptPasswordParms { + pub prompt: String, + pub confirm: bool, +} + +impl PromptPasswordParms { + pub fn with_prompt>(mut self, prompt: S) -> Self { + self.prompt = prompt.into(); + self + } + pub const fn with_confirm(mut self) -> Self { + self.confirm = true; + self + } +} + +#[derive(Default)] +pub struct PromptConfirmParms { + pub prompt: String, + pub default: bool, +} + +impl PromptConfirmParms { + pub fn with_prompt>(mut self, prompt: S) -> Self { + self.prompt = prompt.into(); + self + } + pub fn with_default(mut self, default: bool) -> Self { + self.default = default; + self + } +} + +#[derive(Default)] +pub struct PromptChoiceParms { + pub prompt: String, + pub choices: Vec, + pub default: Option, + pub report: bool, +} + +impl PromptChoiceParms { + pub fn with_prompt>(mut self, prompt: S) -> Self { + self.prompt = prompt.into(); + self.report = true; + self + } + + // pub fn dont_report(mut self) -> Self { + // self.report = false; + // self + // } + pub fn with_choices(mut self, choices: Vec) -> Self { + self.choices = choices; + self + } + + pub fn with_default(mut self, index: usize) -> Self { + self.default = Some(index); + self + } +} + +#[derive(Default)] +pub struct PromptMultiChoiceParms { + pub prompt: String, + pub choices: Vec, + pub defaults: Option>, + pub report: bool, +} + +impl PromptMultiChoiceParms { + pub fn with_prompt>(mut self, prompt: S) -> Self { + self.prompt = prompt.into(); + self.report = true; + self + } + + pub fn dont_report(mut self) -> Self { + self.report = false; + self + } + + pub fn with_choices(mut self, choices: Vec) -> Self { + self.choices = choices; + self + } + + pub fn with_defaults(mut self, defaults: Vec) -> Self { + self.defaults = Some(defaults); + self + } +} diff --git a/src/lib/client.rs b/src/lib/client.rs new file mode 100644 index 0000000..abde217 --- /dev/null +++ b/src/lib/client.rs @@ -0,0 +1,1480 @@ +// have you considered + +// TO USE ASYNC + +// in traits (required for mocking unit tests) +// https://rust-lang.github.io/async-book/07_workarounds/05_async_in_traits.html +// https://github.com/dtolnay/async-trait +// see https://blog.rust-lang.org/inside-rust/2022/11/17/async-fn-in-trait-nightly.html +// I think we can use the async-trait crate and switch to the native feature +// which is currently in nightly. alternatively we can use nightly as it looks +// certain that the implementation is going to make it to stable but we don't +// want to inadvertlty use other features of nightly that might be removed. +use std::{ + collections::{HashMap, HashSet}, + fmt::{Display, Write}, + fs::create_dir_all, + path::Path, + time::Duration, +}; + +use anyhow::{bail, Context, Result}; +use async_trait::async_trait; +use console::Style; +use futures::stream::{self, StreamExt}; +use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle}; +#[cfg(test)] +use mockall::*; +use nostr::{nips::nip01::Coordinate, Event}; +use nostr_database::{NostrDatabase, Order}; +use nostr_sdk::{ + prelude::RelayLimits, EventBuilder, EventId, Kind, NostrSigner, Options, PublicKey, + SingleLetterTag, Timestamp, Url, +}; +use nostr_sqlite::SQLiteDatabase; + +use crate::{ + config::get_dirs, + login::{get_logged_in_user, get_user_ref_from_cache}, + repo_ref::RepoRef, + repo_state::RepoState, + sub_commands::{ + list::status_kinds, + send::{event_is_patch_set_root, event_is_revision_root}, + }, +}; + +#[allow(clippy::struct_field_names)] +pub struct Client { + client: nostr_sdk::Client, + fallback_relays: Vec, + more_fallback_relays: Vec, + blaster_relays: Vec, +} + +#[cfg_attr(test, automock)] +#[async_trait] +pub trait Connect { + fn default() -> Self; + fn new(opts: Params) -> Self; + async fn set_signer(&mut self, signer: NostrSigner); + async fn connect(&self, relay_url: &Url) -> Result<()>; + async fn disconnect(&self) -> Result<()>; + fn get_fallback_relays(&self) -> &Vec; + fn get_more_fallback_relays(&self) -> &Vec; + fn get_blaster_relays(&self) -> &Vec; + async fn send_event_to( + &self, + git_repo_path: &Path, + url: &str, + event: nostr::event::Event, + ) -> Result; + async fn get_events( + &self, + relays: Vec, + filters: Vec, + ) -> Result>; + async fn get_events_per_relay( + &self, + relays: Vec, + filters: Vec, + progress_reporter: MultiProgress, + ) -> Result<(Vec>>, MultiProgress)>; + async fn fetch_all( + &self, + git_repo_path: &Path, + repo_coordinates: &HashSet, + user_profiles: &HashSet, + ) -> Result<(Vec>, MultiProgress)>; + async fn fetch_all_from_relay( + &self, + git_repo_path: &Path, + request: FetchRequest, + pb: &Option, + ) -> Result; +} + +#[async_trait] +impl Connect for Client { + fn default() -> Self { + let fallback_relays: Vec = if std::env::var("NGITTEST").is_ok() { + vec![ + "ws://localhost:8051".to_string(), + "ws://localhost:8052".to_string(), + ] + } else { + vec![ + "wss://relay.damus.io".to_string(), /* free, good reliability, have been known + * to delete all messages */ + "wss://nos.lol".to_string(), + "wss://relay.nostr.band".to_string(), + ] + }; + + let more_fallback_relays: Vec = if std::env::var("NGITTEST").is_ok() { + vec![ + "ws://localhost:8055".to_string(), + "ws://localhost:8056".to_string(), + ] + } else { + vec![ + "wss://purplerelay.com".to_string(), // free but reliability not tested + "wss://purplepages.es".to_string(), // for profile events but unreliable + "wss://relayable.org".to_string(), // free but not always reliable + ] + }; + + let blaster_relays: Vec = if std::env::var("NGITTEST").is_ok() { + vec!["ws://localhost:8057".to_string()] + } else { + vec!["wss://nostr.mutinywallet.com".to_string()] + }; + Client { + client: nostr_sdk::ClientBuilder::new() + .opts(Options::new().relay_limits(RelayLimits::disable())) + .build(), + fallback_relays, + more_fallback_relays, + blaster_relays, + } + } + fn new(opts: Params) -> Self { + Client { + client: nostr_sdk::ClientBuilder::new() + .opts(Options::new().relay_limits(RelayLimits::disable())) + .signer(&opts.keys.unwrap_or(nostr::Keys::generate())) + // .database( + // SQLiteDatabase::open(get_dirs()?.cache_dir().join("nostr-cache.sqlite")). + // await?, ) + .build(), + fallback_relays: opts.fallback_relays, + more_fallback_relays: opts.more_fallback_relays, + blaster_relays: opts.blaster_relays, + } + } + + async fn set_signer(&mut self, signer: NostrSigner) { + self.client.set_signer(Some(signer)).await; + } + + async fn connect(&self, relay_url: &Url) -> Result<()> { + self.client + .add_relay(relay_url) + .await + .context("cannot add relay")?; + + let relay = self.client.relay(relay_url).await?; + + if !relay.is_connected().await { + #[allow(clippy::large_futures)] + relay + .connect(Some(std::time::Duration::from_secs(CONNECTION_TIMEOUT))) + .await; + } + + if !relay.is_connected().await { + bail!("connection timeout"); + } + Ok(()) + } + + async fn disconnect(&self) -> Result<()> { + self.client.disconnect().await?; + Ok(()) + } + + fn get_fallback_relays(&self) -> &Vec { + &self.fallback_relays + } + + fn get_more_fallback_relays(&self) -> &Vec { + &self.more_fallback_relays + } + + fn get_blaster_relays(&self) -> &Vec { + &self.blaster_relays + } + + async fn send_event_to( + &self, + git_repo_path: &Path, + url: &str, + event: Event, + ) -> Result { + self.client.add_relay(url).await?; + #[allow(clippy::large_futures)] + self.client.connect_relay(url).await?; + let res = self.client.send_event_to(vec![url], event.clone()).await?; + if let Some(err) = res.failed.get(&Url::parse(url)?) { + bail!(if let Some(err) = err { + err.to_string() + } else { + "error: unknown".to_string() + }); + } + save_event_in_cache(git_repo_path, &event).await?; + if event.kind().eq(&Kind::GitRepoAnnouncement) { + save_event_in_global_cache(git_repo_path, &event).await?; + } + Ok(event.id()) + } + + async fn get_events( + &self, + relays: Vec, + filters: Vec, + ) -> Result> { + let (relay_results, _) = self + .get_events_per_relay( + relays.iter().map(|r| Url::parse(r).unwrap()).collect(), + filters, + MultiProgress::new(), + ) + .await?; + Ok(get_dedup_events(relay_results)) + } + + async fn get_events_per_relay( + &self, + relays: Vec, + filters: Vec, + progress_reporter: MultiProgress, + ) -> Result<(Vec>>, MultiProgress)> { + // add relays + for relay in &relays { + self.client + .add_relay(relay.as_str()) + .await + .context("cannot add relay")?; + } + + let relays_map = self.client.relays().await; + + let futures: Vec<_> = relays + .clone() + .iter() + // don't look for events on blaster + .filter(|r| !r.as_str().contains("nostr.mutinywallet.com")) + .map(|r| (relays_map.get(r).unwrap(), filters.clone())) + .map(|(relay, filters)| async { + let pb = if std::env::var("NGITTEST").is_err() { + let pb = progress_reporter.add( + ProgressBar::new(1) + .with_prefix(format!("{: <11}{}", "connecting", relay.url())) + .with_style(pb_style()?), + ); + pb.enable_steady_tick(Duration::from_millis(300)); + Some(pb) + } else { + None + }; + #[allow(clippy::large_futures)] + match get_events_of(relay, filters, &pb).await { + Err(error) => { + if let Some(pb) = pb { + pb.set_style(pb_after_style(false)); + pb.set_prefix(format!("{: <11}{}", "error", relay.url())); + pb.finish_with_message( + console::style( + error.to_string().replace("relay pool error:", "error:"), + ) + .for_stderr() + .red() + .to_string(), + ); + } + Err(error) + } + Ok(res) => { + if let Some(pb) = pb { + pb.set_style(pb_after_style(true)); + pb.set_prefix(format!( + "{: <11}{}", + format!("{} events", res.len()), + relay.url() + )); + pb.finish_with_message(""); + } + Ok(res) + } + } + }) + .collect(); + + let relay_results: Vec>> = + stream::iter(futures).buffer_unordered(15).collect().await; + + Ok((relay_results, progress_reporter)) + } + + #[allow(clippy::too_many_lines)] + async fn fetch_all( + &self, + git_repo_path: &Path, + repo_coordinates: &HashSet, + user_profiles: &HashSet, + ) -> Result<(Vec>, MultiProgress)> { + let fallback_relays = &self + .fallback_relays + .iter() + .filter_map(|r| Url::parse(r).ok()) + .collect::>(); + + let mut request = create_relays_request( + git_repo_path, + repo_coordinates, + user_profiles, + fallback_relays.clone(), + ) + .await?; + + let progress_reporter = MultiProgress::new(); + + let mut processed_relays = HashSet::new(); + + let mut relay_reports: Vec> = vec![]; + + loop { + let relays = request + .repo_relays + .union(&request.user_relays_for_profiles) + // don't look for events on blaster + .filter(|&r| !r.as_str().contains("nostr.mutinywallet.com")) + .cloned() + .collect::>() + .difference(&processed_relays) + .cloned() + .collect::>(); + if relays.is_empty() { + break; + } + let profile_relays_only = request + .user_relays_for_profiles + .difference(&request.repo_relays) + .collect::>(); + for relay in &request.repo_relays { + self.client + .add_relay(relay.as_str()) + .await + .context("cannot add relay")?; + } + + let dim = Style::new().color256(247); + + let futures: Vec<_> = relays + .iter() + .map(|r| { + if profile_relays_only.contains(r) { + // if relay isn't a repo relay, just filter for user profile + FetchRequest { + selected_relay: Some(r.to_owned()), + repo_coordinates_without_relays: vec![], + proposals: HashSet::new(), + missing_contributor_profiles: request + .missing_contributor_profiles + .union( + &request + .profiles_to_fetch_from_user_relays + .clone() + .into_keys() + .collect(), + ) + .copied() + .collect(), + ..request.clone() + } + } else { + FetchRequest { + selected_relay: Some(r.to_owned()), + ..request.clone() + } + } + }) + .map(|request| async { + let relay_column_width = request.relay_column_width; + + let relay_url = request + .selected_relay + .clone() + .context("fetch_all_from_relay called without a relay")?; + + let pb = if std::env::var("NGITTEST").is_err() { + let pb = progress_reporter.add( + ProgressBar::new(1) + .with_prefix( + dim.apply_to(format!( + "{: { + if let Some(pb) = pb { + pb.set_style(pb_after_style(false)); + pb.set_prefix( + dim.apply_to(format!("{: Ok(res), + } + }) + .collect(); + + for report in stream::iter(futures) + .buffer_unordered(15) + .collect::>>() + .await + { + relay_reports.push(report); + } + processed_relays.extend(relays.clone()); + + if let Ok(repo_ref) = get_repo_ref_from_cache(git_repo_path, repo_coordinates).await { + request.repo_relays = repo_ref + .relays + .iter() + .filter_map(|r| Url::parse(r).ok()) + .collect(); + } + + request.user_relays_for_profiles = { + let mut set = HashSet::new(); + for user in &request + .profiles_to_fetch_from_user_relays + .clone() + .into_keys() + .collect::>() + { + if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, user).await { + for r in user_ref.relays.write() { + if let Ok(url) = Url::parse(&r) { + set.insert(url); + } + } + } + } + set + }; + } + Ok((relay_reports, progress_reporter)) + } + + async fn fetch_all_from_relay( + &self, + git_repo_path: &Path, + request: FetchRequest, + pb: &Option, + ) -> Result { + let mut fresh_coordinates: HashSet = HashSet::new(); + for (c, _) in request.repo_coordinates_without_relays.clone() { + fresh_coordinates.insert(c); + } + let mut fresh_proposal_roots = request.proposals.clone(); + let mut fresh_profiles: HashSet = request + .missing_contributor_profiles + .union( + &request + .profiles_to_fetch_from_user_relays + .clone() + .into_keys() + .collect(), + ) + .copied() + .collect(); + + let mut report = FetchReport::default(); + + let relay_url = request + .selected_relay + .clone() + .context("fetch_all_from_relay called without a relay")?; + + let relay_column_width = request.relay_column_width; + + self.connect(&relay_url).await?; + + let dim = Style::new().color256(247); + + loop { + let filters = + get_fetch_filters(&fresh_coordinates, &fresh_proposal_roots, &fresh_profiles); + + if let Some(pb) = &pb { + pb.set_prefix( + dim.apply_to(format!( + "{: = get_events_of(&relay, filters.clone(), &None) + .await? + .iter() + // don't process events that don't match filters + .filter(|e| filters.iter().any(|f| f.match_event(e))) + .cloned() + .collect(); + // TODO: try reconcile + + process_fetched_events( + events, + &request, + git_repo_path, + &mut fresh_coordinates, + &mut fresh_proposal_roots, + &mut fresh_profiles, + &mut report, + ) + .await?; + + if fresh_coordinates.is_empty() + && fresh_proposal_roots.is_empty() + && fresh_profiles.is_empty() + { + break; + } + } + if let Some(pb) = pb { + pb.set_style(pb_after_style(true)); + pb.set_prefix( + dim.apply_to(format!( + "{: , + pb: &Option, +) -> Result> { + // relay.reconcile(filter, opts).await?; + + if !relay.is_connected().await { + #[allow(clippy::large_futures)] + relay + .connect(Some(std::time::Duration::from_secs(CONNECTION_TIMEOUT))) + .await; + } + + if !relay.is_connected().await { + bail!("connection timeout"); + } else if let Some(pb) = pb { + pb.set_prefix(format!("connected {}", relay.url())); + } + let events = relay + .get_events_of( + filters, + // 20 is nostr_sdk default + std::time::Duration::from_secs(GET_EVENTS_TIMEOUT), + nostr_sdk::FilterOptions::ExitOnEOSE, + ) + .await?; + Ok(events) +} + +#[derive(Default)] +pub struct Params { + pub keys: Option, + pub fallback_relays: Vec, + pub more_fallback_relays: Vec, + pub blaster_relays: Vec, +} + +fn get_dedup_events(relay_results: Vec>>) -> Vec { + let mut dedup_events: Vec = vec![]; + for events in relay_results.into_iter().flatten() { + for event in events { + if !dedup_events.iter().any(|e| event.id.eq(&e.id)) { + dedup_events.push(event); + } + } + } + dedup_events +} + +pub async fn sign_event(event_builder: EventBuilder, signer: &NostrSigner) -> Result { + if signer.r#type().eq(&nostr_signer::NostrSignerType::NIP46) { + let term = console::Term::stderr(); + term.write_line("signing event with remote signer...")?; + let event = signer + .sign_event_builder(event_builder) + .await + .context("failed to sign event")?; + term.clear_last_lines(1)?; + Ok(event) + } else { + signer + .sign_event_builder(event_builder) + .await + .context("failed to sign event") + } +} + +pub async fn fetch_public_key(signer: &NostrSigner) -> Result { + let term = console::Term::stderr(); + term.write_line("fetching npub from remote signer...")?; + let public_key = signer + .public_key() + .await + .context("failed to get npub from remote signer")?; + term.clear_last_lines(1)?; + Ok(public_key) +} + +fn pb_style() -> Result { + Ok( + ProgressStyle::with_template(" {spinner} {prefix} {msg} {timeout_in}")?.with_key( + "timeout_in", + |state: &ProgressState, w: &mut dyn Write| { + if state.elapsed().as_secs() > 3 && state.elapsed().as_secs() < GET_EVENTS_TIMEOUT { + let dim = Style::new().color256(247); + write!( + w, + "{}", + dim.apply_to(format!( + "timeout in {:.1}s", + GET_EVENTS_TIMEOUT - state.elapsed().as_secs() + )) + ) + .unwrap(); + } + }, + ), + ) +} + +fn pb_after_style(succeed: bool) -> indicatif::ProgressStyle { + ProgressStyle::with_template( + format!( + " {} {}", + if succeed { + console::style("✔".to_string()) + .for_stderr() + .green() + .to_string() + } else { + console::style("✘".to_string()) + .for_stderr() + .red() + .to_string() + }, + "{prefix} {msg}", + ) + .as_str(), + ) + .unwrap() +} + +async fn get_local_cache_database(git_repo_path: &Path) -> Result { + SQLiteDatabase::open(git_repo_path.join(".git/nostr-cache.sqlite")) + .await + .context("cannot open or create nostr cache database at .git/nostr-cache.sqlite") +} + +async fn get_global_cache_database(git_repo_path: &Path) -> Result { + SQLiteDatabase::open(if std::env::var("NGITTEST").is_err() { + create_dir_all(get_dirs()?.cache_dir()).context(format!( + "cannot create cache directory in: {:?}", + get_dirs()?.cache_dir() + ))?; + get_dirs()?.cache_dir().join("nostr-cache.sqlite") + } else { + git_repo_path.join(".git/test-global-cache.sqlite") + }) + .await + .context("cannot open ngit global nostr cache database") +} + +pub async fn get_events_from_cache( + git_repo_path: &Path, + filters: Vec, +) -> Result> { + get_local_cache_database(git_repo_path) + .await? + .query(filters.clone(), Order::Asc) + .await + .context( + "cannot execute query on opened git repo nostr cache database .git/nostr-cache.sqlite", + ) +} + +pub async fn get_event_from_global_cache( + git_repo_path: &Path, + filters: Vec, +) -> Result> { + get_global_cache_database(git_repo_path) + .await? + .query(filters.clone(), Order::Asc) + .await + .context("cannot execute query on opened ngit nostr cache database") +} + +pub async fn save_event_in_cache(git_repo_path: &Path, event: &nostr::Event) -> Result { + get_local_cache_database(git_repo_path) + .await? + .save_event(event) + .await + .context("cannot save event in local cache") +} + +pub async fn save_event_in_global_cache( + git_repo_path: &Path, + event: &nostr::Event, +) -> Result { + get_global_cache_database(git_repo_path) + .await? + .save_event(event) + .await + .context("cannot save event in local cache") +} + +pub async fn get_repo_ref_from_cache( + git_repo_path: &Path, + repo_coordinates: &HashSet, +) -> Result { + let mut maintainers = HashSet::new(); + let mut new_coordinate: bool; + + for c in repo_coordinates { + maintainers.insert(c.public_key); + } + let mut repo_events = vec![]; + loop { + new_coordinate = false; + let repo_events_filter = get_filter_repo_events(repo_coordinates); + + let events = [ + get_event_from_global_cache(git_repo_path, vec![repo_events_filter.clone()]).await?, + get_events_from_cache(git_repo_path, vec![repo_events_filter]).await?, + ] + .concat(); + for e in events { + if let Ok(repo_ref) = RepoRef::try_from(e.clone()) { + for m in repo_ref.maintainers { + if maintainers.insert(m) { + new_coordinate = true; + } + } + repo_events.push(e); + } + } + if !new_coordinate { + break; + } + } + repo_events.sort_by_key(|e| e.created_at); + let repo_ref = RepoRef::try_from( + repo_events + .first() + .context("no repo events at specified coordinates")? + .clone(), + )?; + + let mut events: HashMap = HashMap::new(); + for m in &maintainers { + if let Some(e) = repo_events.iter().find(|e| e.author().eq(m)) { + events.insert( + Coordinate { + kind: e.kind, + identifier: e.identifier().unwrap().to_string(), + public_key: e.author(), + relays: vec![], + }, + e.clone(), + ); + } + } + + Ok(RepoRef { + // use all maintainers from all events found, not just maintainers in the most + // recent event + maintainers: maintainers.iter().copied().collect::>(), + events, + ..repo_ref + }) +} + +pub async fn get_state_from_cache(git_repo_path: &Path, repo_ref: &RepoRef) -> Result { + RepoState::try_from( + get_events_from_cache( + git_repo_path, + vec![get_filter_state_events(&repo_ref.coordinates())], + ) + .await?, + ) +} + +#[allow(clippy::too_many_lines)] +async fn create_relays_request( + git_repo_path: &Path, + repo_coordinates: &HashSet, + user_profiles: &HashSet, + fallback_relays: HashSet, +) -> Result { + let repo_ref = get_repo_ref_from_cache(git_repo_path, repo_coordinates).await; + + let repo_coordinates = { + // add coordinates of users listed in maintainers to explicitly specified + // coodinates + let mut repo_coordinates = repo_coordinates.clone(); + if let Ok(repo_ref) = &repo_ref { + for c in repo_ref.coordinates() { + if !repo_coordinates + .iter() + .any(|e| e.identifier.eq(&c.identifier) && e.public_key.eq(&c.public_key)) + { + repo_coordinates.insert(c); + } + } + } + repo_coordinates + }; + + let repo_coordinates_without_relays = { + let mut set = HashSet::new(); + for c in &repo_coordinates { + set.insert(Coordinate { + kind: c.kind, + identifier: c.identifier.clone(), + public_key: c.public_key, + relays: vec![], + }); + } + set + }; + + let mut proposals: HashSet = HashSet::new(); + let mut missing_contributor_profiles: HashSet = HashSet::new(); + let mut contributors: HashSet = HashSet::new(); + + if !repo_coordinates_without_relays.is_empty() { + if let Ok(repo_ref) = &repo_ref { + for m in &repo_ref.maintainers { + contributors.insert(m.to_owned()); + } + } + + for event in &get_events_from_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .kinds(vec![Kind::GitPatch]) + .custom_tag( + SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), + repo_coordinates_without_relays + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + ), + ], + ) + .await? + { + if event_is_patch_set_root(event) || event_is_revision_root(event) { + proposals.insert(event.id()); + contributors.insert(event.author()); + } + } + + let profile_events = get_event_from_global_cache( + git_repo_path, + vec![get_filter_contributor_profiles(contributors.clone())], + ) + .await?; + for c in &contributors { + if let Some(event) = profile_events + .iter() + .find(|e| e.kind() == Kind::Metadata && e.author().eq(c)) + { + save_event_in_cache(git_repo_path, event).await?; + } else { + missing_contributor_profiles.insert(c.to_owned()); + } + } + } + + let profiles_to_fetch_from_user_relays = { + let mut user_profiles = user_profiles.clone(); + if let Ok(Some(current_user)) = get_logged_in_user(git_repo_path).await { + user_profiles.insert(current_user); + } + let mut map: HashMap = HashMap::new(); + for public_key in &user_profiles { + if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { + map.insert( + public_key.to_owned(), + (user_ref.metadata.created_at, user_ref.relays.created_at), + ); + } else { + map.insert( + public_key.to_owned(), + (Timestamp::from(0), Timestamp::from(0)), + ); + } + } + map + }; + + let user_relays_for_profiles = { + let mut set = HashSet::new(); + for user in &profiles_to_fetch_from_user_relays + .clone() + .into_keys() + .collect::>() + { + if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, user).await { + for r in user_ref.relays.write() { + if let Ok(url) = Url::parse(&r) { + set.insert(url); + } + } + } else { + missing_contributor_profiles.insert(user.to_owned()); + } + } + set + }; + + let existing_events: HashSet = { + let mut existing_events: HashSet = HashSet::new(); + for filter in get_fetch_filters( + &repo_coordinates_without_relays, + &proposals, + &missing_contributor_profiles + .union( + &profiles_to_fetch_from_user_relays + .clone() + .into_keys() + .collect(), + ) + .copied() + .collect(), + ) { + for (id, _) in get_local_cache_database(git_repo_path) + .await? + .negentropy_items(filter) + .await? + { + existing_events.insert(id); + } + } + existing_events + }; + + let relays = { + let mut relays = fallback_relays; + if let Ok(repo_ref) = &repo_ref { + for r in &repo_ref.relays { + if let Ok(url) = Url::parse(r) { + relays.insert(url); + } + } + } + for c in repo_coordinates { + for r in &c.relays { + if let Ok(url) = Url::parse(r) { + relays.insert(url); + } + } + } + relays + }; + + let relay_column_width = relays + .union(&user_relays_for_profiles) + .reduce(|a, r| { + if r.to_string() + .chars() + .count() + .gt(&a.to_string().chars().count()) + { + r + } else { + a + } + }) + .unwrap() + .to_string() + .chars() + .count() + + 2; + + Ok(FetchRequest { + selected_relay: None, + repo_relays: relays, + relay_column_width, + repo_coordinates_without_relays: if let Ok(repo_ref) = &repo_ref { + repo_ref.coordinates_with_timestamps() + } else { + repo_coordinates_without_relays + .iter() + .map(|c| (c.clone(), None)) + .collect() + }, + state: if let Ok(repo_ref) = &repo_ref { + if let Ok(existing_state) = get_state_from_cache(git_repo_path, repo_ref).await { + Some((existing_state.event.created_at, existing_state.event.id)) + } else { + None + } + } else { + None + }, + proposals, + contributors, + missing_contributor_profiles, + existing_events, + profiles_to_fetch_from_user_relays, + user_relays_for_profiles, + }) +} + +#[allow(clippy::too_many_lines)] +async fn process_fetched_events( + events: Vec, + request: &FetchRequest, + git_repo_path: &Path, + fresh_coordinates: &mut HashSet, + fresh_proposal_roots: &mut HashSet, + fresh_profiles: &mut HashSet, + report: &mut FetchReport, +) -> Result<()> { + for event in &events { + if !request.existing_events.contains(&event.id) { + save_event_in_cache(git_repo_path, event).await?; + if event.kind().eq(&Kind::GitRepoAnnouncement) { + save_event_in_global_cache(git_repo_path, event).await?; + let new_coordinate = !request + .repo_coordinates_without_relays + .iter() + .map(|(c, _)| c.clone()) + .any(|c| { + c.identifier.eq(event.identifier().unwrap()) + && c.public_key.eq(&event.pubkey) + }); + let update_to_existing = !new_coordinate + && request + .repo_coordinates_without_relays + .iter() + .any(|(c, t)| { + c.identifier.eq(event.identifier().unwrap()) + && c.public_key.eq(&event.pubkey) + && if let Some(t) = t { + event.created_at.gt(t) + } else { + true + } + }); + if update_to_existing { + report.updated_repo_announcements.push(( + Coordinate { + kind: event.kind(), + public_key: event.author(), + identifier: event.identifier().unwrap().to_owned(), + relays: vec![], + }, + event.created_at, + )); + } + // if contains new maintainer + if let Ok(repo_ref) = &RepoRef::try_from(event.clone()) { + for m in &repo_ref.maintainers { + if !request + .repo_coordinates_without_relays // prexisting maintainers + .iter() + .map(|(c, _)| c.clone()) + .collect::>() + .union(&report.repo_coordinates_without_relays) // already added maintainers + .any(|c| c.identifier.eq(&repo_ref.identifier) && m.eq(&c.public_key)) + { + let c = Coordinate { + kind: event.kind(), + public_key: *m, + identifier: repo_ref.identifier.clone(), + relays: vec![], + }; + fresh_coordinates.insert(c.clone()); + report.repo_coordinates_without_relays.insert(c); + + if !request.contributors.contains(m) + && !request + .profiles_to_fetch_from_user_relays + .clone() + .into_keys() + .collect::>() + .contains(m) + && !fresh_profiles.contains(m) + { + fresh_profiles.insert(m.to_owned()); + } + } + } + } + } else if event.kind().eq(&STATE_KIND) { + let existing_state = if report.updated_state.is_some() { + report.updated_state + } else { + request.state + }; + if let Some((timestamp, id)) = existing_state { + if event.created_at.gt(×tamp) + || (event.created_at.eq(×tamp) && event.id.gt(&id)) + { + report.updated_state = Some((event.created_at, event.id)); + } + } + } else if event_is_patch_set_root(event) { + fresh_proposal_roots.insert(event.id); + report.proposals.insert(event.id); + if !request.contributors.contains(&event.author()) + && !fresh_profiles.contains(&event.author()) + { + fresh_profiles.insert(event.author()); + } + } else if [Kind::RelayList, Kind::Metadata].contains(&event.kind()) { + if request + .missing_contributor_profiles + .contains(&event.author()) + { + report.contributor_profiles.insert(event.author()); + } else if let Some((_, (metadata_timestamp, relay_list_timestamp))) = request + .profiles_to_fetch_from_user_relays + .get_key_value(&event.author()) + { + if (Kind::Metadata.eq(&event.kind()) + && event.created_at().gt(metadata_timestamp)) + || (Kind::RelayList.eq(&event.kind()) + && event.created_at().gt(relay_list_timestamp)) + { + report.profile_updates.insert(event.author()); + } + } + save_event_in_global_cache(git_repo_path, event).await?; + } + } + } + for event in &events { + if !request.existing_events.contains(&event.id) + && !event.event_ids().any(|id| report.proposals.contains(id)) + { + if event.kind().eq(&Kind::GitPatch) && !event_is_patch_set_root(event) { + report.commits.insert(event.id); + } else if status_kinds().contains(&event.kind()) { + report.statuses.insert(event.id); + } + } + } + Ok(()) +} + +pub fn consolidate_fetch_reports(reports: Vec>) -> FetchReport { + let mut report = FetchReport::default(); + for relay_report in reports.into_iter().flatten() { + for c in relay_report.repo_coordinates_without_relays { + if !report + .repo_coordinates_without_relays + .iter() + .any(|e| e.eq(&c)) + { + report.repo_coordinates_without_relays.insert(c); + } + } + for (r, t) in relay_report.updated_repo_announcements { + if let Some(i) = report + .updated_repo_announcements + .iter() + .position(|(e, _)| e.eq(&r)) + { + let (_, existing_t) = &report.updated_repo_announcements[i]; + if t.gt(existing_t) { + report.updated_repo_announcements[i] = (r, t); + } + } else { + report.updated_repo_announcements.push((r, t)); + } + } + if let Some((timestamp, id)) = relay_report.updated_state { + if let Some((existing_timestamp, existing_id)) = report.updated_state { + if timestamp.gt(&existing_timestamp) + || (timestamp.eq(&existing_timestamp) && id.gt(&existing_id)) + { + report.updated_state = Some((timestamp, id)); + } + } else { + report.updated_state = Some((timestamp, id)); + } + } + for c in relay_report.proposals { + report.proposals.insert(c); + } + for c in relay_report.commits { + report.commits.insert(c); + } + for c in relay_report.statuses { + report.statuses.insert(c); + } + for c in relay_report.contributor_profiles { + report.contributor_profiles.insert(c); + } + for c in relay_report.profile_updates { + report.profile_updates.insert(c); + } + } + report +} +pub fn get_fetch_filters( + repo_coordinates: &HashSet, + proposal_ids: &HashSet, + required_profiles: &HashSet, +) -> Vec { + [ + if repo_coordinates.is_empty() { + vec![] + } else { + vec![ + get_filter_state_events(repo_coordinates), + get_filter_repo_events(repo_coordinates), + nostr::Filter::default() + .kinds(vec![Kind::GitPatch, Kind::EventDeletion]) + .custom_tag( + SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), + repo_coordinates + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + ), + ] + }, + if proposal_ids.is_empty() { + vec![] + } else { + vec![ + nostr::Filter::default() + .events(proposal_ids.clone()) + .kinds([vec![Kind::GitPatch, Kind::EventDeletion], status_kinds()].concat()), + ] + }, + if required_profiles.is_empty() { + vec![] + } else { + vec![get_filter_contributor_profiles(required_profiles.clone())] + }, + ] + .concat() +} + +pub fn get_filter_repo_events(repo_coordinates: &HashSet) -> nostr::Filter { + nostr::Filter::default() + .kind(Kind::GitRepoAnnouncement) + .identifiers( + repo_coordinates + .iter() + .map(|c| c.identifier.clone()) + .collect::>(), + ) + .authors( + repo_coordinates + .iter() + .map(|c| c.public_key) + .collect::>(), + ) +} + +pub static STATE_KIND: nostr::Kind = Kind::Custom(30618); +pub fn get_filter_state_events(repo_coordinates: &HashSet) -> nostr::Filter { + nostr::Filter::default() + .kind(STATE_KIND) + .identifiers( + repo_coordinates + .iter() + .map(|c| c.identifier.clone()) + .collect::>(), + ) + .authors( + repo_coordinates + .iter() + .map(|c| c.public_key) + .collect::>(), + ) +} + +pub fn get_filter_contributor_profiles(contributors: HashSet) -> nostr::Filter { + nostr::Filter::default() + .kinds(vec![Kind::Metadata, Kind::RelayList]) + .authors(contributors) +} + +#[derive(Default)] +pub struct FetchReport { + repo_coordinates_without_relays: HashSet, + updated_repo_announcements: Vec<(Coordinate, Timestamp)>, + updated_state: Option<(Timestamp, EventId)>, + proposals: HashSet, + /// commits against existing propoals + commits: HashSet, + statuses: HashSet, + contributor_profiles: HashSet, + profile_updates: HashSet, +} + +impl Display for FetchReport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // report: "1 new maintainer, 1 announcement, 1 proposal, 3 commits, 2 statuses" + let mut display_items: Vec = vec![]; + if !self.repo_coordinates_without_relays.is_empty() { + display_items.push(format!( + "{} new maintainer{}", + self.repo_coordinates_without_relays.len(), + if self.repo_coordinates_without_relays.len() > 1 { + "s" + } else { + "" + }, + )); + } + if !self.updated_repo_announcements.is_empty() { + display_items.push(format!( + "{} announcement update{}", + self.updated_repo_announcements.len(), + if self.updated_repo_announcements.len() > 1 { + "s" + } else { + "" + }, + )); + } + if self.updated_state.is_some() { + display_items.push("new state".to_string()); + } + if !self.proposals.is_empty() { + display_items.push(format!( + "{} proposal{}", + self.proposals.len(), + if self.proposals.len() > 1 { "s" } else { "" }, + )); + } + if !self.commits.is_empty() { + display_items.push(format!( + "{} commit{}", + self.commits.len(), + if self.commits.len() > 1 { "s" } else { "" }, + )); + } + if !self.statuses.is_empty() { + display_items.push(format!( + "{} status{}", + self.statuses.len(), + if self.statuses.len() > 1 { "es" } else { "" }, + )); + } + if !self.contributor_profiles.is_empty() { + display_items.push(format!( + "{} user profile{}", + self.contributor_profiles.len(), + if self.contributor_profiles.len() > 1 { + "s" + } else { + "" + }, + )); + } + if !self.profile_updates.is_empty() { + display_items.push(format!( + "{} profile update{}", + self.profile_updates.len(), + if self.profile_updates.len() > 1 { + "s" + } else { + "" + }, + )); + } + write!(f, "{}", display_items.join(", ")) + } +} + +#[derive(Default, Clone)] +pub struct FetchRequest { + repo_relays: HashSet, + selected_relay: Option, + relay_column_width: usize, + repo_coordinates_without_relays: Vec<(Coordinate, Option)>, + state: Option<(Timestamp, EventId)>, + proposals: HashSet, + contributors: HashSet, + missing_contributor_profiles: HashSet, + existing_events: HashSet, + profiles_to_fetch_from_user_relays: HashMap, + user_relays_for_profiles: HashSet, +} + +pub async fn fetching_with_report( + 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("fetching updates...")?; + 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(); + } + let report = consolidate_fetch_reports(relay_reports); + if report.to_string().is_empty() { + println!("no updates"); + } else { + println!("updates: {report}"); + } + Ok(report) +} diff --git a/src/lib/git/mod.rs b/src/lib/git/mod.rs new file mode 100644 index 0000000..5919667 --- /dev/null +++ b/src/lib/git/mod.rs @@ -0,0 +1,2566 @@ +use std::{ + collections::HashSet, + env::current_dir, + path::{Path, PathBuf}, +}; + +use anyhow::{bail, Context, Result}; +use git2::{DiffOptions, Oid, Revwalk}; +use nostr::nips::nip01::Coordinate; +use nostr_sdk::{ + hashes::{sha1::Hash as Sha1Hash, Hash}, + PublicKey, Url, +}; + +use crate::sub_commands::list::{get_commit_id_from_patch, tag_value}; + +pub struct Repo { + pub git_repo: git2::Repository, +} + +impl Repo { + pub fn discover() -> Result { + Ok(Self { + git_repo: git2::Repository::discover(current_dir()?)?, + }) + } + pub fn from_path(path: &PathBuf) -> Result { + Ok(Self { + git_repo: git2::Repository::open(path)?, + }) + } +} + +// pub type CommitId = [u8; 7]; +// pub type Sha1 = [u8; 20]; + +pub trait RepoActions { + fn get_path(&self) -> Result<&Path>; + fn get_origin_url(&self) -> Result; + fn get_remote_branch_names(&self) -> Result>; + fn get_local_branch_names(&self) -> Result>; + fn get_origin_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)>; + fn get_local_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)>; + fn get_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)>; + fn get_checked_out_branch_name(&self) -> Result; + fn get_tip_of_branch(&self, branch_name: &str) -> Result; + fn get_commit_or_tip_of_reference(&self, reference: &str) -> Result; + fn get_root_commit(&self) -> Result; + fn does_commit_exist(&self, commit: &str) -> Result; + fn get_head_commit(&self) -> Result; + fn get_commit_parent(&self, commit: &Sha1Hash) -> Result; + fn get_commit_message(&self, commit: &Sha1Hash) -> Result; + fn get_commit_message_summary(&self, commit: &Sha1Hash) -> Result; + #[allow(clippy::doc_link_with_quotes)] + /// returns vector ["name", "email", "unixtime", "offset"] + /// eg ["joe bloggs", "joe@pm.me", "12176","-300"] + fn get_commit_author(&self, commit: &Sha1Hash) -> Result>; + #[allow(clippy::doc_link_with_quotes)] + /// returns vector ["name", "email", "unixtime", "offset"] + /// eg ["joe bloggs", "joe@pm.me", "12176","-300"] + fn get_commit_comitter(&self, commit: &Sha1Hash) -> Result>; + fn get_commits_ahead_behind( + &self, + base_commit: &Sha1Hash, + latest_commit: &Sha1Hash, + ) -> Result<(Vec, Vec)>; + fn get_refs(&self, commit: &Sha1Hash) -> Result>; + // including (un)staged changes and (un)tracked files + fn has_outstanding_changes(&self) -> Result; + fn make_patch_from_commit( + &self, + commit: &Sha1Hash, + series_count: &Option<(u64, u64)>, + ) -> Result; + fn extract_commit_pgp_signature(&self, commit: &Sha1Hash) -> Result; + fn checkout(&self, ref_name: &str) -> Result; + fn create_branch_at_commit(&self, branch_name: &str, commit: &str) -> Result<()>; + fn apply_patch_chain( + &self, + branch_name: &str, + patch_and_ancestors: Vec, + ) -> Result>; + fn create_commit_from_patch(&self, patch: &nostr::Event) -> Result; + fn parse_starting_commits(&self, starting_commits: &str) -> Result>; + fn ancestor_of(&self, decendant: &Sha1Hash, ancestor: &Sha1Hash) -> Result; + fn get_git_config_item(&self, item: &str, global: Option) -> Result>; + fn save_git_config_item(&self, item: &str, value: &str, global: bool) -> Result<()>; + fn remove_git_config_item(&self, item: &str, global: bool) -> Result; +} + +impl RepoActions for Repo { + fn get_path(&self) -> Result<&Path> { + self.git_repo + .path() + .parent() + .context("cannot find repositiory path as .git has no parent") + } + + fn get_origin_url(&self) -> Result { + Ok(self + .git_repo + .find_remote("origin") + .context("cannot find origin")? + .url() + .context("cannot find origin url")? + .to_string()) + } + + fn get_origin_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)> { + let main_branch_name = { + let remote_branches = self + .get_remote_branch_names() + .context("cannot find any local branches")?; + if remote_branches.contains(&"origin/main".to_string()) { + "origin/main" + } else if remote_branches.contains(&"origin/master".to_string()) { + "origin/master" + } else { + bail!("no main or master branch locally in this git repository to initiate from",) + } + }; + + let tip = self + .get_tip_of_branch(main_branch_name) + .context(format!( + "branch {main_branch_name} was listed as a remote branch but cannot get its tip commit id", + ))?; + + Ok((main_branch_name, tip)) + } + + fn get_local_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)> { + let main_branch_name = { + let local_branches = self + .get_local_branch_names() + .context("cannot find any local branches")?; + if local_branches.contains(&"main".to_string()) { + "main" + } else if local_branches.contains(&"master".to_string()) { + "master" + } else { + bail!("no main or master branch locally in this git repository to initiate from",) + } + }; + + let tip = self + .get_tip_of_branch(main_branch_name) + .context(format!( + "branch {main_branch_name} was listed as a local branch but cannot get its tip commit id", + ))?; + + Ok((main_branch_name, tip)) + } + + fn get_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)> { + if let Ok(main_tuple) = self + .get_origin_main_or_master_branch() + .context("the default branches (main or master) do not exist") + { + Ok(main_tuple) + } else { + self.get_local_main_or_master_branch() + .context("the default branches (main or master) do not exist") + } + } + + fn get_local_branch_names(&self) -> Result> { + let local_branches = self + .git_repo + .branches(Some(git2::BranchType::Local)) + .context("getting GitRepo branches should not error even for a blank repository")?; + + let mut branch_names = vec![]; + + for iter in local_branches { + let branch = iter?.0; + if let Some(name) = branch.name()? { + branch_names.push(name.to_string()); + } + } + Ok(branch_names) + } + + fn get_remote_branch_names(&self) -> Result> { + let remote_branches = self + .git_repo + .branches(Some(git2::BranchType::Remote)) + .context("getting GitRepo branches should not error even for a blank repository")?; + + let mut branch_names = vec![]; + + for iter in remote_branches { + let branch = iter?.0; + if let Some(name) = branch.name()? { + branch_names.push(name.to_string()); + } + } + Ok(branch_names) + } + + fn get_checked_out_branch_name(&self) -> Result { + Ok(self + .git_repo + .head()? + .shorthand() + .context("an object without a shorthand is checked out")? + .to_string()) + } + + fn get_tip_of_branch(&self, branch_name: &str) -> Result { + let branch = if let Ok(branch) = self + .git_repo + .find_branch(branch_name, git2::BranchType::Local) + .context(format!("cannot find local branch {branch_name}")) + { + branch + } else { + self.git_repo + .find_branch(branch_name, git2::BranchType::Remote) + .context(format!("cannot find local or remote branch {branch_name}"))? + }; + Ok(oid_to_sha1(&branch.into_reference().peel_to_commit()?.id())) + } + + fn get_commit_or_tip_of_reference(&self, sha1_or_reference: &str) -> Result { + let oid = { + if let Ok(oid) = Oid::from_str(sha1_or_reference) { + self.git_repo.find_commit(oid)?; + oid + } else { + self.git_repo + .find_reference(sha1_or_reference)? + .peel_to_commit()? + .id() + } + }; + Ok(oid_to_sha1(&oid)) + } + + fn get_root_commit(&self) -> Result { + let mut revwalk = self + .git_repo + .revwalk() + .context("revwalk should be created from git repo")?; + revwalk + .push(sha1_to_oid(&self.get_head_commit()?)?) + .context("revwalk should accept tip oid")?; + Ok(oid_to_sha1( + &revwalk + .last() + .context("revwalk from tip should be at least contain the tip oid")? + .context("revwalk iter from branch tip should not result in an error")?, + )) + } + + fn does_commit_exist(&self, commit: &str) -> Result { + if self.git_repo.find_commit(Oid::from_str(commit)?).is_ok() { + Ok(true) + } else { + Ok(false) + } + } + + fn get_head_commit(&self) -> Result { + let head = self + .git_repo + .head() + .context("failed to get git repo head")?; + let oid = head.peel_to_commit()?.id(); + Ok(oid_to_sha1(&oid)) + } + + fn get_commit_parent(&self, commit: &Sha1Hash) -> Result { + let parent_oid = self + .git_repo + .find_commit(sha1_to_oid(commit)?) + .context(format!("could not find commit {commit}"))? + .parent_id(0) + .context(format!("could not find parent of commit {commit}"))?; + Ok(oid_to_sha1(&parent_oid)) + } + + fn get_commit_message(&self, commit: &Sha1Hash) -> Result { + Ok(self + .git_repo + .find_commit(sha1_to_oid(commit)?) + .context(format!("could not find commit {commit}"))? + .message_raw() + .context("commit message has unusual characters in (not valid utf-8)")? + .to_string()) + } + + fn get_commit_message_summary(&self, commit: &Sha1Hash) -> Result { + Ok(self + .git_repo + .find_commit(sha1_to_oid(commit)?) + .context(format!("could not find commit {commit}"))? + .message_raw() + .context("commit message has unusual characters in (not valid utf-8)")? + .split('\r') + .collect::>()[0] + .split('\n') + .collect::>()[0] + .to_string() + .trim() + .to_string()) + } + + fn get_commit_author(&self, commit: &Sha1Hash) -> Result> { + let commit = self + .git_repo + .find_commit(sha1_to_oid(commit)?) + .context(format!("could not find commit {commit}"))?; + let sig = commit.author(); + Ok(git_sig_to_tag_vec(&sig)) + } + + fn get_commit_comitter(&self, commit: &Sha1Hash) -> Result> { + let commit = self + .git_repo + .find_commit(sha1_to_oid(commit)?) + .context(format!("could not find commit {commit}"))?; + let sig = commit.committer(); + Ok(git_sig_to_tag_vec(&sig)) + } + + fn get_refs(&self, commit: &Sha1Hash) -> Result> { + Ok(self + .git_repo + .references()? + .filter(|r| { + if let Ok(r) = r { + if let Ok(ref_tip) = r.peel_to_commit() { + ref_tip.id().to_string().eq(&commit.to_string()) + } else { + false + } + } else { + false + } + }) + .map(|r| r.unwrap().shorthand().unwrap().to_string()) + .collect::>()) + } + + fn make_patch_from_commit( + &self, + commit: &Sha1Hash, + series_count: &Option<(u64, u64)>, + ) -> Result { + let c = self + .git_repo + .find_commit(Oid::from_bytes(commit.as_byte_array()).context(format!( + "failed to convert commit_id format for {}", + &commit + ))?) + .context(format!("failed to find commit {}", &commit))?; + let mut options = git2::EmailCreateOptions::default(); + if let Some((n, total)) = series_count { + options.subject_prefix(format!("PATCH {n}/{total}")); + } + let patch = git2::Email::from_commit(&c, &mut options) + .context(format!("failed to create patch from commit {}", &commit))?; + + Ok(std::str::from_utf8(patch.as_slice()) + .context("patch content could not be converted to a utf8 string")? + .to_owned()) + } + + fn extract_commit_pgp_signature(&self, commit: &Sha1Hash) -> Result { + let oid = Oid::from_bytes(commit.as_byte_array()).context(format!( + "failed to convert commit_id format for {}", + &commit + ))?; + + let (sign, _data) = self + .git_repo + .extract_signature(&oid, None) + .context("failed to extract signature - perhaps there is no signature?")?; + + Ok(std::str::from_utf8(&sign) + .context("commit signature cannot be converted to a utf8 string")? + .to_owned()) + } + + // including (un)staged changes and (un)tracked files + fn has_outstanding_changes(&self) -> Result { + let diff = self.git_repo.diff_tree_to_workdir_with_index( + Some(&self.git_repo.head()?.peel_to_tree()?), + Some(DiffOptions::new().include_untracked(true)), + )?; + + Ok(diff.deltas().len().gt(&0)) + } + + fn get_commits_ahead_behind( + &self, + base_commit: &Sha1Hash, + latest_commit: &Sha1Hash, + ) -> Result<(Vec, Vec)> { + let mut ahead: Vec = vec![]; + let mut behind: Vec = vec![]; + + let get_revwalk = |commit: &Sha1Hash| -> Result { + let mut revwalk = self + .git_repo + .revwalk() + .context("revwalk should be created from git repo")?; + revwalk + .push(sha1_to_oid(commit)?) + .context("revwalk should accept commit oid")?; + Ok(revwalk) + }; + + // scan through the base commit ancestory until a common ancestor is found + let most_recent_shared_commit = match get_revwalk(base_commit) + .context("failed to get revwalk for base_commit")? + .find(|base_res| { + let base_oid = base_res.as_ref().unwrap(); + + if get_revwalk(latest_commit) + .unwrap() + .any(|latest_res| base_oid.eq(latest_res.as_ref().unwrap())) + { + true + } else { + // add commits not found in latest ancestory to 'behind' vector + behind.push(oid_to_sha1(base_oid)); + false + } + }) { + None => { + bail!(format!( + "{} is not an ancestor of {}", + latest_commit, base_commit + )); + } + Some(res) => res.context("revwalk failed to reveal commit")?, + }; + + // scan through the latest commits until shared commit is reached + get_revwalk(latest_commit) + .context("failed to get revwalk for latest_commit")? + .any(|latest_res| { + let latest_oid = latest_res.as_ref().unwrap(); + if latest_oid.eq(&most_recent_shared_commit) { + true + } else { + // add commits not found in base to 'ahead' vector + ahead.push(oid_to_sha1(latest_oid)); + false + } + }); + Ok((ahead, behind)) + } + + fn checkout(&self, ref_name: &str) -> Result { + let (object, reference) = self.git_repo.revparse_ext(ref_name)?; + + self.git_repo.checkout_tree(&object, None)?; + + match reference { + // gref is an actual reference like branches or tags + Some(gref) => self.git_repo.set_head(gref.name().unwrap()), + // this is a commit, not a reference + None => self.git_repo.set_head_detached(object.id()), + }?; + let oid = self.git_repo.head()?.peel_to_commit()?.id(); + + Ok(oid_to_sha1(&oid)) + } + + fn create_branch_at_commit(&self, branch_name: &str, commit: &str) -> Result<()> { + let branch_checkedout = self.get_checked_out_branch_name()?.eq(branch_name); + if branch_checkedout { + let (name, _) = self.get_main_or_master_branch()?; + self.checkout(name)?; + } + + self.git_repo + .branch( + branch_name, + &self.git_repo.find_commit(Oid::from_str(commit)?)?, + true, + ) + .context("branch could not be created")?; + + if branch_checkedout { + self.checkout(branch_name)?; + } + Ok(()) + } + /* returns patches applied */ + fn apply_patch_chain( + &self, + branch_name: &str, + patch_and_ancestors: Vec, + ) -> Result> { + let branch_tip_result = self.get_tip_of_branch(branch_name); + + // filter out existing ancestors in branch + let mut patches_to_apply: Vec = patch_and_ancestors + .into_iter() + .filter(|e| { + let commit_id = get_commit_id_from_patch(e).unwrap(); + if let Ok(branch_tip) = branch_tip_result { + !branch_tip.to_string().eq(&commit_id) + && !self + .ancestor_of(&branch_tip, &str_to_sha1(&commit_id).unwrap()) + .unwrap() + } else { + true + } + }) + .collect(); + + let parent_commit_id = tag_value( + if let Ok(last_patch) = patches_to_apply.last().context("no patches") { + last_patch + } else { + self.checkout(branch_name) + .context("no patches and so cannot create a proposal branch")?; + return Ok(vec![]); + }, + "parent-commit", + )?; + + // check patches can be applied + if !self.does_commit_exist(&parent_commit_id)? { + bail!("cannot find parent commit ({parent_commit_id}). run git pull and try again.") + } + + // checkout branch + self.create_branch_at_commit(branch_name, &parent_commit_id)?; + self.checkout(branch_name)?; + + // apply commits + patches_to_apply.reverse(); + + for patch in &patches_to_apply { + let commit_id = get_commit_id_from_patch(patch)?; + // only create new commits - otherwise make them the tip + if !self.does_commit_exist(&commit_id)? { + self.create_commit_from_patch(patch)?; + } + self.create_branch_at_commit(branch_name, &commit_id)?; + self.checkout(branch_name)?; + } + Ok(patches_to_apply) + } + fn create_commit_from_patch(&self, patch: &nostr::Event) -> Result { + let commit_id = get_commit_id_from_patch(patch)?; + if self.does_commit_exist(&commit_id)? { + return Ok(Oid::from_str(&commit_id)?); + } + let parent_commit_id = tag_value(patch, "parent-commit")?; + + let parent_commit = self + .git_repo + .find_commit(Oid::from_str(&parent_commit_id)?) + .context("parrent commit doesnt exist")?; + let parent_tree = parent_commit.tree()?; + + // let mut apply_opts = git2::ApplyOptions::new(); + // apply_opts.check(false); + let mut existing_index = self.git_repo.index()?; + let mut index = self.git_repo.apply_to_tree( + &parent_tree, + &git2::Diff::from_buffer(patch.content.as_bytes())?, + // Some(&mut apply_opts), + None, + )?; + let tree = self + .git_repo + .find_tree(index.write_tree_to(&self.git_repo)?)?; + + let pgp_sig = if let Ok(pgp_sig) = tag_value(patch, "commit-pgp-sig") { + if pgp_sig.is_empty() { + None + } else { + Some(pgp_sig) + } + } else { + None + }; + + let commit_buff = self.git_repo.commit_create_buffer( + &extract_sig_from_patch_tags(&patch.tags, "author")?, + &extract_sig_from_patch_tags(&patch.tags, "committer")?, + tag_value(patch, "description")?.as_str(), + &tree, + &[&parent_commit], + )?; + + let mut applied_oid = self + .git_repo + .commit_signed( + commit_buff.as_str().unwrap(), + pgp_sig.unwrap_or(String::new()).as_str(), + None, + ) + .context("failed to create signed commit")?; + + // I beleive this was added to address a bug where commit author / committer + // were identical when in a scenario when they should be different but I dont + // think we have a test case for it. surely we should be using the + // extract_sig_from_patch_tags outputs to address this? + if !applied_oid.to_string().eq(&commit_id) { + let commit = self.git_repo.find_commit(applied_oid)?; + applied_oid = commit + .amend( + None, + Some(&commit.author()), + Some(&commit.committer()), + None, + None, + None, + ) + .context("cannot amend commit to produce new oid")?; + } + if !applied_oid.to_string().eq(&commit_id) { + bail!( + "when applied the patch commit id ({}) doesn't match the one specified in the event tag ({})", + applied_oid.to_string(), + get_commit_id_from_patch(patch)?, + ); + } + self.git_repo.set_index(&mut existing_index)?; + Ok(applied_oid) + } + fn parse_starting_commits(&self, starting_commits: &str) -> Result> { + let revspec = self + .git_repo + .revparse(starting_commits) + .context("specified value not in a valid format")?; + if revspec.mode().is_no_single() { + let (ahead, _) = self + .get_commits_ahead_behind( + &oid_to_sha1( + &revspec + .from() + .context("cannot get starting commit from specified value")? + .id(), + ), + &self + .get_head_commit() + .context("cannot get head commit with gitlib2")?, + ) + .context("specified commit is not an ancestor of current head")?; + Ok(ahead) + } else if revspec.mode().is_range() { + let (ahead, _) = self + .get_commits_ahead_behind( + &oid_to_sha1( + &revspec + .from() + .context("cannot get starting commit of range from specified value")? + .id(), + ), + &oid_to_sha1( + &revspec + .to() + .context("cannot get end of range commit from specified value")? + .id(), + ), + ) + .context("specified commit is not an ancestor of current head")?; + Ok(ahead) + } else { + bail!("specified value not in a supported format") + } + } + + fn ancestor_of(&self, decendant: &Sha1Hash, ancestor: &Sha1Hash) -> Result { + if let Ok(res) = self + .git_repo + .graph_descendant_of(sha1_to_oid(decendant)?, sha1_to_oid(ancestor)?) + .context("could not run graph_descendant_of in gitlib2") + { + Ok(res) + } else { + Ok(false) + } + } + + /// setting global to None will suppliment local config with global items + /// not in local + fn get_git_config_item(&self, item: &str, global: Option) -> Result> { + let just_global = if let Some(just_global) = global { + just_global + } else { + false + }; + match if just_global { + self.git_repo + .config() + .context("cannot open git config")? + .open_global() + .context("cannot open global git config")? + } else { + self.git_repo.config().context("cannot open git config")? + } + .get_entry(item) + { + Ok(item) => { + if let Some(global) = global { + if item.level().eq(&git2::ConfigLevel::Local) { + if global { + bail!("only local repository login available") + } + } else if !global { + bail!("only global repository login available") + } + } + Ok(Some( + item.value() + .context("cannot find git config item")? + .to_string(), + )) + } + Err(_) => Ok(None), + } + } + + fn save_git_config_item(&self, item: &str, value: &str, global: bool) -> Result<()> { + if global { + self.git_repo + .config() + .context("cannot open git config")? + .open_global() + .context("cannot open global git config")? + } else { + self.git_repo.config().context("cannot open git config")? + } + .set_str(item, value) + .context(format!( + "cannot set {} git config item {}", + if global { "global" } else { "local" }, + item + ))?; + Ok(()) + } + + /// returns false if item doesn't exist + fn remove_git_config_item(&self, item: &str, global: bool) -> Result { + if self.get_git_config_item(item, Some(global))?.is_none() { + Ok(false) + } else { + if global { + self.git_repo + .config() + .context("cannot open git config")? + .open_global() + .context("cannot open global git config")? + } else { + self.git_repo.config().context("cannot open git config")? + } + .remove(item) + .context("cannot remove existing git config item")?; + Ok(true) + } + } +} + +fn oid_to_u8_20_bytes(oid: &Oid) -> [u8; 20] { + let b = oid.as_bytes(); + [ + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13], + b[14], b[15], b[16], b[17], b[18], b[19], + ] +} + +// fn oid_to_shorthand_string(oid: Oid) -> Result { +// let binding = oid.to_string(); +// let b = binding.as_bytes(); +// String::from_utf8(vec![b[0], b[1], b[2], b[3], b[4], b[5], b[6]]) +// .context("oid should always start with 7 u8 btyes of utf8") +// } + +// fn oid_to_sha1_string(oid: Oid) -> Result { +// let b = oid.as_bytes(); +// String::from_utf8(vec![ +// b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], +// b[11], b[12], b[13], b[14], b[15], b[16], b[17], b[18], b[19], +// ]) +// .context("oid should contain 20 u8 btyes of utf8") +// } + +// git2 Oid object to Sha1Hash +pub fn oid_to_sha1(oid: &Oid) -> Sha1Hash { + Sha1Hash::from_byte_array(oid_to_u8_20_bytes(oid)) +} + +/// `Sha1Hash` to git2 `Oid` object +pub fn sha1_to_oid(hash: &Sha1Hash) -> Result { + Oid::from_bytes(hash.as_byte_array()).context("Sha1Hash bytes failed to produce a valid Oid") +} + +pub fn str_to_sha1(s: &str) -> Result { + Ok(oid_to_sha1( + &Oid::from_str(s).context("string is not a sha1 hash")?, + )) +} + +fn git_sig_to_tag_vec(sig: &git2::Signature) -> Vec { + vec![ + sig.name().unwrap_or("").to_string(), + sig.email().unwrap_or("").to_string(), + format!("{}", sig.when().seconds()), + format!("{}", sig.when().offset_minutes()), + ] +} + +fn extract_sig_from_patch_tags<'a>( + tags: &'a [nostr::Tag], + tag_name: &str, +) -> Result> { + let v = tags + .iter() + .find(|t| t.as_vec()[0].eq(tag_name)) + .context(format!("tag '{tag_name}' not present in patch"))? + .as_vec(); + if v.len() != 5 { + bail!("tag '{tag_name}' is incorrectly formatted") + } + git2::Signature::new( + v[1].as_str(), + v[2].as_str(), + &git2::Time::new( + v[3].parse().context("tag time is incorrectly formatted")?, + v[4].parse() + .context("tag time offset is incorrectly formatted")?, + ), + ) + .context("failed to create git signature") +} + +#[derive(Debug, PartialEq)] +pub enum ServerProtocol { + Ssh, + Https, + Http, + Git, +} + +#[derive(Debug, PartialEq)] +pub struct NostrUrlDecoded { + pub coordinates: HashSet, + pub protocol: Option, + pub user: Option, +} + +static INCORRECT_NOSTR_URL_FORMAT_ERROR: &str = "incorrect nostr git url format. try nostr://naddr123 or nostr://npub123/my-repo or nostr://ssh/npub123/relay.damus.io/my-repo"; + +impl NostrUrlDecoded { + pub fn from_str(url: &str) -> Result { + let mut coordinates = HashSet::new(); + let mut protocol = None; + let mut user = None; + let mut relays = vec![]; + + if !url.starts_with("nostr://") { + bail!("nostr git url must start with nostr://"); + } + // process get url parameters if present + for (name, value) in Url::parse(url)?.query_pairs() { + if name.contains("relay") { + let mut decoded = urlencoding::decode(&value) + .context("could not parse relays in nostr git url")? + .to_string(); + if !decoded.starts_with("ws://") && !decoded.starts_with("wss://") { + decoded = format!("wss://{decoded}"); + } + let url = + Url::parse(&decoded).context("could not parse relays in nostr git url")?; + relays.push(url.to_string()); + } else if name == "protocol" { + protocol = match value.as_ref() { + "ssh" => Some(ServerProtocol::Ssh), + "https" => Some(ServerProtocol::Https), + "http" => Some(ServerProtocol::Http), + "git" => Some(ServerProtocol::Git), + _ => None, + }; + } else if name == "user" { + user = Some(value.to_string()); + } + } + + let mut parts: Vec<&str> = url[8..] + .split('?') + .next() + .unwrap_or("") + .split('/') + .collect(); + + // extract optional protocol + if protocol.is_none() { + let part = parts.first().context(INCORRECT_NOSTR_URL_FORMAT_ERROR)?; + let protocol_str = if let Some(at_index) = part.find('@') { + user = Some(part[..at_index].to_string()); + &part[at_index + 1..] + } else { + part + }; + protocol = match protocol_str { + "ssh" => Some(ServerProtocol::Ssh), + "https" => Some(ServerProtocol::Https), + "http" => Some(ServerProtocol::Http), + "git" => Some(ServerProtocol::Git), + _ => protocol, + }; + if protocol.is_some() { + parts.remove(0); + } + } + // extract naddr npub//identifer + let part = parts.first().context(INCORRECT_NOSTR_URL_FORMAT_ERROR)?; + // naddr used + if let Ok(coordinate) = Coordinate::parse(part) { + if coordinate.kind.eq(&nostr_sdk::Kind::GitRepoAnnouncement) { + coordinates.insert(coordinate); + } else { + bail!("naddr doesnt point to a git repository announcement"); + } + // npub//identifer used + } else if let Ok(public_key) = PublicKey::parse(part) { + parts.remove(0); + let identifier = parts + .pop() + .context("nostr url must have an identifier eg. nostr://npub123/repo-identifier")? + .to_string(); + for relay in parts { + let mut decoded = urlencoding::decode(relay) + .context("could not parse relays in nostr git url")? + .to_string(); + if !decoded.starts_with("ws://") && !decoded.starts_with("wss://") { + decoded = format!("wss://{decoded}"); + } + let url = + Url::parse(&decoded).context("could not parse relays in nostr git url")?; + relays.push(url.to_string()); + } + coordinates.insert(Coordinate { + identifier, + public_key, + kind: nostr_sdk::Kind::GitRepoAnnouncement, + relays, + }); + } else { + bail!(INCORRECT_NOSTR_URL_FORMAT_ERROR); + } + + Ok(Self { + coordinates, + protocol, + user, + }) + } +} + +/** produce error when using local repo or custom protocols */ +pub fn convert_clone_url_to_https(url: &str) -> Result { + // Strip credentials if present + let stripped_url = strip_credentials(url); + + // Check if the URL is already in HTTPS format + if stripped_url.starts_with("https://") { + return Ok(stripped_url); + } + // Convert http:// to https:// + else if stripped_url.starts_with("http://") { + return Ok(stripped_url.replace("http://", "https://")); + } + // Check if the URL starts with SSH + else if stripped_url.starts_with("ssh://") { + // Convert SSH to HTTPS + let parts: Vec<&str> = stripped_url + .trim_start_matches("ssh://") + .split('/') + .collect(); + if parts.len() >= 2 { + // Construct the HTTPS URL + return Ok(format!("https://{}/{}", parts[0], parts[1..].join("/"))); + } + bail!("Invalid SSH URL format: {}", url); + } + // Convert ftp:// to https:// + else if stripped_url.starts_with("ftp://") { + return Ok(stripped_url.replace("ftp://", "https://")); + } + // Convert git:// to https:// + else if stripped_url.starts_with("git://") { + return Ok(stripped_url.replace("git://", "https://")); + } + + // If the URL is neither HTTPS, SSH, nor git@, return an error + bail!("Unsupported URL protocol: {}", url); +} + +// Function to strip username and password from the URL +fn strip_credentials(url: &str) -> String { + if let Some(pos) = url.find("://") { + let (protocol, rest) = url.split_at(pos + 3); // Split at "://" + let rest_parts: Vec<&str> = rest.split('@').collect(); + if rest_parts.len() > 1 { + // If there are credentials, return the URL without them + return format!("{}{}", protocol, rest_parts[1]); + } + } else if let Some(at_pos) = url.find('@') { + // Handle user@host:path format + let (_, rest) = url.split_at(at_pos); + // This is a git@ syntax + let host_and_repo = &rest[1..]; // Skip the ':' + return format!("ssh://{}", host_and_repo.replace(':', "/")); + } + url.to_string() // Return the original URL if no credentials are found +} + +#[cfg(test)] +mod tests { + use std::fs; + + use test_utils::{generate_repo_ref_event, git::GitTestRepo}; + + use super::*; + + mod git_config_item_local { + use super::*; + + #[test] + fn save_git_config_item_returns_ok() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.save_git_config_item("test.item", "testvalue", false)?; + Ok(()) + } + + #[test] + fn get_git_config_item_returns_item_just_saved() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.save_git_config_item("test.item", "testvalue", false)?; + assert_eq!( + git_repo + .get_git_config_item("test.item", Some(false))? + .unwrap(), + "testvalue", + ); + Ok(()) + } + + #[test] + fn get_git_config_item_returns_none_if_not_present() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + assert_eq!( + git_repo.get_git_config_item("test.item", Some(false))?, + None + ); + Ok(()) + } + + #[test] + fn get_git_config_item_empty_string_returns_empty_string_instead_of_none() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.save_git_config_item("test.item", "", false)?; + assert_eq!( + git_repo.get_git_config_item("test.item", Some(false))?, + Some("".to_string()), + ); + Ok(()) + } + + #[test] + fn remove_local_git_config_item() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.save_git_config_item("test.item", "testvalue", false)?; + assert!(git_repo.remove_git_config_item("test.item", false)?); + assert_eq!( + git_repo.get_git_config_item("test.item", Some(false))?, + None, + ); + Ok(()) + } + + #[test] + fn remove_git_config_item_returns_false_if_item_wasnt_set() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + assert!(!(git_repo.remove_git_config_item("test.item", false)?)); + Ok(()) + } + } + + #[test] + fn get_commit_parent() -> Result<()> { + let test_repo = GitTestRepo::default(); + let parent_oid = test_repo.populate()?; + std::fs::write(test_repo.dir.join("t100.md"), "some content")?; + let child_oid = test_repo.stage_and_commit("add t100.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + assert_eq!( + // Sha1Hash::from_byte_array("bla".to_string().as_bytes()), + oid_to_sha1(&parent_oid), + git_repo.get_commit_parent(&oid_to_sha1(&child_oid))?, + ); + Ok(()) + } + + mod get_commit_message { + use super::*; + fn run(message: &str) -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + std::fs::write(test_repo.dir.join("t100.md"), "some content")?; + let oid = test_repo.stage_and_commit(message)?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + assert_eq!(message, git_repo.get_commit_message(&oid_to_sha1(&oid))?,); + Ok(()) + } + #[test] + fn one_liner() -> Result<()> { + run("add t100.md") + } + + #[test] + fn multiline() -> Result<()> { + run("add t100.md\r\nanother line\r\nthird line") + } + + #[test] + fn trailing_newlines() -> Result<()> { + run("add t100.md\r\n\r\n\r\n\r\n\r\n\r\n") + } + + #[test] + fn unicode_characters() -> Result<()> { + run("add t100.md ❤️") + } + } + + mod get_commit_message_summary { + use super::*; + fn run(message: &str, summary: &str) -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + std::fs::write(test_repo.dir.join("t100.md"), "some content")?; + let oid = test_repo.stage_and_commit(message)?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + assert_eq!( + summary, + git_repo.get_commit_message_summary(&oid_to_sha1(&oid))?, + ); + Ok(()) + } + #[test] + fn one_liner() -> Result<()> { + run("add t100.md", "add t100.md") + } + + #[test] + fn multiline() -> Result<()> { + run("add t100.md\r\nanother line\r\nthird line", "add t100.md") + } + + #[test] + fn trailing_newlines() -> Result<()> { + run("add t100.md\r\n\r\n\r\n\r\n\r\n\r\n", "add t100.md") + } + + #[test] + fn unicode_characters() -> Result<()> { + run("add t100.md ❤️", "add t100.md ❤️") + } + } + + mod get_commit_author { + use super::*; + + static NAME: &str = "carole"; + static EMAIL: &str = "carole@pm.me"; + + fn prep(time: &git2::Time) -> Result> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + fs::write(test_repo.dir.join("x1.md"), "some content")?; + let oid = test_repo.stage_and_commit_custom_signature( + "add x1.md", + Some(&git2::Signature::new(NAME, EMAIL, time)?), + None, + )?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.get_commit_author(&oid_to_sha1(&oid)) + } + + #[test] + fn name() -> Result<()> { + let res = prep(&git2::Time::new(5000, 0))?; + assert_eq!(NAME, res[0]); + Ok(()) + } + + #[test] + fn email() -> Result<()> { + let res = prep(&git2::Time::new(5000, 0))?; + assert_eq!(EMAIL, res[1]); + Ok(()) + } + + mod time { + use super::*; + + #[test] + fn no_offset() -> Result<()> { + let res = prep(&git2::Time::new(5000, 0))?; + assert_eq!("5000", res[2]); + assert_eq!("0", res[3]); + Ok(()) + } + #[test] + fn positive_offset() -> Result<()> { + let res = prep(&git2::Time::new(5000, 300))?; + assert_eq!("5000", res[2]); + assert_eq!("300", res[3]); + Ok(()) + } + #[test] + fn negative_offset() -> Result<()> { + let res = prep(&git2::Time::new(5000, -300))?; + assert_eq!("5000", res[2]); + assert_eq!("-300", res[3]); + Ok(()) + } + } + + mod extract_sig_from_patch_tags { + use super::*; + + fn test(time: git2::Time) -> Result<()> { + assert_eq!( + extract_sig_from_patch_tags( + &[nostr::Tag::custom( + nostr::TagKind::Custom("author".to_string().into()), + prep(&time)?, + )], + "author", + )? + .to_string(), + git2::Signature::new(NAME, EMAIL, &time)?.to_string(), + ); + Ok(()) + } + + #[test] + fn no_offset() -> Result<()> { + test(git2::Time::new(5000, 0)) + } + + #[test] + fn positive_offset() -> Result<()> { + test(git2::Time::new(5000, 300)) + } + + #[test] + fn negative_offset() -> Result<()> { + test(git2::Time::new(5000, -300)) + } + } + } + + mod get_commit_comitter { + use super::*; + + static NAME: &str = "carole"; + static EMAIL: &str = "carole@pm.me"; + + fn prep(time: &git2::Time) -> Result> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + fs::write(test_repo.dir.join("x1.md"), "some content")?; + let oid = test_repo.stage_and_commit_custom_signature( + "add x1.md", + None, + Some(&git2::Signature::new(NAME, EMAIL, time)?), + )?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.get_commit_comitter(&oid_to_sha1(&oid)) + } + + #[test] + fn name() -> Result<()> { + let res = prep(&git2::Time::new(5000, 0))?; + assert_eq!(NAME, res[0]); + Ok(()) + } + + #[test] + fn email() -> Result<()> { + let res = prep(&git2::Time::new(5000, 0))?; + assert_eq!(EMAIL, res[1]); + Ok(()) + } + } + + mod does_commit_exist { + use super::*; + + #[test] + fn existing_commits_results_in_true() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + + assert!(git_repo.does_commit_exist("431b84edc0d2fa118d63faa3c2db9c73d630a5ae")?); + Ok(()) + } + + #[test] + fn correctly_formatted_hash_that_doesnt_correspond_to_an_existing_commit_results_in_false() + -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + + assert!(!git_repo.does_commit_exist("000004edc0d2fa118d63faa3c2db9c73d630a5ae")?); + Ok(()) + } + + #[test] + fn incorrectly_formatted_hash_that_doesnt_correspond_to_an_existing_commit_results_in_error() + -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + + assert!(git_repo.does_commit_exist("00").is_ok()); + Ok(()) + } + } + + mod make_patch_from_commit { + use super::*; + #[test] + fn simple_patch_matches_string() -> Result<()> { + let test_repo = GitTestRepo::default(); + let oid = test_repo.populate()?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + assert_eq!( + "\ + From 431b84edc0d2fa118d63faa3c2db9c73d630a5ae Mon Sep 17 00:00:00 2001\n\ + From: Joe Bloggs \n\ + Date: Thu, 1 Jan 1970 00:00:00 +0000\n\ + Subject: [PATCH] add t2.md\n\ + \n\ + ---\n \ + t2.md | 1 +\n \ + 1 file changed, 1 insertion(+)\n \ + create mode 100644 t2.md\n\ + \n\ + diff --git a/t2.md b/t2.md\n\ + new file mode 100644\n\ + index 0000000..a66525d\n\ + --- /dev/null\n\ + +++ b/t2.md\n\ + @@ -0,0 +1 @@\n\ + +some content1\n\\ \ + No newline at end of file\n\ + --\n\ + libgit2 1.7.2\n\ + \n\ + ", + git_repo.make_patch_from_commit(&oid_to_sha1(&oid), &None)?, + ); + Ok(()) + } + + #[test] + fn series_count() -> Result<()> { + let test_repo = GitTestRepo::default(); + let oid = test_repo.populate()?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + assert_eq!( + "\ + From 431b84edc0d2fa118d63faa3c2db9c73d630a5ae Mon Sep 17 00:00:00 2001\n\ + From: Joe Bloggs \n\ + Date: Thu, 1 Jan 1970 00:00:00 +0000\n\ + Subject: [PATCH 3/5] add t2.md\n\ + \n\ + ---\n \ + t2.md | 1 +\n \ + 1 file changed, 1 insertion(+)\n \ + create mode 100644 t2.md\n\ + \n\ + diff --git a/t2.md b/t2.md\n\ + new file mode 100644\n\ + index 0000000..a66525d\n\ + --- /dev/null\n\ + +++ b/t2.md\n\ + @@ -0,0 +1 @@\n\ + +some content1\n\\ \ + No newline at end of file\n\ + --\n\ + libgit2 1.7.2\n\ + \n\ + ", + git_repo.make_patch_from_commit(&oid_to_sha1(&oid), &Some((3, 5)))?, + ); + Ok(()) + } + } + + mod get_main_or_master_branch { + + use super::*; + + #[test] + fn return_origin_main_if_exists() -> Result<()> { + let test_origin_repo = GitTestRepo::new("main")?; + let main_origin_oid = test_origin_repo.populate()?; + + let test_repo = GitTestRepo::new("main")?; + test_repo.populate()?; + test_repo.add_remote("origin", test_origin_repo.dir.to_str().unwrap())?; + test_repo + .git_repo + .find_remote("origin")? + .fetch(&["main"], None, None)?; + + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + test_repo.stage_and_commit("add t3.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + let (name, commit_hash) = git_repo.get_main_or_master_branch()?; + assert_eq!(name, "origin/main"); + assert_eq!(commit_hash, oid_to_sha1(&main_origin_oid)); + Ok(()) + } + + mod returns_main { + use super::*; + #[test] + fn when_it_exists() -> Result<()> { + let test_repo = GitTestRepo::new("main")?; + let main_oid = test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + let (name, commit_hash) = git_repo.get_main_or_master_branch()?; + assert_eq!(name, "main"); + assert_eq!(commit_hash, oid_to_sha1(&main_oid)); + Ok(()) + } + + #[test] + fn when_it_exists_and_other_branch_checkedout() -> Result<()> { + let test_repo = GitTestRepo::new("main")?; + let main_oid = test_repo.populate()?; + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let feature_oid = test_repo.stage_and_commit("add t3.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + let (name, commit_hash) = git_repo.get_main_or_master_branch()?; + assert_eq!(name, "main"); + assert_eq!(commit_hash, oid_to_sha1(&main_oid)); + assert_ne!(commit_hash, oid_to_sha1(&feature_oid)); + Ok(()) + } + + #[test] + fn when_exists_even_if_master_is_checkedout() -> Result<()> { + let test_repo = GitTestRepo::new("main")?; + let main_oid = test_repo.populate()?; + test_repo.create_branch("master")?; + test_repo.checkout("master")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let master_oid = test_repo.stage_and_commit("add t3.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + let (name, commit_hash) = git_repo.get_main_or_master_branch()?; + assert_eq!(name, "main"); + assert_eq!(commit_hash, oid_to_sha1(&main_oid)); + assert_ne!(commit_hash, oid_to_sha1(&master_oid)); + Ok(()) + } + } + + #[test] + fn returns_master_if_exists_and_main_doesnt() -> Result<()> { + let test_repo = GitTestRepo::new("master")?; + let master_oid = test_repo.populate()?; + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let feature_oid = test_repo.stage_and_commit("add t3.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + let (name, commit_hash) = git_repo.get_main_or_master_branch()?; + assert_eq!(name, "master"); + assert_eq!(commit_hash, oid_to_sha1(&master_oid)); + assert_ne!(commit_hash, oid_to_sha1(&feature_oid)); + Ok(()) + } + #[test] + fn returns_error_if_no_main_or_master() -> Result<()> { + let test_repo = GitTestRepo::new("feature")?; + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + assert!(git_repo.get_main_or_master_branch().is_err()); + Ok(()) + } + } + + mod get_origin_url { + use super::*; + + #[test] + fn returns_origin_url() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.add_remote("origin", "https://localhost:1000")?; + let git_repo = Repo::from_path(&test_repo.dir)?; + assert_eq!(git_repo.get_origin_url()?, "https://localhost:1000"); + Ok(()) + } + } + mod get_checked_out_branch_name { + use super::*; + + #[test] + fn returns_checked_out_branch_name() -> Result<()> { + let test_repo = GitTestRepo::default(); + let _ = test_repo.populate()?; + // create feature branch + test_repo.create_branch("example-feature")?; + test_repo.checkout("example-feature")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + assert_eq!( + git_repo.get_checked_out_branch_name()?, + "example-feature".to_string() + ); + Ok(()) + } + } + + mod get_commits_ahead_behind { + use super::*; + mod returns_main { + use super::*; + + #[test] + fn when_on_same_commit_return_empty() -> Result<()> { + let test_repo = GitTestRepo::default(); + let oid = test_repo.populate()?; + // create feature branch + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + let (ahead, behind) = + git_repo.get_commits_ahead_behind(&oid_to_sha1(&oid), &oid_to_sha1(&oid))?; + assert_eq!(ahead, vec![]); + assert_eq!(behind, vec![]); + Ok(()) + } + + #[test] + fn when_2_commit_behind() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch + test_repo.create_branch("feature")?; + let feature_oid = test_repo.checkout("feature")?; + // checkout main and add 2 commits + test_repo.checkout("main")?; + std::fs::write(test_repo.dir.join("t5.md"), "some content")?; + let behind_1_oid = test_repo.stage_and_commit("add t5.md")?; + std::fs::write(test_repo.dir.join("t6.md"), "some content")?; + let behind_2_oid = test_repo.stage_and_commit("add t6.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + let (ahead, behind) = git_repo.get_commits_ahead_behind( + &oid_to_sha1(&behind_2_oid), + &oid_to_sha1(&feature_oid), + )?; + assert_eq!(ahead, vec![]); + assert_eq!( + behind, + vec![oid_to_sha1(&behind_2_oid), oid_to_sha1(&behind_1_oid),], + ); + Ok(()) + } + + #[test] + fn when_2_commit_ahead() -> Result<()> { + let test_repo = GitTestRepo::default(); + let main_oid = test_repo.populate()?; + // create feature branch and add 2 commits + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + let (ahead, behind) = git_repo.get_commits_ahead_behind( + &oid_to_sha1(&main_oid), + &oid_to_sha1(&ahead_2_oid), + )?; + assert_eq!( + ahead, + vec![oid_to_sha1(&ahead_2_oid), oid_to_sha1(&ahead_1_oid),], + ); + assert_eq!(behind, vec![]); + Ok(()) + } + + #[test] + fn when_2_commit_ahead_and_2_commits_behind() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch and add 2 commits + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; + // checkout main and add 2 commits + test_repo.checkout("main")?; + std::fs::write(test_repo.dir.join("t5.md"), "some content")?; + let behind_1_oid = test_repo.stage_and_commit("add t5.md")?; + std::fs::write(test_repo.dir.join("t6.md"), "some content")?; + let behind_2_oid = test_repo.stage_and_commit("add t6.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + let (ahead, behind) = git_repo.get_commits_ahead_behind( + &oid_to_sha1(&behind_2_oid), + &oid_to_sha1(&ahead_2_oid), + )?; + assert_eq!( + ahead, + vec![oid_to_sha1(&ahead_2_oid), oid_to_sha1(&ahead_1_oid)], + ); + assert_eq!( + behind, + vec![oid_to_sha1(&behind_2_oid), oid_to_sha1(&behind_1_oid)], + ); + Ok(()) + } + } + } + + mod create_branch_at_commit { + use super::*; + #[test] + fn doesnt_error() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch and add 2 commits + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + test_repo.stage_and_commit("add t4.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + let branch_name = "test-name-1"; + git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; + + Ok(()) + } + + #[test] + fn branch_gets_created() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch and add 2 commits + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + test_repo.stage_and_commit("add t4.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + let branch_name = "test-name-1"; + git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; + + assert!(test_repo.checkout(branch_name).is_ok()); + Ok(()) + } + + #[test] + fn branch_created_with_correct_commit() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch and add 2 commits + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + test_repo.stage_and_commit("add t4.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + let branch_name = "test-name-1"; + git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; + + assert_eq!(test_repo.checkout(branch_name)?, ahead_1_oid); + Ok(()) + } + + mod when_branch_already_exists { + use super::*; + + #[test] + fn when_new_tip_specified_it_is_updated() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch and add 2 commits + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + let branch_name = "test-name-1"; + git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; + + git_repo.create_branch_at_commit(branch_name, &ahead_2_oid.to_string())?; + assert_eq!(test_repo.checkout(branch_name)?, ahead_2_oid); + Ok(()) + } + + #[test] + fn when_same_tip_is_specified_it_doesnt_error() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch and add 2 commits + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + test_repo.stage_and_commit("add t4.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + let branch_name = "test-name-1"; + git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; + + git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; + assert_eq!(test_repo.checkout(branch_name)?, ahead_1_oid); + Ok(()) + } + + #[test] + fn when_branch_is_checkedout_new_tip_specified_it_is_updated() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch and add 2 commits + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + let branch_name = "test-name-1"; + git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; + test_repo.checkout(branch_name)?; + git_repo.create_branch_at_commit(branch_name, &ahead_2_oid.to_string())?; + test_repo.checkout("main")?; + + assert_eq!(test_repo.checkout(branch_name)?, ahead_2_oid); + Ok(()) + } + } + } + + mod create_commit_from_patch { + + use test_utils::TEST_KEY_1_SIGNER; + + use super::*; + use crate::{repo_ref::RepoRef, sub_commands::send::generate_patch_event}; + + async fn generate_patch_from_head_commit(test_repo: &GitTestRepo) -> Result { + let original_oid = test_repo.git_repo.head()?.peel_to_commit()?.id(); + let git_repo = Repo::from_path(&test_repo.dir)?; + generate_patch_event( + &git_repo, + &git_repo.get_root_commit()?, + &oid_to_sha1(&original_oid), + Some(nostr::EventId::all_zeros()), + &TEST_KEY_1_SIGNER, + &RepoRef::try_from(generate_repo_ref_event()).unwrap(), + None, + None, + None, + &None, + &[], + ) + .await + } + fn test_patch_applies_to_repository(patch_event: nostr::Event) -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + println!("{:?}", &patch_event); + git_repo.create_commit_from_patch(&patch_event)?; + let commit_id = tag_value(&patch_event, "commit")?; + // does commit with id exist? + assert!(git_repo.does_commit_exist(&commit_id)?); + Ok(()) + } + + mod patch_created_as_commit_with_matching_id { + use test_utils::git::joe_signature; + + use super::*; + + #[tokio::test] + async fn simple_signature_author_committer_same_as_git_user_0_unixtime_no_pgp_signature() + -> Result<()> { + let source_repo = GitTestRepo::default(); + source_repo.populate()?; + fs::write(source_repo.dir.join("x1.md"), "some content")?; + source_repo.stage_and_commit("add x1.md")?; + + test_patch_applies_to_repository( + generate_patch_from_head_commit(&source_repo).await?, + ) + } + + #[tokio::test] + async fn signature_with_specific_author_time() -> Result<()> { + let source_repo = GitTestRepo::default(); + source_repo.populate()?; + fs::write(source_repo.dir.join("x1.md"), "some content")?; + source_repo.stage_and_commit_custom_signature( + "add x1.md", + Some(&git2::Signature::new( + joe_signature().name().unwrap(), + joe_signature().email().unwrap(), + &git2::Time::new(5000, 0), + )?), + None, + )?; + + test_patch_applies_to_repository( + generate_patch_from_head_commit(&source_repo).await?, + ) + } + + #[tokio::test] + async fn author_name_and_email_not_current_git_user() -> Result<()> { + let source_repo = GitTestRepo::default(); + source_repo.populate()?; + fs::write(source_repo.dir.join("x1.md"), "some content")?; + source_repo.stage_and_commit_custom_signature( + "add x1.md", + Some(&git2::Signature::new( + "carole", + "carole@pm.me", + &git2::Time::new(0, 0), + )?), + None, + )?; + + test_patch_applies_to_repository( + generate_patch_from_head_commit(&source_repo).await?, + ) + } + + #[tokio::test] + async fn comiiter_name_and_email_not_current_git_user_or_author() -> Result<()> { + let source_repo = GitTestRepo::default(); + source_repo.populate()?; + fs::write(source_repo.dir.join("x1.md"), "some content")?; + source_repo.stage_and_commit_custom_signature( + "add x1.md", + Some(&git2::Signature::new( + "carole", + "carole@pm.me", + &git2::Time::new(0, 0), + )?), + Some(&git2::Signature::new( + "bob", + "bob@pm.me", + &git2::Time::new(0, 0), + )?), + )?; + + test_patch_applies_to_repository( + generate_patch_from_head_commit(&source_repo).await?, + ) + } + + // TODO: pgp signature + + #[tokio::test] + async fn unique_author_and_commiter_details() -> Result<()> { + let source_repo = GitTestRepo::default(); + source_repo.populate()?; + fs::write(source_repo.dir.join("x1.md"), "some content")?; + source_repo.stage_and_commit_custom_signature( + "add x1.md", + Some(&git2::Signature::new( + "carole", + "carole@pm.me", + &git2::Time::new(5000, 0), + )?), + Some(&git2::Signature::new( + "bob", + "bob@pm.me", + &git2::Time::new(1000, 0), + )?), + )?; + + test_patch_applies_to_repository( + generate_patch_from_head_commit(&source_repo).await?, + ) + } + } + } + + mod apply_patch_chain { + use test_utils::TEST_KEY_1_SIGNER; + + use super::*; + use crate::{ + repo_ref::RepoRef, sub_commands::send::generate_cover_letter_and_patch_events, + }; + + static BRANCH_NAME: &str = "add-example-feature"; + // returns original_repo, cover_letter_event, patch_events + async fn generate_test_repo_and_events() + -> Result<(GitTestRepo, nostr::Event, Vec)> { + let original_repo = GitTestRepo::default(); + let oid3 = original_repo.populate_with_test_branch()?; + let oid2 = original_repo.git_repo.find_commit(oid3)?.parent_id(0)?; + let oid1 = original_repo.git_repo.find_commit(oid2)?.parent_id(0)?; + // TODO: generate cover_letter and patch events + let git_repo = Repo::from_path(&original_repo.dir)?; + + let mut events = generate_cover_letter_and_patch_events( + Some(("test".to_string(), "test".to_string())), + &git_repo, + &[oid_to_sha1(&oid1), oid_to_sha1(&oid2), oid_to_sha1(&oid3)], + &TEST_KEY_1_SIGNER, + &RepoRef::try_from(generate_repo_ref_event()).unwrap(), + &None, + &[], + ) + .await?; + + events.reverse(); + + Ok((original_repo, events.pop().unwrap(), events)) + } + + mod when_branch_and_commits_dont_exist { + use super::*; + + mod when_branch_root_is_tip_of_main { + use super::*; + + #[tokio::test] + async fn branch_gets_created_with_name_specified_in_proposal() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert!( + git_repo + .get_local_branch_names()? + .contains(&BRANCH_NAME.to_string()) + ); + Ok(()) + } + + #[tokio::test] + async fn branch_checked_out() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!( + git_repo.get_checked_out_branch_name()?, + BRANCH_NAME.to_string(), + ); + Ok(()) + } + + #[tokio::test] + async fn patches_get_created_as_commits() -> Result<()> { + let (original_repo, _, patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!( + test_repo.git_repo.head()?.peel_to_commit()?.id(), + original_repo.git_repo.head()?.peel_to_commit()?.id(), + ); + Ok(()) + } + + #[tokio::test] + async fn branch_tip_is_most_recent_patch() -> Result<()> { + let (original_repo, _, patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!( + git_repo.get_tip_of_branch(BRANCH_NAME)?, + oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),), + ); + Ok(()) + } + + #[tokio::test] + async fn previously_checked_out_branch_tip_does_not_change() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let existing_branch = test_repo.get_checked_out_branch_name()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + let previous_tip_of_existing_branch = + git_repo.get_tip_of_branch(existing_branch.as_str())?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!( + previous_tip_of_existing_branch, + git_repo.get_tip_of_branch(existing_branch.as_str())?, + ); + Ok(()) + } + + #[tokio::test] + async fn returns_all_patches_applied() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!(res.len(), 3); + Ok(()) + } + } + + mod when_branch_root_is_tip_behind_main { + use super::*; + + #[tokio::test] + async fn branch_gets_created_with_name_specified_in_proposal() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + std::fs::write(test_repo.dir.join("m3.md"), "some content")?; + test_repo.stage_and_commit("add m3.md")?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert!( + git_repo + .get_local_branch_names()? + .contains(&BRANCH_NAME.to_string()) + ); + Ok(()) + } + + #[tokio::test] + async fn branch_checked_out() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + std::fs::write(test_repo.dir.join("m3.md"), "some content")?; + test_repo.stage_and_commit("add m3.md")?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!( + git_repo.get_checked_out_branch_name()?, + BRANCH_NAME.to_string(), + ); + Ok(()) + } + + #[tokio::test] + async fn branch_tip_is_most_recent_patch() -> Result<()> { + let (original_repo, _, patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + std::fs::write(test_repo.dir.join("m3.md"), "some content")?; + test_repo.stage_and_commit("add m3.md")?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!( + git_repo.get_tip_of_branch(BRANCH_NAME)?, + oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),), + ); + Ok(()) + } + + #[tokio::test] + async fn previously_checked_out_branch_tip_does_not_change() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + std::fs::write(test_repo.dir.join("m3.md"), "some content")?; + test_repo.stage_and_commit("add m3.md")?; + let existing_branch = test_repo.get_checked_out_branch_name()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + let previous_tip_of_existing_branch = + git_repo.get_tip_of_branch(existing_branch.as_str())?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!( + previous_tip_of_existing_branch, + git_repo.get_tip_of_branch(existing_branch.as_str())?, + ); + Ok(()) + } + + #[tokio::test] + async fn returns_all_patches_applied() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!(res.len(), 3); + Ok(()) + } + } + + // TODO when_proposal_root_is_tip_ahead_of_main_and_doesnt_exist + } + + mod when_branch_and_first_commits_exists { + use super::*; + + mod when_branch_already_checked_out { + use super::*; + + #[tokio::test] + async fn branch_tip_is_most_recent_patch() -> Result<()> { + let (original_repo, _, mut patch_events) = + generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + + assert_eq!( + git_repo.get_tip_of_branch(BRANCH_NAME)?, + oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),), + ); + Ok(()) + } + + #[tokio::test] + async fn returns_all_patches_applied() -> Result<()> { + let (_, _, mut patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; + let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!(res.len(), 2); + Ok(()) + } + } + mod when_branch_not_checked_out { + use super::*; + + #[tokio::test] + async fn branch_tip_is_most_recent_patch() -> Result<()> { + let (original_repo, _, mut patch_events) = + generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; + git_repo.checkout("main")?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + + assert_eq!( + git_repo.get_tip_of_branch(BRANCH_NAME)?, + oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),), + ); + Ok(()) + } + + #[tokio::test] + async fn branch_checked_out() -> Result<()> { + let (_, _, mut patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; + git_repo.checkout("main")?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + + assert_eq!( + git_repo.get_checked_out_branch_name()?, + BRANCH_NAME.to_string(), + ); + Ok(()) + } + + #[tokio::test] + async fn returns_all_patches_applied() -> Result<()> { + let (_, _, mut patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; + git_repo.checkout("main")?; + let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!(res.len(), 2); + Ok(()) + } + } + // TODO when branch ahead (rebased or user commits) + } + mod when_branch_exists_and_is_up_to_date { + use super::*; + + mod when_branch_already_checked_out { + use super::*; + + #[tokio::test] + async fn returns_all_patches_applied_0() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events.clone())?; + let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!(res.len(), 0); + Ok(()) + } + } + mod when_branch_not_checked_out { + use super::*; + + #[tokio::test] + async fn branch_checked_out() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events.clone())?; + git_repo.checkout("main")?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + + assert_eq!( + git_repo.get_checked_out_branch_name()?, + BRANCH_NAME.to_string(), + ); + Ok(()) + } + + #[tokio::test] + async fn returns_all_patches_applied_0() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events().await?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events.clone())?; + git_repo.checkout("main")?; + let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!(res.len(), 0); + Ok(()) + } + } + } + } + mod parse_starting_commits { + use super::*; + + mod head_1_returns_latest_commit { + use super::*; + + #[test] + fn when_on_main_and_other_commits_are_more_recent_on_feature_branch() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + test_repo.populate_with_test_branch()?; + test_repo.checkout("main")?; + + assert_eq!( + git_repo.parse_starting_commits("HEAD~1")?, + vec![str_to_sha1("431b84edc0d2fa118d63faa3c2db9c73d630a5ae")?], + ); + Ok(()) + } + + #[test] + fn when_checked_out_branch_ahead_of_main() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + test_repo.populate_with_test_branch()?; + + assert_eq!( + git_repo.parse_starting_commits("HEAD~1")?, + vec![str_to_sha1("82ff2bcc9aa94d1bd8faee723d4c8cc190d6061c")?], + ); + Ok(()) + } + } + mod head_2_returns_latest_2_commits_youngest_first { + use super::*; + + #[test] + fn when_on_main_and_other_commits_are_more_recent_on_feature_branch() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + test_repo.populate_with_test_branch()?; + test_repo.checkout("main")?; + + assert_eq!( + git_repo.parse_starting_commits("HEAD~2")?, + vec![ + str_to_sha1("431b84edc0d2fa118d63faa3c2db9c73d630a5ae")?, + str_to_sha1("af474d8d271490e5c635aad337abdc050034b16a")?, + ], + ); + Ok(()) + } + } + mod head_3_returns_latest_3_commits_youngest_first { + use super::*; + + #[test] + fn when_checked_out_branch_ahead_of_main() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + test_repo.populate_with_test_branch()?; + + assert_eq!( + git_repo.parse_starting_commits("HEAD~3")?, + vec![ + str_to_sha1("82ff2bcc9aa94d1bd8faee723d4c8cc190d6061c")?, + str_to_sha1("a23e6b05aaeb7d1471b4a838b51f337d5644eeb0")?, + str_to_sha1("7ab82116068982671a8111f27dc10599172334b2")?, + ], + ); + Ok(()) + } + } + mod range_of_3_commits_not_in_branch_history_returns_3_commits_youngest_first { + use super::*; + + #[test] + fn when_checked_out_branch_ahead_of_main() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + test_repo.populate_with_test_branch()?; + test_repo.checkout("main")?; + + assert_eq!( + git_repo.parse_starting_commits("af474d8..a23e6b0")?, + vec![ + str_to_sha1("a23e6b05aaeb7d1471b4a838b51f337d5644eeb0")?, + str_to_sha1("7ab82116068982671a8111f27dc10599172334b2")?, + str_to_sha1("431b84edc0d2fa118d63faa3c2db9c73d630a5ae")?, + ], + ); + Ok(()) + } + } + } + mod ancestor_of { + use super::*; + + #[test] + fn deep_ancestor_returns_true() -> Result<()> { + let test_repo = GitTestRepo::default(); + let from_main_in_feature_history = test_repo.populate()?; + + // create feature branch and add 2 commits + test_repo.create_branch("feature")?; + + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + assert!(git_repo.ancestor_of( + &oid_to_sha1(&ahead_2_oid), + &oid_to_sha1(&from_main_in_feature_history) + )?); + Ok(()) + } + + #[test] + fn commit_parent_returns_true() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + + // create feature branch and add 2 commits + test_repo.create_branch("feature")?; + + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + assert!(git_repo.ancestor_of(&oid_to_sha1(&ahead_2_oid), &oid_to_sha1(&ahead_1_oid))?); + Ok(()) + } + + #[test] + fn same_commit_returns_false() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + + // create feature branch and add 2 commits + test_repo.create_branch("feature")?; + + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + assert!(!git_repo.ancestor_of(&oid_to_sha1(&ahead_2_oid), &oid_to_sha1(&ahead_2_oid))?); + Ok(()) + } + + #[test] + fn commit_not_in_history_returns_false() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + + // create feature branch and add 2 commits + test_repo.create_branch("feature")?; + + // create commit not in feature history + std::fs::write(test_repo.dir.join("notfeature.md"), "some content")?; + let on_main_after_feature = test_repo.stage_and_commit("add notfeature.md")?; + + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + assert!(!git_repo.ancestor_of( + &oid_to_sha1(&ahead_2_oid), + &oid_to_sha1(&on_main_after_feature) + )?); + Ok(()) + } + } + mod convert_clone_url_to_https { + use super::*; + + #[test] + fn test_https_url() { + let url = "https://github.com/user/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://github.com/user/repo.git"); + } + + #[test] + fn test_http_url() { + let url = "http://github.com/user/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://github.com/user/repo.git"); + } + + #[test] + fn test_http_url_with_credentials() { + let url = "http://username:password@github.com/user/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://github.com/user/repo.git"); + } + + #[test] + fn test_git_at_url() { + let url = "git@github.com:user/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://github.com/user/repo.git"); + } + + #[test] + fn test_user_at_url() { + let url = "user1@github.com:user/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://github.com/user/repo.git"); + } + + #[test] + fn test_ssh_url() { + let url = "ssh://github.com/user/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://github.com/user/repo.git"); + } + + #[test] + fn test_ftp_url() { + let url = "ftp://example.com/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://example.com/repo.git"); + } + + #[test] + fn test_git_protocol_url() { + let url = "git://example.com/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://example.com/repo.git"); + } + + #[test] + fn test_invalid_url() { + let url = "unsupported://example.com/repo.git"; + let result = convert_clone_url_to_https(url); + assert!(result.is_err()); + } + } +} diff --git a/src/lib/login/key_encryption.rs b/src/lib/login/key_encryption.rs new file mode 100644 index 0000000..3841d50 --- /dev/null +++ b/src/lib/login/key_encryption.rs @@ -0,0 +1,105 @@ +use anyhow::Result; +use nostr::{prelude::*, Keys}; + +pub fn encrypt_key(keys: &Keys, password: &str) -> Result { + let log2_rounds: u8 = if password.len() > 20 { + // we have enough of entropy - no need to spend CPU time adding much more + 1 + } else { + println!("this may take a few seconds..."); + // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait + 15 + }; + Ok(nostr::nips::nip49::EncryptedSecretKey::new( + keys.secret_key()?, + password, + log2_rounds, + KeySecurity::Medium, + )? + .to_bech32()?) +} + +pub fn decrypt_key(encrypted_key: &str, password: &str) -> Result { + let encrypted_key = nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted_key)?; + // to request that log_n gets exposed + if encrypted_key.log_n() > 14 { + println!("this may take a few seconds..."); + } + Ok(nostr::Keys::new(encrypted_key.to_secret_key(password)?)) +} + +#[cfg(test)] +mod tests { + use test_utils::*; + + use super::*; + + #[test] + fn encrypt_key_produces_string_prefixed_with() -> Result<()> { + let s = encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?; + assert!(s.starts_with("ncryptsec")); + Ok(()) + } + + #[test] + // ensures password encryption hasn't changed + fn decrypts_with_strong_password_from_reference_string() -> Result<()> { + let decrypted_key = decrypt_key(TEST_KEY_1_ENCRYPTED, TEST_PASSWORD)?; + + assert_eq!( + format!( + "{}", + TEST_KEY_1_KEYS.secret_key().unwrap().to_bech32().unwrap() + ), + format!( + "{}", + decrypted_key.secret_key().unwrap().to_bech32().unwrap() + ), + ); + Ok(()) + } + + #[test] + // ensures password encryption hasn't changed + fn decrypts_with_weak_password_from_reference_string() -> Result<()> { + let decrypted_key = decrypt_key(TEST_KEY_1_ENCRYPTED_WEAK, TEST_WEAK_PASSWORD)?; + + assert_eq!( + format!( + "{}", + TEST_KEY_1_KEYS.secret_key().unwrap().to_bech32().unwrap() + ), + format!( + "{}", + decrypted_key.secret_key().unwrap().to_bech32().unwrap() + ), + ); + Ok(()) + } + + #[test] + fn decrypts_key_encrypted_using_encrypt_key() -> Result<()> { + let key = nostr::Keys::generate(); + let s = encrypt_key(&key, TEST_PASSWORD)?; + let newkey = decrypt_key(s.as_str(), TEST_PASSWORD)?; + + assert_eq!( + format!("{}", key.secret_key().unwrap().to_bech32().unwrap()), + format!("{}", newkey.secret_key().unwrap().to_bech32().unwrap()), + ); + Ok(()) + } + + #[test] + fn decrypt_key_successfully_decrypts_key_encrypted_using_encrypt_key() -> Result<()> { + let key = nostr::Keys::generate(); + let s = encrypt_key(&key, TEST_PASSWORD)?; + let newkey = decrypt_key(s.as_str(), TEST_PASSWORD)?; + + assert_eq!( + format!("{}", key.secret_key().unwrap().to_bech32().unwrap()), + format!("{}", newkey.secret_key().unwrap().to_bech32().unwrap()), + ); + Ok(()) + } +} diff --git a/src/lib/login/mod.rs b/src/lib/login/mod.rs new file mode 100644 index 0000000..19bb97c --- /dev/null +++ b/src/lib/login/mod.rs @@ -0,0 +1,695 @@ +use std::{collections::HashSet, path::Path, str::FromStr, time::Duration}; + +use anyhow::{bail, Context, Result}; +use nostr::{ + nips::{nip05, nip46::NostrConnectURI}, + PublicKey, +}; +use nostr_sdk::{ + Alphabet, FromBech32, JsonUtil, Keys, Kind, NostrSigner, SingleLetterTag, Timestamp, ToBech32, +}; +use nostr_signer::Nip46Signer; + +#[cfg(not(test))] +use crate::client::Client; +#[cfg(test)] +use crate::client::MockConnect; +use crate::{ + cli_interactor::{ + Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptPasswordParms, + }, + client::{fetch_public_key, get_event_from_global_cache, Connect}, + config::{UserMetadata, UserRef, UserRelayRef, UserRelays}, + git::{Repo, RepoActions}, + key_handling::encryption::{decrypt_key, encrypt_key}, +}; + +/// handles the encrpytion and storage of key material +#[allow(clippy::too_many_arguments)] +pub async fn launch( + git_repo: &Repo, + bunker_uri: &Option, + bunker_app_key: &Option, + nsec: &Option, + password: &Option, + #[cfg(test)] client: Option<&MockConnect>, + #[cfg(not(test))] client: Option<&Client>, + change_user: bool, + silent: bool, +) -> Result<(NostrSigner, UserRef)> { + if let Ok(signer) = match get_signer_without_prompts( + git_repo, + bunker_uri, + bunker_app_key, + nsec, + password, + change_user, + ) + .await + { + Ok(signer) => Ok(signer), + Err(error) => { + if error + .to_string() + .eq("git config item nostr.nsec is an ncryptsec") + { + println!( + "login as {}", + if let Ok(public_key) = PublicKey::from_bech32( + get_config_item(git_repo, "nostr.npub") + .unwrap_or("unknown ncryptsec".to_string()), + ) { + if let Ok(user_ref) = + get_user_details(&public_key, client, git_repo.get_path()?, silent) + .await + { + user_ref.metadata.name + } else { + "unknown ncryptsec".to_string() + } + } else { + "unknown ncryptsec".to_string() + } + ); + loop { + // prompt for password + let password = Interactor::default() + .password(PromptPasswordParms::default().with_prompt("password")) + .context("failed to get password input from interactor.password")?; + if let Ok(keys) = get_keys_with_password(git_repo, &password) { + break Ok(NostrSigner::Keys(keys)); + } + println!("incorrect password"); + } + } else { + if nsec.is_some() { + bail!(error); + } + Err(error) + } + } + } { + // get user ref + let user_ref = get_user_details( + &signer + .public_key() + .await + .context("cannot get public key from signer")?, + client, + git_repo.get_path()?, + silent, + ) + .await?; + if !silent { + print_logged_in_as(&user_ref, client.is_none())?; + } + Ok((signer, user_ref)) + } else if silent { + bail!("TODO: enable interactive login in nostr git remote helper"); + } else { + fresh_login(git_repo, client, change_user).await + } +} + +fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> { + if !offline_mode && user_ref.metadata.created_at.eq(&Timestamp::from(0)) { + println!("cannot find profile..."); + } else if !offline_mode && user_ref.metadata.name.eq(&user_ref.public_key.to_bech32()?) { + println!("cannot extract account name from account metadata..."); + } else if !offline_mode && user_ref.relays.created_at.eq(&Timestamp::from(0)) { + println!( + "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience." + ); + } + println!("logged in as {}", user_ref.metadata.name); + Ok(()) +} + +async fn get_signer_without_prompts( + git_repo: &Repo, + bunker_uri: &Option, + bunker_app_key: &Option, + nsec: &Option, + password: &Option, + save_local: bool, +) -> Result { + if let Some(nsec) = nsec { + Ok(NostrSigner::Keys(get_keys_from_nsec( + git_repo, nsec, password, save_local, + )?)) + } else if let Some(password) = password { + Ok(NostrSigner::Keys(get_keys_with_password( + git_repo, password, + )?)) + } else if let Some(bunker_uri) = bunker_uri { + if let Some(bunker_app_key) = bunker_app_key { + let signer = get_nip46_signer_from_uri_and_key(bunker_uri, bunker_app_key) + .await + .context("failed to connect with remote signer")?; + if save_local { + save_to_git_config( + git_repo, + &signer.public_key().await?.to_bech32()?, + &None, + &Some((bunker_uri.to_string(),bunker_app_key.to_string())), + false, + ) + .context("failed to save bunker details local git config nostr.bunker-uri and nostr.bunker-app-key")?; + } + Ok(signer) + } else { + bail!( + "bunker-app-key parameter must be provided alongside bunker-uri. if unknown, login interactively." + ) + } + } else if !save_local { + get_signer_with_git_config_nsec_or_bunker_without_prompts(git_repo).await + } else { + bail!("user wants prompts to specify new keys") + } +} + +fn get_keys_from_nsec( + git_repo: &Repo, + nsec: &String, + password: &Option, + save_local: bool, +) -> Result { + #[allow(unused_assignments)] + let mut s = String::new(); + let keys = if nsec.contains("ncryptsec") { + s = nsec.to_string(); + decrypt_key( + nsec, + password + .clone() + .context("password must be supplied when using ncryptsec as nsec parameter")? + .as_str(), + ) + .context("failed to decrypt key with provided password") + .context("failed to decrypt ncryptsec supplied as nsec with password")? + } else { + s = nsec.to_string(); + nostr::Keys::from_str(nsec).context("invalid nsec parameter")? + }; + if save_local { + if let Some(password) = password { + s = encrypt_key(&keys, password)?; + } + save_to_git_config( + git_repo, + &keys.public_key().to_bech32()?, + &Some(s), + &None, + false, + ) + .context("failed to save encrypted nsec in local git config nostr.nsec")?; + } + Ok(keys) +} + +fn save_to_git_config( + git_repo: &Repo, + npub: &str, + nsec: &Option, + bunker: &Option<(String, String)>, + global: bool, +) -> Result<()> { + if let Err(error) = silently_save_to_git_config(git_repo, npub, nsec, bunker, global) { + println!( + "failed to save login details to {} git config", + if global { "global" } else { "local" } + ); + if let Some(nsec) = nsec { + if nsec.contains("ncryptsec") { + println!("manually set git config nostr.nsec to: {nsec}"); + } else { + println!("manually set git config nostr.nsec"); + } + } + if let Some(bunker) = bunker { + println!("manually set git config as follows:"); + println!("nostr.bunker-uri: {}", bunker.0); + println!("nostr.bunker-app-key: {}", bunker.1); + } + Err(error) + } else { + println!( + "saved login details to {} git config", + if global { "global" } else { "local" } + ); + Ok(()) + } +} +fn silently_save_to_git_config( + git_repo: &Repo, + npub: &str, + nsec: &Option, + bunker: &Option<(String, String)>, + global: bool, +) -> Result<()> { + // must do this first otherwise it might remove the global items just added + if global { + git_repo.remove_git_config_item("nostr.npub", false)?; + git_repo.remove_git_config_item("nostr.nsec", false)?; + git_repo.remove_git_config_item("nostr.bunker-uri", false)?; + git_repo.remove_git_config_item("nostr.bunker-app-key", false)?; + } + if let Some(bunker) = bunker { + git_repo.remove_git_config_item("nostr.nsec", global)?; + git_repo.save_git_config_item("nostr.bunker-uri", &bunker.0, global)?; + git_repo.save_git_config_item("nostr.bunker-app-key", &bunker.1, global)?; + } + if let Some(nsec) = nsec { + git_repo.save_git_config_item("nostr.nsec", nsec, global)?; + git_repo.remove_git_config_item("nostr.bunker-uri", global)?; + git_repo.remove_git_config_item("nostr.bunker-app-key", global)?; + } + git_repo.save_git_config_item("nostr.npub", npub, global) +} + +fn get_keys_with_password(git_repo: &Repo, password: &str) -> Result { + decrypt_key( + &git_repo + .get_git_config_item("nostr.nsec", None) + .context("failed get git config")? + .context("git config item nostr.nsec doesn't exist so cannot decrypt it")?, + password, + ) + .context("failed to decrypt stored nsec key with provided password") +} + +async fn get_nip46_signer_from_uri_and_key(uri: &str, app_key: &str) -> Result { + let term = console::Term::stderr(); + term.write_line("connecting to remote signer...")?; + let uri = NostrConnectURI::parse(uri)?; + let signer = NostrSigner::nip46( + Nip46Signer::new( + uri, + nostr::Keys::from_str(app_key).context("invalid app key")?, + Duration::from_secs(30), + None, + ) + .await?, + ); + term.clear_last_lines(1)?; + Ok(signer) +} + +async fn get_signer_with_git_config_nsec_or_bunker_without_prompts( + git_repo: &Repo, +) -> Result { + if let Ok(local_nsec) = &git_repo + .get_git_config_item("nostr.nsec", Some(false)) + .context("failed get local git config")? + .context("git local config item nostr.nsec doesn't exist") + { + if local_nsec.contains("ncryptsec") { + bail!("git global config item nostr.nsec is an ncryptsec") + } + Ok(NostrSigner::Keys( + nostr::Keys::from_str(local_nsec).context("invalid nsec parameter")?, + )) + } else if let Ok((uri, app_key)) = get_git_config_bunker_uri_and_app_key(git_repo, Some(false)) + { + get_nip46_signer_from_uri_and_key(&uri, &app_key).await + } else if let Ok(global_nsec) = &git_repo + .get_git_config_item("nostr.nsec", Some(true)) + .context("failed get global git config")? + .context("git global config item nostr.nsec doesn't exist") + { + if global_nsec.contains("ncryptsec") { + bail!("git global config item nostr.nsec is an ncryptsec") + } + Ok(NostrSigner::Keys( + nostr::Keys::from_str(global_nsec).context("invalid nsec parameter")?, + )) + } else if let Ok((uri, app_key)) = get_git_config_bunker_uri_and_app_key(git_repo, Some(true)) { + get_nip46_signer_from_uri_and_key(&uri, &app_key).await + } else { + bail!("cannot get nsec or bunker from git config") + } +} + +fn get_git_config_bunker_uri_and_app_key( + git_repo: &Repo, + global: Option, +) -> Result<(String, String)> { + Ok(( + git_repo + .get_git_config_item("nostr.bunker-uri", global) + .context("failed get local git config")? + .context("git local config item nostr.bunker-uri doesn't exist")? + .to_string(), + git_repo + .get_git_config_item("nostr.bunker-app-key", global) + .context("failed get local git config")? + .context("git local config item nostr.bunker-app-key doesn't exist")? + .to_string(), + )) +} + +async fn fresh_login( + git_repo: &Repo, + #[cfg(test)] client: Option<&MockConnect>, + #[cfg(not(test))] client: Option<&Client>, + always_save: bool, +) -> Result<(NostrSigner, UserRef)> { + let mut public_key: Option = None; + // prompt for nsec + let mut prompt = "login with nostr address / nsec"; + let signer = loop { + let input = Interactor::default() + .input(PromptInputParms::default().with_prompt(prompt)) + .context("failed to get nsec input from interactor")?; + if let Ok(keys) = nostr::Keys::from_str(&input) { + if let Err(error) = save_keys(git_repo, &keys, always_save) { + println!("{error}"); + } + break NostrSigner::Keys(keys); + } + let uri = if let Ok(uri) = NostrConnectURI::parse(&input) { + uri + } else if input.contains('@') { + if let Ok(uri) = fetch_nip46_uri_from_nip05(&input).await { + uri + } else { + prompt = "failed. try again with nostr address / bunker uri / nsec"; + continue; + } + } else { + prompt = "invalid. try again with nostr address / bunker uri / nsec"; + continue; + }; + let app_key = Keys::generate().secret_key()?.to_secret_hex(); + match get_nip46_signer_from_uri_and_key(&uri.to_string(), &app_key).await { + Ok(signer) => { + let pub_key = fetch_public_key(&signer).await?; + if let Err(error) = + save_bunker(git_repo, &pub_key, &uri.to_string(), &app_key, always_save) + { + println!("{error}"); + } + public_key = Some(pub_key); + break signer; + } + Err(_) => { + prompt = "failed. try again with nostr address / bunker uri / nsec"; + } + } + }; + let public_key = if let Some(public_key) = public_key { + public_key + } else { + signer.public_key().await? + }; + // lookup profile + let user_ref = get_user_details(&public_key, client, git_repo.get_path()?, false).await?; + print_logged_in_as(&user_ref, client.is_none())?; + Ok((signer, user_ref)) +} + +pub async fn fetch_nip46_uri_from_nip05(nip05: &str) -> Result { + let term = console::Term::stderr(); + term.write_line("contacting login service provider...")?; + let res = nip05::profile(&nip05, None).await; + term.clear_last_lines(1)?; + match res { + Ok(profile) => { + if profile.nip46.is_empty() { + println!("nip05 provider isn't configured for remote login"); + bail!("nip05 provider isn't configured for remote login") + } + Ok(NostrConnectURI::Bunker { + signer_public_key: profile.public_key, + relays: profile.nip46, + secret: None, + }) + } + Err(error) => { + println!("error contacting login service provider: {error}"); + Err(error).context("error contacting login service provider") + } + } +} + +fn save_bunker( + git_repo: &Repo, + public_key: &PublicKey, + uri: &str, + app_key: &str, + always_save: bool, +) -> Result<()> { + if always_save + || Interactor::default() + .confirm(PromptConfirmParms::default().with_prompt("save login details?"))? + { + let global = !Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt("just for this repository?") + .with_default(false), + )?; + let npub = public_key.to_bech32()?; + if let Err(error) = save_to_git_config( + git_repo, + &npub, + &None, + &Some((uri.to_string(), app_key.to_string())), + global, + ) { + if global { + if Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt("save in repository git config?") + .with_default(true), + )? { + save_to_git_config( + git_repo, + &npub, + &None, + &Some((uri.to_string(), app_key.to_string())), + false, + )?; + } + } else { + Err(error)?; + } + }; + } + Ok(()) +} + +fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<()> { + if always_save + || Interactor::default() + .confirm(PromptConfirmParms::default().with_prompt("save login details?"))? + { + let global = !Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt("just for this repository?") + .with_default(false), + )?; + + let encrypt = Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt("require password?") + .with_default(false), + )?; + + let npub = keys.public_key().to_bech32()?; + let nsec_string = if encrypt { + let password = Interactor::default() + .password( + PromptPasswordParms::default() + .with_prompt("encrypt with password") + .with_confirm(), + ) + .context("failed to get password input from interactor.password")?; + encrypt_key(keys, &password)? + } else { + keys.secret_key()?.to_bech32()? + }; + + if let Err(error) = + save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, global) + { + if global { + if Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt("save in repository git config?") + .with_default(true), + )? { + save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, false)?; + } + } else { + Err(error)?; + } + }; + }; + Ok(()) +} + +fn get_config_item(git_repo: &Repo, name: &str) -> Result { + git_repo + .get_git_config_item(name, None) + .context("failed get git config")? + .context(format!("git config item {name} doesn't exist")) +} + +fn extract_user_metadata( + public_key: &nostr::PublicKey, + events: &[nostr::Event], +) -> Result { + let event = events + .iter() + .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key)) + .max_by_key(|e| e.created_at); + + let metadata: Option = if let Some(event) = event { + Some( + nostr::Metadata::from_json(event.content.clone()) + .context("metadata cannot be found in kind 0 event content")?, + ) + } else { + None + }; + + Ok(UserMetadata { + name: if let Some(metadata) = metadata { + if let Some(n) = metadata.name { + n + } else if let Some(n) = metadata.custom.get("displayName") { + // strip quote marks that custom.get() adds + let binding = n.to_string(); + let mut chars = binding.chars(); + chars.next(); + chars.next_back(); + chars.as_str().to_string() + } else if let Some(n) = metadata.display_name { + n + } else { + public_key.to_bech32()? + } + } else { + public_key.to_bech32()? + }, + created_at: if let Some(event) = event { + event.created_at + } else { + Timestamp::from(0) + }, + }) +} + +fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays { + let event = events + .iter() + .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key)) + .max_by_key(|e| e.created_at); + + UserRelays { + relays: if let Some(event) = event { + event + .tags + .iter() + .filter(|t| { + t.kind() + .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase( + Alphabet::R, + ))) + }) + .map(|t| UserRelayRef { + url: t.as_vec()[1].clone(), + read: t.as_vec().len() == 2 || t.as_vec()[2].eq("read"), + write: t.as_vec().len() == 2 || t.as_vec()[2].eq("write"), + }) + .collect() + } else { + vec![] + }, + created_at: if let Some(event) = event { + event.created_at + } else { + Timestamp::from(0) + }, + } +} + +async fn get_user_details( + public_key: &PublicKey, + #[cfg(test)] client: Option<&crate::client::MockConnect>, + #[cfg(not(test))] client: Option<&Client>, + git_repo_path: &Path, + cache_only: bool, +) -> Result { + if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { + Ok(user_ref) + } else { + let empty = UserRef { + public_key: public_key.to_owned(), + metadata: extract_user_metadata(public_key, &[])?, + relays: extract_user_relays(public_key, &[]), + }; + if cache_only { + Ok(empty) + } else if let Some(client) = client { + let term = console::Term::stderr(); + term.write_line("searching for profile...")?; + let (_, progress_reporter) = client + .fetch_all( + git_repo_path, + &HashSet::new(), + &HashSet::from_iter(vec![*public_key]), + ) + .await?; + if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { + progress_reporter.clear()?; + // if std::env::var("NGITTEST").is_err() {term.clear_last_lines(1)?;} + Ok(user_ref) + } else { + Ok(empty) + } + } else { + Ok(empty) + } + } +} +pub async fn get_logged_in_user(git_repo_path: &Path) -> Result> { + let git_repo = Repo::from_path(&git_repo_path.to_path_buf())?; + Ok( + if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? { + if let Ok(pubic_key) = PublicKey::parse(npub) { + Some(pubic_key) + } else { + None + } + } else { + None + }, + ) +} + +pub async fn get_user_ref_from_cache( + git_repo_path: &Path, + public_key: &PublicKey, +) -> Result { + let filters = vec![ + nostr::Filter::default() + .author(*public_key) + .kind(Kind::Metadata), + nostr::Filter::default() + .author(*public_key) + .kind(Kind::RelayList), + ]; + + let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?; + + if events.is_empty() { + bail!("no metadata and profile list in cache for selected public key"); + } + Ok(UserRef { + public_key: public_key.to_owned(), + metadata: extract_user_metadata(public_key, &events)?, + relays: extract_user_relays(public_key, &events), + }) +} diff --git a/src/lib/login/user.rs b/src/lib/login/user.rs new file mode 100644 index 0000000..547fe7e --- /dev/null +++ b/src/lib/login/user.rs @@ -0,0 +1,47 @@ +use anyhow::{anyhow, Result}; +use directories::ProjectDirs; +use nostr::PublicKey; +use nostr_sdk::Timestamp; +use serde::{self, Deserialize, Serialize}; + +pub fn get_dirs() -> Result { + ProjectDirs::from("", "", "ngit").ok_or(anyhow!( + "should find operating system home directories with rust-directories crate" + )) +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UserRef { + pub public_key: PublicKey, + pub metadata: UserMetadata, + pub relays: UserRelays, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UserMetadata { + pub name: String, + pub created_at: Timestamp, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UserRelays { + pub relays: Vec, + pub created_at: Timestamp, +} + +impl UserRelays { + pub fn write(&self) -> Vec { + self.relays + .iter() + .filter(|r| r.write) + .map(|r| r.url.clone()) + .collect() + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UserRelayRef { + pub url: String, + pub read: bool, + pub write: bool, +} diff --git a/src/lib/mod.rs b/src/lib/mod.rs new file mode 100644 index 0000000..61dfc49 --- /dev/null +++ b/src/lib/mod.rs @@ -0,0 +1,16 @@ +mod cli_interactor; +mod client; +mod config; +mod git; +mod key_handling; +mod login; +mod repo_ref; +mod repo_state; + +pub use client; +pub use config; +pub use git; +pub use key_handling; +pub use login; +pub use repo_ref; +pub use repo_state; diff --git a/src/lib/repo_ref.rs b/src/lib/repo_ref.rs new file mode 100644 index 0000000..0e57d96 --- /dev/null +++ b/src/lib/repo_ref.rs @@ -0,0 +1,700 @@ +use std::{ + collections::{HashMap, HashSet}, + fs::File, + io::BufReader, + str::FromStr, +}; + +use anyhow::{bail, Context, Result}; +use console::Style; +use nostr::{nips::nip01::Coordinate, FromBech32, PublicKey, Tag, TagStandard, ToBech32}; +use nostr_sdk::{Kind, NostrSigner, Timestamp}; +use serde::{Deserialize, Serialize}; + +#[cfg(not(test))] +use crate::client::Client; +use crate::{ + cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, + client::{get_event_from_global_cache, get_events_from_cache, sign_event, Connect}, + git::{NostrUrlDecoded, Repo, RepoActions}, +}; + +#[derive(Default)] +pub struct RepoRef { + pub name: String, + pub description: String, + pub identifier: String, + pub root_commit: String, + pub git_server: Vec, + pub web: Vec, + pub relays: Vec, + pub maintainers: Vec, + pub events: HashMap, + // code languages and hashtags +} + +impl TryFrom for RepoRef { + type Error = anyhow::Error; + + fn try_from(event: nostr::Event) -> Result { + if !event.kind.eq(&Kind::GitRepoAnnouncement) { + bail!("incorrect kind"); + } + let mut r = Self::default(); + + if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("d")) { + r.identifier = t.as_vec()[1].clone(); + } + + if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("name")) { + r.name = t.as_vec()[1].clone(); + } + + if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("description")) { + r.description = t.as_vec()[1].clone(); + } + + if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("clone")) { + r.git_server = t.clone().to_vec(); + r.git_server.remove(0); + } + + if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("web")) { + r.web = t.clone().to_vec(); + r.web.remove(0); + } + + if let Some(t) = event.tags.iter().find(|t| { + t.as_vec()[0].eq("r") + && t.as_vec()[1].len().eq(&40) + && git2::Oid::from_str(t.as_vec()[1].as_str()).is_ok() + }) { + r.root_commit = t.as_vec()[1].clone(); + } + + if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("relays")) { + r.relays = t.clone().to_vec(); + r.relays.remove(0); + } + + if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("maintainers")) { + let mut maintainers = t.clone().to_vec(); + maintainers.remove(0); + if !maintainers.contains(&event.pubkey.to_string()) { + r.maintainers.push(event.pubkey); + } + for pk in maintainers { + r.maintainers.push( + nostr_sdk::prelude::PublicKey::from_str(&pk) + .context(format!("cannot convert entry from maintainers tag {pk} into a valid nostr public key. it should be in hex format")) + .context("invalid repository event")?, + ); + } + } else { + r.maintainers = vec![event.pubkey]; + } + r.events = HashMap::new(); + r.events.insert( + Coordinate { + kind: event.kind, + identifier: event.identifier().unwrap().to_string(), + public_key: event.author(), + relays: vec![], + }, + event, + ); + Ok(r) + } +} + +impl RepoRef { + pub async fn to_event(&self, signer: &NostrSigner) -> Result { + sign_event( + nostr_sdk::EventBuilder::new( + nostr::event::Kind::GitRepoAnnouncement, + "", + [ + vec![ + Tag::identifier(if self.identifier.to_string().is_empty() { + // fiatjaf thought a random string. its not in the draft nip. + // thread_rng() + // .sample_iter(&Alphanumeric) + // .take(15) + // .map(char::from) + // .collect() + + // an identifier based on first commit is better so that users dont + // accidentally create two seperate identifiers for the same repo + // there is a hesitancy to use the commit id + // in another conversaion with fiatjaf he suggested the first 6 + // character of the commit id + // here we are using 7 which is the standard for shorthand commit id + self.root_commit.to_string()[..7].to_string() + } else { + self.identifier.to_string() + }), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("r")), + vec![self.root_commit.to_string(), "euc".to_string()], + ), + Tag::from_standardized(TagStandard::Name(self.name.clone())), + Tag::from_standardized(TagStandard::Description(self.description.clone())), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("clone")), + self.git_server.clone(), + ), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("web")), + self.web.clone(), + ), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("relays")), + self.relays.clone(), + ), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("maintainers")), + self.maintainers + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + ), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), + vec![format!("git repository: {}", self.name.clone())], + ), + ], + // code languages and hashtags + ] + .concat(), + ), + signer, + ) + .await + .context("failed to create repository reference event") + } + /// coordinates without relay hints + pub fn coordinates(&self) -> HashSet { + let mut res = HashSet::new(); + for m in &self.maintainers { + res.insert(Coordinate { + kind: Kind::GitRepoAnnouncement, + public_key: *m, + identifier: self.identifier.clone(), + relays: vec![], + }); + } + res + } + + /// coordinates without relay hints + pub fn coordinate_with_hint(&self) -> Coordinate { + Coordinate { + kind: Kind::GitRepoAnnouncement, + public_key: *self + .maintainers + .first() + .context("no maintainers in repo ref") + .unwrap(), + identifier: self.identifier.clone(), + relays: if let Some(relay) = self.relays.first() { + vec![relay.to_string()] + } else { + vec![] + }, + } + } + + /// coordinates without relay hints + pub fn coordinates_with_timestamps(&self) -> Vec<(Coordinate, Option)> { + self.coordinates() + .iter() + .map(|c| (c.clone(), self.events.get(c).map(|e| e.created_at))) + .collect::)>>() + } +} + +pub async fn get_repo_coordinates( + git_repo: &Repo, + #[cfg(test)] client: &crate::client::MockConnect, + #[cfg(not(test))] client: &Client, +) -> Result> { + try_and_get_repo_coordinates(git_repo, client, true).await +} + +pub async fn try_and_get_repo_coordinates( + git_repo: &Repo, + #[cfg(test)] client: &crate::client::MockConnect, + #[cfg(not(test))] client: &Client, + prompt_user: bool, +) -> Result> { + let mut repo_coordinates = get_repo_coordinates_from_git_config(git_repo)?; + + if repo_coordinates.is_empty() { + repo_coordinates = get_repo_coordinates_from_nostr_remotes(git_repo)?; + } + + if repo_coordinates.is_empty() { + repo_coordinates = get_repo_coordinates_from_maintainers_yaml(git_repo, client).await?; + } + + if repo_coordinates.is_empty() { + if prompt_user { + repo_coordinates = get_repo_coordinates_from_user_prompt(git_repo)?; + } else { + bail!("couldn't find repo coordinates in git config nostr.repo or in maintainers.yaml"); + } + } + Ok(repo_coordinates) +} + +fn get_repo_coordinates_from_git_config(git_repo: &Repo) -> Result> { + let mut repo_coordinates = HashSet::new(); + if let Some(repo_override) = git_repo.get_git_config_item("nostr.repo", Some(false))? { + for s in repo_override.split(',') { + if let Ok(c) = Coordinate::parse(s) { + repo_coordinates.insert(c); + } + } + } + Ok(repo_coordinates) +} + +fn get_repo_coordinates_from_nostr_remotes(git_repo: &Repo) -> Result> { + let mut repo_coordinates = HashSet::new(); + for remote_name in git_repo.git_repo.remotes()?.iter().flatten() { + if let Some(remote_url) = git_repo.git_repo.find_remote(remote_name)?.url() { + if let Ok(nostr_url_decoded) = NostrUrlDecoded::from_str(remote_url) { + for c in nostr_url_decoded.coordinates { + repo_coordinates.insert(c); + } + } + } + } + Ok(repo_coordinates) +} + +async fn get_repo_coordinates_from_maintainers_yaml( + git_repo: &Repo, + #[cfg(test)] client: &crate::client::MockConnect, + #[cfg(not(test))] client: &Client, +) -> Result> { + let mut repo_coordinates = HashSet::new(); + if let Ok(repo_config) = get_repo_config_from_yaml(git_repo) { + let maintainers = { + let mut maintainers = HashSet::new(); + for m in &repo_config.maintainers { + if let Ok(maintainer) = PublicKey::parse(m) { + maintainers.insert(maintainer); + } + } + maintainers + }; + if let Some(identifier) = repo_config.identifier { + for public_key in maintainers { + repo_coordinates.insert(Coordinate { + kind: Kind::GitRepoAnnouncement, + public_key, + identifier: identifier.clone(), + relays: vec![], + }); + } + } else { + // if repo_config.identifier.is_empty() { + // this will only apply for a few repositories created before ngit v1.3 + // that haven't updated their maintainers.yaml + if let Ok(Some(current_user_npub)) = git_repo.get_git_config_item("nostr.npub", None) { + if let Ok(current_user) = PublicKey::parse(current_user_npub) { + for m in &repo_config.maintainers { + if let Ok(maintainer) = PublicKey::parse(m) { + if current_user.eq(&maintainer) { + println!( + "please run `ngit init` to add the repo identifier to maintainers.yaml" + ); + } + } + } + } + } + // look find all repo refs with root_commit. for identifier + let filter = nostr::Filter::default() + .kind(nostr::Kind::GitRepoAnnouncement) + .reference(git_repo.get_root_commit()?.to_string()) + .authors(maintainers.clone()); + let mut events = + get_events_from_cache(git_repo.get_path()?, vec![filter.clone()]).await?; + if events.is_empty() { + events = + get_event_from_global_cache(git_repo.get_path()?, vec![filter.clone()]).await?; + } + if events.is_empty() { + println!( + "finding repository events for this repository for npubs in maintainers.yaml" + ); + events = client + .get_events(client.get_fallback_relays().clone(), vec![filter.clone()]) + .await?; + } + if let Some(e) = events.first() { + if let Some(identifier) = e.identifier() { + for m in &repo_config.maintainers { + if let Ok(maintainer) = PublicKey::parse(m) { + repo_coordinates.insert(Coordinate { + kind: Kind::GitRepoAnnouncement, + public_key: maintainer, + identifier: identifier.to_string(), + relays: vec![], + }); + } + } + } + } else { + let c = ask_for_naddr()?; + git_repo.save_git_config_item("nostr.repo", &c.to_bech32()?, false)?; + repo_coordinates.insert(c); + } + } + } + Ok(repo_coordinates) +} + +fn get_repo_coordinates_from_user_prompt(git_repo: &Repo) -> Result> { + let mut repo_coordinates = HashSet::new(); + // TODO: present list of events filter by root_commit + // TODO: fallback to search based on identifier + let c = ask_for_naddr()?; + // PROBLEM: we are saving this before checking whether it actually exists, which + // means next time the user won't be prompted and may not know how to + // change the selected repo + git_repo.save_git_config_item("nostr.repo", &c.to_bech32()?, false)?; + repo_coordinates.insert(c); + Ok(repo_coordinates) +} + +fn ask_for_naddr() -> Result { + let dim = Style::new().color256(247); + println!( + "{}", + dim.apply_to("hint: https://gitworkshop.dev/repos lists repositories and their naddr"), + ); + + Ok(loop { + if let Ok(c) = Coordinate::parse( + Interactor::default() + .input(PromptInputParms::default().with_prompt("repository naddr"))?, + ) { + break c; + } + println!("not a valid naddr"); + }) +} + +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] +pub struct RepoConfigYaml { + pub identifier: Option, + pub maintainers: Vec, + pub relays: Vec, +} + +pub fn get_repo_config_from_yaml(git_repo: &Repo) -> Result { + let path = git_repo.get_path()?.join("maintainers.yaml"); + let file = File::open(path) + .context("should open maintainers.yaml if it exists") + .context("maintainers.yaml doesnt exist")?; + let reader = BufReader::new(file); + let repo_config_yaml: RepoConfigYaml = serde_yaml::from_reader(reader) + .context("should read maintainers.yaml with serde_yaml") + .context("maintainers.yaml incorrectly formatted")?; + Ok(repo_config_yaml) +} + +pub fn extract_pks(pk_strings: Vec) -> Result> { + let mut pks: Vec = vec![]; + for s in pk_strings { + pks.push( + nostr_sdk::prelude::PublicKey::from_bech32(s.clone()) + .context(format!("cannot convert {s} into a valid nostr public key"))?, + ); + } + Ok(pks) +} + +pub fn save_repo_config_to_yaml( + git_repo: &Repo, + identifier: String, + maintainers: Vec, + relays: Vec, +) -> Result<()> { + let path = git_repo.get_path()?.join("maintainers.yaml"); + let file = if path.exists() { + std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path) + .context("cannot open maintainers.yaml file with write and truncate options")? + } else { + std::fs::File::create(path).context("cannot create maintainers.yaml file")? + }; + let mut maintainers_npubs = vec![]; + for m in maintainers { + maintainers_npubs.push( + m.to_bech32() + .context("cannot convert public key into npub")?, + ); + } + serde_yaml::to_writer( + file, + &RepoConfigYaml { + identifier: Some(identifier), + maintainers: maintainers_npubs, + relays, + }, + ) + .context("cannot write maintainers to maintainers.yaml file serde_yaml") +} + +#[cfg(test)] +mod tests { + use test_utils::*; + + use super::*; + + async fn create() -> nostr::Event { + RepoRef { + identifier: "123412341".to_string(), + name: "test name".to_string(), + description: "test description".to_string(), + root_commit: "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2".to_string(), + git_server: vec!["https://localhost:1000".to_string()], + web: vec![ + "https://exampleproject.xyz".to_string(), + "https://gitworkshop.dev/123".to_string(), + ], + relays: vec!["ws://relay1.io".to_string(), "ws://relay2.io".to_string()], + maintainers: vec![TEST_KEY_1_KEYS.public_key(), TEST_KEY_2_KEYS.public_key()], + events: HashMap::new(), + } + .to_event(&TEST_KEY_1_SIGNER) + .await + .unwrap() + } + mod try_from { + use super::*; + + #[tokio::test] + async fn identifier() { + assert_eq!( + RepoRef::try_from(create().await).unwrap().identifier, + "123412341", + ) + } + + #[tokio::test] + async fn name() { + assert_eq!(RepoRef::try_from(create().await).unwrap().name, "test name",) + } + + #[tokio::test] + async fn description() { + assert_eq!( + RepoRef::try_from(create().await).unwrap().description, + "test description", + ) + } + + #[tokio::test] + async fn root_commit_is_r_tag() { + assert_eq!( + RepoRef::try_from(create().await).unwrap().root_commit, + "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2", + ) + } + + mod root_commit_is_empty_if_no_r_tag_which_is_sha1_format { + use nostr::JsonUtil; + + use super::*; + async fn create_with_incorrect_first_commit_ref(s: &str) -> nostr::Event { + nostr::Event::from_json( + create() + .await + .as_json() + .replace("5e664e5a7845cd1373c79f580ca4fe29ab5b34d2", s), + ) + .unwrap() + } + + #[tokio::test] + async fn less_than_40_characters() { + let s = "5e664e5a7845cd1373"; + assert_eq!( + RepoRef::try_from(create_with_incorrect_first_commit_ref(s).await) + .unwrap() + .root_commit, + "", + ) + } + + #[tokio::test] + async fn more_than_40_characters() { + let s = "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2111111111"; + assert_eq!( + RepoRef::try_from(create_with_incorrect_first_commit_ref(s).await) + .unwrap() + .root_commit, + "", + ) + } + + #[tokio::test] + async fn not_hex_characters() { + let s = "xxx64e5a7845cd1373c79f580ca4fe29ab5b34d2"; + assert_eq!( + RepoRef::try_from(create_with_incorrect_first_commit_ref(s).await) + .unwrap() + .root_commit, + "", + ) + } + } + + #[tokio::test] + async fn git_server() { + assert_eq!( + RepoRef::try_from(create().await).unwrap().git_server, + vec!["https://localhost:1000"], + ) + } + + #[tokio::test] + async fn web() { + assert_eq!( + RepoRef::try_from(create().await).unwrap().web, + vec![ + "https://exampleproject.xyz".to_string(), + "https://gitworkshop.dev/123".to_string() + ], + ) + } + + #[tokio::test] + async fn relays() { + assert_eq!( + RepoRef::try_from(create().await).unwrap().relays, + vec!["ws://relay1.io".to_string(), "ws://relay2.io".to_string()], + ) + } + + #[tokio::test] + async fn maintainers() { + assert_eq!( + RepoRef::try_from(create().await).unwrap().maintainers, + vec![TEST_KEY_1_KEYS.public_key(), TEST_KEY_2_KEYS.public_key()], + ) + } + } + + mod to_event { + use super::*; + mod tags { + use super::*; + + #[tokio::test] + async fn identifier() { + assert!( + create() + .await + .tags + .iter() + .any(|t| t.as_vec()[0].eq("d") && t.as_vec()[1].eq("123412341")) + ) + } + + #[tokio::test] + async fn name() { + assert!( + create() + .await + .tags + .iter() + .any(|t| t.as_vec()[0].eq("name") && t.as_vec()[1].eq("test name")) + ) + } + + #[tokio::test] + async fn alt() { + assert!( + create().await.tags.iter().any(|t| t.as_vec()[0].eq("alt") + && t.as_vec()[1].eq("git repository: test name")) + ) + } + + #[tokio::test] + async fn description() { + assert!(create().await.tags.iter().any( + |t| t.as_vec()[0].eq("description") && t.as_vec()[1].eq("test description") + )) + } + + #[tokio::test] + async fn root_commit_as_reference() { + assert!(create().await.tags.iter().any(|t| t.as_vec()[0].eq("r") + && t.as_vec()[1].eq("5e664e5a7845cd1373c79f580ca4fe29ab5b34d2"))) + } + + #[tokio::test] + async fn git_server() { + assert!(create().await.tags.iter().any( + |t| t.as_vec()[0].eq("clone") && t.as_vec()[1].eq("https://localhost:1000") + )) + } + + #[tokio::test] + async fn relays() { + let event = create().await; + let relays_tag: &nostr::Tag = event + .tags + .iter() + .find(|t| t.as_vec()[0].eq("relays")) + .unwrap(); + assert_eq!(relays_tag.as_vec().len(), 3); + assert_eq!(relays_tag.as_vec()[1], "ws://relay1.io"); + assert_eq!(relays_tag.as_vec()[2], "ws://relay2.io"); + } + + #[tokio::test] + async fn web() { + let event = create().await; + let web_tag: &nostr::Tag = + event.tags.iter().find(|t| t.as_vec()[0].eq("web")).unwrap(); + assert_eq!(web_tag.as_vec().len(), 3); + assert_eq!(web_tag.as_vec()[1], "https://exampleproject.xyz"); + assert_eq!(web_tag.as_vec()[2], "https://gitworkshop.dev/123"); + } + + #[tokio::test] + async fn maintainers() { + let event = create().await; + let maintainers_tag: &nostr::Tag = event + .tags + .iter() + .find(|t| t.as_vec()[0].eq("maintainers")) + .unwrap(); + assert_eq!(maintainers_tag.as_vec().len(), 3); + assert_eq!( + maintainers_tag.as_vec()[1], + TEST_KEY_1_KEYS.public_key().to_string() + ); + assert_eq!( + maintainers_tag.as_vec()[2], + TEST_KEY_2_KEYS.public_key().to_string() + ); + } + + #[tokio::test] + async fn no_other_tags() { + assert_eq!(create().await.tags.len(), 9) + } + } + } +} diff --git a/src/lib/repo_state.rs b/src/lib/repo_state.rs new file mode 100644 index 0000000..a5cebab --- /dev/null +++ b/src/lib/repo_state.rs @@ -0,0 +1,40 @@ +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use git2::Oid; + +pub struct RepoState { + pub identifier: String, + pub state: HashMap, + pub event: nostr::Event, +} + +impl RepoState { + pub fn try_from(mut state_events: Vec) -> Result { + state_events.sort_by_key(|e| e.created_at); + let event = state_events.first().context("no state events")?; + let mut state = HashMap::new(); + for tag in &event.tags { + if let Some(name) = tag.as_vec().first() { + if ["refs/heads/", "refs/tags", "HEAD"] + .iter() + .any(|s| name.starts_with(*s)) + { + if let Some(value) = tag.as_vec().get(1) { + if Oid::from_str(value).is_ok() || value.contains("ref: refs/") { + state.insert(name.to_owned(), value.to_owned()); + } + } + } + } + } + Ok(RepoState { + identifier: event + .identifier() + .context("existing event must have an identifier")? + .to_string(), + state, + event: event.clone(), + }) + } +} diff --git a/src/login.rs b/src/login.rs deleted file mode 100644 index 19bb97c..0000000 --- a/src/login.rs +++ /dev/null @@ -1,695 +0,0 @@ -use std::{collections::HashSet, path::Path, str::FromStr, time::Duration}; - -use anyhow::{bail, Context, Result}; -use nostr::{ - nips::{nip05, nip46::NostrConnectURI}, - PublicKey, -}; -use nostr_sdk::{ - Alphabet, FromBech32, JsonUtil, Keys, Kind, NostrSigner, SingleLetterTag, Timestamp, ToBech32, -}; -use nostr_signer::Nip46Signer; - -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{ - cli_interactor::{ - Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptPasswordParms, - }, - client::{fetch_public_key, get_event_from_global_cache, Connect}, - config::{UserMetadata, UserRef, UserRelayRef, UserRelays}, - git::{Repo, RepoActions}, - key_handling::encryption::{decrypt_key, encrypt_key}, -}; - -/// handles the encrpytion and storage of key material -#[allow(clippy::too_many_arguments)] -pub async fn launch( - git_repo: &Repo, - bunker_uri: &Option, - bunker_app_key: &Option, - nsec: &Option, - password: &Option, - #[cfg(test)] client: Option<&MockConnect>, - #[cfg(not(test))] client: Option<&Client>, - change_user: bool, - silent: bool, -) -> Result<(NostrSigner, UserRef)> { - if let Ok(signer) = match get_signer_without_prompts( - git_repo, - bunker_uri, - bunker_app_key, - nsec, - password, - change_user, - ) - .await - { - Ok(signer) => Ok(signer), - Err(error) => { - if error - .to_string() - .eq("git config item nostr.nsec is an ncryptsec") - { - println!( - "login as {}", - if let Ok(public_key) = PublicKey::from_bech32( - get_config_item(git_repo, "nostr.npub") - .unwrap_or("unknown ncryptsec".to_string()), - ) { - if let Ok(user_ref) = - get_user_details(&public_key, client, git_repo.get_path()?, silent) - .await - { - user_ref.metadata.name - } else { - "unknown ncryptsec".to_string() - } - } else { - "unknown ncryptsec".to_string() - } - ); - loop { - // prompt for password - let password = Interactor::default() - .password(PromptPasswordParms::default().with_prompt("password")) - .context("failed to get password input from interactor.password")?; - if let Ok(keys) = get_keys_with_password(git_repo, &password) { - break Ok(NostrSigner::Keys(keys)); - } - println!("incorrect password"); - } - } else { - if nsec.is_some() { - bail!(error); - } - Err(error) - } - } - } { - // get user ref - let user_ref = get_user_details( - &signer - .public_key() - .await - .context("cannot get public key from signer")?, - client, - git_repo.get_path()?, - silent, - ) - .await?; - if !silent { - print_logged_in_as(&user_ref, client.is_none())?; - } - Ok((signer, user_ref)) - } else if silent { - bail!("TODO: enable interactive login in nostr git remote helper"); - } else { - fresh_login(git_repo, client, change_user).await - } -} - -fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> { - if !offline_mode && user_ref.metadata.created_at.eq(&Timestamp::from(0)) { - println!("cannot find profile..."); - } else if !offline_mode && user_ref.metadata.name.eq(&user_ref.public_key.to_bech32()?) { - println!("cannot extract account name from account metadata..."); - } else if !offline_mode && user_ref.relays.created_at.eq(&Timestamp::from(0)) { - println!( - "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience." - ); - } - println!("logged in as {}", user_ref.metadata.name); - Ok(()) -} - -async fn get_signer_without_prompts( - git_repo: &Repo, - bunker_uri: &Option, - bunker_app_key: &Option, - nsec: &Option, - password: &Option, - save_local: bool, -) -> Result { - if let Some(nsec) = nsec { - Ok(NostrSigner::Keys(get_keys_from_nsec( - git_repo, nsec, password, save_local, - )?)) - } else if let Some(password) = password { - Ok(NostrSigner::Keys(get_keys_with_password( - git_repo, password, - )?)) - } else if let Some(bunker_uri) = bunker_uri { - if let Some(bunker_app_key) = bunker_app_key { - let signer = get_nip46_signer_from_uri_and_key(bunker_uri, bunker_app_key) - .await - .context("failed to connect with remote signer")?; - if save_local { - save_to_git_config( - git_repo, - &signer.public_key().await?.to_bech32()?, - &None, - &Some((bunker_uri.to_string(),bunker_app_key.to_string())), - false, - ) - .context("failed to save bunker details local git config nostr.bunker-uri and nostr.bunker-app-key")?; - } - Ok(signer) - } else { - bail!( - "bunker-app-key parameter must be provided alongside bunker-uri. if unknown, login interactively." - ) - } - } else if !save_local { - get_signer_with_git_config_nsec_or_bunker_without_prompts(git_repo).await - } else { - bail!("user wants prompts to specify new keys") - } -} - -fn get_keys_from_nsec( - git_repo: &Repo, - nsec: &String, - password: &Option, - save_local: bool, -) -> Result { - #[allow(unused_assignments)] - let mut s = String::new(); - let keys = if nsec.contains("ncryptsec") { - s = nsec.to_string(); - decrypt_key( - nsec, - password - .clone() - .context("password must be supplied when using ncryptsec as nsec parameter")? - .as_str(), - ) - .context("failed to decrypt key with provided password") - .context("failed to decrypt ncryptsec supplied as nsec with password")? - } else { - s = nsec.to_string(); - nostr::Keys::from_str(nsec).context("invalid nsec parameter")? - }; - if save_local { - if let Some(password) = password { - s = encrypt_key(&keys, password)?; - } - save_to_git_config( - git_repo, - &keys.public_key().to_bech32()?, - &Some(s), - &None, - false, - ) - .context("failed to save encrypted nsec in local git config nostr.nsec")?; - } - Ok(keys) -} - -fn save_to_git_config( - git_repo: &Repo, - npub: &str, - nsec: &Option, - bunker: &Option<(String, String)>, - global: bool, -) -> Result<()> { - if let Err(error) = silently_save_to_git_config(git_repo, npub, nsec, bunker, global) { - println!( - "failed to save login details to {} git config", - if global { "global" } else { "local" } - ); - if let Some(nsec) = nsec { - if nsec.contains("ncryptsec") { - println!("manually set git config nostr.nsec to: {nsec}"); - } else { - println!("manually set git config nostr.nsec"); - } - } - if let Some(bunker) = bunker { - println!("manually set git config as follows:"); - println!("nostr.bunker-uri: {}", bunker.0); - println!("nostr.bunker-app-key: {}", bunker.1); - } - Err(error) - } else { - println!( - "saved login details to {} git config", - if global { "global" } else { "local" } - ); - Ok(()) - } -} -fn silently_save_to_git_config( - git_repo: &Repo, - npub: &str, - nsec: &Option, - bunker: &Option<(String, String)>, - global: bool, -) -> Result<()> { - // must do this first otherwise it might remove the global items just added - if global { - git_repo.remove_git_config_item("nostr.npub", false)?; - git_repo.remove_git_config_item("nostr.nsec", false)?; - git_repo.remove_git_config_item("nostr.bunker-uri", false)?; - git_repo.remove_git_config_item("nostr.bunker-app-key", false)?; - } - if let Some(bunker) = bunker { - git_repo.remove_git_config_item("nostr.nsec", global)?; - git_repo.save_git_config_item("nostr.bunker-uri", &bunker.0, global)?; - git_repo.save_git_config_item("nostr.bunker-app-key", &bunker.1, global)?; - } - if let Some(nsec) = nsec { - git_repo.save_git_config_item("nostr.nsec", nsec, global)?; - git_repo.remove_git_config_item("nostr.bunker-uri", global)?; - git_repo.remove_git_config_item("nostr.bunker-app-key", global)?; - } - git_repo.save_git_config_item("nostr.npub", npub, global) -} - -fn get_keys_with_password(git_repo: &Repo, password: &str) -> Result { - decrypt_key( - &git_repo - .get_git_config_item("nostr.nsec", None) - .context("failed get git config")? - .context("git config item nostr.nsec doesn't exist so cannot decrypt it")?, - password, - ) - .context("failed to decrypt stored nsec key with provided password") -} - -async fn get_nip46_signer_from_uri_and_key(uri: &str, app_key: &str) -> Result { - let term = console::Term::stderr(); - term.write_line("connecting to remote signer...")?; - let uri = NostrConnectURI::parse(uri)?; - let signer = NostrSigner::nip46( - Nip46Signer::new( - uri, - nostr::Keys::from_str(app_key).context("invalid app key")?, - Duration::from_secs(30), - None, - ) - .await?, - ); - term.clear_last_lines(1)?; - Ok(signer) -} - -async fn get_signer_with_git_config_nsec_or_bunker_without_prompts( - git_repo: &Repo, -) -> Result { - if let Ok(local_nsec) = &git_repo - .get_git_config_item("nostr.nsec", Some(false)) - .context("failed get local git config")? - .context("git local config item nostr.nsec doesn't exist") - { - if local_nsec.contains("ncryptsec") { - bail!("git global config item nostr.nsec is an ncryptsec") - } - Ok(NostrSigner::Keys( - nostr::Keys::from_str(local_nsec).context("invalid nsec parameter")?, - )) - } else if let Ok((uri, app_key)) = get_git_config_bunker_uri_and_app_key(git_repo, Some(false)) - { - get_nip46_signer_from_uri_and_key(&uri, &app_key).await - } else if let Ok(global_nsec) = &git_repo - .get_git_config_item("nostr.nsec", Some(true)) - .context("failed get global git config")? - .context("git global config item nostr.nsec doesn't exist") - { - if global_nsec.contains("ncryptsec") { - bail!("git global config item nostr.nsec is an ncryptsec") - } - Ok(NostrSigner::Keys( - nostr::Keys::from_str(global_nsec).context("invalid nsec parameter")?, - )) - } else if let Ok((uri, app_key)) = get_git_config_bunker_uri_and_app_key(git_repo, Some(true)) { - get_nip46_signer_from_uri_and_key(&uri, &app_key).await - } else { - bail!("cannot get nsec or bunker from git config") - } -} - -fn get_git_config_bunker_uri_and_app_key( - git_repo: &Repo, - global: Option, -) -> Result<(String, String)> { - Ok(( - git_repo - .get_git_config_item("nostr.bunker-uri", global) - .context("failed get local git config")? - .context("git local config item nostr.bunker-uri doesn't exist")? - .to_string(), - git_repo - .get_git_config_item("nostr.bunker-app-key", global) - .context("failed get local git config")? - .context("git local config item nostr.bunker-app-key doesn't exist")? - .to_string(), - )) -} - -async fn fresh_login( - git_repo: &Repo, - #[cfg(test)] client: Option<&MockConnect>, - #[cfg(not(test))] client: Option<&Client>, - always_save: bool, -) -> Result<(NostrSigner, UserRef)> { - let mut public_key: Option = None; - // prompt for nsec - let mut prompt = "login with nostr address / nsec"; - let signer = loop { - let input = Interactor::default() - .input(PromptInputParms::default().with_prompt(prompt)) - .context("failed to get nsec input from interactor")?; - if let Ok(keys) = nostr::Keys::from_str(&input) { - if let Err(error) = save_keys(git_repo, &keys, always_save) { - println!("{error}"); - } - break NostrSigner::Keys(keys); - } - let uri = if let Ok(uri) = NostrConnectURI::parse(&input) { - uri - } else if input.contains('@') { - if let Ok(uri) = fetch_nip46_uri_from_nip05(&input).await { - uri - } else { - prompt = "failed. try again with nostr address / bunker uri / nsec"; - continue; - } - } else { - prompt = "invalid. try again with nostr address / bunker uri / nsec"; - continue; - }; - let app_key = Keys::generate().secret_key()?.to_secret_hex(); - match get_nip46_signer_from_uri_and_key(&uri.to_string(), &app_key).await { - Ok(signer) => { - let pub_key = fetch_public_key(&signer).await?; - if let Err(error) = - save_bunker(git_repo, &pub_key, &uri.to_string(), &app_key, always_save) - { - println!("{error}"); - } - public_key = Some(pub_key); - break signer; - } - Err(_) => { - prompt = "failed. try again with nostr address / bunker uri / nsec"; - } - } - }; - let public_key = if let Some(public_key) = public_key { - public_key - } else { - signer.public_key().await? - }; - // lookup profile - let user_ref = get_user_details(&public_key, client, git_repo.get_path()?, false).await?; - print_logged_in_as(&user_ref, client.is_none())?; - Ok((signer, user_ref)) -} - -pub async fn fetch_nip46_uri_from_nip05(nip05: &str) -> Result { - let term = console::Term::stderr(); - term.write_line("contacting login service provider...")?; - let res = nip05::profile(&nip05, None).await; - term.clear_last_lines(1)?; - match res { - Ok(profile) => { - if profile.nip46.is_empty() { - println!("nip05 provider isn't configured for remote login"); - bail!("nip05 provider isn't configured for remote login") - } - Ok(NostrConnectURI::Bunker { - signer_public_key: profile.public_key, - relays: profile.nip46, - secret: None, - }) - } - Err(error) => { - println!("error contacting login service provider: {error}"); - Err(error).context("error contacting login service provider") - } - } -} - -fn save_bunker( - git_repo: &Repo, - public_key: &PublicKey, - uri: &str, - app_key: &str, - always_save: bool, -) -> Result<()> { - if always_save - || Interactor::default() - .confirm(PromptConfirmParms::default().with_prompt("save login details?"))? - { - let global = !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt("just for this repository?") - .with_default(false), - )?; - let npub = public_key.to_bech32()?; - if let Err(error) = save_to_git_config( - git_repo, - &npub, - &None, - &Some((uri.to_string(), app_key.to_string())), - global, - ) { - if global { - if Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt("save in repository git config?") - .with_default(true), - )? { - save_to_git_config( - git_repo, - &npub, - &None, - &Some((uri.to_string(), app_key.to_string())), - false, - )?; - } - } else { - Err(error)?; - } - }; - } - Ok(()) -} - -fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<()> { - if always_save - || Interactor::default() - .confirm(PromptConfirmParms::default().with_prompt("save login details?"))? - { - let global = !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt("just for this repository?") - .with_default(false), - )?; - - let encrypt = Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt("require password?") - .with_default(false), - )?; - - let npub = keys.public_key().to_bech32()?; - let nsec_string = if encrypt { - let password = Interactor::default() - .password( - PromptPasswordParms::default() - .with_prompt("encrypt with password") - .with_confirm(), - ) - .context("failed to get password input from interactor.password")?; - encrypt_key(keys, &password)? - } else { - keys.secret_key()?.to_bech32()? - }; - - if let Err(error) = - save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, global) - { - if global { - if Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt("save in repository git config?") - .with_default(true), - )? { - save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, false)?; - } - } else { - Err(error)?; - } - }; - }; - Ok(()) -} - -fn get_config_item(git_repo: &Repo, name: &str) -> Result { - git_repo - .get_git_config_item(name, None) - .context("failed get git config")? - .context(format!("git config item {name} doesn't exist")) -} - -fn extract_user_metadata( - public_key: &nostr::PublicKey, - events: &[nostr::Event], -) -> Result { - let event = events - .iter() - .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key)) - .max_by_key(|e| e.created_at); - - let metadata: Option = if let Some(event) = event { - Some( - nostr::Metadata::from_json(event.content.clone()) - .context("metadata cannot be found in kind 0 event content")?, - ) - } else { - None - }; - - Ok(UserMetadata { - name: if let Some(metadata) = metadata { - if let Some(n) = metadata.name { - n - } else if let Some(n) = metadata.custom.get("displayName") { - // strip quote marks that custom.get() adds - let binding = n.to_string(); - let mut chars = binding.chars(); - chars.next(); - chars.next_back(); - chars.as_str().to_string() - } else if let Some(n) = metadata.display_name { - n - } else { - public_key.to_bech32()? - } - } else { - public_key.to_bech32()? - }, - created_at: if let Some(event) = event { - event.created_at - } else { - Timestamp::from(0) - }, - }) -} - -fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays { - let event = events - .iter() - .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key)) - .max_by_key(|e| e.created_at); - - UserRelays { - relays: if let Some(event) = event { - event - .tags - .iter() - .filter(|t| { - t.kind() - .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase( - Alphabet::R, - ))) - }) - .map(|t| UserRelayRef { - url: t.as_vec()[1].clone(), - read: t.as_vec().len() == 2 || t.as_vec()[2].eq("read"), - write: t.as_vec().len() == 2 || t.as_vec()[2].eq("write"), - }) - .collect() - } else { - vec![] - }, - created_at: if let Some(event) = event { - event.created_at - } else { - Timestamp::from(0) - }, - } -} - -async fn get_user_details( - public_key: &PublicKey, - #[cfg(test)] client: Option<&crate::client::MockConnect>, - #[cfg(not(test))] client: Option<&Client>, - git_repo_path: &Path, - cache_only: bool, -) -> Result { - if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { - Ok(user_ref) - } else { - let empty = UserRef { - public_key: public_key.to_owned(), - metadata: extract_user_metadata(public_key, &[])?, - relays: extract_user_relays(public_key, &[]), - }; - if cache_only { - Ok(empty) - } else if let Some(client) = client { - let term = console::Term::stderr(); - term.write_line("searching for profile...")?; - let (_, progress_reporter) = client - .fetch_all( - git_repo_path, - &HashSet::new(), - &HashSet::from_iter(vec![*public_key]), - ) - .await?; - if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { - progress_reporter.clear()?; - // if std::env::var("NGITTEST").is_err() {term.clear_last_lines(1)?;} - Ok(user_ref) - } else { - Ok(empty) - } - } else { - Ok(empty) - } - } -} -pub async fn get_logged_in_user(git_repo_path: &Path) -> Result> { - let git_repo = Repo::from_path(&git_repo_path.to_path_buf())?; - Ok( - if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? { - if let Ok(pubic_key) = PublicKey::parse(npub) { - Some(pubic_key) - } else { - None - } - } else { - None - }, - ) -} - -pub async fn get_user_ref_from_cache( - git_repo_path: &Path, - public_key: &PublicKey, -) -> Result { - let filters = vec![ - nostr::Filter::default() - .author(*public_key) - .kind(Kind::Metadata), - nostr::Filter::default() - .author(*public_key) - .kind(Kind::RelayList), - ]; - - let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?; - - if events.is_empty() { - bail!("no metadata and profile list in cache for selected public key"); - } - Ok(UserRef { - public_key: public_key.to_owned(), - metadata: extract_user_metadata(public_key, &events)?, - relays: extract_user_relays(public_key, &events), - }) -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index e833e4a..0000000 --- a/src/main.rs +++ /dev/null @@ -1,32 +0,0 @@ -#![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; -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 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/repo_ref.rs b/src/repo_ref.rs deleted file mode 100644 index 0e57d96..0000000 --- a/src/repo_ref.rs +++ /dev/null @@ -1,700 +0,0 @@ -use std::{ - collections::{HashMap, HashSet}, - fs::File, - io::BufReader, - str::FromStr, -}; - -use anyhow::{bail, Context, Result}; -use console::Style; -use nostr::{nips::nip01::Coordinate, FromBech32, PublicKey, Tag, TagStandard, ToBech32}; -use nostr_sdk::{Kind, NostrSigner, Timestamp}; -use serde::{Deserialize, Serialize}; - -#[cfg(not(test))] -use crate::client::Client; -use crate::{ - cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, - client::{get_event_from_global_cache, get_events_from_cache, sign_event, Connect}, - git::{NostrUrlDecoded, Repo, RepoActions}, -}; - -#[derive(Default)] -pub struct RepoRef { - pub name: String, - pub description: String, - pub identifier: String, - pub root_commit: String, - pub git_server: Vec, - pub web: Vec, - pub relays: Vec, - pub maintainers: Vec, - pub events: HashMap, - // code languages and hashtags -} - -impl TryFrom for RepoRef { - type Error = anyhow::Error; - - fn try_from(event: nostr::Event) -> Result { - if !event.kind.eq(&Kind::GitRepoAnnouncement) { - bail!("incorrect kind"); - } - let mut r = Self::default(); - - if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("d")) { - r.identifier = t.as_vec()[1].clone(); - } - - if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("name")) { - r.name = t.as_vec()[1].clone(); - } - - if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("description")) { - r.description = t.as_vec()[1].clone(); - } - - if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("clone")) { - r.git_server = t.clone().to_vec(); - r.git_server.remove(0); - } - - if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("web")) { - r.web = t.clone().to_vec(); - r.web.remove(0); - } - - if let Some(t) = event.tags.iter().find(|t| { - t.as_vec()[0].eq("r") - && t.as_vec()[1].len().eq(&40) - && git2::Oid::from_str(t.as_vec()[1].as_str()).is_ok() - }) { - r.root_commit = t.as_vec()[1].clone(); - } - - if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("relays")) { - r.relays = t.clone().to_vec(); - r.relays.remove(0); - } - - if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("maintainers")) { - let mut maintainers = t.clone().to_vec(); - maintainers.remove(0); - if !maintainers.contains(&event.pubkey.to_string()) { - r.maintainers.push(event.pubkey); - } - for pk in maintainers { - r.maintainers.push( - nostr_sdk::prelude::PublicKey::from_str(&pk) - .context(format!("cannot convert entry from maintainers tag {pk} into a valid nostr public key. it should be in hex format")) - .context("invalid repository event")?, - ); - } - } else { - r.maintainers = vec![event.pubkey]; - } - r.events = HashMap::new(); - r.events.insert( - Coordinate { - kind: event.kind, - identifier: event.identifier().unwrap().to_string(), - public_key: event.author(), - relays: vec![], - }, - event, - ); - Ok(r) - } -} - -impl RepoRef { - pub async fn to_event(&self, signer: &NostrSigner) -> Result { - sign_event( - nostr_sdk::EventBuilder::new( - nostr::event::Kind::GitRepoAnnouncement, - "", - [ - vec![ - Tag::identifier(if self.identifier.to_string().is_empty() { - // fiatjaf thought a random string. its not in the draft nip. - // thread_rng() - // .sample_iter(&Alphanumeric) - // .take(15) - // .map(char::from) - // .collect() - - // an identifier based on first commit is better so that users dont - // accidentally create two seperate identifiers for the same repo - // there is a hesitancy to use the commit id - // in another conversaion with fiatjaf he suggested the first 6 - // character of the commit id - // here we are using 7 which is the standard for shorthand commit id - self.root_commit.to_string()[..7].to_string() - } else { - self.identifier.to_string() - }), - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("r")), - vec![self.root_commit.to_string(), "euc".to_string()], - ), - Tag::from_standardized(TagStandard::Name(self.name.clone())), - Tag::from_standardized(TagStandard::Description(self.description.clone())), - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("clone")), - self.git_server.clone(), - ), - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("web")), - self.web.clone(), - ), - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("relays")), - self.relays.clone(), - ), - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("maintainers")), - self.maintainers - .iter() - .map(std::string::ToString::to_string) - .collect::>(), - ), - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), - vec![format!("git repository: {}", self.name.clone())], - ), - ], - // code languages and hashtags - ] - .concat(), - ), - signer, - ) - .await - .context("failed to create repository reference event") - } - /// coordinates without relay hints - pub fn coordinates(&self) -> HashSet { - let mut res = HashSet::new(); - for m in &self.maintainers { - res.insert(Coordinate { - kind: Kind::GitRepoAnnouncement, - public_key: *m, - identifier: self.identifier.clone(), - relays: vec![], - }); - } - res - } - - /// coordinates without relay hints - pub fn coordinate_with_hint(&self) -> Coordinate { - Coordinate { - kind: Kind::GitRepoAnnouncement, - public_key: *self - .maintainers - .first() - .context("no maintainers in repo ref") - .unwrap(), - identifier: self.identifier.clone(), - relays: if let Some(relay) = self.relays.first() { - vec![relay.to_string()] - } else { - vec![] - }, - } - } - - /// coordinates without relay hints - pub fn coordinates_with_timestamps(&self) -> Vec<(Coordinate, Option)> { - self.coordinates() - .iter() - .map(|c| (c.clone(), self.events.get(c).map(|e| e.created_at))) - .collect::)>>() - } -} - -pub async fn get_repo_coordinates( - git_repo: &Repo, - #[cfg(test)] client: &crate::client::MockConnect, - #[cfg(not(test))] client: &Client, -) -> Result> { - try_and_get_repo_coordinates(git_repo, client, true).await -} - -pub async fn try_and_get_repo_coordinates( - git_repo: &Repo, - #[cfg(test)] client: &crate::client::MockConnect, - #[cfg(not(test))] client: &Client, - prompt_user: bool, -) -> Result> { - let mut repo_coordinates = get_repo_coordinates_from_git_config(git_repo)?; - - if repo_coordinates.is_empty() { - repo_coordinates = get_repo_coordinates_from_nostr_remotes(git_repo)?; - } - - if repo_coordinates.is_empty() { - repo_coordinates = get_repo_coordinates_from_maintainers_yaml(git_repo, client).await?; - } - - if repo_coordinates.is_empty() { - if prompt_user { - repo_coordinates = get_repo_coordinates_from_user_prompt(git_repo)?; - } else { - bail!("couldn't find repo coordinates in git config nostr.repo or in maintainers.yaml"); - } - } - Ok(repo_coordinates) -} - -fn get_repo_coordinates_from_git_config(git_repo: &Repo) -> Result> { - let mut repo_coordinates = HashSet::new(); - if let Some(repo_override) = git_repo.get_git_config_item("nostr.repo", Some(false))? { - for s in repo_override.split(',') { - if let Ok(c) = Coordinate::parse(s) { - repo_coordinates.insert(c); - } - } - } - Ok(repo_coordinates) -} - -fn get_repo_coordinates_from_nostr_remotes(git_repo: &Repo) -> Result> { - let mut repo_coordinates = HashSet::new(); - for remote_name in git_repo.git_repo.remotes()?.iter().flatten() { - if let Some(remote_url) = git_repo.git_repo.find_remote(remote_name)?.url() { - if let Ok(nostr_url_decoded) = NostrUrlDecoded::from_str(remote_url) { - for c in nostr_url_decoded.coordinates { - repo_coordinates.insert(c); - } - } - } - } - Ok(repo_coordinates) -} - -async fn get_repo_coordinates_from_maintainers_yaml( - git_repo: &Repo, - #[cfg(test)] client: &crate::client::MockConnect, - #[cfg(not(test))] client: &Client, -) -> Result> { - let mut repo_coordinates = HashSet::new(); - if let Ok(repo_config) = get_repo_config_from_yaml(git_repo) { - let maintainers = { - let mut maintainers = HashSet::new(); - for m in &repo_config.maintainers { - if let Ok(maintainer) = PublicKey::parse(m) { - maintainers.insert(maintainer); - } - } - maintainers - }; - if let Some(identifier) = repo_config.identifier { - for public_key in maintainers { - repo_coordinates.insert(Coordinate { - kind: Kind::GitRepoAnnouncement, - public_key, - identifier: identifier.clone(), - relays: vec![], - }); - } - } else { - // if repo_config.identifier.is_empty() { - // this will only apply for a few repositories created before ngit v1.3 - // that haven't updated their maintainers.yaml - if let Ok(Some(current_user_npub)) = git_repo.get_git_config_item("nostr.npub", None) { - if let Ok(current_user) = PublicKey::parse(current_user_npub) { - for m in &repo_config.maintainers { - if let Ok(maintainer) = PublicKey::parse(m) { - if current_user.eq(&maintainer) { - println!( - "please run `ngit init` to add the repo identifier to maintainers.yaml" - ); - } - } - } - } - } - // look find all repo refs with root_commit. for identifier - let filter = nostr::Filter::default() - .kind(nostr::Kind::GitRepoAnnouncement) - .reference(git_repo.get_root_commit()?.to_string()) - .authors(maintainers.clone()); - let mut events = - get_events_from_cache(git_repo.get_path()?, vec![filter.clone()]).await?; - if events.is_empty() { - events = - get_event_from_global_cache(git_repo.get_path()?, vec![filter.clone()]).await?; - } - if events.is_empty() { - println!( - "finding repository events for this repository for npubs in maintainers.yaml" - ); - events = client - .get_events(client.get_fallback_relays().clone(), vec![filter.clone()]) - .await?; - } - if let Some(e) = events.first() { - if let Some(identifier) = e.identifier() { - for m in &repo_config.maintainers { - if let Ok(maintainer) = PublicKey::parse(m) { - repo_coordinates.insert(Coordinate { - kind: Kind::GitRepoAnnouncement, - public_key: maintainer, - identifier: identifier.to_string(), - relays: vec![], - }); - } - } - } - } else { - let c = ask_for_naddr()?; - git_repo.save_git_config_item("nostr.repo", &c.to_bech32()?, false)?; - repo_coordinates.insert(c); - } - } - } - Ok(repo_coordinates) -} - -fn get_repo_coordinates_from_user_prompt(git_repo: &Repo) -> Result> { - let mut repo_coordinates = HashSet::new(); - // TODO: present list of events filter by root_commit - // TODO: fallback to search based on identifier - let c = ask_for_naddr()?; - // PROBLEM: we are saving this before checking whether it actually exists, which - // means next time the user won't be prompted and may not know how to - // change the selected repo - git_repo.save_git_config_item("nostr.repo", &c.to_bech32()?, false)?; - repo_coordinates.insert(c); - Ok(repo_coordinates) -} - -fn ask_for_naddr() -> Result { - let dim = Style::new().color256(247); - println!( - "{}", - dim.apply_to("hint: https://gitworkshop.dev/repos lists repositories and their naddr"), - ); - - Ok(loop { - if let Ok(c) = Coordinate::parse( - Interactor::default() - .input(PromptInputParms::default().with_prompt("repository naddr"))?, - ) { - break c; - } - println!("not a valid naddr"); - }) -} - -#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] -pub struct RepoConfigYaml { - pub identifier: Option, - pub maintainers: Vec, - pub relays: Vec, -} - -pub fn get_repo_config_from_yaml(git_repo: &Repo) -> Result { - let path = git_repo.get_path()?.join("maintainers.yaml"); - let file = File::open(path) - .context("should open maintainers.yaml if it exists") - .context("maintainers.yaml doesnt exist")?; - let reader = BufReader::new(file); - let repo_config_yaml: RepoConfigYaml = serde_yaml::from_reader(reader) - .context("should read maintainers.yaml with serde_yaml") - .context("maintainers.yaml incorrectly formatted")?; - Ok(repo_config_yaml) -} - -pub fn extract_pks(pk_strings: Vec) -> Result> { - let mut pks: Vec = vec![]; - for s in pk_strings { - pks.push( - nostr_sdk::prelude::PublicKey::from_bech32(s.clone()) - .context(format!("cannot convert {s} into a valid nostr public key"))?, - ); - } - Ok(pks) -} - -pub fn save_repo_config_to_yaml( - git_repo: &Repo, - identifier: String, - maintainers: Vec, - relays: Vec, -) -> Result<()> { - let path = git_repo.get_path()?.join("maintainers.yaml"); - let file = if path.exists() { - std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(path) - .context("cannot open maintainers.yaml file with write and truncate options")? - } else { - std::fs::File::create(path).context("cannot create maintainers.yaml file")? - }; - let mut maintainers_npubs = vec![]; - for m in maintainers { - maintainers_npubs.push( - m.to_bech32() - .context("cannot convert public key into npub")?, - ); - } - serde_yaml::to_writer( - file, - &RepoConfigYaml { - identifier: Some(identifier), - maintainers: maintainers_npubs, - relays, - }, - ) - .context("cannot write maintainers to maintainers.yaml file serde_yaml") -} - -#[cfg(test)] -mod tests { - use test_utils::*; - - use super::*; - - async fn create() -> nostr::Event { - RepoRef { - identifier: "123412341".to_string(), - name: "test name".to_string(), - description: "test description".to_string(), - root_commit: "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2".to_string(), - git_server: vec!["https://localhost:1000".to_string()], - web: vec![ - "https://exampleproject.xyz".to_string(), - "https://gitworkshop.dev/123".to_string(), - ], - relays: vec!["ws://relay1.io".to_string(), "ws://relay2.io".to_string()], - maintainers: vec![TEST_KEY_1_KEYS.public_key(), TEST_KEY_2_KEYS.public_key()], - events: HashMap::new(), - } - .to_event(&TEST_KEY_1_SIGNER) - .await - .unwrap() - } - mod try_from { - use super::*; - - #[tokio::test] - async fn identifier() { - assert_eq!( - RepoRef::try_from(create().await).unwrap().identifier, - "123412341", - ) - } - - #[tokio::test] - async fn name() { - assert_eq!(RepoRef::try_from(create().await).unwrap().name, "test name",) - } - - #[tokio::test] - async fn description() { - assert_eq!( - RepoRef::try_from(create().await).unwrap().description, - "test description", - ) - } - - #[tokio::test] - async fn root_commit_is_r_tag() { - assert_eq!( - RepoRef::try_from(create().await).unwrap().root_commit, - "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2", - ) - } - - mod root_commit_is_empty_if_no_r_tag_which_is_sha1_format { - use nostr::JsonUtil; - - use super::*; - async fn create_with_incorrect_first_commit_ref(s: &str) -> nostr::Event { - nostr::Event::from_json( - create() - .await - .as_json() - .replace("5e664e5a7845cd1373c79f580ca4fe29ab5b34d2", s), - ) - .unwrap() - } - - #[tokio::test] - async fn less_than_40_characters() { - let s = "5e664e5a7845cd1373"; - assert_eq!( - RepoRef::try_from(create_with_incorrect_first_commit_ref(s).await) - .unwrap() - .root_commit, - "", - ) - } - - #[tokio::test] - async fn more_than_40_characters() { - let s = "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2111111111"; - assert_eq!( - RepoRef::try_from(create_with_incorrect_first_commit_ref(s).await) - .unwrap() - .root_commit, - "", - ) - } - - #[tokio::test] - async fn not_hex_characters() { - let s = "xxx64e5a7845cd1373c79f580ca4fe29ab5b34d2"; - assert_eq!( - RepoRef::try_from(create_with_incorrect_first_commit_ref(s).await) - .unwrap() - .root_commit, - "", - ) - } - } - - #[tokio::test] - async fn git_server() { - assert_eq!( - RepoRef::try_from(create().await).unwrap().git_server, - vec!["https://localhost:1000"], - ) - } - - #[tokio::test] - async fn web() { - assert_eq!( - RepoRef::try_from(create().await).unwrap().web, - vec![ - "https://exampleproject.xyz".to_string(), - "https://gitworkshop.dev/123".to_string() - ], - ) - } - - #[tokio::test] - async fn relays() { - assert_eq!( - RepoRef::try_from(create().await).unwrap().relays, - vec!["ws://relay1.io".to_string(), "ws://relay2.io".to_string()], - ) - } - - #[tokio::test] - async fn maintainers() { - assert_eq!( - RepoRef::try_from(create().await).unwrap().maintainers, - vec![TEST_KEY_1_KEYS.public_key(), TEST_KEY_2_KEYS.public_key()], - ) - } - } - - mod to_event { - use super::*; - mod tags { - use super::*; - - #[tokio::test] - async fn identifier() { - assert!( - create() - .await - .tags - .iter() - .any(|t| t.as_vec()[0].eq("d") && t.as_vec()[1].eq("123412341")) - ) - } - - #[tokio::test] - async fn name() { - assert!( - create() - .await - .tags - .iter() - .any(|t| t.as_vec()[0].eq("name") && t.as_vec()[1].eq("test name")) - ) - } - - #[tokio::test] - async fn alt() { - assert!( - create().await.tags.iter().any(|t| t.as_vec()[0].eq("alt") - && t.as_vec()[1].eq("git repository: test name")) - ) - } - - #[tokio::test] - async fn description() { - assert!(create().await.tags.iter().any( - |t| t.as_vec()[0].eq("description") && t.as_vec()[1].eq("test description") - )) - } - - #[tokio::test] - async fn root_commit_as_reference() { - assert!(create().await.tags.iter().any(|t| t.as_vec()[0].eq("r") - && t.as_vec()[1].eq("5e664e5a7845cd1373c79f580ca4fe29ab5b34d2"))) - } - - #[tokio::test] - async fn git_server() { - assert!(create().await.tags.iter().any( - |t| t.as_vec()[0].eq("clone") && t.as_vec()[1].eq("https://localhost:1000") - )) - } - - #[tokio::test] - async fn relays() { - let event = create().await; - let relays_tag: &nostr::Tag = event - .tags - .iter() - .find(|t| t.as_vec()[0].eq("relays")) - .unwrap(); - assert_eq!(relays_tag.as_vec().len(), 3); - assert_eq!(relays_tag.as_vec()[1], "ws://relay1.io"); - assert_eq!(relays_tag.as_vec()[2], "ws://relay2.io"); - } - - #[tokio::test] - async fn web() { - let event = create().await; - let web_tag: &nostr::Tag = - event.tags.iter().find(|t| t.as_vec()[0].eq("web")).unwrap(); - assert_eq!(web_tag.as_vec().len(), 3); - assert_eq!(web_tag.as_vec()[1], "https://exampleproject.xyz"); - assert_eq!(web_tag.as_vec()[2], "https://gitworkshop.dev/123"); - } - - #[tokio::test] - async fn maintainers() { - let event = create().await; - let maintainers_tag: &nostr::Tag = event - .tags - .iter() - .find(|t| t.as_vec()[0].eq("maintainers")) - .unwrap(); - assert_eq!(maintainers_tag.as_vec().len(), 3); - assert_eq!( - maintainers_tag.as_vec()[1], - TEST_KEY_1_KEYS.public_key().to_string() - ); - assert_eq!( - maintainers_tag.as_vec()[2], - TEST_KEY_2_KEYS.public_key().to_string() - ); - } - - #[tokio::test] - async fn no_other_tags() { - assert_eq!(create().await.tags.len(), 9) - } - } - } -} diff --git a/src/repo_state.rs b/src/repo_state.rs deleted file mode 100644 index a5cebab..0000000 --- a/src/repo_state.rs +++ /dev/null @@ -1,40 +0,0 @@ -use std::collections::HashMap; - -use anyhow::{Context, Result}; -use git2::Oid; - -pub struct RepoState { - pub identifier: String, - pub state: HashMap, - pub event: nostr::Event, -} - -impl RepoState { - pub fn try_from(mut state_events: Vec) -> Result { - state_events.sort_by_key(|e| e.created_at); - let event = state_events.first().context("no state events")?; - let mut state = HashMap::new(); - for tag in &event.tags { - if let Some(name) = tag.as_vec().first() { - if ["refs/heads/", "refs/tags", "HEAD"] - .iter() - .any(|s| name.starts_with(*s)) - { - if let Some(value) = tag.as_vec().get(1) { - if Oid::from_str(value).is_ok() || value.contains("ref: refs/") { - state.insert(name.to_owned(), value.to_owned()); - } - } - } - } - } - Ok(RepoState { - identifier: event - .identifier() - .context("existing event must have an identifier")? - .to_string(), - state, - event: event.clone(), - }) - } -} diff --git a/src/sub_commands/fetch.rs b/src/sub_commands/fetch.rs deleted file mode 100644 index b1e83c5..0000000 --- a/src/sub_commands/fetch.rs +++ /dev/null @@ -1,44 +0,0 @@ -use std::collections::HashSet; - -use anyhow::{Context, Result}; -use clap; -use nostr::nips::nip01::Coordinate; - -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{ - cli::Cli, - client::{fetching_with_report, Connect}, - git::{Repo, RepoActions}, - repo_ref::get_repo_coordinates, -}; - -#[derive(clap::Args)] -pub struct SubCommandArgs { - /// address pointer to repo announcement - #[arg(long, action)] - repo: Vec, -} - -pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { - let _ = args; - let git_repo = Repo::discover().context("cannot find a git repository")?; - #[cfg(not(test))] - let client = Client::default(); - #[cfg(test)] - let client = ::default(); - let repo_coordinates = if command_args.repo.is_empty() { - get_repo_coordinates(&git_repo, &client).await? - } else { - let mut repo_coordinates = HashSet::new(); - for repo in &command_args.repo { - repo_coordinates.insert(Coordinate::parse(repo.clone())?); - } - repo_coordinates - }; - fetching_with_report(git_repo.get_path()?, &client, &repo_coordinates).await?; - client.disconnect().await?; - Ok(()) -} diff --git a/src/sub_commands/init.rs b/src/sub_commands/init.rs deleted file mode 100644 index 5b7e03d..0000000 --- a/src/sub_commands/init.rs +++ /dev/null @@ -1,385 +0,0 @@ -use std::collections::HashMap; - -use anyhow::{Context, Result}; -use nostr::{nips::nip01::Coordinate, FromBech32, PublicKey, ToBech32}; -use nostr_sdk::Kind; - -use super::send::send_events; -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{ - cli::Cli, - cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, - client::{fetching_with_report, get_repo_ref_from_cache, Connect}, - git::{convert_clone_url_to_https, Repo, RepoActions}, - login, - repo_ref::{ - extract_pks, get_repo_config_from_yaml, save_repo_config_to_yaml, - try_and_get_repo_coordinates, RepoRef, - }, -}; - -#[derive(Debug, clap::Args)] -pub struct SubCommandArgs { - #[clap(short, long)] - /// name of repository - title: Option, - #[clap(short, long)] - /// optional description - description: Option, - #[clap(long)] - /// git server url users can clone from - clone_url: Vec, - #[clap(short, long, value_parser, num_args = 1..)] - /// homepage - web: Vec, - #[clap(short, long, value_parser, num_args = 1..)] - /// relays contributors push patches and comments to - relays: Vec, - #[clap(short, long, value_parser, num_args = 1..)] - /// npubs of other maintainers - other_maintainers: Vec, - #[clap(long)] - /// usually root commit but will be more recent commit for forks - earliest_unique_commit: Option, - #[clap(short, long)] - /// shortname with no spaces or special characters - identifier: Option, -} - -#[allow(clippy::too_many_lines)] -pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { - let git_repo = Repo::discover().context("cannot find a git repository")?; - let git_repo_path = git_repo.get_path()?; - - let root_commit = git_repo - .get_root_commit() - .context("failed to get root commit of the repository")?; - - // TODO: check for empty repo - // TODO: check for existing maintaiers file - - #[cfg(not(test))] - let mut client = Client::default(); - #[cfg(test)] - let mut client = ::default(); - - let repo_coordinates = if let Ok(repo_coordinates) = - try_and_get_repo_coordinates(&git_repo, &client, false).await - { - Some(repo_coordinates) - } else { - None - }; - - let repo_ref = if let Some(repo_coordinates) = repo_coordinates { - fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; - Some(get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?) - } else { - None - }; - - let (signer, user_ref) = login::launch( - &git_repo, - &cli_args.bunker_uri, - &cli_args.bunker_app_key, - &cli_args.nsec, - &cli_args.password, - Some(&client), - false, - false, - ) - .await?; - - let repo_config_result = get_repo_config_from_yaml(&git_repo); - // TODO: check for other claims - - let name = match &args.title { - Some(t) => t.clone(), - None => Interactor::default().input( - PromptInputParms::default() - .with_prompt("name") - .with_default(if let Some(repo_ref) = &repo_ref { - repo_ref.name.clone() - } else { - String::new() - }), - )?, - }; - - let identifier = match &args.identifier { - Some(t) => t.clone(), - None => Interactor::default().input( - PromptInputParms::default() - .with_prompt("identifier") - .with_default(if let Some(repo_ref) = &repo_ref { - repo_ref.identifier.clone() - } else { - let fallback = name - .clone() - .replace(' ', "-") - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c.eq(&'/') { - c - } else { - '-' - } - }) - .collect(); - if let Ok(config) = &repo_config_result { - if let Some(identifier) = &config.identifier { - identifier.to_string() - } else { - fallback - } - } else { - fallback - } - }), - )?, - }; - - let description = match &args.description { - Some(t) => t.clone(), - None => Interactor::default().input( - PromptInputParms::default() - .with_prompt("description") - .with_default(if let Some(repo_ref) = &repo_ref { - repo_ref.description.clone() - } else { - String::new() - }), - )?, - }; - - let git_server = if args.clone_url.is_empty() { - Interactor::default() - .input( - PromptInputParms::default() - .with_prompt("clone url (for fetch)") - .with_default(if let Some(repo_ref) = &repo_ref { - repo_ref.git_server.clone().join(" ") - } else if let Ok(url) = git_repo.get_origin_url() { - if let Ok(fetch_url) = convert_clone_url_to_https(&url) { - fetch_url - } else { - // local repo or custom protocol - url - } - } else { - String::new() - }), - )? - .split(' ') - .map(std::string::ToString::to_string) - .collect() - } else { - args.clone_url.clone() - }; - - let web: Vec = if args.web.is_empty() { - Interactor::default() - .input( - PromptInputParms::default() - .with_prompt("web") - .optional() - .with_default(if let Some(repo_ref) = &repo_ref { - repo_ref.web.clone().join(" ") - } else { - format!("https://gitworkshop.dev/repo/{}", &identifier) - }), - )? - .split(' ') - .map(std::string::ToString::to_string) - .collect() - } else { - args.web.clone() - }; - - let maintainers: Vec = { - let mut dont_ask = !args.other_maintainers.is_empty(); - let mut maintainers_string = if !args.other_maintainers.is_empty() { - [args.other_maintainers.clone()].concat().join(" ") - } else if repo_ref.is_none() && repo_config_result.is_err() { - signer.public_key().await?.to_bech32()? - } else { - let maintainers = if let Ok(config) = &repo_config_result { - config.maintainers.clone() - } else if let Some(repo_ref) = &repo_ref { - repo_ref - .maintainers - .clone() - .iter() - .map(|k| k.to_bech32().unwrap()) - .collect() - } else { - //unreachable - vec![signer.public_key().await?.to_bech32()?] - }; - // add current user if not present - if maintainers.iter().any(|m| { - if let Ok(m_pubkey) = PublicKey::from_bech32(m) { - user_ref.public_key.eq(&m_pubkey) - } else { - false - } - }) { - maintainers.join(" ") - } else { - [maintainers, vec![signer.public_key().await?.to_bech32()?]] - .concat() - .join(" ") - } - }; - 'outer: loop { - if !dont_ask { - println!("{}", &maintainers_string); - maintainers_string = Interactor::default().input( - PromptInputParms::default() - .with_prompt("maintainers") - .with_default(maintainers_string), - )?; - } - let mut maintainers: Vec = vec![]; - for m in maintainers_string.split(' ') { - if let Ok(m_pubkey) = PublicKey::from_bech32(m) { - maintainers.push(m_pubkey); - } else { - println!("not a valid set of npubs seperated by a space"); - dont_ask = false; - continue 'outer; - } - } - // add current user incase removed - if !maintainers.iter().any(|m| user_ref.public_key.eq(m)) { - maintainers.push(signer.public_key().await?); - } - break maintainers; - } - }; - - // TODO: check if relays are free to post to so contributors can submit patches - // TODO: recommend some reliable free ones - let relays: Vec = if args.relays.is_empty() { - Interactor::default() - .input( - PromptInputParms::default() - .with_prompt("relays") - .with_default(if let Ok(config) = &repo_config_result { - config.relays.clone().join(" ") - } else if let Some(repo_ref) = &repo_ref { - repo_ref.relays.clone().join(" ") - } else { - user_ref.relays.write().join(" ") - }), - )? - .split(' ') - .map(std::string::ToString::to_string) - .collect() - } else { - args.relays.clone() - }; - - let earliest_unique_commit = match &args.earliest_unique_commit { - Some(t) => t.clone(), - None => { - let mut earliest_unique_commit = if let Some(repo_ref) = &repo_ref { - repo_ref.root_commit.clone() - } else { - root_commit.to_string() - }; - loop { - earliest_unique_commit = Interactor::default().input( - PromptInputParms::default() - .with_prompt("earliest unique commit") - .with_default(earliest_unique_commit.clone()), - )?; - if let Ok(exists) = git_repo.does_commit_exist(&earliest_unique_commit) { - if exists { - break earliest_unique_commit; - } - println!("commit does not exist on current repository"); - } else { - println!("commit id not formatted correctly"); - } - if earliest_unique_commit.len().ne(&40) { - println!("commit id must be 40 characters long"); - } - } - } - }; - - println!("publishing repostory reference..."); - - let repo_ref = RepoRef { - identifier: identifier.clone(), - name, - description, - root_commit: earliest_unique_commit, - git_server, - web, - relays: relays.clone(), - maintainers: maintainers.clone(), - events: HashMap::new(), - }; - let repo_event = repo_ref.to_event(&signer).await?; - - client.set_signer(signer).await; - - send_events( - &client, - git_repo_path, - vec![repo_event], - user_ref.relays.write(), - relays.clone(), - !cli_args.disable_cli_spinners, - false, - ) - .await?; - - git_repo.save_git_config_item( - "nostr.repo", - &Coordinate { - kind: Kind::GitRepoAnnouncement, - public_key: user_ref.public_key, - identifier: identifier.clone(), - relays: vec![], - } - .to_bech32()?, - false, - )?; - - // if yaml file doesnt exist or needs updating - if match &repo_config_result { - Ok(config) => { - ! as Clone>::clone(&config.identifier) - .unwrap_or_default() - .eq(&identifier) - || !extract_pks(config.maintainers.clone())?.eq(&maintainers) - || !config.relays.eq(&relays) - } - Err(_) => true, - } { - save_repo_config_to_yaml( - &git_repo, - identifier.clone(), - maintainers.clone(), - relays.clone(), - )?; - println!( - "maintainers.yaml {}. commit and push.", - if repo_config_result.is_err() { - "created" - } else { - "updated" - } - ); - println!( - "this optional file helps in identifying who the maintainers are over time through the commit history" - ); - } - Ok(()) -} diff --git a/src/sub_commands/list.rs b/src/sub_commands/list.rs deleted file mode 100644 index ac1f4ab..0000000 --- a/src/sub_commands/list.rs +++ /dev/null @@ -1,906 +0,0 @@ -use std::{collections::HashSet, io::Write, ops::Add, path::Path}; - -use anyhow::{bail, Context, Result}; -use nostr::nips::nip01::Coordinate; -use nostr_sdk::{Kind, PublicKey}; - -use super::send::event_is_patch_set_root; -#[cfg(test)] -use crate::client::MockConnect; -#[cfg(not(test))] -use crate::client::{Client, Connect}; -use crate::{ - cli_interactor::{Interactor, InteractorPrompt, PromptChoiceParms, PromptConfirmParms}, - client::{fetching_with_report, get_events_from_cache, get_repo_ref_from_cache}, - git::{str_to_sha1, Repo, RepoActions}, - repo_ref::{get_repo_coordinates, RepoRef}, - sub_commands::send::{ - commit_msg_from_patch_oneliner, event_is_cover_letter, event_is_revision_root, - event_to_cover_letter, patch_supports_commit_ids, - }, -}; - -#[allow(clippy::too_many_lines)] -pub async fn launch() -> Result<()> { - let git_repo = Repo::discover().context("cannot find a git repository")?; - let git_repo_path = git_repo.get_path()?; - - // TODO: check for empty repo - // TODO: check for existing maintaiers file - // TODO: check for other claims - - #[cfg(not(test))] - let client = Client::default(); - #[cfg(test)] - let client = ::default(); - - let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; - - fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; - - let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?; - - let proposals_and_revisions: Vec = - get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?; - if proposals_and_revisions.is_empty() { - println!("no proposals found... create one? try `ngit send`"); - return Ok(()); - } - - let statuses: Vec = { - let mut statuses = get_events_from_cache( - git_repo_path, - vec![ - nostr::Filter::default() - .kinds(status_kinds().clone()) - .events(proposals_and_revisions.iter().map(nostr::Event::id)), - ], - ) - .await?; - statuses.sort_by_key(|e| e.created_at); - statuses.reverse(); - statuses - }; - - let mut open_proposals: Vec<&nostr::Event> = vec![]; - let mut draft_proposals: Vec<&nostr::Event> = vec![]; - let mut closed_proposals: Vec<&nostr::Event> = vec![]; - let mut applied_proposals: Vec<&nostr::Event> = vec![]; - - let proposals: Vec = proposals_and_revisions - .iter() - .filter(|e| !event_is_revision_root(e)) - .cloned() - .collect(); - - for proposal in &proposals { - let status = if let Some(e) = statuses - .iter() - .filter(|e| { - status_kinds().contains(&e.kind()) - && e.tags() - .iter() - .any(|t| t.as_vec()[1].eq(&proposal.id.to_string())) - }) - .collect::>() - .first() - { - e.kind() - } else { - Kind::GitStatusOpen - }; - if status.eq(&Kind::GitStatusOpen) { - open_proposals.push(proposal); - } else if status.eq(&Kind::GitStatusClosed) { - closed_proposals.push(proposal); - } else if status.eq(&Kind::GitStatusDraft) { - draft_proposals.push(proposal); - } else if status.eq(&Kind::GitStatusApplied) { - applied_proposals.push(proposal); - } - } - - let mut selected_status = Kind::GitStatusOpen; - - loop { - let proposals_for_status = if selected_status == Kind::GitStatusOpen { - &open_proposals - } else if selected_status == Kind::GitStatusDraft { - &draft_proposals - } else if selected_status == Kind::GitStatusClosed { - &closed_proposals - } else if selected_status == Kind::GitStatusApplied { - &applied_proposals - } else { - &open_proposals - }; - - let prompt = if proposals.len().eq(&open_proposals.len()) { - "all proposals" - } else if selected_status == Kind::GitStatusOpen { - if open_proposals.is_empty() { - "proposals menu" - } else { - "open proposals" - } - } else if selected_status == Kind::GitStatusDraft { - "draft proposals" - } else if selected_status == Kind::GitStatusClosed { - "closed proposals" - } else { - "applied proposals" - }; - - let mut choices: Vec = proposals_for_status - .iter() - .map(|e| { - if let Ok(cl) = event_to_cover_letter(e) { - cl.title - } else if let Ok(msg) = tag_value(e, "description") { - msg.split('\n').collect::>()[0].to_string() - } else { - e.id.to_string() - } - }) - .collect(); - - if !selected_status.eq(&Kind::GitStatusOpen) && open_proposals.len().gt(&0) { - choices.push(format!("({}) Open proposals...", open_proposals.len())); - } - if !selected_status.eq(&Kind::GitStatusDraft) && draft_proposals.len().gt(&0) { - choices.push(format!("({}) Draft proposals...", draft_proposals.len())); - } - if !selected_status.eq(&Kind::GitStatusClosed) && closed_proposals.len().gt(&0) { - choices.push(format!("({}) Closed proposals...", closed_proposals.len())); - } - if !selected_status.eq(&Kind::GitStatusApplied) && applied_proposals.len().gt(&0) { - choices.push(format!( - "({}) Applied proposals...", - applied_proposals.len() - )); - } - - let selected_index = Interactor::default().choice( - PromptChoiceParms::default() - .with_prompt(prompt) - .with_choices(choices.clone()), - )?; - - if (selected_index + 1).gt(&proposals_for_status.len()) { - if choices[selected_index].contains("Open") { - selected_status = Kind::GitStatusOpen; - } else if choices[selected_index].contains("Draft") { - selected_status = Kind::GitStatusDraft; - } else if choices[selected_index].contains("Closed") { - selected_status = Kind::GitStatusClosed; - } else if choices[selected_index].contains("Applied") { - selected_status = Kind::GitStatusApplied; - } - continue; - } - - let cover_letter = event_to_cover_letter(proposals_for_status[selected_index]) - .context("cannot extract proposal details from proposal root event")?; - - let commits_events: Vec = get_all_proposal_patch_events_from_cache( - git_repo_path, - &repo_ref, - &proposals_for_status[selected_index].id(), - ) - .await?; - - let Ok(most_recent_proposal_patch_chain) = - get_most_recent_patch_with_ancestors(commits_events.clone()) - else { - if Interactor::default().confirm( - PromptConfirmParms::default() - .with_default(true) - .with_prompt( - "cannot find any patches on this proposal. choose another proposal?", - ), - )? { - continue; - } - return Ok(()); - }; - // for commit in &most_recent_proposal_patch_chain { - // println!("recent_event: {:?}", commit.as_json()); - // } - - let binding_patch_text_ref = format!("{} commits", most_recent_proposal_patch_chain.len()); - let patch_text_ref = if most_recent_proposal_patch_chain.len().gt(&1) { - binding_patch_text_ref.as_str() - } else { - "1 commit" - }; - - let no_support_for_patches_as_branch = most_recent_proposal_patch_chain - .iter() - .any(|event| !patch_supports_commit_ids(event)); - - if no_support_for_patches_as_branch { - println!("{patch_text_ref}"); - return match Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec![ - "learn why 'patch only' proposals can't be checked out".to_string(), - format!("apply to current branch with `git am`"), - format!("download to ./patches"), - "back".to_string(), - ]), - )? { - 0 => { - println!("Some proposals are posted as 'patch only'\n"); - println!( - "they are not anchored against a particular state of the code base like a standard proposal or a GitHub Pull Request can be\n" - ); - println!( - "they are designed to reviewed by studying the diff (in a tool like gitworkshop.dev) and if acceptable by a maintainer, applied to the latest version of master with any conflicts resolved as the do so\n" - ); - println!( - "this has proven to be a smoother workflow for large scale projects with a high frequency of changes, even when patches are exchanged via email\n" - ); - println!( - "by default ngit posts proposals that support both the branch and patch model so either workflow can be used" - ); - Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec!["back".to_string()]), - )?; - continue; - } - 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), - 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), - 3 => continue, - _ => { - bail!("unexpected choice") - } - }; - } - - let branch_exists = git_repo - .get_local_branch_names() - .context("gitlib2 will not show a list of local branch names")? - .iter() - .any(|n| n.eq(&cover_letter.get_branch_name().unwrap())); - - let checked_out_proposal_branch = git_repo - .get_checked_out_branch_name()? - .eq(&cover_letter.get_branch_name()?); - - let proposal_base_commit = str_to_sha1(&tag_value( - most_recent_proposal_patch_chain.last().context( - "there should be at least one patch as we have already checked for this", - )?, - "parent-commit", - )?) - .context("cannot get valid parent commit id from patch")?; - - let (main_branch_name, master_tip) = git_repo.get_main_or_master_branch()?; - - if !git_repo.does_commit_exist(&proposal_base_commit.to_string())? { - println!("your '{main_branch_name}' branch may not be up-to-date."); - println!("the proposal parent commit doesnt exist in your local repository."); - return match Interactor::default().choice(PromptChoiceParms::default().with_default(0).with_choices( - vec![ - format!( - "manually run `git pull` on '{main_branch_name}' and select proposal again" - ), - format!("apply to current branch with `git am`"), - format!("download to ./patches"), - "back".to_string(), - ], - ))? { - 0 | 3 => continue, - 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), - 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), - _ => { - bail!("unexpected choice") - } - }; - } - - let proposal_tip = str_to_sha1( - &get_commit_id_from_patch(most_recent_proposal_patch_chain.first().context( - "there should be at least one patch as we have already checked for this", - )?) - .context("cannot get valid commit_id from patch")?, - ) - .context("cannot get valid commit_id from patch")?; - - let (_, proposal_behind_main) = - git_repo.get_commits_ahead_behind(&master_tip, &proposal_base_commit)?; - - // branch doesnt exist - if !branch_exists { - return match Interactor::default() - .choice(PromptChoiceParms::default().with_default(0).with_choices(vec![ - format!( - "create and checkout proposal branch ({} ahead {} behind '{main_branch_name}')", - most_recent_proposal_patch_chain.len(), - proposal_behind_main.len(), - ), - format!("apply to current branch with `git am`"), - format!("download to ./patches"), - "back".to_string(), - ]))? { - 0 => { - check_clean(&git_repo)?; - let _ = git_repo - .apply_patch_chain( - &cover_letter.get_branch_name()?, - most_recent_proposal_patch_chain, - ) - .context("cannot apply patch chain")?; - - println!( - "checked out proposal as '{}' branch", - cover_letter.get_branch_name()? - ); - Ok(()) - } - 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), - 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), - 3 => continue, - _ => { - bail!("unexpected choice") - } - }; - } - - let local_branch_tip = git_repo.get_tip_of_branch(&cover_letter.get_branch_name()?)?; - - // up-to-date - if proposal_tip.eq(&local_branch_tip) { - if checked_out_proposal_branch { - println!("branch checked out and up-to-date"); - return match Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec!["exit".to_string(), "back".to_string()]), - )? { - 0 => Ok(()), - 1 => continue, - _ => { - bail!("unexpected choice") - } - }; - } - - return match Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec![ - format!( - "checkout proposal branch ({} ahead {} behind '{main_branch_name}')", - most_recent_proposal_patch_chain.len(), - proposal_behind_main.len(), - ), - format!("apply to current branch with `git am`"), - format!("download to ./patches"), - "back".to_string(), - ]), - )? { - 0 => { - check_clean(&git_repo)?; - git_repo.checkout(&cover_letter.get_branch_name()?)?; - println!( - "checked out proposal as '{}' branch", - cover_letter.get_branch_name()? - ); - Ok(()) - } - 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), - 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), - 3 => continue, - _ => { - bail!("unexpected choice") - } - }; - } - - let (local_ahead_of_main, local_beind_main) = - git_repo.get_commits_ahead_behind(&master_tip, &local_branch_tip)?; - - // new appendments to proposal - if let Some(index) = most_recent_proposal_patch_chain.iter().position(|patch| { - get_commit_id_from_patch(patch) - .unwrap_or_default() - .eq(&local_branch_tip.to_string()) - }) { - return match Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec![ - format!("checkout proposal branch and apply {} appendments", &index,), - format!("apply to current branch with `git am`"), - format!("download to ./patches"), - "back".to_string(), - ]), - )? { - 0 => { - check_clean(&git_repo)?; - git_repo.checkout(&cover_letter.get_branch_name()?)?; - let _ = git_repo - .apply_patch_chain( - &cover_letter.get_branch_name()?, - most_recent_proposal_patch_chain, - ) - .context("cannot apply patch chain")?; - println!( - "checked out proposal branch and applied {} appendments ({} ahead {} behind '{main_branch_name}')", - &index, - local_ahead_of_main.len().add(&index), - local_beind_main.len(), - ); - Ok(()) - } - 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), - 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), - 3 => continue, - _ => { - bail!("unexpected choice") - } - }; - } - - // new proposal revision / rebase - // tip of local in proposal history (new, amended or rebased version but no - // local changes) - if commits_events.iter().any(|patch| { - get_commit_id_from_patch(patch) - .unwrap_or_default() - .eq(&local_branch_tip.to_string()) - }) { - println!( - "updated proposal available ({} ahead {} behind '{main_branch_name}'). existing version is {} ahead {} behind '{main_branch_name}'", - most_recent_proposal_patch_chain.len(), - proposal_behind_main.len(), - local_ahead_of_main.len(), - local_beind_main.len(), - ); - return match Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec![ - format!("checkout and overwrite existing proposal branch"), - format!("checkout existing outdated proposal branch"), - format!("apply to current branch with `git am`"), - format!("download to ./patches"), - "back".to_string(), - ]), - )? { - 0 => { - check_clean(&git_repo)?; - git_repo.create_branch_at_commit( - &cover_letter.get_branch_name()?, - &proposal_base_commit.to_string(), - )?; - git_repo.checkout(&cover_letter.get_branch_name()?)?; - let chain_length = most_recent_proposal_patch_chain.len(); - let _ = git_repo - .apply_patch_chain( - &cover_letter.get_branch_name()?, - most_recent_proposal_patch_chain, - ) - .context("cannot apply patch chain")?; - println!( - "checked out new version of proposal ({} ahead {} behind '{main_branch_name}'), replacing old version ({} ahead {} behind '{main_branch_name}')", - chain_length, - proposal_behind_main.len(), - local_ahead_of_main.len(), - local_beind_main.len(), - ); - Ok(()) - } - 1 => { - check_clean(&git_repo)?; - git_repo.checkout(&cover_letter.get_branch_name()?)?; - println!( - "checked out old proposal in existing branch ({} ahead {} behind '{main_branch_name}')", - local_ahead_of_main.len(), - local_beind_main.len(), - ); - Ok(()) - } - 2 => launch_git_am_with_patches(most_recent_proposal_patch_chain), - 3 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), - 4 => continue, - _ => { - bail!("unexpected choice") - } - }; - } - // tip of proposal in branch in history (local appendments made to up-to-date - // proposal) - else if git_repo.ancestor_of(&local_branch_tip, &proposal_tip)? { - let (local_ahead_of_proposal, _) = git_repo - .get_commits_ahead_behind(&proposal_tip, &local_branch_tip) - .context("cannot get commits ahead behind for propsal_top and local_branch_tip")?; - - println!( - "local proposal branch exists with {} unpublished commits on top of the most up-to-date version of the proposal ({} ahead {} behind '{main_branch_name}')", - local_ahead_of_proposal.len(), - local_ahead_of_main.len(), - proposal_behind_main.len(), - ); - return match Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec![ - format!( - "checkout proposal branch with {} unpublished commits", - local_ahead_of_proposal.len(), - ), - "back".to_string(), - ]), - )? { - 0 => { - git_repo.checkout(&cover_letter.get_branch_name()?)?; - println!( - "checked out proposal branch with {} unpublished commits ({} ahead {} behind '{main_branch_name}')", - local_ahead_of_proposal.len(), - local_ahead_of_main.len(), - proposal_behind_main.len(), - ); - Ok(()) - } - 1 => continue, - _ => { - bail!("unexpected choice") - } - }; - } - - println!("you have an amended/rebase version the proposal that is unpublished"); - // user probably has a unpublished amended or rebase version of the latest - // proposal version - // if tip of proposal commits exist (were once part of branch but have been - // amended and git clean up job hasn't removed them) - if git_repo.does_commit_exist(&proposal_tip.to_string())? { - println!( - "you have previously applied the latest version of the proposal ({} ahead {} behind '{main_branch_name}') but your local proposal branch has amended or rebased it ({} ahead {} behind '{main_branch_name}')", - most_recent_proposal_patch_chain.len(), - proposal_behind_main.len(), - local_ahead_of_main.len(), - local_beind_main.len(), - ); - } - // user probably has a unpublished amended or rebase version of an older - // proposal version - else { - println!( - "your local proposal branch ({} ahead {} behind '{main_branch_name}') has conflicting changes with the latest published proposal ({} ahead {} behind '{main_branch_name}')", - local_ahead_of_main.len(), - local_beind_main.len(), - most_recent_proposal_patch_chain.len(), - proposal_behind_main.len(), - ); - - println!( - "its likely that you have rebased / amended an old proposal version because git has no record of the latest proposal commit." - ); - println!( - "it is possible that you have been working off the latest version and git has delete this commit as part of a clean up" - ); - } - println!("to view the latest proposal but retain your changes:"); - println!(" 1) create a new branch off the tip commit of this one to store your changes"); - println!(" 2) run `ngit list` and checkout the latest published version of this proposal"); - - println!("if you are confident in your changes consider running `ngit push --force`"); - - return match Interactor::default().choice( - PromptChoiceParms::default() - .with_default(0) - .with_choices(vec![ - format!("checkout local branch with unpublished changes"), - format!("discard unpublished changes and checkout new revision",), - format!("apply to current branch with `git am`"), - format!("download to ./patches"), - "back".to_string(), - ]), - )? { - 0 => { - check_clean(&git_repo)?; - git_repo.checkout(&cover_letter.get_branch_name()?)?; - println!( - "checked out old proposal in existing branch ({} ahead {} behind '{main_branch_name}')", - local_ahead_of_main.len(), - local_beind_main.len(), - ); - Ok(()) - } - 1 => { - check_clean(&git_repo)?; - git_repo.create_branch_at_commit( - &cover_letter.get_branch_name()?, - &proposal_base_commit.to_string(), - )?; - let chain_length = most_recent_proposal_patch_chain.len(); - let _ = git_repo - .apply_patch_chain( - &cover_letter.get_branch_name()?, - most_recent_proposal_patch_chain, - ) - .context("cannot apply patch chain")?; - - git_repo.checkout(&cover_letter.get_branch_name()?)?; - println!( - "checked out latest version of proposal ({} ahead {} behind '{main_branch_name}'), replacing unpublished version ({} ahead {} behind '{main_branch_name}')", - chain_length, - proposal_behind_main.len(), - local_ahead_of_main.len(), - local_beind_main.len(), - ); - Ok(()) - } - 2 => launch_git_am_with_patches(most_recent_proposal_patch_chain), - 3 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), - 4 => continue, - _ => { - bail!("unexpected choice") - } - }; - } -} - -fn launch_git_am_with_patches(mut patches: Vec) -> Result<()> { - println!("applying to current branch with `git am`"); - // TODO: add PATCH x/n to appended patches - patches.reverse(); - - let mut am = std::process::Command::new("git") - .arg("am") - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .spawn() - .context("failed to spawn git am")?; - - let stdin = am - .stdin - .as_mut() - .context("git am process failed to take stdin")?; - - for patch in patches { - stdin - .write(format!("{}\n\n", patch.content).as_bytes()) - .context("failed to write patch content into git am stdin buffer")?; - } - stdin.flush()?; - let output = am - .wait_with_output() - .context("failed to read git am stdout")?; - print!("{:?}", output.stdout); - Ok(()) -} - -fn event_id_extra_shorthand(event: &nostr::Event) -> String { - event.id.to_string()[..5].to_string() -} - -fn save_patches_to_dir(mut patches: Vec, git_repo: &Repo) -> Result<()> { - // TODO: add PATCH x/n to appended patches - patches.reverse(); - let path = git_repo.get_path()?.join("patches"); - std::fs::create_dir_all(&path)?; - let id = event_id_extra_shorthand( - patches - .first() - .context("there must be at least one patch to save")?, - ); - for (i, patch) in patches.iter().enumerate() { - let path = path.join(format!( - "{}-{:0>4}-{}.patch", - &id, - i.add(&1), - commit_msg_from_patch_oneliner(patch)? - )); - let mut file = std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(path) - .context("open new patch file with write and truncate options")?; - file.write_all(patch.content().as_bytes())?; - file.write_all("\n\n".as_bytes())?; - file.flush()?; - } - println!("created {} patch files in ./patches/{id}-*", patches.len()); - Ok(()) -} - -fn check_clean(git_repo: &Repo) -> Result<()> { - if git_repo.has_outstanding_changes()? { - bail!( - "cannot pull proposal branch when repository is not clean. discard or stash (un)staged changes and try again." - ); - } - Ok(()) -} - -pub fn tag_value(event: &nostr::Event, tag_name: &str) -> Result { - Ok(event - .tags - .iter() - .find(|t| t.as_vec()[0].eq(tag_name)) - .context(format!("tag '{tag_name}'not present"))? - .as_vec()[1] - .clone()) -} - -pub fn get_commit_id_from_patch(event: &nostr::Event) -> Result { - let value = tag_value(event, "commit"); - - if value.is_ok() { - value - } else if event.content.starts_with("From ") && event.content.len().gt(&45) { - Ok(event.content[5..45].to_string()) - } else { - bail!("event is not a patch") - } -} - -fn get_event_parent_id(event: &nostr::Event) -> Result { - Ok(if let Some(reply_tag) = event - .tags - .iter() - .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("reply")) - { - reply_tag - } else { - event - .tags - .iter() - .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("root")) - .context("no reply or root e tag present".to_string())? - } - .as_vec()[1] - .clone()) -} - -pub fn get_most_recent_patch_with_ancestors( - mut patches: Vec, -) -> Result> { - patches.sort_by_key(|e| e.created_at); - - let youngest_patch = patches.last().context("no patches found")?; - - let patches_with_youngest_created_at: Vec<&nostr::Event> = patches - .iter() - .filter(|p| p.created_at.eq(&youngest_patch.created_at)) - .collect(); - - let mut res = vec![]; - - let mut event_id_to_search = patches_with_youngest_created_at - .clone() - .iter() - .find(|p| { - !patches_with_youngest_created_at.iter().any(|p2| { - if let Ok(reply_to) = get_event_parent_id(p2) { - reply_to.eq(&p.id.to_string()) - } else { - false - } - }) - }) - .context("cannot find patches_with_youngest_created_at")? - .id - .to_string(); - - while let Some(event) = patches - .iter() - .find(|e| e.id.to_string().eq(&event_id_to_search)) - { - res.push(event.clone()); - if event_is_patch_set_root(event) { - break; - } - event_id_to_search = get_event_parent_id(event).unwrap_or_default(); - } - Ok(res) -} - -pub fn status_kinds() -> Vec { - vec![ - nostr::Kind::GitStatusOpen, - nostr::Kind::GitStatusApplied, - nostr::Kind::GitStatusClosed, - nostr::Kind::GitStatusDraft, - ] -} - -pub async fn get_proposals_and_revisions_from_cache( - git_repo_path: &Path, - repo_coordinates: HashSet, -) -> Result> { - let mut proposals = get_events_from_cache( - git_repo_path, - vec![ - nostr::Filter::default() - .kind(nostr::Kind::GitPatch) - .custom_tag( - nostr::SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), - repo_coordinates - .iter() - .map(std::string::ToString::to_string) - .collect::>(), - ), - ], - ) - .await? - .iter() - .filter(|e| event_is_patch_set_root(e)) - .cloned() - .collect::>(); - proposals.sort_by_key(|e| e.created_at); - proposals.reverse(); - Ok(proposals) -} - -pub async fn get_all_proposal_patch_events_from_cache( - git_repo_path: &Path, - repo_ref: &RepoRef, - proposal_id: &nostr::EventId, -) -> Result> { - let mut commit_events = get_events_from_cache( - git_repo_path, - vec![ - nostr::Filter::default() - .kind(nostr::Kind::GitPatch) - .event(*proposal_id), - nostr::Filter::default() - .kind(nostr::Kind::GitPatch) - .id(*proposal_id), - ], - ) - .await?; - - let permissioned_users: HashSet = [ - repo_ref.maintainers.clone(), - vec![ - commit_events - .iter() - .find(|e| e.id().eq(proposal_id)) - .context("proposal not in cache")? - .author(), - ], - ] - .concat() - .iter() - .copied() - .collect(); - commit_events.retain(|e| permissioned_users.contains(&e.author())); - - let revision_roots: HashSet = commit_events - .iter() - .filter(|e| event_is_revision_root(e)) - .map(nostr::Event::id) - .collect(); - - if !revision_roots.is_empty() { - for event in get_events_from_cache( - git_repo_path, - vec![ - nostr::Filter::default() - .kind(nostr::Kind::GitPatch) - .events(revision_roots) - .authors(permissioned_users.clone()), - ], - ) - .await? - { - commit_events.push(event); - } - } - - Ok(commit_events - .iter() - .filter(|e| !event_is_cover_letter(e) && permissioned_users.contains(&e.author())) - .cloned() - .collect()) -} diff --git a/src/sub_commands/login.rs b/src/sub_commands/login.rs deleted file mode 100644 index 8a3788f..0000000 --- a/src/sub_commands/login.rs +++ /dev/null @@ -1,52 +0,0 @@ -use anyhow::{Context, Result}; -use clap; - -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{cli::Cli, client::Connect, git::Repo, login}; - -#[derive(clap::Args)] -pub struct SubCommandArgs { - /// don't fetch user metadata and relay list from relays - #[arg(long, action)] - offline: bool, -} - -pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { - let git_repo = Repo::discover().context("cannot find a git repository")?; - if command_args.offline { - login::launch( - &git_repo, - &args.bunker_uri, - &args.bunker_app_key, - &args.nsec, - &args.password, - None, - true, - false, - ) - .await?; - Ok(()) - } else { - #[cfg(not(test))] - let client = Client::default(); - #[cfg(test)] - let client = ::default(); - - login::launch( - &git_repo, - &args.bunker_uri, - &args.bunker_app_key, - &args.nsec, - &args.password, - Some(&client), - true, - false, - ) - .await?; - client.disconnect().await?; - Ok(()) - } -} diff --git a/src/sub_commands/mod.rs b/src/sub_commands/mod.rs deleted file mode 100644 index 29a60f9..0000000 --- a/src/sub_commands/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod fetch; -pub mod init; -pub mod list; -pub mod login; -pub mod pull; -pub mod push; -pub mod send; diff --git a/src/sub_commands/pull.rs b/src/sub_commands/pull.rs deleted file mode 100644 index e33a744..0000000 --- a/src/sub_commands/pull.rs +++ /dev/null @@ -1,209 +0,0 @@ -use anyhow::{bail, Context, Result}; - -use super::{ - list::{ - get_all_proposal_patch_events_from_cache, get_commit_id_from_patch, - get_proposals_and_revisions_from_cache, tag_value, - }, - send::event_to_cover_letter, -}; -#[cfg(test)] -use crate::client::MockConnect; -#[cfg(not(test))] -use crate::client::{Client, Connect}; -use crate::{ - client::{fetching_with_report, get_repo_ref_from_cache}, - git::{str_to_sha1, Repo, RepoActions}, - repo_ref::get_repo_coordinates, - sub_commands::{list::get_most_recent_patch_with_ancestors, send::event_is_revision_root}, -}; - -#[allow(clippy::too_many_lines)] -pub async fn launch() -> Result<()> { - let git_repo = Repo::discover().context("cannot find a git repository")?; - let git_repo_path = git_repo.get_path()?; - - let (main_or_master_branch_name, _) = git_repo - .get_main_or_master_branch() - .context("no main or master branch")?; - - let branch_name = git_repo - .get_checked_out_branch_name() - .context("cannot get checked out branch name")?; - - if branch_name == main_or_master_branch_name { - bail!("checkout a branch associated with a proposal first") - } - #[cfg(not(test))] - let client = Client::default(); - #[cfg(test)] - let client = ::default(); - - let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; - - fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; - - let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?; - - let proposal_root_event = - get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()) - .await? - .iter() - .find(|e| { - event_to_cover_letter(e) - .is_ok_and(|cl| cl.get_branch_name().is_ok_and(|s| s.eq(&branch_name))) - && !event_is_revision_root(e) - }) - .context("cannot find proposal that matches the current branch name")? - .clone(); - let commit_events = get_all_proposal_patch_events_from_cache( - git_repo_path, - &repo_ref, - &proposal_root_event.id(), - ) - .await?; - - let most_recent_proposal_patch_chain = - get_most_recent_patch_with_ancestors(commit_events.clone()) - .context("cannot get most recent patch for proposal")?; - - let local_branch_tip = git_repo.get_tip_of_branch(&branch_name)?; - - let (main_branch_name, master_tip) = git_repo.get_main_or_master_branch()?; - - let (local_ahead_of_main, local_beind_main) = - git_repo.get_commits_ahead_behind(&master_tip, &local_branch_tip)?; - - let proposal_base_commit = str_to_sha1(&tag_value( - most_recent_proposal_patch_chain - .last() - .context("there should be at least one patch as we have already checked for this")?, - "parent-commit", - )?) - .context("cannot get valid parent commit id from patch")?; - - let (_, proposal_behind_main) = - git_repo.get_commits_ahead_behind(&master_tip, &proposal_base_commit)?; - - let proposal_tip = - str_to_sha1( - &get_commit_id_from_patch(most_recent_proposal_patch_chain.first().context( - "there should be at least one patch as we have already checked for this", - )?) - .context("cannot get valid commit_id from patch")?, - ) - .context("cannot get valid commit_id from patch")?; - - // if uptodate - if proposal_tip.eq(&local_branch_tip) { - println!("branch already up-to-date"); - } - // if new appendments - else if most_recent_proposal_patch_chain.iter().any(|patch| { - get_commit_id_from_patch(patch) - .unwrap_or_default() - .eq(&local_branch_tip.to_string()) - }) { - check_clean(&git_repo)?; - let applied = git_repo - .apply_patch_chain(&branch_name, most_recent_proposal_patch_chain) - .context("cannot apply patch chain")?; - println!("applied {} new commits", applied.len(),); - } - // if parent commit doesnt exist - else if !git_repo.does_commit_exist(&proposal_base_commit.to_string())? { - println!( - "a new version of the proposal has a prant commit that doesnt exist in your local repository." - ); - println!("your '{main_branch_name}' branch may not be up-to-date."); - println!("manually run `git pull` on '{main_branch_name}' and try again"); - } - // if new revision and no local changes (tip of local in proposal history) - else if commit_events.iter().any(|patch| { - get_commit_id_from_patch(patch) - .unwrap_or_default() - .eq(&local_branch_tip.to_string()) - }) { - check_clean(&git_repo)?; - - git_repo.create_branch_at_commit(&branch_name, &proposal_base_commit.to_string())?; - let applied = git_repo - .apply_patch_chain(&branch_name, most_recent_proposal_patch_chain) - .context("cannot apply patch chain")?; - - println!( - "pulled new version of proposal ({} ahead {} behind '{main_branch_name}'), replacing old version ({} ahead {} behind '{main_branch_name}')", - applied.len(), - proposal_behind_main.len(), - local_ahead_of_main.len(), - local_beind_main.len(), - ); - } - // if tip of proposal in branch in history (local appendments made to up-to-date - // proposal) - else if git_repo.ancestor_of(&local_branch_tip, &proposal_tip)? { - let (local_ahead_of_proposal, _) = git_repo - .get_commits_ahead_behind(&proposal_tip, &local_branch_tip) - .context("cannot get commits ahead behind for propsal_top and local_branch_tip")?; - println!( - "local proposal branch exists with {} unpublished commits on top of the most up-to-date version of the proposal", - local_ahead_of_proposal.len() - ); - } else { - println!("you have an amended/rebase version the proposal that is unpublished"); - // user probably has a unpublished amended or rebase version of the latest - // proposal version - // if tip of proposal commits exist (were once part of branch but have been - // amended and git clean up job hasn't removed them) - if git_repo.does_commit_exist(&proposal_tip.to_string())? { - println!( - "you have previously applied the latest version of the proposal ({} ahead {} behind '{main_branch_name}') but your local proposal branch has amended or rebased it ({} ahead {} behind '{main_branch_name}')", - most_recent_proposal_patch_chain.len(), - proposal_behind_main.len(), - local_ahead_of_main.len(), - local_beind_main.len(), - ); - } - // user probably has a unpublished amended or rebase version of an older - // proposal version - else { - println!( - "your local proposal branch ({} ahead {} behind '{main_branch_name}') has conflicting changes with the latest published proposal ({} ahead {} behind '{main_branch_name}')", - local_ahead_of_main.len(), - local_beind_main.len(), - most_recent_proposal_patch_chain.len(), - proposal_behind_main.len(), - ); - - println!( - "its likely that you have rebased / amended an old proposal version because git has no record of the latest proposal commit." - ); - println!( - "it is possible that you have been working off the latest version and git has delete this commit as part of a clean up" - ); - } - println!("to view the latest proposal but retain your changes:"); - println!(" 1) create a new branch off the tip commit of this one to store your changes"); - println!(" 2) run `ngit list` and checkout the latest published version of this proposal"); - - println!("if you are confident in your changes consider running `ngit push --force`"); - - // TODO: this copy could be refined further based on this: - // - amended commits in the proposal - // - if local_base eq proposal base - // - amended an older version of proposal - // - if local_base is behind proposal_base - // - rebased the proposal - // - if local_base is ahead of proposal_base - } - Ok(()) -} - -fn check_clean(git_repo: &Repo) -> Result<()> { - if git_repo.has_outstanding_changes()? { - bail!( - "cannot pull proposal branch when repository is not clean. discard or stash (un)staged changes and try again." - ); - } - Ok(()) -} diff --git a/src/sub_commands/push.rs b/src/sub_commands/push.rs deleted file mode 100644 index 7a82c7a..0000000 --- a/src/sub_commands/push.rs +++ /dev/null @@ -1,223 +0,0 @@ -use anyhow::{bail, Context, Result}; - -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{ - cli::Cli, - client::{fetching_with_report, get_repo_ref_from_cache, Connect}, - git::{str_to_sha1, Repo, RepoActions}, - login, - repo_ref::get_repo_coordinates, - sub_commands::{ - self, - list::{ - get_all_proposal_patch_events_from_cache, get_commit_id_from_patch, - get_most_recent_patch_with_ancestors, get_proposals_and_revisions_from_cache, - tag_value, - }, - send::{ - event_is_revision_root, event_to_cover_letter, generate_patch_event, - identify_ahead_behind, send_events, - }, - }, -}; - -#[derive(Debug, clap::Args)] -pub struct SubCommandArgs { - #[arg(long, action)] - /// send proposal revision from checked out proposal branch - force: bool, -} - -#[allow(clippy::too_many_lines)] -pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { - let git_repo = Repo::discover().context("cannot find a git repository")?; - let git_repo_path = git_repo.get_path()?; - - let (main_or_master_branch_name, _) = git_repo - .get_main_or_master_branch() - .context("no main or master branch")?; - - let root_commit = git_repo - .get_root_commit() - .context("failed to get root commit of the repository")?; - - let branch_name = git_repo - .get_checked_out_branch_name() - .context("cannot get checked out branch name")?; - - if branch_name == main_or_master_branch_name { - bail!("checkout a branch associated with a proposal first") - } - #[cfg(not(test))] - let mut client = Client::default(); - #[cfg(test)] - let mut client = ::default(); - - let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; - - fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; - - let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?; - - let proposal_root_event = - get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()) - .await? - .iter() - .find(|e| { - event_to_cover_letter(e) - .is_ok_and(|cl| cl.get_branch_name().is_ok_and(|s| s.eq(&branch_name))) - && !event_is_revision_root(e) - }) - .context("cannot find proposal that matches the current branch name")? - .clone(); - - let commit_events = get_all_proposal_patch_events_from_cache( - git_repo_path, - &repo_ref, - &proposal_root_event.id(), - ) - .await?; - - let most_recent_proposal_patch_chain = get_most_recent_patch_with_ancestors(commit_events) - .context("cannot get most recent patch for proposal")?; - - let branch_tip = git_repo.get_tip_of_branch(&branch_name)?; - - let most_recent_patch_commit_id = str_to_sha1( - &get_commit_id_from_patch( - most_recent_proposal_patch_chain - .first() - .context("no patches found")?, - ) - .context("latest patch event doesnt have a commit tag")?, - ) - .context("latest patch event commit tag isn't a valid SHA1 hash")?; - - let proposal_base_commit_id = str_to_sha1( - &tag_value( - most_recent_proposal_patch_chain - .last() - .context("no patches found")?, - "parent-commit", - ) - .context("patch is incorrectly formatted")?, - ) - .context("latest patch event parent-commit tag isn't a valid SHA1 hash")?; - - if most_recent_patch_commit_id.eq(&branch_tip) { - bail!("proposal already up-to-date with local branch"); - } - - if args.force { - println!("preparing to force push proposal revision..."); - sub_commands::send::launch( - cli_args, - &sub_commands::send::SubCommandArgs { - // if not ahead of master prompt, otherwise assume proposal revision is all commits - // ahead - since_or_range: if let Ok((_, _, ahead, _)) = - identify_ahead_behind(&git_repo, &None, &None) - { - if ahead.is_empty() { - String::new() - } else { - format!("HEAD~{}", ahead.len()) - } - } else { - String::new() - }, - in_reply_to: vec![proposal_root_event.id.to_string()], - title: None, - description: None, - no_cover_letter: true, - }, - true, - ) - .await?; - println!("force pushed proposal revision"); - return Ok(()); - } - - if most_recent_proposal_patch_chain.iter().any(|e| { - let c = tag_value(e, "parent-commit").unwrap_or_default(); - c.eq(&branch_tip.to_string()) - }) { - bail!("proposal is ahead of local branch"); - } - - let Ok((ahead, behind)) = git_repo - .get_commits_ahead_behind(&most_recent_patch_commit_id, &branch_tip) - .context("the latest patch in proposal doesnt share an ancestor with your branch.") - else { - if git_repo.ancestor_of(&proposal_base_commit_id, &branch_tip)? { - bail!("local unpublished proposal ammendments. consider force pushing."); - } - bail!("local unpublished proposal has been rebased. consider force pushing"); - }; - - if !behind.is_empty() { - bail!( - "your local proposal branch is {} behind patches on nostr. consider rebasing or force pushing", - behind.len() - ) - } - - println!( - "{} commits ahead. preparing to create creating patch events.", - ahead.len() - ); - - let (signer, user_ref) = login::launch( - &git_repo, - &cli_args.bunker_uri, - &cli_args.bunker_app_key, - &cli_args.nsec, - &cli_args.password, - Some(&client), - false, - false, - ) - .await?; - - let mut patch_events: Vec = vec![]; - for commit in &ahead { - patch_events.push( - generate_patch_event( - &git_repo, - &root_commit, - commit, - Some(proposal_root_event.id), - &signer, - &repo_ref, - patch_events.last().map(nostr::Event::id), - None, - None, - &None, - &[], - ) - .await - .context("cannot make patch event from commit")?, - ); - } - println!("pushing {} commits", ahead.len()); - - client.set_signer(signer).await; - - send_events( - &client, - git_repo_path, - patch_events, - user_ref.relays.write(), - repo_ref.relays.clone(), - !cli_args.disable_cli_spinners, - false, - ) - .await?; - - println!("pushed {} commits", ahead.len()); - - Ok(()) -} diff --git a/src/sub_commands/send.rs b/src/sub_commands/send.rs deleted file mode 100644 index 3c4df9d..0000000 --- a/src/sub_commands/send.rs +++ /dev/null @@ -1,1363 +0,0 @@ -use std::{path::Path, str::FromStr, time::Duration}; - -use anyhow::{bail, Context, Result}; -use console::Style; -use futures::future::join_all; -use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; -use nostr::{ - nips::{ - nip01::Coordinate, - nip10::Marker, - nip19::{Nip19, Nip19Event}, - }, - EventBuilder, FromBech32, Tag, TagKind, ToBech32, UncheckedUrl, -}; -use nostr_sdk::{hashes::sha1::Hash as Sha1Hash, Kind, NostrSigner, TagStandard}; - -use super::list::tag_value; -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{ - cli::Cli, - cli_interactor::{ - Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms, - }, - client::{ - fetching_with_report, get_events_from_cache, get_repo_ref_from_cache, sign_event, Connect, - }, - git::{Repo, RepoActions}, - login, - repo_ref::{get_repo_coordinates, RepoRef}, -}; - -#[derive(Debug, clap::Args)] -pub struct SubCommandArgs { - #[arg(default_value = "")] - /// commits to send as proposal; like in `git format-patch` eg. HEAD~2 - pub(crate) since_or_range: String, - #[clap(long, value_parser, num_args = 0.., value_delimiter = ' ')] - /// references to an existing proposal for which this is a new - /// version and/or events / npubs to tag as mentions - pub(crate) in_reply_to: Vec, - /// don't prompt for a cover letter - #[arg(long, action)] - pub(crate) no_cover_letter: bool, - /// optional cover letter title - #[clap(short, long)] - pub(crate) title: Option, - #[clap(short, long)] - /// optional cover letter description - pub(crate) description: Option, -} - -#[allow(clippy::too_many_lines)] -pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Result<()> { - let git_repo = Repo::discover().context("cannot find a git repository")?; - let git_repo_path = git_repo.get_path()?; - - let (main_branch_name, main_tip) = git_repo - .get_main_or_master_branch() - .context("the default branches (main or master) do not exist")?; - - #[cfg(not(test))] - let mut client = Client::default(); - #[cfg(test)] - let mut client = ::default(); - - let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; - - if !no_fetch { - fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; - } - - let (root_proposal_id, mention_tags) = - get_root_proposal_id_and_mentions_from_in_reply_to(git_repo.get_path()?, &args.in_reply_to) - .await?; - - if let Some(root_ref) = args.in_reply_to.first() { - if root_proposal_id.is_some() { - println!("creating proposal revision for: {root_ref}"); - } - } - - let mut commits: Vec = { - if args.since_or_range.is_empty() { - let branch_name = git_repo.get_checked_out_branch_name()?; - let proposed_commits = if branch_name.eq(main_branch_name) { - vec![main_tip] - } else { - let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?; - ahead - }; - choose_commits(&git_repo, proposed_commits)? - } else { - git_repo - .parse_starting_commits(&args.since_or_range) - .context("cannot parse specified starting commit or range")? - } - }; - - if commits.is_empty() { - bail!("no commits selected"); - } - println!("creating proposal from {} commits:", commits.len()); - - let dim = Style::new().color256(247); - for commit in &commits { - println!( - "{} {}", - dim.apply_to(commit.to_string().chars().take(7).collect::()), - git_repo.get_commit_message_summary(commit)? - ); - } - - let (first_commit_ahead, behind) = - git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?; - - // check proposal ahead of origin/main - if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt( - format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1) - ) - .with_default(false) - ).context("failed to get confirmation response from interactor confirm")? { - bail!("aborting because selected commits were ahead of origin/master"); - } - - // check if a selected commit is already in origin - if commits.iter().any(|c| c.eq(&main_tip)) { - if !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt( - format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?") - ) - .with_default(false) - ).context("failed to get confirmation response from interactor confirm")? { - bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'"); - } - } - // check proposal isn't behind origin/main - else if !behind.is_empty() && !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt( - format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len()) - ) - .with_default(false) - ).context("failed to get confirmation response from interactor confirm")? { - bail!("aborting so commits can be rebased"); - } - - let title = if args.no_cover_letter { - None - } else { - match &args.title { - Some(t) => Some(t.clone()), - None => { - if Interactor::default().confirm( - PromptConfirmParms::default() - .with_default(false) - .with_prompt("include cover letter?"), - )? { - Some( - Interactor::default() - .input(PromptInputParms::default().with_prompt("title"))? - .clone(), - ) - } else { - None - } - } - } - }; - - let cover_letter_title_description = if let Some(title) = title { - Some(( - title, - if let Some(t) = &args.description { - t.clone() - } else { - Interactor::default() - .input(PromptInputParms::default().with_prompt("cover letter description"))? - .clone() - }, - )) - } else { - None - }; - let (signer, user_ref) = login::launch( - &git_repo, - &cli_args.bunker_uri, - &cli_args.bunker_app_key, - &cli_args.nsec, - &cli_args.password, - Some(&client), - false, - false, - ) - .await?; - - client.set_signer(signer.clone()).await; - - let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?; - - // oldest first - commits.reverse(); - - let events = generate_cover_letter_and_patch_events( - cover_letter_title_description.clone(), - &git_repo, - &commits, - &signer, - &repo_ref, - &root_proposal_id, - &mention_tags, - ) - .await?; - - println!( - "posting {} patch{} {} a covering letter...", - if cover_letter_title_description.is_none() { - events.len() - } else { - events.len() - 1 - }, - if cover_letter_title_description.is_none() && events.len().eq(&1) - || cover_letter_title_description.is_some() && events.len().eq(&2) - { - "" - } else { - "es" - }, - if cover_letter_title_description.is_none() { - "without" - } else { - "with" - } - ); - - send_events( - &client, - git_repo_path, - events.clone(), - user_ref.relays.write(), - repo_ref.relays.clone(), - !cli_args.disable_cli_spinners, - false, - ) - .await?; - - if root_proposal_id.is_none() { - if let Some(event) = events.first() { - let event_bech32 = if let Some(relay) = repo_ref.relays.first() { - Nip19Event::new(event.id(), vec![relay]).to_bech32()? - } else { - event.id().to_bech32()? - }; - println!( - "{}", - dim.apply_to(format!( - "view in gitworkshop.dev: https://gitworkshop.dev/repo/{}/proposal/{}", - repo_ref.coordinate_with_hint().to_bech32()?, - &event_bech32, - )) - ); - println!( - "{}", - dim.apply_to(format!( - "view in another client: https://njump.me/{}", - &event_bech32, - )) - ); - } - } - // TODO check if there is already a similarly named - Ok(()) -} - -#[allow(clippy::module_name_repetitions)] -#[allow(clippy::too_many_lines)] -pub async fn send_events( - #[cfg(test)] client: &crate::client::MockConnect, - #[cfg(not(test))] client: &Client, - git_repo_path: &Path, - events: Vec, - my_write_relays: Vec, - repo_read_relays: Vec, - animate: bool, - silent: bool, -) -> Result<()> { - let fallback = [ - client.get_fallback_relays().clone(), - if events - .iter() - .any(|e| e.kind().eq(&Kind::GitRepoAnnouncement)) - { - client.get_blaster_relays().clone() - } else { - vec![] - }, - ] - .concat(); - let mut relays: Vec<&String> = vec![]; - - let all = &[ - repo_read_relays.clone(), - my_write_relays.clone(), - fallback.clone(), - ] - .concat(); - // add duplicates first - for r in &repo_read_relays { - let r_clean = remove_trailing_slash(r); - if !my_write_relays - .iter() - .filter(|x| r_clean.eq(&remove_trailing_slash(x))) - .count() - > 1 - && !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) - { - relays.push(r); - } - } - - for r in all { - let r_clean = remove_trailing_slash(r); - if !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) { - relays.push(r); - } - } - - let m = if silent { - MultiProgress::with_draw_target(ProgressDrawTarget::hidden()) - } else { - MultiProgress::new() - }; - let pb_style = ProgressStyle::with_template(if animate { - " {spinner} {prefix} {bar} {pos}/{len} {msg}" - } else { - " - {prefix} {bar} {pos}/{len} {msg}" - })? - .progress_chars("##-"); - - let pb_after_style = - |symbol| ProgressStyle::with_template(format!(" {symbol} {}", "{prefix} {msg}",).as_str()); - let pb_after_style_succeeded = pb_after_style(if animate { - console::style("✔".to_string()) - .for_stderr() - .green() - .to_string() - } else { - "y".to_string() - })?; - - let pb_after_style_failed = pb_after_style(if animate { - console::style("✘".to_string()) - .for_stderr() - .red() - .to_string() - } else { - "x".to_string() - })?; - - #[allow(clippy::borrow_deref_ref)] - join_all(relays.iter().map(|&relay| async { - let relay_clean = remove_trailing_slash(&*relay); - let details = format!( - "{}{}{} {}", - if my_write_relays - .iter() - .any(|r| relay_clean.eq(&remove_trailing_slash(r))) - { - " [my-relay]" - } else { - "" - }, - if repo_read_relays - .iter() - .any(|r| relay_clean.eq(&remove_trailing_slash(r))) - { - " [repo-relay]" - } else { - "" - }, - if fallback - .iter() - .any(|r| relay_clean.eq(&remove_trailing_slash(r))) - { - " [default]" - } else { - "" - }, - relay_clean, - ); - let pb = m.add( - ProgressBar::new(events.len() as u64) - .with_prefix(details.to_string()) - .with_style(pb_style.clone()), - ); - if animate { - pb.enable_steady_tick(Duration::from_millis(300)); - } - pb.inc(0); // need to make pb display intially - let mut failed = false; - for event in &events { - match client - .send_event_to(git_repo_path, relay.as_str(), event.clone()) - .await - { - Ok(_) => pb.inc(1), - Err(e) => { - pb.set_style(pb_after_style_failed.clone()); - pb.finish_with_message( - console::style( - e.to_string() - .replace("relay pool error:", "error:") - .replace("event not published: ", "error: "), - ) - .for_stderr() - .red() - .to_string(), - ); - failed = true; - break; - } - }; - } - if !failed { - pb.set_style(pb_after_style_succeeded.clone()); - pb.finish_with_message(""); - } - })) - .await; - Ok(()) -} - -fn remove_trailing_slash(s: &String) -> String { - match s.as_str().strip_suffix('/') { - Some(s) => s, - None => s, - } - .to_string() -} - -fn choose_commits(git_repo: &Repo, proposed_commits: Vec) -> Result> { - let mut proposed_commits = if proposed_commits.len().gt(&10) { - vec![] - } else { - proposed_commits - }; - - let tip_of_head = git_repo.get_tip_of_branch(&git_repo.get_checked_out_branch_name()?)?; - let most_recent_commit = proposed_commits.first().unwrap_or(&tip_of_head); - - let mut last_15_commits = vec![*most_recent_commit]; - - while last_15_commits.len().lt(&15) { - if let Ok(parent_commit) = git_repo.get_commit_parent(last_15_commits.last().unwrap()) { - last_15_commits.push(parent_commit); - } else { - break; - } - } - - let term = console::Term::stderr(); - let mut printed_error_line = false; - - let selected_commits = 'outer: loop { - let selected = Interactor::default().multi_choice( - PromptMultiChoiceParms::default() - .with_prompt("select commits for proposal") - .dont_report() - .with_choices( - last_15_commits - .iter() - .map(|h| summarise_commit_for_selection(git_repo, h).unwrap()) - .collect(), - ) - .with_defaults( - last_15_commits - .iter() - .map(|h| proposed_commits.iter().any(|c| c.eq(h))) - .collect(), - ), - )?; - proposed_commits = selected.iter().map(|i| last_15_commits[*i]).collect(); - - if printed_error_line { - term.clear_last_lines(1)?; - } - - if proposed_commits.is_empty() { - term.write_line("no commits selected")?; - printed_error_line = true; - continue; - } - for (i, selected_i) in selected.iter().enumerate() { - if i.gt(&0) && selected_i.ne(&(selected[i - 1] + 1)) { - term.write_line("commits must be consecutive. try again.")?; - printed_error_line = true; - continue 'outer; - } - } - - break proposed_commits; - }; - Ok(selected_commits) -} - -fn summarise_commit_for_selection(git_repo: &Repo, commit: &Sha1Hash) -> Result { - let references = git_repo.get_refs(commit)?; - let dim = Style::new().color256(247); - let prefix = format!("({})", git_repo.get_commit_author(commit)?[0],); - let references_string = if references.is_empty() { - String::new() - } else { - format!( - " {}", - references - .iter() - .map(|r| format!("[{r}]")) - .collect::>() - .join(" ") - ) - }; - - Ok(format!( - "{} {}{} {}", - dim.apply_to(prefix), - git_repo.get_commit_message_summary(commit)?, - Style::new().magenta().apply_to(references_string), - dim.apply_to(commit.to_string().chars().take(7).collect::(),), - )) -} - -async fn get_root_proposal_id_and_mentions_from_in_reply_to( - git_repo_path: &Path, - in_reply_to: &[String], -) -> Result<(Option, Vec)> { - let root_proposal_id = if let Some(first) = in_reply_to.first() { - match event_tag_from_nip19_or_hex(first, "in-reply-to", Marker::Root, true, false)? - .as_standardized() - { - Some(nostr_sdk::TagStandard::Event { - event_id, - relay_url: _, - marker: _, - public_key: _, - }) => { - let events = - get_events_from_cache(git_repo_path, vec![nostr::Filter::new().id(*event_id)]) - .await?; - - if let Some(first) = events.iter().find(|e| e.id.eq(event_id)) { - if event_is_patch_set_root(first) { - Some(event_id.to_string()) - } else { - None - } - } else { - None - } - } - _ => None, - } - } else { - return Ok((None, vec![])); - }; - - let mut mention_tags = vec![]; - for (i, reply_to) in in_reply_to.iter().enumerate() { - if i.ne(&0) || root_proposal_id.is_none() { - mention_tags.push( - event_tag_from_nip19_or_hex(reply_to, "in-reply-to", Marker::Mention, true, false) - .context(format!( - "{reply_to} in 'in-reply-to' not a valid nostr reference" - ))?, - ); - } - } - - Ok((root_proposal_id, mention_tags)) -} - -#[allow(clippy::too_many_lines)] -pub async fn generate_cover_letter_and_patch_events( - cover_letter_title_description: Option<(String, String)>, - git_repo: &Repo, - commits: &[Sha1Hash], - signer: &NostrSigner, - repo_ref: &RepoRef, - root_proposal_id: &Option, - mentions: &[nostr::Tag], -) -> Result> { - let root_commit = git_repo - .get_root_commit() - .context("failed to get root commit of the repository")?; - - let mut events = vec![]; - - if let Some((title, description)) = cover_letter_title_description { - events.push(sign_event(EventBuilder::new( - nostr::event::Kind::GitPatch, - format!( - "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}", - commits.last().unwrap(), - commits.len() - ), - [ - repo_ref.maintainers.iter().map(|m| Tag::coordinate(Coordinate { - kind: nostr::Kind::GitRepoAnnouncement, - public_key: *m, - identifier: repo_ref.identifier.to_string(), - relays: repo_ref.relays.clone(), - })).collect::>(), - vec![ - Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))), - Tag::hashtag("cover-letter"), - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), - vec![format!("git patch cover letter: {}", title.clone())], - ), - ], - if let Some(event_ref) = root_proposal_id.clone() { - vec![ - Tag::hashtag("root"), - Tag::hashtag("revision-root"), - // TODO check if id is for a root proposal (perhaps its for an issue?) - event_tag_from_nip19_or_hex(&event_ref,"proposal",Marker::Reply, false, false)?, - ] - } else { - vec![ - Tag::hashtag("root"), - ] - }, - mentions.to_vec(), - // this is not strictly needed but makes for prettier branch names - // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding - // a change like this, or the removal of this tag will require the actual branch name to be tracked - // so pulling and pushing still work - if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { - if !branch_name.eq("main") - && !branch_name.eq("master") - && !branch_name.eq("origin/main") - && !branch_name.eq("origin/master") - { - vec![ - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), - vec![if let Some(branch_name) = branch_name.strip_prefix("pr/") { - branch_name.to_string() - } else { - branch_name - }], - ), - ] - } - else { vec![] } - } else { - vec![] - }, - repo_ref.maintainers - .iter() - .map(|pk| Tag::public_key(*pk)) - .collect(), - ].concat(), - ), signer).await - .context("failed to create cover-letter event")?); - } - - for (i, commit) in commits.iter().enumerate() { - events.push( - generate_patch_event( - git_repo, - &root_commit, - commit, - events.first().map(|event| event.id), - signer, - repo_ref, - events.last().map(nostr::Event::id), - if events.is_empty() && commits.len().eq(&1) { - None - } else { - Some(((i + 1).try_into()?, commits.len().try_into()?)) - }, - if events.is_empty() { - if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { - if !branch_name.eq("main") - && !branch_name.eq("master") - && !branch_name.eq("origin/main") - && !branch_name.eq("origin/master") - { - Some(if let Some(branch_name) = branch_name.strip_prefix("pr/") { - branch_name.to_string() - } else { - branch_name - }) - } else { - None - } - } else { - None - } - } else { - None - }, - root_proposal_id, - if events.is_empty() { mentions } else { &[] }, - ) - .await - .context("failed to generate patch event")?, - ); - } - Ok(events) -} - -fn event_tag_from_nip19_or_hex( - reference: &str, - reference_name: &str, - marker: Marker, - allow_npub_reference: bool, - prompt_for_correction: bool, -) -> Result { - let mut bech32 = reference.to_string(); - loop { - if bech32.is_empty() { - bech32 = Interactor::default().input( - PromptInputParms::default().with_prompt(&format!("{reference_name} reference")), - )?; - } - if let Ok(nip19) = Nip19::from_bech32(bech32.clone()) { - match nip19 { - Nip19::Event(n) => { - break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: n.event_id, - relay_url: n.relays.first().map(UncheckedUrl::new), - marker: Some(marker), - public_key: None, - })); - } - Nip19::EventId(id) => { - break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: id, - relay_url: None, - marker: Some(marker), - public_key: None, - })); - } - Nip19::Coordinate(coordinate) => { - break Ok(Tag::coordinate(coordinate)); - } - Nip19::Profile(profile) => { - if allow_npub_reference { - break Ok(Tag::public_key(profile.public_key)); - } - } - Nip19::Pubkey(public_key) => { - if allow_npub_reference { - break Ok(Tag::public_key(public_key)); - } - } - _ => {} - } - } - if let Ok(id) = nostr::EventId::from_str(&bech32) { - break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: id, - relay_url: None, - marker: Some(marker), - public_key: None, - })); - } - if prompt_for_correction { - println!("not a valid {reference_name} event reference"); - } else { - bail!(format!("not a valid {reference_name} event reference")); - } - - bech32 = String::new(); - } -} - -pub struct CoverLetter { - pub title: String, - pub description: String, - pub branch_name: String, - pub event_id: Option, -} - -impl CoverLetter { - pub fn get_branch_name(&self) -> Result { - Ok(format!( - "pr/{}({})", - self.branch_name, - &self - .event_id - .context("proposal root event_id must be know to get it's branch name")? - .to_hex() - .as_str()[..8], - )) - } -} -pub fn event_is_cover_letter(event: &nostr::Event) -> bool { - // TODO: look for Subject:[ PATCH 0/n ] but watch out for: - // [PATCH v1 0/n ] or - // [PATCH subsystem v2 0/n ] - event.kind.eq(&Kind::GitPatch) - && event.tags().iter().any(|t| t.as_vec()[1].eq("root")) - && event - .tags() - .iter() - .any(|t| t.as_vec()[1].eq("cover-letter")) -} - -pub fn commit_msg_from_patch(patch: &nostr::Event) -> Result { - if let Ok(msg) = tag_value(patch, "description") { - Ok(msg) - } else { - let start_index = patch - .content - .find("] ") - .context("event is not formatted as a patch or cover letter")? - + 2; - let end_index = patch.content[start_index..] - .find("\ndiff --git") - .unwrap_or(patch.content.len()); - Ok(patch.content[start_index..end_index].to_string()) - } -} - -pub fn commit_msg_from_patch_oneliner(patch: &nostr::Event) -> Result { - Ok(commit_msg_from_patch(patch)? - .split('\n') - .collect::>()[0] - .to_string()) -} - -pub fn event_to_cover_letter(event: &nostr::Event) -> Result { - if !event_is_patch_set_root(event) { - bail!("event is not a patch set root event (root patch or cover letter)") - } - - let title = commit_msg_from_patch_oneliner(event)?; - let full = commit_msg_from_patch(event)?; - let description = full[title.len()..].trim().to_string(); - - Ok(CoverLetter { - title: title.clone(), - description, - // TODO should this be prefixed by format!("{}-"e.id.to_string()[..5]?) - branch_name: if let Ok(name) = match tag_value(event, "branch-name") { - Ok(name) => { - if !name.eq("main") && !name.eq("master") { - Ok(name) - } else { - Err(()) - } - } - _ => Err(()), - } { - name - } else { - let s = title - .replace(' ', "-") - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c.eq(&'/') { - c - } else { - '-' - } - }) - .collect(); - s - }, - event_id: Some(event.id()), - }) -} - -pub fn event_is_patch_set_root(event: &nostr::Event) -> bool { - event.kind.eq(&Kind::GitPatch) && event.tags().iter().any(|t| t.as_vec()[1].eq("root")) -} - -pub fn event_is_revision_root(event: &nostr::Event) -> bool { - event.kind.eq(&Kind::GitPatch) - && event - .tags() - .iter() - .any(|t| t.as_vec()[1].eq("revision-root")) -} - -pub fn patch_supports_commit_ids(event: &nostr::Event) -> bool { - event.kind.eq(&Kind::GitPatch) - && event - .tags() - .iter() - .any(|t| t.as_vec()[0].eq("commit-pgp-sig")) -} - -#[allow(clippy::too_many_arguments)] -#[allow(clippy::too_many_lines)] -pub async fn generate_patch_event( - git_repo: &Repo, - root_commit: &Sha1Hash, - commit: &Sha1Hash, - thread_event_id: Option, - signer: &nostr_sdk::NostrSigner, - repo_ref: &RepoRef, - parent_patch_event_id: Option, - series_count: Option<(u64, u64)>, - branch_name: Option, - root_proposal_id: &Option, - mentions: &[nostr::Tag], -) -> Result { - let commit_parent = git_repo - .get_commit_parent(commit) - .context("failed to get parent commit")?; - let relay_hint = repo_ref.relays.first().map(nostr::UncheckedUrl::from); - - sign_event( - EventBuilder::new( - nostr::event::Kind::GitPatch, - git_repo - .make_patch_from_commit(commit, &series_count) - .context(format!("cannot make patch for commit {commit}"))?, - [ - repo_ref - .maintainers - .iter() - .map(|m| { - Tag::coordinate(Coordinate { - kind: nostr::Kind::GitRepoAnnouncement, - public_key: *m, - identifier: repo_ref.identifier.to_string(), - relays: repo_ref.relays.clone(), - }) - }) - .collect::>(), - vec![ - Tag::from_standardized(TagStandard::Reference(root_commit.to_string())), - // commit id reference is a trade-off. its now - // unclear which one is the root commit id but it - // enables easier location of code comments againt - // code that makes it into the main branch, assuming - // the commit id is correct - Tag::from_standardized(TagStandard::Reference(commit.to_string())), - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("alt")), - vec![format!( - "git patch: {}", - git_repo - .get_commit_message_summary(commit) - .unwrap_or_default() - )], - ), - ], - if let Some(thread_event_id) = thread_event_id { - vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: thread_event_id, - relay_url: relay_hint.clone(), - marker: Some(Marker::Root), - public_key: None, - })] - } else if let Some(event_ref) = root_proposal_id.clone() { - vec![ - Tag::hashtag("root"), - Tag::hashtag("revision-root"), - // TODO check if id is for a root proposal (perhaps its for an issue?) - event_tag_from_nip19_or_hex( - &event_ref, - "proposal", - Marker::Reply, - false, - false, - )?, - ] - } else { - vec![Tag::hashtag("root")] - }, - mentions.to_vec(), - if let Some(id) = parent_patch_event_id { - vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: id, - relay_url: relay_hint.clone(), - marker: Some(Marker::Reply), - public_key: None, - })] - } else { - vec![] - }, - // see comment on branch names in cover letter event creation - if let Some(branch_name) = branch_name { - if thread_event_id.is_none() { - vec![Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), - vec![branch_name.to_string()], - )] - } else { - vec![] - } - } else { - vec![] - }, - // whilst it is in nip34 draft to tag the maintainers - // I'm not sure it is a good idea because if they are - // interested in all patches then their specialised - // client should subscribe to patches tagged with the - // repo reference. maintainers of large repos will not - // be interested in every patch. - repo_ref - .maintainers - .iter() - .map(|pk| Tag::public_key(*pk)) - .collect(), - vec![ - // a fallback is now in place to extract this from the patch - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("commit")), - vec![commit.to_string()], - ), - // this is required as patches cannot be relied upon to include the 'base - // commit' - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")), - vec![commit_parent.to_string()], - ), - // this is required to ensure the commit id matches - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")), - vec![ - git_repo - .extract_commit_pgp_signature(commit) - .unwrap_or_default(), - ], - ), - // removing description tag will not cause anything to break - Tag::from_standardized(nostr_sdk::TagStandard::Description( - git_repo.get_commit_message(commit)?.to_string(), - )), - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("author")), - git_repo.get_commit_author(commit)?, - ), - // this is required to ensure the commit id matches - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("committer")), - git_repo.get_commit_comitter(commit)?, - ), - ], - ] - .concat(), - ), - signer, - ) - .await - .context("failed to sign event") -} -// TODO -// - find profile -// - file relays -// - find repo events -// - - -/** - * returns `(from_branch,to_branch,ahead,behind)` - */ -pub fn identify_ahead_behind( - git_repo: &Repo, - from_branch: &Option, - to_branch: &Option, -) -> Result<(String, String, Vec, Vec)> { - let (from_branch, from_tip) = match from_branch { - Some(name) => ( - name.to_string(), - git_repo - .get_tip_of_branch(name) - .context(format!("cannot find from_branch '{name}'"))?, - ), - None => ( - if let Ok(name) = git_repo.get_checked_out_branch_name() { - name - } else { - "head".to_string() - }, - git_repo - .get_head_commit() - .context("failed to get head commit") - .context( - "checkout a commit or specify a from_branch. head does not reveal a commit", - )?, - ), - }; - - let (to_branch, to_tip) = match to_branch { - Some(name) => ( - name.to_string(), - git_repo - .get_tip_of_branch(name) - .context(format!("cannot find to_branch '{name}'"))?, - ), - None => { - let (name, commit) = git_repo - .get_main_or_master_branch() - .context("the default branches (main or master) do not exist")?; - (name.to_string(), commit) - } - }; - - match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) { - Err(e) => { - if e.to_string().contains("is not an ancestor of") { - return Err(e).context(format!( - "'{from_branch}' is not branched from '{to_branch}'" - )); - } - Err(e).context(format!( - "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'" - )) - } - Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)), - } -} - -#[cfg(test)] -mod tests { - use test_utils::git::GitTestRepo; - - use super::*; - mod identify_ahead_behind { - - use super::*; - use crate::git::oid_to_sha1; - - #[test] - fn when_from_branch_doesnt_exist_return_error() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - let branch_name = "doesnt_exist"; - assert_eq!( - identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None) - .unwrap_err() - .to_string(), - format!("cannot find from_branch '{}'", &branch_name), - ); - Ok(()) - } - - #[test] - fn when_to_branch_doesnt_exist_return_error() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - let branch_name = "doesnt_exist"; - assert_eq!( - identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string())) - .unwrap_err() - .to_string(), - format!("cannot find to_branch '{}'", &branch_name), - ); - Ok(()) - } - - #[test] - fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> { - let test_repo = GitTestRepo::new("notmain")?; - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - - assert_eq!( - identify_ahead_behind(&git_repo, &None, &None) - .unwrap_err() - .to_string(), - "the default branches (main or master) do not exist", - ); - Ok(()) - } - - #[test] - fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - // create feature branch with 1 commit ahead - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let head_oid = test_repo.stage_and_commit("add t3.md")?; - - // make feature branch 1 commit behind - test_repo.checkout("main")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let main_oid = test_repo.stage_and_commit("add t4.md")?; - - let (from_branch, to_branch, ahead, behind) = - identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?; - - assert_eq!(from_branch, "feature"); - assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]); - assert_eq!(to_branch, "main"); - assert_eq!(behind, vec![oid_to_sha1(&main_oid)]); - Ok(()) - } - - #[test] - fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - // create dev branch with 1 commit ahead - test_repo.create_branch("dev")?; - test_repo.checkout("dev")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let dev_oid_first = test_repo.stage_and_commit("add t3.md")?; - - // create feature branch with 1 commit ahead of dev - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let feature_oid = test_repo.stage_and_commit("add t4.md")?; - - // make feature branch 1 behind - test_repo.checkout("dev")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let dev_oid = test_repo.stage_and_commit("add t3.md")?; - - let (from_branch, to_branch, ahead, behind) = identify_ahead_behind( - &git_repo, - &Some("feature".to_string()), - &Some("dev".to_string()), - )?; - - assert_eq!(from_branch, "feature"); - assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]); - assert_eq!(to_branch, "dev"); - assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]); - - let (from_branch, to_branch, ahead, behind) = - identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?; - - assert_eq!(from_branch, "feature"); - assert_eq!( - ahead, - vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)] - ); - assert_eq!(to_branch, "main"); - assert_eq!(behind, vec![]); - - Ok(()) - } - } - - mod event_to_cover_letter { - use super::*; - - fn generate_cover_letter(title: &str, description: &str) -> Result { - Ok(nostr::event::EventBuilder::new( - nostr::event::Kind::GitPatch, - format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"), - [ - Tag::hashtag("cover-letter"), - Tag::hashtag("root"), - ], - ) - .to_event(&nostr::Keys::generate())?) - } - - #[test] - fn basic_title() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter("the title", "description here")?)? - .title, - "the title", - ); - Ok(()) - } - - #[test] - fn basic_description() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter("the title", "description here")?)? - .description, - "description here", - ); - Ok(()) - } - - #[test] - fn description_trimmed() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter( - "the title", - " \n \ndescription here\n\n " - )?)? - .description, - "description here", - ); - Ok(()) - } - - #[test] - fn multi_line_description() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter( - "the title", - "description here\n\nmore here\nmore" - )?)? - .description, - "description here\n\nmore here\nmore", - ); - Ok(()) - } - - #[test] - fn new_lines_in_title_forms_part_of_description() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter( - "the title\nwith new line", - "description here\n\nmore here\nmore" - )?)? - .title, - "the title", - ); - assert_eq!( - event_to_cover_letter(&generate_cover_letter( - "the title\nwith new line", - "description here\n\nmore here\nmore" - )?)? - .description, - "with new line\n\ndescription here\n\nmore here\nmore", - ); - Ok(()) - } - - mod blank_description { - use super::*; - - #[test] - fn title_correct() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter("the title", "")?)?.title, - "the title", - ); - Ok(()) - } - - #[test] - fn description_is_empty_string() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter("the title", "")?)?.description, - "", - ); - Ok(()) - } - } - } -} -- cgit v1.2.3