From 6e9245542f070c39a1975f0d53d88913c4ac667d Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Sun, 1 Oct 2023 00:00:00 +0100 Subject: feat(prs-create) find commits and create events - identify commits - create pull request event - create patch events --- Cargo.lock | 98 +++++++++ Cargo.toml | 1 + src/cli_interactor.rs | 27 ++- src/git.rs | 476 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 4 + src/sub_commands/mod.rs | 1 + src/sub_commands/prs/create.rs | 371 ++++++++++++++++++++++++++++++++ src/sub_commands/prs/mod.rs | 22 ++ test_utils/Cargo.toml | 2 + test_utils/src/git.rs | 123 +++++++++++ test_utils/src/lib.rs | 130 ++++++++++- tests/prs_create.rs | 163 ++++++++++++++ 12 files changed, 1416 insertions(+), 2 deletions(-) create mode 100644 src/git.rs create mode 100644 src/sub_commands/prs/create.rs create mode 100644 src/sub_commands/prs/mod.rs create mode 100644 test_utils/src/git.rs create mode 100644 tests/prs_create.rs diff --git a/Cargo.lock b/Cargo.lock index 994445e..5f0293e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,6 +446,7 @@ version = "1.0.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" dependencies = [ + "jobserver", "libc", ] @@ -967,6 +968,21 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" +[[package]] +name = "git2" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf97ba92db08df386e10c8ede66a2a0369bd277090afd8710e19e38de9ec0cd" +dependencies = [ + "bitflags 2.4.0", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + [[package]] name = "h2" version = "0.3.21" @@ -1196,6 +1212,15 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" +[[package]] +name = "jobserver" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" +dependencies = [ + "libc", +] + [[package]] name = "js-sys" version = "0.3.64" @@ -1231,6 +1256,46 @@ version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +[[package]] +name = "libgit2-sys" +version = "0.16.1+1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2a2bb3680b094add03bb3732ec520ece34da31a8cd2d633d1389d0f0fb60d0c" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-keyutils" version = "0.2.3" @@ -1357,6 +1422,7 @@ dependencies = [ "dialoguer", "directories", "duplicate", + "git2", "keyring", "mockall", "nostr", @@ -1524,6 +1590,24 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db4d56a4c0478783083cfafcc42493dd4a981d41669da64b4572a2a089b51b1d" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -1617,6 +1701,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" + [[package]] name = "polling" version = "2.8.0" @@ -2362,8 +2452,10 @@ dependencies = [ "assert_cmd", "dialoguer", "directories", + "git2", "nostr", "once_cell", + "rand", "rexpect 0.5.0 (git+https://github.com/phaer/rexpect.git?branch=skip-ansi-escape-codes)", "strip-ansi-escapes", ] @@ -2592,6 +2684,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index 6d8ebaf..1b2c458 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ chacha20poly1305 = "0.10.1" clap = { version = "4.3.19", features = ["derive"] } dialoguer = "0.10.4" directories = "5.0.1" +git2 = "0.18.1" keyring = "2.0.5" nostr = "0.23.0" passwords = "3.1.13" diff --git a/src/cli_interactor.rs b/src/cli_interactor.rs index d7de087..cf6e3d0 100644 --- a/src/cli_interactor.rs +++ b/src/cli_interactor.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use dialoguer::{theme::ColorfulTheme, Input, Password}; +use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password}; #[cfg(test)] use mockall::*; @@ -12,6 +12,7 @@ pub struct Interactor { pub trait InteractorPrompt { fn input(&self, parms: PromptInputParms) -> Result; fn password(&self, parms: PromptPasswordParms) -> Result; + fn confirm(&self, params: PromptConfirmParms) -> Result; } impl InteractorPrompt for Interactor { fn input(&self, parms: PromptInputParms) -> Result { @@ -29,6 +30,13 @@ impl InteractorPrompt for Interactor { let pass: String = p.interact()?; Ok(pass) } + fn confirm(&self, params: PromptConfirmParms) -> Result { + let confirm: bool = Confirm::with_theme(&self.theme) + .with_prompt(params.prompt) + .default(params.default) + .interact()?; + Ok(confirm) + } } #[derive(Default)] @@ -59,3 +67,20 @@ impl PromptPasswordParms { self } } + +#[derive(Default)] +pub struct PromptConfirmParms { + pub prompt: String, + pub default: bool, +} + +impl PromptConfirmParms { + pub fn with_prompt>(mut self, prompt: S) -> Self { + self.prompt = prompt.into(); + self + } + pub fn with_default(mut self, default: bool) -> Self { + self.default = default; + self + } +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..ddbc646 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,476 @@ +use std::env::current_dir; +#[cfg(test)] +use std::path::PathBuf; + +use anyhow::{bail, Context, Result}; +use git2::{Oid, Revwalk}; +use nostr::prelude::{sha1::Hash as Sha1Hash, Hash}; + +pub struct Repo { + git_repo: git2::Repository, +} + +impl Repo { + pub fn discover() -> Result { + Ok(Self { + git_repo: git2::Repository::discover(current_dir()?)?, + }) + } + #[cfg(test)] + 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_local_branch_names(&self) -> Result>; + fn get_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)>; + fn get_tip_of_local_branch(&self, branch_name: &str) -> Result; + fn get_root_commit(&self, branch_name: &str) -> Result; + fn get_head_commit(&self) -> Result; + fn get_commit_parent(&self, commit: &Sha1Hash) -> Result; + fn get_commits_ahead_behind( + &self, + base_commit: &Sha1Hash, + latest_commit: &Sha1Hash, + ) -> Result<(Vec, Vec)>; + fn make_patch_from_commit(&self, commit: &Sha1Hash) -> Result; +} + +impl RepoActions for Repo { + fn get_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_local_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_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_tip_of_local_branch(&self, branch_name: &str) -> Result { + let branch = self + .git_repo + .find_branch(branch_name, git2::BranchType::Local) + .context(format!("cannot find branch {branch_name}"))?; + Ok(oid_to_sha1(&branch.into_reference().peel_to_commit()?.id())) + } + + fn get_root_commit(&self, branch_name: &str) -> Result { + let tip = self.get_tip_of_local_branch(branch_name)?; + let mut revwalk = self + .git_repo + .revwalk() + .context("revwalk should be created from git repo")?; + revwalk + .push(sha1_to_oid(&tip)?) + .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 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 make_patch_from_commit(&self, commit: &Sha1Hash) -> 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 patch = git2::Email::from_commit(&c, &mut git2::EmailCreateOptions::default()) + .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 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 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 +fn sha1_to_oid(hash: &Sha1Hash) -> Result { + Oid::from_bytes(hash.as_byte_array()).context("Sha1Hash bytes failed to produce a valid Oid") +} + +#[cfg(test)] +mod tests { + use test_utils::git::GitTestRepo; + + use super::*; + + 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.1\n\ + \n\ + ", + git_repo.make_patch_from_commit(&oid_to_sha1(&oid))?, + ); + Ok(()) + } + } + + mod get_main_or_master_branch { + + use super::*; + 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_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(()) + } + } + } +} diff --git a/src/main.rs b/src/main.rs index e6eac32..5094c11 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use clap::{Parser, Subcommand}; mod cli_interactor; mod config; +mod git; mod key_handling; mod login; mod sub_commands; @@ -28,11 +29,14 @@ pub struct Cli { enum Commands { /// save encrypted nsec for future use Login(sub_commands::login::SubCommandArgs), + /// create and issue Prs + Prs(sub_commands::prs::SubCommandArgs), } fn main() -> Result<()> { let cli = Cli::parse(); match &cli.command { Commands::Login(args) => sub_commands::login::launch(&cli, args), + Commands::Prs(args) => sub_commands::prs::launch(&cli, args), } } diff --git a/src/sub_commands/mod.rs b/src/sub_commands/mod.rs index 320cbbb..3c3da1d 100644 --- a/src/sub_commands/mod.rs +++ b/src/sub_commands/mod.rs @@ -1 +1,2 @@ pub mod login; +pub mod prs; diff --git a/src/sub_commands/prs/create.rs b/src/sub_commands/prs/create.rs new file mode 100644 index 0000000..dd32c65 --- /dev/null +++ b/src/sub_commands/prs/create.rs @@ -0,0 +1,371 @@ +use anyhow::{bail, Context, Result}; +use nostr::{prelude::sha1::Hash as Sha1Hash, EventBuilder, Marker, Tag, TagKind}; + +use crate::{ + cli_interactor::{Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms}, + git::{Repo, RepoActions}, + login, Cli, +}; + +#[derive(Debug, clap::Args)] +pub struct SubCommandArgs { + #[clap(short, long)] + /// title of pull request (defaults to first line of first commit) + title: Option, + #[clap(short, long)] + /// optional description + description: Option, + #[clap(long)] + /// branch to get changes from (defaults to head) + from_branch: Option, + #[clap(long)] + /// destination branch (defaults to main or master) + to_branch: Option, +} + +pub fn launch( + cli_args: &Cli, + _pr_args: &super::SubCommandArgs, + args: &SubCommandArgs, +) -> Result<()> { + let git_repo = Repo::discover().context("cannot find a git repository")?; + + let (from_branch, to_branch, ahead, behind) = + identify_ahead_behind(&git_repo, &args.from_branch, &args.to_branch)?; + + if ahead.is_empty() { + bail!(format!( + "'{from_branch}' is 0 commits ahead of '{to_branch}' so no patches were created" + )); + } + + if behind.is_empty() { + println!( + "creating patch for {} commits from '{from_branch}' that can be merged into '{to_branch}'", + ahead.len(), + ); + } else { + if !Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt( + format!( + "'{from_branch}' is {} commits behind '{to_branch}' and {} ahead. Consider rebasing before sending patches. Proceed anyway?", + behind.len(), + ahead.len(), + ) + ) + .with_default(false) + ).context("failed to get confirmation response from interactor confirm")? { + bail!("aborting so branch can be rebased"); + } + println!( + "creating patch for {} commit{} from '{from_branch}' that {} {} behind '{to_branch}'", + ahead.len(), + if ahead.len() > 1 { "s" } else { "" }, + if ahead.len() > 1 { "are" } else { "is" }, + behind.len(), + ); + } + + let title = match &args.title { + Some(t) => t.clone(), + None => Interactor::default().input(PromptInputParms::default().with_prompt("title"))?, + }; + + let description = match &args.description { + Some(t) => t.clone(), + None => Interactor::default() + .input(PromptInputParms::default().with_prompt("description (Optional)"))?, + }; + + let root_commit = git_repo + .get_root_commit(to_branch.as_str()) + .context("failed to get root commit of the repository")?; + // create PR event + + let keys = login::launch(&cli_args.nsec, &cli_args.password)?; + + let pr_event = EventBuilder::new( + nostr::event::Kind::Custom(318), + format!("{title}\r\n\r\n{description}"), + &[Tag::Hashtag(format!("r-{root_commit}"))], + // TODO: suggested branch name + // Tag::Generic( + // TagKind::Custom("suggested-branch-name".to_string()), + // vec![], + // ), + // TODO: add Repo event as root + // TODO: people tag maintainers + // TODO: add relay tags + ) + .to_event(&keys) + .context("failed to create pr event")?; + + let mut patch_events = vec![]; + for commit in &ahead { + let commit_parent = git_repo + .get_commit_parent(commit) + .context("failed to create patch event")?; + patch_events.push( + EventBuilder::new( + nostr::event::Kind::Custom(317), + git_repo + .make_patch_from_commit(commit) + .context(format!("cannot make patch for commit {commit}"))?, + &[ + Tag::Hashtag(format!("r-{root_commit}")), + Tag::Hashtag(commit.to_string()), + Tag::Hashtag(commit_parent.to_string()), + Tag::Event( + pr_event.id, + None, // TODO: add relay + Some(Marker::Root), + ), + Tag::Generic( + TagKind::Custom("commit".to_string()), + vec![commit.to_string()], + ), + Tag::Generic( + TagKind::Custom("parent-commit".to_string()), + vec![commit_parent.to_string()], + ), + // TODO: add Repo event tags + // TODO: people tag maintainers + // TODO: add relay tags + ], + ) + .to_event(&keys), + ); + } + + // TODO check if there is already a similarly named PR + // TODO connect to relays and post + + Ok(()) +} + +// TODO +// - find profile +// - file relays +// - find repo events +// - + +/** + * returns `(from_branch,to_branch,ahead,behind)` + */ +fn identify_ahead_behind( + git_repo: &Repo, + from_branch: &Option, + to_branch: &Option, +) -> Result<(String, String, Vec, Vec)> { + let (from_branch, from_tip) = match from_branch { + Some(name) => ( + name.to_string(), + git_repo + .get_tip_of_local_branch(name) + .context(format!("cannot find from_branch '{name}'"))?, + ), + None => ( + "head".to_string(), + git_repo + .get_head_commit() + .context("failed to get head commit") + .context( + "checkout a commit or specify a from_branch. head does not reveal a commit", + )?, + ), + }; + + let (to_branch, to_tip) = match to_branch { + Some(name) => ( + name.to_string(), + git_repo + .get_tip_of_local_branch(name) + .context(format!("cannot find to_branch '{name}'"))?, + ), + None => { + let (name, commit) = git_repo + .get_main_or_master_branch() + .context("a destination branch (to_branch) is not specified and the defaults (main or master) do not exist")?; + (name.to_string(), commit) + } + }; + + match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) { + Err(e) => { + if e.to_string().contains("is not an ancestor of") { + return Err(e).context(format!( + "'{from_branch}' is not branched from '{to_branch}'" + )); + } + Err(e).context(format!( + "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'" + )) + } + Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)), + } +} + +#[cfg(test)] +mod tests { + use test_utils::git::GitTestRepo; + + use super::*; + mod identify_ahead_behind { + + use super::*; + use crate::git::oid_to_sha1; + + #[test] + fn when_from_branch_doesnt_exist_return_error() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + + test_repo.populate()?; + let branch_name = "doesnt_exist"; + assert_eq!( + identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None) + .unwrap_err() + .to_string(), + format!("cannot find from_branch '{}'", &branch_name), + ); + Ok(()) + } + + #[test] + fn when_to_branch_doesnt_exist_return_error() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + + test_repo.populate()?; + let branch_name = "doesnt_exist"; + assert_eq!( + identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string())) + .unwrap_err() + .to_string(), + format!("cannot find to_branch '{}'", &branch_name), + ); + Ok(()) + } + + #[test] + fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> { + let test_repo = GitTestRepo::new("notmain")?; + let git_repo = Repo::from_path(&test_repo.dir)?; + + test_repo.populate()?; + + assert_eq!( + identify_ahead_behind(&git_repo, &None, &None) + .unwrap_err() + .to_string(), + "a destination branch (to_branch) is not specified and the defaults (main or master) do not exist", + ); + Ok(()) + } + + #[test] + fn when_from_branch_is_none_return_as_head() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + + test_repo.populate()?; + // create feature branch with 1 commit ahead + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let head_oid = test_repo.stage_and_commit("add t3.md")?; + + // make feature branch 1 commit behind + test_repo.checkout("main")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let main_oid = test_repo.stage_and_commit("add t4.md")?; + // checkout feature + test_repo.checkout("feature")?; + + let (from_branch, to_branch, ahead, behind) = + identify_ahead_behind(&git_repo, &None, &None)?; + + assert_eq!(from_branch, "head"); + assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]); + assert_eq!(to_branch, "main"); + assert_eq!(behind, vec![oid_to_sha1(&main_oid)]); + Ok(()) + } + + #[test] + fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + + test_repo.populate()?; + // create feature branch with 1 commit ahead + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let head_oid = test_repo.stage_and_commit("add t3.md")?; + + // make feature branch 1 commit behind + test_repo.checkout("main")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let main_oid = test_repo.stage_and_commit("add t4.md")?; + + let (from_branch, to_branch, ahead, behind) = + identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?; + + assert_eq!(from_branch, "feature"); + assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]); + assert_eq!(to_branch, "main"); + assert_eq!(behind, vec![oid_to_sha1(&main_oid)]); + Ok(()) + } + + #[test] + fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + + test_repo.populate()?; + // create dev branch with 1 commit ahead + test_repo.create_branch("dev")?; + test_repo.checkout("dev")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let dev_oid_first = test_repo.stage_and_commit("add t3.md")?; + + // create feature branch with 1 commit ahead of dev + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let feature_oid = test_repo.stage_and_commit("add t4.md")?; + + // make feature branch 1 behind + test_repo.checkout("dev")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let dev_oid = test_repo.stage_and_commit("add t3.md")?; + + let (from_branch, to_branch, ahead, behind) = identify_ahead_behind( + &git_repo, + &Some("feature".to_string()), + &Some("dev".to_string()), + )?; + + assert_eq!(from_branch, "feature"); + assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]); + assert_eq!(to_branch, "dev"); + assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]); + + let (from_branch, to_branch, ahead, behind) = + identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?; + + assert_eq!(from_branch, "feature"); + assert_eq!( + ahead, + vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)] + ); + assert_eq!(to_branch, "main"); + assert_eq!(behind, vec![]); + + Ok(()) + } + } +} diff --git a/src/sub_commands/prs/mod.rs b/src/sub_commands/prs/mod.rs new file mode 100644 index 0000000..c316e73 --- /dev/null +++ b/src/sub_commands/prs/mod.rs @@ -0,0 +1,22 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::Cli; +pub mod create; + +#[derive(clap::Parser)] +pub struct SubCommandArgs { + #[command(subcommand)] + pub prs_command: Commands, +} + +#[derive(Debug, Subcommand)] +pub enum Commands { + Create(create::SubCommandArgs), +} + +pub fn launch(cli_args: &Cli, pr_args: &SubCommandArgs) -> Result<()> { + match &pr_args.prs_command { + Commands::Create(args) => create::launch(cli_args, pr_args, args), + } +} diff --git a/test_utils/Cargo.toml b/test_utils/Cargo.toml index 1a39957..3d936b5 100644 --- a/test_utils/Cargo.toml +++ b/test_utils/Cargo.toml @@ -8,7 +8,9 @@ anyhow = "1.0.75" assert_cmd = "2.0.12" dialoguer = "0.10.4" directories = "5.0.1" +git2 = "0.18.1" nostr = "0.23.0" once_cell = "1.18.0" +rand = "0.8" rexpect = { git = "https://github.com/phaer/rexpect.git", branch= "skip-ansi-escape-codes" } strip-ansi-escapes = "0.2.0" diff --git a/test_utils/src/git.rs b/test_utils/src/git.rs new file mode 100644 index 0000000..166693d --- /dev/null +++ b/test_utils/src/git.rs @@ -0,0 +1,123 @@ +//create + +// implement drop? +use std::{env::current_dir, fs, path::PathBuf}; + +use anyhow::Result; +use git2::{Oid, RepositoryInitOptions, Signature, Time}; + +pub struct GitTestRepo { + pub dir: PathBuf, + pub git_repo: git2::Repository, +} + +impl Default for GitTestRepo { + fn default() -> Self { + Self::new("main").unwrap() + } +} +impl GitTestRepo { + pub fn new(main_branch_name: &str) -> Result { + let path = current_dir()?.join(format!("tmpgit-{}", rand::random::())); + let git_repo = git2::Repository::init_opts( + &path, + RepositoryInitOptions::new() + .initial_head(main_branch_name) + .mkpath(true), + )?; + Ok(Self { + dir: path, + git_repo, + }) + } + + pub fn initial_commit(&self) -> Result { + let oid = self.git_repo.index()?.write_tree()?; + let tree = self.git_repo.find_tree(oid)?; + let commit_oid = self.git_repo.commit( + Some("HEAD"), + &joe_signature(), + &joe_signature(), + "Initial commit", + &tree, + &[], + )?; + Ok(commit_oid) + } + + pub fn populate(&self) -> Result { + self.initial_commit()?; + fs::write(self.dir.join("t1.md"), "some content")?; + self.stage_and_commit("add t1.md")?; + fs::write(self.dir.join("t2.md"), "some content1")?; + self.stage_and_commit("add t2.md") + } + + pub fn stage_and_commit(&self, message: &str) -> Result { + let prev_oid = self.git_repo.head().unwrap().peel_to_commit()?; + + let mut index = self.git_repo.index()?; + index.add_all(["."], git2::IndexAddOption::DEFAULT, None)?; + index.write()?; + + let oid = self.git_repo.commit( + Some("HEAD"), + &joe_signature(), + &joe_signature(), + message, + &self.git_repo.find_tree(index.write_tree()?)?, + &[&prev_oid], + )?; + + Ok(oid) + } + + pub fn create_branch(&self, branch_name: &str) -> Result<()> { + self.git_repo + .branch(branch_name, &self.git_repo.head()?.peel_to_commit()?, false)?; + Ok(()) + } + + pub 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) + } +} + +impl Drop for GitTestRepo { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } +} +pub fn joe_signature() -> Signature<'static> { + Signature::new("Joe Bloggs", "joe.bloggs@pm.me", &Time::new(0, 0)).unwrap() +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn methods_do_not_throw() -> Result<()> { + let repo = GitTestRepo::new("main")?; + + repo.populate()?; + repo.create_branch("feature")?; + repo.checkout("feature")?; + fs::write(repo.dir.join("t3.md"), "some content")?; + repo.stage_and_commit("add t3.md")?; + + Ok(()) + } +} diff --git a/test_utils/src/lib.rs b/test_utils/src/lib.rs index 1a4231a..0f870f6 100644 --- a/test_utils/src/lib.rs +++ b/test_utils/src/lib.rs @@ -1,4 +1,4 @@ -use std::ffi::OsStr; +use std::{ffi::OsStr, path::PathBuf}; use anyhow::{ensure, Context, Result}; use dialoguer::theme::{ColorfulTheme, Theme}; @@ -8,6 +8,8 @@ use once_cell::sync::Lazy; use rexpect::session::{Options, PtySession}; use strip_ansi_escapes::strip_str; +pub mod git; + pub static TEST_KEY_1_NSEC: &str = "nsec1ppsg5sm2aexq06juxmu9evtutr6jkwkhp98exxxvwamhru9lyx9s3rwseq"; pub static TEST_KEY_1_SK_HEX: &str = @@ -75,6 +77,34 @@ impl CliTester { i.prompt().context("initial password prompt")?; Ok(i) } + + pub fn expect_confirm( + &mut self, + prompt: &str, + default: Option, + ) -> Result { + let mut i = CliTesterConfirmPrompt { + tester: self, + prompt: prompt.to_string(), + default, + }; + i.prompt(false, default).context("initial confirm prompt")?; + Ok(i) + } + + pub fn expect_confirm_eventually( + &mut self, + prompt: &str, + default: Option, + ) -> Result { + let mut i = CliTesterConfirmPrompt { + tester: self, + prompt: prompt.to_string(), + default, + }; + i.prompt(true, default).context("initial confirm prompt")?; + Ok(i) + } } pub struct CliTesterInputPrompt<'a> { @@ -199,6 +229,71 @@ impl CliTesterPasswordPrompt<'_> { } } +pub struct CliTesterConfirmPrompt<'a> { + tester: &'a mut CliTester, + prompt: String, + default: Option, +} + +impl CliTesterConfirmPrompt<'_> { + fn prompt(&mut self, eventually: bool, default: Option) -> Result<&mut Self> { + let mut s = String::new(); + self.tester + .formatter + .format_confirm_prompt(&mut s, self.prompt.as_str(), default) + .expect("diagluer theme formatter should succeed"); + ensure!( + s.contains(self.prompt.as_str()), + "dialoguer must be broken as formatted prompt success doesnt contain prompt" + ); + + if eventually { + self.tester + .expect_eventually(sanatize(s).as_str()) + .context("expect input prompt eventually")?; + } else { + self.tester + .expect(sanatize(s).as_str()) + .context("expect confirm prompt")?; + } + + Ok(self) + } + + pub fn succeeds_with(&mut self, input: Option) -> Result<&mut Self> { + self.tester.send_line(match input { + None => "", + Some(true) => "y", + Some(false) => "n", + })?; + self.tester + .expect("\r") + .context("expect new line after confirm input to be printed")?; + + let mut s = String::new(); + self.tester + .formatter + .format_confirm_prompt_selection( + &mut s, + self.prompt.as_str(), + match input { + None => self.default, + Some(_) => input, + }, + ) + .expect("diagluer theme formatter should succeed"); + if !s.contains(self.prompt.as_str()) { + panic!("dialoguer must be broken as formatted prompt success doesnt contain prompt"); + } + let formatted_success = format!("{}\r\n", sanatize(s)); + + self.tester + .expect(formatted_success.as_str()) + .context("expect immediate prompt success")?; + Ok(self) + } +} + impl CliTester { pub fn new(args: I) -> Self where @@ -210,6 +305,17 @@ impl CliTester { formatter: ColorfulTheme::default(), } } + pub fn new_from_dir(dir: &PathBuf, args: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + Self { + rexpect_session: rexpect_with_from_dir(dir, args, 2000) + .expect("rexpect to spawn new process"), + formatter: ColorfulTheme::default(), + } + } pub fn new_with_timeout(timeout_ms: u64, args: I) -> Self where I: IntoIterator, @@ -338,6 +444,28 @@ where ) } +pub fn rexpect_with_from_dir( + dir: &PathBuf, + args: I, + timeout_ms: u64, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let mut cmd = std::process::Command::new(assert_cmd::cargo::cargo_bin("ngit")); + cmd.current_dir(dir); + cmd.args(args); + // using branch for PR https://github.com/rust-cli/rexpect/pull/103 to strip ansi escape codes + rexpect::session::spawn_with_options( + cmd, + Options { + timeout_ms: Some(timeout_ms), + strip_ansi_escape_codes: true, + }, + ) +} + /// backup and remove application config and data pub fn before() -> Result<()> { backup_existing_config() diff --git a/tests/prs_create.rs b/tests/prs_create.rs new file mode 100644 index 0000000..d598e34 --- /dev/null +++ b/tests/prs_create.rs @@ -0,0 +1,163 @@ +use anyhow::Result; +use serial_test::serial; +use test_utils::{git::GitTestRepo, *}; + +#[test] +fn when_to_branch_doesnt_exist_return_error() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let mut p = CliTester::new_from_dir( + &test_repo.dir, + ["prs", "create", "--to-branch", "nonexistant"], + ); + p.expect("Error: cannot find to_branch 'nonexistant'")?; + Ok(()) +} + +#[test] +fn when_no_to_branch_specified_and_no_main_or_master_branch_return_error() -> Result<()> { + let test_repo = GitTestRepo::new("notmain")?; + test_repo.populate()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]); + p.expect("Error: a destination branch (to_branch) is not specified and the defaults (main or master) do not exist")?; + Ok(()) +} + +#[test] +fn when_from_branch_doesnt_exist_return_error() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let mut p = CliTester::new_from_dir( + &test_repo.dir, + ["prs", "create", "--from-branch", "nonexistant"], + ); + p.expect("Error: cannot find from_branch 'nonexistant'")?; + Ok(()) +} + +#[test] +fn when_no_commits_ahead_of_main_return_error() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch with 1 commit ahead + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + + let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]); + p.expect("Error: 'head' is 0 commits ahead of 'main' so no patches were created")?; + Ok(()) +} + +mod when_commits_behind_ask_to_proceed { + use super::*; + + fn prep_test_repo() -> Result { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch with 2 commit ahead + 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")?; + test_repo.stage_and_commit("add t4.md")?; + // checkout main and add 1 commit + test_repo.checkout("main")?; + std::fs::write(test_repo.dir.join("t5.md"), "some content")?; + test_repo.stage_and_commit("add t5.md")?; + // checkout feature branch + test_repo.checkout("feature")?; + Ok(test_repo) + } + static BEHIND_LEN: u8 = 1; + static AHEAD_LEN: u8 = 2; + + fn expect_confirm_prompt( + p: &mut CliTester, + behind: u8, + ahead: u8, + ) -> Result { + p.expect_confirm( + format!("'head' is {behind} commits behind 'main' and {ahead} ahead. Consider rebasing before sending patches. Proceed anyway?").as_str(), + Some(false), + ) + } + + #[test] + fn asked_with_default_no() -> Result<()> { + let test_repo = prep_test_repo()?; + + let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]); + expect_confirm_prompt(&mut p, BEHIND_LEN, AHEAD_LEN)?; + p.exit()?; + Ok(()) + } + + #[test] + fn when_response_is_false_aborts() -> Result<()> { + let test_repo = prep_test_repo()?; + + let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]); + + expect_confirm_prompt(&mut p, BEHIND_LEN, AHEAD_LEN)?.succeeds_with(Some(false))?; + + p.expect_end_with("Error: aborting so branch can be rebased\r\n")?; + + Ok(()) + } + #[test] + #[serial] + fn when_response_is_true_proceeds() -> Result<()> { + let test_repo = prep_test_repo()?; + + let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]); + expect_confirm_prompt(&mut p, BEHIND_LEN, AHEAD_LEN)?.succeeds_with(Some(true))?; + p.expect( + format!("creating patch for {AHEAD_LEN} commits from 'head' that are {BEHIND_LEN} behind 'main'",) + .as_str(), + )?; + p.exit()?; + Ok(()) + } +} + +mod when_no_commits_behind { + use super::*; + + #[test] + #[serial] + fn message_for_creating_patches() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch with 2 commit ahead + 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")?; + test_repo.stage_and_commit("add t4.md")?; + + let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]); + + p.expect("creating patch for 2 commits from 'head' that can be merged into 'main'")?; + p.exit()?; + Ok(()) + } +} + +// #[test] +// #[serial] +// fn succeeds_with_text_logged_in_as_npub() -> Result<()> { +// with_fresh_config(|| { +// let mut p = CliTester::new(["login"]); + +// p.expect_input(EXPECTED_NSEC_PROMPT)? +// .succeeds_with(TEST_KEY_1_NSEC)?; + +// p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? +// .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? +// .succeeds_with(TEST_PASSWORD)?; + +// p.expect_end_with(format!("logged in as {}\r\n", +// TEST_KEY_1_NPUB).as_str()) }) +// } -- cgit v1.2.3