use std::{
env::current_dir,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use git2::{DiffOptions, Oid, Revwalk};
pub use identify_ahead_behind::identify_ahead_behind;
use nostr_sdk::{
Tags,
hashes::{Hash, sha1::Hash as Sha1Hash},
};
use nostr_url::NostrUrlDecoded;
use crate::git_events::{get_commit_id_from_patch, tag_value};
pub mod identify_ahead_behind;
pub mod nostr_url;
pub mod utils;
pub struct Repo {
pub git_repo: git2::Repository,
}
impl Repo {
pub fn discover() -> Result<Self> {
Ok(Self {
git_repo: git2::Repository::discover(current_dir()?)?,
})
}
pub fn from_path(path: &PathBuf) -> Result<Self> {
Ok(Self {
git_repo: git2::Repository::open(path)?,
})
}
}
pub trait RepoActions {
fn get_path(&self) -> Result<&Path>;
fn get_origin_url(&self) -> Result<String>;
fn get_remote_branch_names(&self) -> Result<Vec<String>>;
fn get_local_branch_names(&self) -> Result<Vec<String>>;
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<String>;
fn get_tip_of_branch(&self, branch_name: &str) -> Result<Sha1Hash>;
fn get_commit_or_tip_of_reference(&self, reference: &str) -> Result<Sha1Hash>;
fn get_root_commit(&self) -> Result<Sha1Hash>;
fn does_commit_exist(&self, commit: &str) -> Result<bool>;
fn get_head_commit(&self) -> Result<Sha1Hash>;
fn get_commit_parent(&self, commit: &Sha1Hash) -> Result<Sha1Hash>;
fn get_commit_message(&self, commit: &Sha1Hash) -> Result<String>;
fn get_commit_message_summary(&self, commit: &Sha1Hash) -> Result<String>;
#[allow(clippy::doc_link_with_quotes)]
fn get_commit_author(&self, commit: &Sha1Hash) -> Result<Vec<String>>;
#[allow(clippy::doc_link_with_quotes)]
fn get_commit_comitter(&self, commit: &Sha1Hash) -> Result<Vec<String>>;
fn get_commit_committer_time(&self, commit: &Sha1Hash) -> Result<i64>;
fn find_best_guess_parent_commit(&self, patch_timestamp: i64) -> Result<Option<Sha1Hash>>;
fn get_commits_ahead_behind(
&self,
base_commit: &Sha1Hash,
latest_commit: &Sha1Hash,
) -> Result<(Vec<Sha1Hash>, Vec<Sha1Hash>)>;
fn get_refs(&self, commit: &Sha1Hash) -> Result<Vec<String>>;
fn has_outstanding_changes(&self) -> Result<bool>;
fn make_patch_from_commit(
&self,
commit: &Sha1Hash,
series_count: &Option<(u64, u64)>,
) -> Result<String>;
fn are_commits_too_big_for_patches(&self, commits: &[Sha1Hash]) -> bool;
fn extract_commit_pgp_signature(&self, commit: &Sha1Hash) -> Result<String>;
fn checkout(&self, ref_name: &str) -> Result<Sha1Hash>;
fn create_branch_at_commit(&self, branch_name: &str, commit: &str) -> Result<()>;
fn apply_patch_chain(
&self,
branch_name: &str,
patch_and_ancestors: Vec<nostr::Event>,
) -> Result<Vec<nostr::Event>>;
fn create_commit_from_patch(
&self,
patch: &nostr::Event,
parent_commit_id_override: Option<String>,
) -> Result<Oid>;
fn parse_starting_commits(&self, starting_commits: &str) -> Result<Vec<Sha1Hash>>;
fn ancestor_of(&self, decendant: &Sha1Hash, ancestor: &Sha1Hash) -> Result<bool>;
fn get_upstream_for_branch(&self, branch_name: &str) -> Result<Option<String>>;
fn get_git_config_item(&self, item: &str, global: Option<bool>) -> Result<Option<String>>;
fn save_git_config_item(&self, item: &str, value: &str, global: bool) -> Result<()>;
fn remove_git_config_item(&self, item: &str, global: bool) -> Result<bool>;
#[allow(async_fn_in_trait)]
async fn get_first_nostr_remote_when_in_ngit_binary(
&self,
) -> Result<Option<(String, NostrUrlDecoded)>>;
}
impl RepoActions for Repo {
fn get_path(&self) -> Result<&Path> {
self.git_repo.workdir().context(
"failed to find repository working directory (bare repositories are not supported)",
)
}
fn get_origin_url(&self) -> Result<String> {
Ok(self
.git_repo
.find_remote("origin")
.context("failed to find origin")?
.url()
.context("failed to 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("failed to 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 failed to 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("failed to 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 failed to 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<Vec<String>> {
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<Vec<String>> {
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<String> {
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<Sha1Hash> {
let branch = if let Ok(branch) = self
.git_repo
.find_branch(branch_name, git2::BranchType::Local)
.context(format!("failed to find local branch {branch_name}"))
{
branch
} else {
self.git_repo
.find_branch(branch_name, git2::BranchType::Remote)
.context(format!(
"failed to 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<Sha1Hash> {
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<Sha1Hash> {
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<bool> {
if self.git_repo.find_commit(Oid::from_str(commit)?).is_ok() {
Ok(true)
} else {
Ok(false)
}
}
fn get_head_commit(&self) -> Result<Sha1Hash> {
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<Sha1Hash> {
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<String> {
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<String> {
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::<Vec<&str>>()[0]
.split('\n')
.collect::<Vec<&str>>()[0]
.to_string()
.trim()
.to_string())
}
fn get_commit_author(&self, commit: &Sha1Hash) -> Result<Vec<String>> {
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<Vec<String>> {
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_commit_committer_time(&self, commit_hash: &Sha1Hash) -> Result<i64> {
let commit = self
.git_repo
.find_commit(sha1_to_oid(commit_hash)?)
.context(format!("could not find commit {commit_hash}"))?;
let time = commit.committer().when().seconds();
Ok(time)
}
fn find_best_guess_parent_commit(&self, patch_timestamp: i64) -> Result<Option<Sha1Hash>> {
let (main_branch_name, _) = self
.get_main_or_master_branch()
.context("failed to get main/master branch")?;
let mut revwalk = self
.git_repo
.revwalk()
.context("failed to create revwalk")?;
revwalk
.push_ref(&format!("refs/heads/{}", main_branch_name))
.context("failed to push main branch to revwalk")?;
let mut best_commit: Option<(i64, Sha1Hash)> = None;
for oid_result in revwalk {
let oid = oid_result.context("failed to get oid from revwalk")?;
let commit = self
.git_repo
.find_commit(oid)
.context("failed to find commit")?;
let committer_time = commit.committer().when().seconds();
if committer_time < patch_timestamp
&& (best_commit.is_none() || committer_time > best_commit.as_ref().unwrap().0)
{
best_commit = Some((committer_time, oid_to_sha1(&oid)));
}
}
Ok(best_commit.map(|(_, sha1)| sha1))
}
fn get_refs(&self, commit: &Sha1Hash) -> Result<Vec<String>> {
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::<Vec<String>>())
}
fn make_patch_from_commit(
&self,
commit: &Sha1Hash,
series_count: &Option<(u64, u64)>,
) -> Result<String> {
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();
options.diff_options().old_prefix("a/").new_prefix("b/");
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 are_commits_too_big_for_patches(&self, commits: &[Sha1Hash]) -> bool {
commits.iter().any(|commit| {
if let Ok(patch) = self.make_patch_from_commit(commit, &None) {
patch.len()
> ((65 - 1) * 1024)
} else {
true
}
})
}
fn extract_commit_pgp_signature(&self, commit: &Sha1Hash) -> Result<String> {
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 failed to be converted to a utf8 string")?
.to_owned())
}
fn has_outstanding_changes(&self) -> Result<bool> {
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<Sha1Hash>, Vec<Sha1Hash>)> {
let mut ahead: Vec<Sha1Hash> = vec![];
let mut behind: Vec<Sha1Hash> = vec![];
let get_revwalk = |commit: &Sha1Hash| -> Result<Revwalk> {
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)
};
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 {
behind.push(oid_to_sha1(base_oid));
false
}
}) {
None => {
bail!(format!(
"{latest_commit} is not an ancestor of {base_commit}"
));
}
Some(res) => res.context("revwalk failed to reveal commit")?,
};
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 {
ahead.push(oid_to_sha1(latest_oid));
false
}
});
Ok((ahead, behind))
}
fn checkout(&self, ref_name: &str) -> Result<Sha1Hash> {
let (object, reference) = self.git_repo.revparse_ext(ref_name)?;
self.git_repo.checkout_tree(&object, None)?;
match reference {
Some(gref) => self.git_repo.set_head(gref.name().unwrap()),
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(())
}
fn apply_patch_chain(
&self,
branch_name: &str,
patch_and_ancestors: Vec<nostr::Event>,
) -> Result<Vec<nostr::Event>> {
let branch_tip_result = self.get_tip_of_branch(branch_name);
let mut patches_to_apply: Vec<nostr::Event> = patch_and_ancestors
.into_iter()
.filter(|e| {
let Ok(commit_id) = get_commit_id_from_patch(e) else {
return true;
};
let Ok(branch_tip) = branch_tip_result else {
return true;
};
let Ok(commit_sha1) = str_to_sha1(&commit_id) else {
return true;
};
!branch_tip.to_string().eq(&commit_id)
&& !self.ancestor_of(&branch_tip, &commit_sha1).unwrap_or(false)
})
.collect();
let parent_commit_id = match patches_to_apply.last() {
Some(last_patch) => {
crate::git_events::get_parent_commit_from_patch(last_patch, Some(self))?
}
None => {
self.checkout(branch_name)
.context("no patches and so failed to create a proposal branch")?;
return Ok(vec![]);
}
};
if !self.does_commit_exist(&parent_commit_id)? {
bail!("failed to find parent commit ({parent_commit_id}). run git pull and try again.")
}
self.create_branch_at_commit(branch_name, &parent_commit_id)?;
self.checkout(branch_name)?;
patches_to_apply.reverse();
let mut next_parent_override: Option<String> = None;
for patch in &patches_to_apply {
let tag_commit_id = get_commit_id_from_patch(patch).ok();
let applied_oid = if let Some(ref id) = tag_commit_id {
if self.does_commit_exist(id)? && next_parent_override.is_none() {
id.clone()
} else {
self.create_commit_from_patch(patch, next_parent_override.take())?
.to_string()
}
} else {
self.create_commit_from_patch(patch, next_parent_override.take())?
.to_string()
};
next_parent_override = Some(applied_oid.clone());
self.create_branch_at_commit(branch_name, &applied_oid)?;
self.checkout(branch_name)?;
}
Ok(patches_to_apply)
}
fn create_commit_from_patch(
&self,
patch: &nostr::Event,
parent_commit_id_override: Option<String>,
) -> Result<Oid> {
let commit_id = get_commit_id_from_patch(patch);
if parent_commit_id_override.is_none() {
if let Ok(commit_id) = &commit_id {
if self.does_commit_exist(commit_id).unwrap_or(false) {
return Ok(Oid::from_str(commit_id)?);
}
}
}
let parent_commit_id = if let Some(commit_id) = parent_commit_id_override.clone() {
commit_id
} else if let Ok(parent) = tag_value(patch, "parent-commit") {
parent
} else {
let metadata = crate::mbox_parser::parse_mbox_patch(&patch.content)
.context("failed to parse patch for timestamp")?;
let timestamp = metadata
.committer_timestamp
.unwrap_or(metadata.author_timestamp);
let best_guess = self
.find_best_guess_parent_commit(timestamp)
.context("failed to find best guess parent commit")?;
match best_guess {
Some(sha1) => sha1.to_string(),
None => bail!(
"no parent-commit tag and could not determine best guess parent from patch timestamp"
),
}
};
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 existing_index = self.git_repo.index()?;
let normalized_content = normalize_diff_prefix(&patch.content);
let mut index = self.git_repo.apply_to_tree(
&parent_tree,
&git2::Diff::from_buffer(normalized_content.as_bytes())?,
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 author_data =
extract_signature_data_with_fallback(&patch.tags, "author", &patch.content)?;
let committer_data =
extract_signature_data_with_fallback(&patch.tags, "committer", &patch.content)?;
let author_sig = author_data.to_signature()?;
let committer_sig = committer_data.to_signature()?;
let commit_buff = self.git_repo.commit_create_buffer(
&author_sig,
&committer_sig,
extract_description_from_patch(patch)?.as_str(),
&tree,
&[&parent_commit],
)?;
let mut applied_oid = self
.git_repo
.commit_signed(
commit_buff.as_str().unwrap(),
pgp_sig.as_deref().unwrap_or(""),
None,
)
.context("failed to create signed commit")?;
let custom_parent = if let Some(ovderride_parent) = parent_commit_id_override {
if let Ok(tag_parent) = tag_value(patch, "parent-commit") {
ovderride_parent != tag_parent
} else {
true
}
} else {
false
};
if !custom_parent {
if let Ok(commit_id) = &commit_id {
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("failed to amend commit to produce new oid")?;
}
}
}
self.git_repo.set_index(&mut existing_index)?;
Ok(applied_oid)
}
fn parse_starting_commits(&self, starting_commits: &str) -> Result<Vec<Sha1Hash>> {
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("failed to get starting commit from specified value")?
.id(),
),
&self
.get_head_commit()
.context("failed to 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("failed to get starting commit of range from specified value")?
.id(),
),
&oid_to_sha1(
&revspec
.to()
.context("failed to 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<bool> {
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)
}
}
fn get_upstream_for_branch(&self, branch_name: &str) -> Result<Option<String>> {
let branch = self
.git_repo
.find_branch(branch_name, git2::BranchType::Local)
.context(format!("failed to find local branch {branch_name}"))?;
let upstream = branch.upstream();
match upstream {
Ok(upstream_branch) => {
let name = upstream_branch.name()?.map(|s| s.to_string());
Ok(name)
}
Err(_) => Ok(None),
}
}
fn get_git_config_item(&self, item: &str, global: Option<bool>) -> Result<Option<String>> {
let just_global = global.unwrap_or(false);
match if just_global {
self.git_repo
.config()
.context("failed to open git config")?
.open_global()
.context("failed to open global git config")?
} else {
self.git_repo
.config()
.context("failed to open git config")?
}
.get_entry(item)
{
Ok(item) => {
if let Some(global) = global {
if item.level().eq(&git2::ConfigLevel::Local) {
if global {
return Ok(None);
}
} else if !global {
return Ok(None);
}
}
Ok(Some(
item.value()
.context("failed to 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("failed to open git config")?
.open_global()
.context("failed to open global git config")?
} else {
self.git_repo
.config()
.context("failed to open git config")?
}
.set_str(item, value)
.context(format!(
"failed to set {} git config item {}",
if global { "global" } else { "local" },
item
))?;
Ok(())
}
fn remove_git_config_item(&self, item: &str, global: bool) -> Result<bool> {
if self.get_git_config_item(item, Some(global))?.is_none() {
Ok(false)
} else {
if global {
self.git_repo
.config()
.context("failed to open git config")?
.open_global()
.context("failed to open global git config")?
} else {
self.git_repo
.config()
.context("failed to open git config")?
}
.remove(item)
.context("failed to remove existing git config item")?;
Ok(true)
}
}
async fn get_first_nostr_remote_when_in_ngit_binary(
&self,
) -> Result<Option<(String, NostrUrlDecoded)>> {
for remote_name in self.git_repo.remotes()?.iter().flatten() {
if let Some(remote_url) = self.git_repo.find_remote(remote_name)?.url() {
if let Ok(nostr_url_decoded) =
NostrUrlDecoded::parse_and_resolve(remote_url, &Some(self)).await
{
return Ok(Some((remote_name.to_string(), nostr_url_decoded)));
}
}
}
Ok(None)
}
}
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],
]
}
pub fn oid_to_shorthand_string(oid: Oid) -> Result<String> {
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")
}
pub fn oid_to_sha1(oid: &Oid) -> Sha1Hash {
Sha1Hash::from_byte_array(oid_to_u8_20_bytes(oid))
}
pub fn sha1_to_oid(hash: &Sha1Hash) -> Result<Oid> {
Oid::from_bytes(hash.as_byte_array()).context("Sha1Hash bytes failed to produce a valid Oid")
}
pub fn str_to_sha1(s: &str) -> Result<Sha1Hash> {
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<String> {
vec![
sig.name().unwrap_or("").to_string(),
sig.email().unwrap_or("").to_string(),
format!("{}", sig.when().seconds()),
format!("{}", sig.when().offset_minutes()),
]
}
struct SignatureData {
name: String,
email: String,
timestamp: i64,
offset_minutes: i32,
}
fn extract_signature_data_from_tags(tags: &Tags, tag_name: &str) -> Result<SignatureData> {
let v = tags
.iter()
.find(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq(tag_name))
.context(format!("tag '{tag_name}' not present in patch"))?
.as_slice();
if v.len() != 5 {
bail!("tag '{tag_name}' is incorrectly formatted")
}
Ok(SignatureData {
name: v[1].clone(),
email: v[2].clone(),
timestamp: v[3].parse().context("tag time is incorrectly formatted")?,
offset_minutes: v[4]
.parse()
.context("tag time offset is incorrectly formatted")?,
})
}
fn extract_signature_data_with_fallback(
tags: &Tags,
tag_name: &str,
patch_content: &str,
) -> Result<SignatureData> {
if let Ok(data) = extract_signature_data_from_tags(tags, tag_name) {
return Ok(data);
}
let metadata = crate::mbox_parser::parse_mbox_patch(patch_content)
.context("failed to parse patch content for fallback metadata")?;
if tag_name == "author" {
Ok(SignatureData {
name: metadata.author_name,
email: metadata.author_email,
timestamp: metadata.author_timestamp,
offset_minutes: metadata.author_offset_minutes,
})
} else if tag_name == "committer" {
let timestamp = metadata
.committer_timestamp
.unwrap_or(metadata.author_timestamp);
Ok(SignatureData {
name: metadata.author_name,
email: metadata.author_email,
timestamp,
offset_minutes: metadata.author_offset_minutes,
})
} else {
bail!("unknown tag name for signature extraction: {}", tag_name)
}
}
impl SignatureData {
fn to_signature(&self) -> Result<git2::Signature<'_>> {
git2::Signature::new(
&self.name,
&self.email,
&git2::Time::new(self.timestamp, self.offset_minutes),
)
.context("failed to create git signature")
}
}
fn normalize_diff_prefix(content: &str) -> String {
let needs_normalization = content
.lines()
.filter(|l| l.starts_with("diff --git "))
.any(|l| {
let rest = &l["diff --git ".len()..];
!rest.starts_with("a/")
});
if !needs_normalization {
return content.to_string();
}
let mut out = String::with_capacity(content.len() + 64);
for line in content.lines() {
if let Some(rest) = line.strip_prefix("diff --git ") {
let (old, new) = split_diff_git_paths(rest);
out.push_str("diff --git a/");
out.push_str(old);
out.push_str(" b/");
out.push_str(new);
} else if let Some(rest) = line.strip_prefix("--- ") {
if rest == "/dev/null" {
out.push_str(line);
} else {
out.push_str("--- a/");
out.push_str(rest);
}
} else if let Some(rest) = line.strip_prefix("+++ ") {
if rest == "/dev/null" {
out.push_str(line);
} else {
out.push_str("+++ b/");
out.push_str(rest);
}
} else {
out.push_str(line);
}
out.push('\n');
}
if !content.ends_with('\n') && out.ends_with('\n') {
out.pop();
}
out
}
fn split_diff_git_paths(rest: &str) -> (&str, &str) {
if let Some(pos) = rest.find(' ') {
let (old, new_with_space) = rest.split_at(pos);
let new = &new_with_space[1..];
if !old.contains(' ') {
return (old, new);
}
let bytes = rest.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if b == b' ' {
let candidate_old = &rest[..i];
let candidate_new = &rest[i + 1..];
if candidate_old == candidate_new {
return (candidate_old, candidate_new);
}
}
}
(old, new)
} else {
(rest, rest)
}
}
fn extract_description_from_patch(patch: &nostr::Event) -> Result<String> {
if let Ok(desc) = tag_value(patch, "description") {
return Ok(desc);
}
crate::mbox_parser::extract_description_from_patch(&patch.content)
.context("failed to extract description from patch content")
}
pub fn get_git_config_item(git_repo: &Option<&Repo>, item: &str) -> Result<Option<String>> {
if let Some(git_repo) = git_repo {
git_repo.get_git_config_item(item, Some(false))
} else {
Ok(
match git2::Config::open_default()?.open_global()?.get_entry(item) {
Ok(item) => item.value().map(|v| v.to_string()),
Err(_) => None,
},
)
}
}
pub fn get_git_config_item_system(item: &str) -> Result<Option<String>> {
let config = git2::Config::open_default().context("failed to open git config")?;
for level in [git2::ConfigLevel::System, git2::ConfigLevel::ProgramData] {
if let Ok(level_config) = config.open_level(level) {
match level_config.get_entry(item) {
Ok(entry) => return Ok(entry.value().map(|v| v.to_string())),
Err(_) => continue,
}
}
}
Ok(None)
}
pub fn save_git_config_item(git_repo: &Option<&Repo>, item: &str, value: &str) -> Result<()> {
if let Some(git_repo) = git_repo {
git_repo.save_git_config_item(item, value, false)
} else {
git2::Config::open_default()?
.open_global()?
.set_str(item, value)
.context(format!("failed to set global git config item {item}"))
}
}
pub fn remove_git_config_item(git_repo: &Option<&Repo>, item: &str) -> Result<bool> {
if let Some(git_repo) = git_repo {
git_repo.remove_git_config_item(item, false)
} else if get_git_config_item(&None, item)?.is_none() {
Ok(false)
} else {
git2::Config::open_default()?
.open_global()?
.remove(item)
.context(format!("failed to remove existing git config item {item}"))?;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use std::fs;
use test_utils::{generate_repo_ref_event, git::GitTestRepo};
use super::*;
mod normalize_diff_prefix {
use super::*;
#[test]
fn no_op_when_already_prefixed() {
let patch = "\
diff --git a/src/foo.rs b/src/foo.rs\n\
index ce01362..a21e91c 100644\n\
--- a/src/foo.rs\n\
+++ b/src/foo.rs\n\
@@ -1 +1 @@\n\
-hello\n\
+world\n";
assert_eq!(normalize_diff_prefix(patch), patch);
}
#[test]
fn adds_prefix_to_no_prefix_diff() {
let patch_no_prefix = "\
diff --git src/foo.rs src/foo.rs\n\
index ce01362..a21e91c 100644\n\
--- src/foo.rs\n\
+++ src/foo.rs\n\
@@ -1 +1 @@\n\
-hello\n\
+world\n";
let expected = "\
diff --git a/src/foo.rs b/src/foo.rs\n\
index ce01362..a21e91c 100644\n\
--- a/src/foo.rs\n\
+++ b/src/foo.rs\n\
@@ -1 +1 @@\n\
-hello\n\
+world\n";
assert_eq!(normalize_diff_prefix(patch_no_prefix), expected);
}
#[test]
fn preserves_dev_null_for_new_file() {
let patch_no_prefix = "\
diff --git src/new.rs src/new.rs\n\
new file mode 100644\n\
index 0000000..a21e91c\n\
--- /dev/null\n\
+++ src/new.rs\n\
@@ -0,0 +1 @@\n\
+hello\n";
let expected = "\
diff --git a/src/new.rs b/src/new.rs\n\
new file mode 100644\n\
index 0000000..a21e91c\n\
--- /dev/null\n\
+++ b/src/new.rs\n\
@@ -0,0 +1 @@\n\
+hello\n";
assert_eq!(normalize_diff_prefix(patch_no_prefix), expected);
}
#[test]
fn preserves_dev_null_for_deleted_file() {
let patch_no_prefix = "\
diff --git src/old.rs src/old.rs\n\
deleted file mode 100644\n\
index a21e91c..0000000\n\
--- src/old.rs\n\
+++ /dev/null\n\
@@ -1 +0,0 @@\n\
-hello\n";
let expected = "\
diff --git a/src/old.rs b/src/old.rs\n\
deleted file mode 100644\n\
index a21e91c..0000000\n\
--- a/src/old.rs\n\
+++ /dev/null\n\
@@ -1 +0,0 @@\n\
-hello\n";
assert_eq!(normalize_diff_prefix(patch_no_prefix), expected);
}
#[test]
fn handles_multiple_files() {
let patch_no_prefix = "\
diff --git src/foo.rs src/foo.rs\n\
index ce01362..a21e91c 100644\n\
--- src/foo.rs\n\
+++ src/foo.rs\n\
@@ -1 +1 @@\n\
-hello\n\
+world\n\
diff --git src/bar.rs src/bar.rs\n\
index 1234567..abcdef0 100644\n\
--- src/bar.rs\n\
+++ src/bar.rs\n\
@@ -1 +1 @@\n\
-foo\n\
+baz\n";
let expected = "\
diff --git a/src/foo.rs b/src/foo.rs\n\
index ce01362..a21e91c 100644\n\
--- a/src/foo.rs\n\
+++ b/src/foo.rs\n\
@@ -1 +1 @@\n\
-hello\n\
+world\n\
diff --git a/src/bar.rs b/src/bar.rs\n\
index 1234567..abcdef0 100644\n\
--- a/src/bar.rs\n\
+++ b/src/bar.rs\n\
@@ -1 +1 @@\n\
-foo\n\
+baz\n";
assert_eq!(normalize_diff_prefix(patch_no_prefix), expected);
}
#[test]
fn libgit2_can_parse_normalized_no_prefix_diff() {
let patch_no_prefix = "\
diff --git src/foo.rs src/foo.rs\n\
index ce01362..a21e91c 100644\n\
--- src/foo.rs\n\
+++ src/foo.rs\n\
@@ -1 +1 @@\n\
-hello\n\
+world\n";
let normalized = normalize_diff_prefix(patch_no_prefix);
let result = git2::Diff::from_buffer(normalized.as_bytes());
assert!(
result.is_ok(),
"libgit2 failed to parse normalized diff: {:?}",
result.err()
);
assert_eq!(result.unwrap().deltas().count(), 1);
}
}
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!(
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<Vec<String>> {
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_signature_data_from_tags {
use super::*;
fn test(time: git2::Time) -> Result<()> {
let data = extract_signature_data_from_tags(
&Tags::from_list(vec![nostr::Tag::custom(
nostr::TagKind::Custom("author".to_string().into()),
prep(&time)?,
)]),
"author",
)?;
let sig = data.to_signature()?;
assert_eq!(
sig.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<Vec<String>> {
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 <joe.bloggs@pm.me>\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.9.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 <joe.bloggs@pm.me>\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.9.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()?;
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()?;
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()?;
test_repo.create_branch("feature")?;
let feature_oid = test_repo.checkout("feature")?;
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()?;
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()?;
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")?;
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()?;
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()?;
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()?;
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()?;
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()?;
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()?;
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::{git_events::generate_patch_event, repo_ref::RepoRef};
async fn generate_patch_from_head_commit(test_repo: &GitTestRepo) -> Result<nostr::Event> {
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(), None)).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, None)?;
let commit_id = tag_value(&patch_event, "commit")?;
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?,
)
}
#[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::{git_events::generate_cover_letter_and_patch_events, repo_ref::RepoRef};
static BRANCH_NAME: &str = "add-example-feature";
async fn generate_test_repo_and_events()
-> Result<(GitTestRepo, nostr::Event, Vec<nostr::Event>)> {
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)?;
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(), None)).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(())
}
}
}
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(())
}
}
}
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()?;
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()?;
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()?;
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()?;
test_repo.create_branch("feature")?;
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 worktree {
use super::*;
#[test]
fn get_path_returns_worktree_working_dir() -> Result<()> {
let test_repo = GitTestRepo::default();
test_repo.populate()?;
let worktree_repo = test_repo.create_worktree("wt-branch")?;
let git_repo = Repo::from_path(&worktree_repo.dir)?;
let path = git_repo.get_path()?;
assert_eq!(path.canonicalize()?, worktree_repo.dir.canonicalize()?,);
Ok(())
}
#[test]
fn get_path_returns_normal_repo_working_dir() -> Result<()> {
let test_repo = GitTestRepo::default();
test_repo.populate()?;
let git_repo = Repo::from_path(&test_repo.dir)?;
let path = git_repo.get_path()?;
assert_eq!(path.canonicalize()?, test_repo.dir.canonicalize()?,);
Ok(())
}
#[test]
fn from_path_works_with_worktree_dir() -> Result<()> {
let test_repo = GitTestRepo::default();
test_repo.populate()?;
let worktree_repo = test_repo.create_worktree("wt-open")?;
let git_repo = Repo::from_path(&worktree_repo.dir)?;
assert_eq!(
git_repo.get_path()?.canonicalize()?,
worktree_repo.dir.canonicalize()?,
);
Ok(())
}
#[test]
fn worktree_can_read_branches() -> Result<()> {
let test_repo = GitTestRepo::default();
test_repo.populate()?;
let worktree_repo = test_repo.create_worktree("wt-branches")?;
let git_repo = Repo::from_path(&worktree_repo.dir)?;
let branches = git_repo.get_local_branch_names()?;
assert!(branches.contains(&"main".to_string()));
assert!(branches.contains(&"wt-branches".to_string()));
Ok(())
}
#[test]
fn worktree_can_read_git_config() -> Result<()> {
let test_repo = GitTestRepo::default();
test_repo.populate()?;
let worktree_repo = test_repo.create_worktree("wt-config")?;
let git_repo = Repo::from_path(&worktree_repo.dir)?;
let nostr_repo = git_repo.get_git_config_item("nostr.repo", None)?;
assert!(nostr_repo.is_some());
Ok(())
}
#[test]
fn worktree_get_head_commit_works() -> Result<()> {
let test_repo = GitTestRepo::default();
test_repo.populate()?;
let worktree_repo = test_repo.create_worktree("wt-head")?;
let git_repo = Repo::from_path(&worktree_repo.dir)?;
let _head = git_repo.get_head_commit()?;
Ok(())
}
#[test]
fn worktree_files_accessible_from_get_path() -> Result<()> {
let test_repo = GitTestRepo::default();
test_repo.populate()?;
let worktree_repo = test_repo.create_worktree("wt-files")?;
let git_repo = Repo::from_path(&worktree_repo.dir)?;
let test_file = worktree_repo.dir.join("worktree-test.txt");
fs::write(&test_file, "hello from worktree")?;
let path = git_repo.get_path()?;
assert!(path.join("worktree-test.txt").exists());
Ok(())
}
#[test]
fn worktree_opened_via_git_dir_env_works() -> Result<()> {
let test_repo = GitTestRepo::default();
test_repo.populate()?;
let worktree_repo = test_repo.create_worktree("wt-gitdir")?;
let git_dir = worktree_repo.git_repo.path().to_path_buf();
let git_repo = Repo::from_path(&git_dir)?;
assert_eq!(
git_repo.get_path()?.canonicalize()?,
worktree_repo.dir.canonicalize()?,
);
Ok(())
}
}
}