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/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 + 9 files changed, 5835 insertions(+) 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 (limited to 'src/lib') 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(), + }) + } +} -- cgit v1.2.3