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/git/mod.rs | 2566 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2566 insertions(+) create mode 100644 src/lib/git/mod.rs (limited to 'src/lib/git') 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()); + } + } +} -- cgit v1.2.3