upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2023-10-01 00:00:00 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2023-10-01 00:00:00 +0100
commit6e9245542f070c39a1975f0d53d88913c4ac667d (patch)
tree835c1d3db05f76f437c5d8ebc5591f1796cdab60
parentaa48a626c08cec353d5563a8831239d2e69c9f3d (diff)
feat(prs-create) find commits and create events
- identify commits - create pull request event - create patch events
-rw-r--r--Cargo.lock98
-rw-r--r--Cargo.toml1
-rw-r--r--src/cli_interactor.rs27
-rw-r--r--src/git.rs476
-rw-r--r--src/main.rs4
-rw-r--r--src/sub_commands/mod.rs1
-rw-r--r--src/sub_commands/prs/create.rs371
-rw-r--r--src/sub_commands/prs/mod.rs22
-rw-r--r--test_utils/Cargo.toml2
-rw-r--r--test_utils/src/git.rs123
-rw-r--r--test_utils/src/lib.rs130
-rw-r--r--tests/prs_create.rs163
12 files changed, 1416 insertions, 2 deletions
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"
446source = "registry+https://github.com/rust-lang/crates.io-index" 446source = "registry+https://github.com/rust-lang/crates.io-index"
447checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" 447checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0"
448dependencies = [ 448dependencies = [
449 "jobserver",
449 "libc", 450 "libc",
450] 451]
451 452
@@ -968,6 +969,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
968checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" 969checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0"
969 970
970[[package]] 971[[package]]
972name = "git2"
973version = "0.18.1"
974source = "registry+https://github.com/rust-lang/crates.io-index"
975checksum = "fbf97ba92db08df386e10c8ede66a2a0369bd277090afd8710e19e38de9ec0cd"
976dependencies = [
977 "bitflags 2.4.0",
978 "libc",
979 "libgit2-sys",
980 "log",
981 "openssl-probe",
982 "openssl-sys",
983 "url",
984]
985
986[[package]]
971name = "h2" 987name = "h2"
972version = "0.3.21" 988version = "0.3.21"
973source = "registry+https://github.com/rust-lang/crates.io-index" 989source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1197,6 +1213,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1197checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" 1213checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38"
1198 1214
1199[[package]] 1215[[package]]
1216name = "jobserver"
1217version = "0.1.26"
1218source = "registry+https://github.com/rust-lang/crates.io-index"
1219checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2"
1220dependencies = [
1221 "libc",
1222]
1223
1224[[package]]
1200name = "js-sys" 1225name = "js-sys"
1201version = "0.3.64" 1226version = "0.3.64"
1202source = "registry+https://github.com/rust-lang/crates.io-index" 1227source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1232,6 +1257,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1232checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" 1257checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
1233 1258
1234[[package]] 1259[[package]]
1260name = "libgit2-sys"
1261version = "0.16.1+1.7.1"
1262source = "registry+https://github.com/rust-lang/crates.io-index"
1263checksum = "f2a2bb3680b094add03bb3732ec520ece34da31a8cd2d633d1389d0f0fb60d0c"
1264dependencies = [
1265 "cc",
1266 "libc",
1267 "libssh2-sys",
1268 "libz-sys",
1269 "openssl-sys",
1270 "pkg-config",
1271]
1272
1273[[package]]
1274name = "libssh2-sys"
1275version = "0.3.0"
1276source = "registry+https://github.com/rust-lang/crates.io-index"
1277checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee"
1278dependencies = [
1279 "cc",
1280 "libc",
1281 "libz-sys",
1282 "openssl-sys",
1283 "pkg-config",
1284 "vcpkg",
1285]
1286
1287[[package]]
1288name = "libz-sys"
1289version = "1.1.12"
1290source = "registry+https://github.com/rust-lang/crates.io-index"
1291checksum = "d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b"
1292dependencies = [
1293 "cc",
1294 "libc",
1295 "pkg-config",
1296 "vcpkg",
1297]
1298
1299[[package]]
1235name = "linux-keyutils" 1300name = "linux-keyutils"
1236version = "0.2.3" 1301version = "0.2.3"
1237source = "registry+https://github.com/rust-lang/crates.io-index" 1302source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1357,6 +1422,7 @@ dependencies = [
1357 "dialoguer", 1422 "dialoguer",
1358 "directories", 1423 "directories",
1359 "duplicate", 1424 "duplicate",
1425 "git2",
1360 "keyring", 1426 "keyring",
1361 "mockall", 1427 "mockall",
1362 "nostr", 1428 "nostr",
@@ -1525,6 +1591,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1525checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" 1591checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
1526 1592
1527[[package]] 1593[[package]]
1594name = "openssl-probe"
1595version = "0.1.5"
1596source = "registry+https://github.com/rust-lang/crates.io-index"
1597checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
1598
1599[[package]]
1600name = "openssl-sys"
1601version = "0.9.93"
1602source = "registry+https://github.com/rust-lang/crates.io-index"
1603checksum = "db4d56a4c0478783083cfafcc42493dd4a981d41669da64b4572a2a089b51b1d"
1604dependencies = [
1605 "cc",
1606 "libc",
1607 "pkg-config",
1608 "vcpkg",
1609]
1610
1611[[package]]
1528name = "option-ext" 1612name = "option-ext"
1529version = "0.2.0" 1613version = "0.2.0"
1530source = "registry+https://github.com/rust-lang/crates.io-index" 1614source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1618,6 +1702,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1618checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 1702checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
1619 1703
1620[[package]] 1704[[package]]
1705name = "pkg-config"
1706version = "0.3.27"
1707source = "registry+https://github.com/rust-lang/crates.io-index"
1708checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964"
1709
1710[[package]]
1621name = "polling" 1711name = "polling"
1622version = "2.8.0" 1712version = "2.8.0"
1623source = "registry+https://github.com/rust-lang/crates.io-index" 1713source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2362,8 +2452,10 @@ dependencies = [
2362 "assert_cmd", 2452 "assert_cmd",
2363 "dialoguer", 2453 "dialoguer",
2364 "directories", 2454 "directories",
2455 "git2",
2365 "nostr", 2456 "nostr",
2366 "once_cell", 2457 "once_cell",
2458 "rand",
2367 "rexpect 0.5.0 (git+https://github.com/phaer/rexpect.git?branch=skip-ansi-escape-codes)", 2459 "rexpect 0.5.0 (git+https://github.com/phaer/rexpect.git?branch=skip-ansi-escape-codes)",
2368 "strip-ansi-escapes", 2460 "strip-ansi-escapes",
2369] 2461]
@@ -2593,6 +2685,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2593checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" 2685checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
2594 2686
2595[[package]] 2687[[package]]
2688name = "vcpkg"
2689version = "0.2.15"
2690source = "registry+https://github.com/rust-lang/crates.io-index"
2691checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
2692
2693[[package]]
2596name = "version_check" 2694name = "version_check"
2597version = "0.9.4" 2695version = "0.9.4"
2598source = "registry+https://github.com/rust-lang/crates.io-index" 2696source = "registry+https://github.com/rust-lang/crates.io-index"
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"
17clap = { version = "4.3.19", features = ["derive"] } 17clap = { version = "4.3.19", features = ["derive"] }
18dialoguer = "0.10.4" 18dialoguer = "0.10.4"
19directories = "5.0.1" 19directories = "5.0.1"
20git2 = "0.18.1"
20keyring = "2.0.5" 21keyring = "2.0.5"
21nostr = "0.23.0" 22nostr = "0.23.0"
22passwords = "3.1.13" 23passwords = "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 @@
1use anyhow::Result; 1use anyhow::Result;
2use dialoguer::{theme::ColorfulTheme, Input, Password}; 2use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password};
3#[cfg(test)] 3#[cfg(test)]
4use mockall::*; 4use mockall::*;
5 5
@@ -12,6 +12,7 @@ pub struct Interactor {
12pub trait InteractorPrompt { 12pub trait InteractorPrompt {
13 fn input(&self, parms: PromptInputParms) -> Result<String>; 13 fn input(&self, parms: PromptInputParms) -> Result<String>;
14 fn password(&self, parms: PromptPasswordParms) -> Result<String>; 14 fn password(&self, parms: PromptPasswordParms) -> Result<String>;
15 fn confirm(&self, params: PromptConfirmParms) -> Result<bool>;
15} 16}
16impl InteractorPrompt for Interactor { 17impl InteractorPrompt for Interactor {
17 fn input(&self, parms: PromptInputParms) -> Result<String> { 18 fn input(&self, parms: PromptInputParms) -> Result<String> {
@@ -29,6 +30,13 @@ impl InteractorPrompt for Interactor {
29 let pass: String = p.interact()?; 30 let pass: String = p.interact()?;
30 Ok(pass) 31 Ok(pass)
31 } 32 }
33 fn confirm(&self, params: PromptConfirmParms) -> Result<bool> {
34 let confirm: bool = Confirm::with_theme(&self.theme)
35 .with_prompt(params.prompt)
36 .default(params.default)
37 .interact()?;
38 Ok(confirm)
39 }
32} 40}
33 41
34#[derive(Default)] 42#[derive(Default)]
@@ -59,3 +67,20 @@ impl PromptPasswordParms {
59 self 67 self
60 } 68 }
61} 69}
70
71#[derive(Default)]
72pub struct PromptConfirmParms {
73 pub prompt: String,
74 pub default: bool,
75}
76
77impl PromptConfirmParms {
78 pub fn with_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
79 self.prompt = prompt.into();
80 self
81 }
82 pub fn with_default(mut self, default: bool) -> Self {
83 self.default = default;
84 self
85 }
86}
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 @@
1use std::env::current_dir;
2#[cfg(test)]
3use std::path::PathBuf;
4
5use anyhow::{bail, Context, Result};
6use git2::{Oid, Revwalk};
7use nostr::prelude::{sha1::Hash as Sha1Hash, Hash};
8
9pub struct Repo {
10 git_repo: git2::Repository,
11}
12
13impl Repo {
14 pub fn discover() -> Result<Self> {
15 Ok(Self {
16 git_repo: git2::Repository::discover(current_dir()?)?,
17 })
18 }
19 #[cfg(test)]
20 pub fn from_path(path: &PathBuf) -> Result<Self> {
21 Ok(Self {
22 git_repo: git2::Repository::open(path)?,
23 })
24 }
25}
26
27// pub type CommitId = [u8; 7];
28// pub type Sha1 = [u8; 20];
29
30pub trait RepoActions {
31 fn get_local_branch_names(&self) -> Result<Vec<String>>;
32 fn get_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)>;
33 fn get_tip_of_local_branch(&self, branch_name: &str) -> Result<Sha1Hash>;
34 fn get_root_commit(&self, branch_name: &str) -> Result<Sha1Hash>;
35 fn get_head_commit(&self) -> Result<Sha1Hash>;
36 fn get_commit_parent(&self, commit: &Sha1Hash) -> Result<Sha1Hash>;
37 fn get_commits_ahead_behind(
38 &self,
39 base_commit: &Sha1Hash,
40 latest_commit: &Sha1Hash,
41 ) -> Result<(Vec<Sha1Hash>, Vec<Sha1Hash>)>;
42 fn make_patch_from_commit(&self, commit: &Sha1Hash) -> Result<String>;
43}
44
45impl RepoActions for Repo {
46 fn get_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)> {
47 let main_branch_name = {
48 let local_branches = self
49 .get_local_branch_names()
50 .context("cannot find any local branches")?;
51 if local_branches.contains(&"main".to_string()) {
52 "main"
53 } else if local_branches.contains(&"master".to_string()) {
54 "master"
55 } else {
56 bail!("no main or master branch locally in this git repository to initiate from",)
57 }
58 };
59
60 let tip = self
61 .get_tip_of_local_branch(main_branch_name)
62 .context(format!(
63 "branch {main_branch_name} was listed as a local branch but cannot get its tip commit id",
64 ))?;
65
66 Ok((main_branch_name, tip))
67 }
68
69 fn get_local_branch_names(&self) -> Result<Vec<String>> {
70 let local_branches = self
71 .git_repo
72 .branches(Some(git2::BranchType::Local))
73 .context("getting GitRepo branches should not error even for a blank repository")?;
74
75 let mut branch_names = vec![];
76
77 for iter in local_branches {
78 let branch = iter?.0;
79 if let Some(name) = branch.name()? {
80 branch_names.push(name.to_string());
81 }
82 }
83 Ok(branch_names)
84 }
85
86 fn get_tip_of_local_branch(&self, branch_name: &str) -> Result<Sha1Hash> {
87 let branch = self
88 .git_repo
89 .find_branch(branch_name, git2::BranchType::Local)
90 .context(format!("cannot find branch {branch_name}"))?;
91 Ok(oid_to_sha1(&branch.into_reference().peel_to_commit()?.id()))
92 }
93
94 fn get_root_commit(&self, branch_name: &str) -> Result<Sha1Hash> {
95 let tip = self.get_tip_of_local_branch(branch_name)?;
96 let mut revwalk = self
97 .git_repo
98 .revwalk()
99 .context("revwalk should be created from git repo")?;
100 revwalk
101 .push(sha1_to_oid(&tip)?)
102 .context("revwalk should accept tip oid")?;
103 Ok(oid_to_sha1(
104 &revwalk
105 .last()
106 .context("revwalk from tip should be at least contain the tip oid")?
107 .context("revwalk iter from branch tip should not result in an error")?,
108 ))
109 }
110
111 fn get_head_commit(&self) -> Result<Sha1Hash> {
112 let head = self
113 .git_repo
114 .head()
115 .context("failed to get git repo head")?;
116 let oid = head.peel_to_commit()?.id();
117 Ok(oid_to_sha1(&oid))
118 }
119
120 fn get_commit_parent(&self, commit: &Sha1Hash) -> Result<Sha1Hash> {
121 let parent_oid = self
122 .git_repo
123 .find_commit(sha1_to_oid(commit)?)
124 .context(format!("could not find commit {commit}"))?
125 .parent_id(0)
126 .context(format!("could not find parent of commit {commit}"))?;
127 Ok(oid_to_sha1(&parent_oid))
128 }
129
130 fn make_patch_from_commit(&self, commit: &Sha1Hash) -> Result<String> {
131 let c = self
132 .git_repo
133 .find_commit(Oid::from_bytes(commit.as_byte_array()).context(format!(
134 "failed to convert commit_id format for {}",
135 &commit
136 ))?)
137 .context(format!("failed to find commit {}", &commit))?;
138 let patch = git2::Email::from_commit(&c, &mut git2::EmailCreateOptions::default())
139 .context(format!("failed to create patch from commit {}", &commit))?;
140
141 Ok(std::str::from_utf8(patch.as_slice())
142 .context("patch content could not be converted to a utf8 string")?
143 .to_owned())
144 }
145
146 fn get_commits_ahead_behind(
147 &self,
148 base_commit: &Sha1Hash,
149 latest_commit: &Sha1Hash,
150 ) -> Result<(Vec<Sha1Hash>, Vec<Sha1Hash>)> {
151 let mut ahead: Vec<Sha1Hash> = vec![];
152 let mut behind: Vec<Sha1Hash> = vec![];
153
154 let get_revwalk = |commit: &Sha1Hash| -> Result<Revwalk> {
155 let mut revwalk = self
156 .git_repo
157 .revwalk()
158 .context("revwalk should be created from git repo")?;
159 revwalk
160 .push(sha1_to_oid(commit)?)
161 .context("revwalk should accept commit oid")?;
162 Ok(revwalk)
163 };
164
165 // scan through the base commit ancestory until a common ancestor is found
166 let most_recent_shared_commit = match get_revwalk(base_commit)
167 .context("failed to get revwalk for base_commit")?
168 .find(|base_res| {
169 let base_oid = base_res.as_ref().unwrap();
170
171 if get_revwalk(latest_commit)
172 .unwrap()
173 .any(|latest_res| base_oid.eq(latest_res.as_ref().unwrap()))
174 {
175 true
176 } else {
177 // add commits not found in latest ancestory to 'behind' vector
178 behind.push(oid_to_sha1(base_oid));
179 false
180 }
181 }) {
182 None => {
183 bail!(format!(
184 "{} is not an ancestor of {}",
185 latest_commit, base_commit
186 ));
187 }
188 Some(res) => res.context("revwalk failed to reveal commit")?,
189 };
190
191 // scan through the latest commits until shared commit is reached
192 get_revwalk(latest_commit)
193 .context("failed to get revwalk for latest_commit")?
194 .any(|latest_res| {
195 let latest_oid = latest_res.as_ref().unwrap();
196 if latest_oid.eq(&most_recent_shared_commit) {
197 true
198 } else {
199 // add commits not found in base to 'ahead' vector
200 ahead.push(oid_to_sha1(latest_oid));
201 false
202 }
203 });
204 Ok((ahead, behind))
205 }
206}
207
208fn oid_to_u8_20_bytes(oid: &Oid) -> [u8; 20] {
209 let b = oid.as_bytes();
210 [
211 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],
212 b[14], b[15], b[16], b[17], b[18], b[19],
213 ]
214}
215
216// fn oid_to_shorthand_string(oid: Oid) -> Result<String> {
217// let binding = oid.to_string();
218// let b = binding.as_bytes();
219// String::from_utf8(vec![b[0], b[1], b[2], b[3], b[4], b[5], b[6]])
220// .context("oid should always start with 7 u8 btyes of utf8")
221// }
222
223// fn oid_to_sha1_string(oid: Oid) -> Result<String> {
224// let b = oid.as_bytes();
225// String::from_utf8(vec![
226// b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10],
227// b[11], b[12], b[13], b[14], b[15], b[16], b[17], b[18], b[19],
228// ])
229// .context("oid should contain 20 u8 btyes of utf8")
230// }
231
232// git2 Oid object to Sha1Hash
233pub fn oid_to_sha1(oid: &Oid) -> Sha1Hash {
234 Sha1Hash::from_byte_array(oid_to_u8_20_bytes(oid))
235}
236
237/// `Sha1Hash` to git2 `Oid` object
238fn sha1_to_oid(hash: &Sha1Hash) -> Result<Oid> {
239 Oid::from_bytes(hash.as_byte_array()).context("Sha1Hash bytes failed to produce a valid Oid")
240}
241
242#[cfg(test)]
243mod tests {
244 use test_utils::git::GitTestRepo;
245
246 use super::*;
247
248 mod make_patch_from_commit {
249 use super::*;
250 #[test]
251 fn simple_patch_matches_string() -> Result<()> {
252 let test_repo = GitTestRepo::default();
253 let oid = test_repo.populate()?;
254
255 let git_repo = Repo::from_path(&test_repo.dir)?;
256
257 assert_eq!(
258 "\
259 From 431b84edc0d2fa118d63faa3c2db9c73d630a5ae Mon Sep 17 00:00:00 2001\n\
260 From: Joe Bloggs <joe.bloggs@pm.me>\n\
261 Date: Thu, 1 Jan 1970 00:00:00 +0000\n\
262 Subject: [PATCH] add t2.md\n\
263 \n\
264 ---\n \
265 t2.md | 1 +\n \
266 1 file changed, 1 insertion(+)\n \
267 create mode 100644 t2.md\n\
268 \n\
269 diff --git a/t2.md b/t2.md\n\
270 new file mode 100644\n\
271 index 0000000..a66525d\n\
272 --- /dev/null\n\
273 +++ b/t2.md\n\
274 @@ -0,0 +1 @@\n\
275 +some content1\n\\ \
276 No newline at end of file\n\
277 --\n\
278 libgit2 1.7.1\n\
279 \n\
280 ",
281 git_repo.make_patch_from_commit(&oid_to_sha1(&oid))?,
282 );
283 Ok(())
284 }
285 }
286
287 mod get_main_or_master_branch {
288
289 use super::*;
290 mod returns_main {
291 use super::*;
292 #[test]
293 fn when_it_exists() -> Result<()> {
294 let test_repo = GitTestRepo::new("main")?;
295 let main_oid = test_repo.populate()?;
296 let git_repo = Repo::from_path(&test_repo.dir)?;
297 let (name, commit_hash) = git_repo.get_main_or_master_branch()?;
298 assert_eq!(name, "main");
299 assert_eq!(commit_hash, oid_to_sha1(&main_oid));
300 Ok(())
301 }
302
303 #[test]
304 fn when_it_exists_and_other_branch_checkedout() -> Result<()> {
305 let test_repo = GitTestRepo::new("main")?;
306 let main_oid = test_repo.populate()?;
307 test_repo.create_branch("feature")?;
308 test_repo.checkout("feature")?;
309 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
310 let feature_oid = test_repo.stage_and_commit("add t3.md")?;
311
312 let git_repo = Repo::from_path(&test_repo.dir)?;
313 let (name, commit_hash) = git_repo.get_main_or_master_branch()?;
314 assert_eq!(name, "main");
315 assert_eq!(commit_hash, oid_to_sha1(&main_oid));
316 assert_ne!(commit_hash, oid_to_sha1(&feature_oid));
317 Ok(())
318 }
319
320 #[test]
321 fn when_exists_even_if_master_is_checkedout() -> Result<()> {
322 let test_repo = GitTestRepo::new("main")?;
323 let main_oid = test_repo.populate()?;
324 test_repo.create_branch("master")?;
325 test_repo.checkout("master")?;
326 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
327 let master_oid = test_repo.stage_and_commit("add t3.md")?;
328
329 let git_repo = Repo::from_path(&test_repo.dir)?;
330 let (name, commit_hash) = git_repo.get_main_or_master_branch()?;
331 assert_eq!(name, "main");
332 assert_eq!(commit_hash, oid_to_sha1(&main_oid));
333 assert_ne!(commit_hash, oid_to_sha1(&master_oid));
334 Ok(())
335 }
336 }
337
338 #[test]
339 fn returns_master_if_exists_and_main_doesnt() -> Result<()> {
340 let test_repo = GitTestRepo::new("master")?;
341 let master_oid = test_repo.populate()?;
342 test_repo.create_branch("feature")?;
343 test_repo.checkout("feature")?;
344 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
345 let feature_oid = test_repo.stage_and_commit("add t3.md")?;
346
347 let git_repo = Repo::from_path(&test_repo.dir)?;
348 let (name, commit_hash) = git_repo.get_main_or_master_branch()?;
349 assert_eq!(name, "master");
350 assert_eq!(commit_hash, oid_to_sha1(&master_oid));
351 assert_ne!(commit_hash, oid_to_sha1(&feature_oid));
352 Ok(())
353 }
354 #[test]
355 fn returns_error_if_no_main_or_master() -> Result<()> {
356 let test_repo = GitTestRepo::new("feature")?;
357 test_repo.populate()?;
358 let git_repo = Repo::from_path(&test_repo.dir)?;
359 assert!(git_repo.get_main_or_master_branch().is_err());
360 Ok(())
361 }
362 }
363
364 mod get_commits_ahead_behind {
365 use super::*;
366 mod returns_main {
367 use super::*;
368
369 #[test]
370 fn when_on_same_commit_return_empty() -> Result<()> {
371 let test_repo = GitTestRepo::default();
372 let oid = test_repo.populate()?;
373 // create feature branch
374 test_repo.create_branch("feature")?;
375 test_repo.checkout("feature")?;
376
377 let git_repo = Repo::from_path(&test_repo.dir)?;
378
379 let (ahead, behind) =
380 git_repo.get_commits_ahead_behind(&oid_to_sha1(&oid), &oid_to_sha1(&oid))?;
381 assert_eq!(ahead, vec![]);
382 assert_eq!(behind, vec![]);
383 Ok(())
384 }
385
386 #[test]
387 fn when_2_commit_behind() -> Result<()> {
388 let test_repo = GitTestRepo::default();
389 test_repo.populate()?;
390 // create feature branch
391 test_repo.create_branch("feature")?;
392 let feature_oid = test_repo.checkout("feature")?;
393 // checkout main and add 2 commits
394 test_repo.checkout("main")?;
395 std::fs::write(test_repo.dir.join("t5.md"), "some content")?;
396 let behind_1_oid = test_repo.stage_and_commit("add t5.md")?;
397 std::fs::write(test_repo.dir.join("t6.md"), "some content")?;
398 let behind_2_oid = test_repo.stage_and_commit("add t6.md")?;
399
400 let git_repo = Repo::from_path(&test_repo.dir)?;
401
402 let (ahead, behind) = git_repo.get_commits_ahead_behind(
403 &oid_to_sha1(&behind_2_oid),
404 &oid_to_sha1(&feature_oid),
405 )?;
406 assert_eq!(ahead, vec![]);
407 assert_eq!(
408 behind,
409 vec![oid_to_sha1(&behind_2_oid), oid_to_sha1(&behind_1_oid),],
410 );
411 Ok(())
412 }
413
414 #[test]
415 fn when_2_commit_ahead() -> Result<()> {
416 let test_repo = GitTestRepo::default();
417 let main_oid = test_repo.populate()?;
418 // create feature branch and add 2 commits
419 test_repo.create_branch("feature")?;
420 test_repo.checkout("feature")?;
421 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
422 let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?;
423 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
424 let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?;
425
426 let git_repo = Repo::from_path(&test_repo.dir)?;
427
428 let (ahead, behind) = git_repo.get_commits_ahead_behind(
429 &oid_to_sha1(&main_oid),
430 &oid_to_sha1(&ahead_2_oid),
431 )?;
432 assert_eq!(
433 ahead,
434 vec![oid_to_sha1(&ahead_2_oid), oid_to_sha1(&ahead_1_oid),],
435 );
436 assert_eq!(behind, vec![]);
437 Ok(())
438 }
439
440 #[test]
441 fn when_2_commit_ahead_and_2_commits_behind() -> Result<()> {
442 let test_repo = GitTestRepo::default();
443 test_repo.populate()?;
444 // create feature branch and add 2 commits
445 test_repo.create_branch("feature")?;
446 test_repo.checkout("feature")?;
447 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
448 let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?;
449 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
450 let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?;
451 // checkout main and add 2 commits
452 test_repo.checkout("main")?;
453 std::fs::write(test_repo.dir.join("t5.md"), "some content")?;
454 let behind_1_oid = test_repo.stage_and_commit("add t5.md")?;
455 std::fs::write(test_repo.dir.join("t6.md"), "some content")?;
456 let behind_2_oid = test_repo.stage_and_commit("add t6.md")?;
457
458 let git_repo = Repo::from_path(&test_repo.dir)?;
459
460 let (ahead, behind) = git_repo.get_commits_ahead_behind(
461 &oid_to_sha1(&behind_2_oid),
462 &oid_to_sha1(&ahead_2_oid),
463 )?;
464 assert_eq!(
465 ahead,
466 vec![oid_to_sha1(&ahead_2_oid), oid_to_sha1(&ahead_1_oid)],
467 );
468 assert_eq!(
469 behind,
470 vec![oid_to_sha1(&behind_2_oid), oid_to_sha1(&behind_1_oid)],
471 );
472 Ok(())
473 }
474 }
475 }
476}
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};
6 6
7mod cli_interactor; 7mod cli_interactor;
8mod config; 8mod config;
9mod git;
9mod key_handling; 10mod key_handling;
10mod login; 11mod login;
11mod sub_commands; 12mod sub_commands;
@@ -28,11 +29,14 @@ pub struct Cli {
28enum Commands { 29enum Commands {
29 /// save encrypted nsec for future use 30 /// save encrypted nsec for future use
30 Login(sub_commands::login::SubCommandArgs), 31 Login(sub_commands::login::SubCommandArgs),
32 /// create and issue Prs
33 Prs(sub_commands::prs::SubCommandArgs),
31} 34}
32 35
33fn main() -> Result<()> { 36fn main() -> Result<()> {
34 let cli = Cli::parse(); 37 let cli = Cli::parse();
35 match &cli.command { 38 match &cli.command {
36 Commands::Login(args) => sub_commands::login::launch(&cli, args), 39 Commands::Login(args) => sub_commands::login::launch(&cli, args),
40 Commands::Prs(args) => sub_commands::prs::launch(&cli, args),
37 } 41 }
38} 42}
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 @@
1pub mod login; 1pub mod login;
2pub 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 @@
1use anyhow::{bail, Context, Result};
2use nostr::{prelude::sha1::Hash as Sha1Hash, EventBuilder, Marker, Tag, TagKind};
3
4use crate::{
5 cli_interactor::{Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms},
6 git::{Repo, RepoActions},
7 login, Cli,
8};
9
10#[derive(Debug, clap::Args)]
11pub struct SubCommandArgs {
12 #[clap(short, long)]
13 /// title of pull request (defaults to first line of first commit)
14 title: Option<String>,
15 #[clap(short, long)]
16 /// optional description
17 description: Option<String>,
18 #[clap(long)]
19 /// branch to get changes from (defaults to head)
20 from_branch: Option<String>,
21 #[clap(long)]
22 /// destination branch (defaults to main or master)
23 to_branch: Option<String>,
24}
25
26pub fn launch(
27 cli_args: &Cli,
28 _pr_args: &super::SubCommandArgs,
29 args: &SubCommandArgs,
30) -> Result<()> {
31 let git_repo = Repo::discover().context("cannot find a git repository")?;
32
33 let (from_branch, to_branch, ahead, behind) =
34 identify_ahead_behind(&git_repo, &args.from_branch, &args.to_branch)?;
35
36 if ahead.is_empty() {
37 bail!(format!(
38 "'{from_branch}' is 0 commits ahead of '{to_branch}' so no patches were created"
39 ));
40 }
41
42 if behind.is_empty() {
43 println!(
44 "creating patch for {} commits from '{from_branch}' that can be merged into '{to_branch}'",
45 ahead.len(),
46 );
47 } else {
48 if !Interactor::default().confirm(
49 PromptConfirmParms::default()
50 .with_prompt(
51 format!(
52 "'{from_branch}' is {} commits behind '{to_branch}' and {} ahead. Consider rebasing before sending patches. Proceed anyway?",
53 behind.len(),
54 ahead.len(),
55 )
56 )
57 .with_default(false)
58 ).context("failed to get confirmation response from interactor confirm")? {
59 bail!("aborting so branch can be rebased");
60 }
61 println!(
62 "creating patch for {} commit{} from '{from_branch}' that {} {} behind '{to_branch}'",
63 ahead.len(),
64 if ahead.len() > 1 { "s" } else { "" },
65 if ahead.len() > 1 { "are" } else { "is" },
66 behind.len(),
67 );
68 }
69
70 let title = match &args.title {
71 Some(t) => t.clone(),
72 None => Interactor::default().input(PromptInputParms::default().with_prompt("title"))?,
73 };
74
75 let description = match &args.description {
76 Some(t) => t.clone(),
77 None => Interactor::default()
78 .input(PromptInputParms::default().with_prompt("description (Optional)"))?,
79 };
80
81 let root_commit = git_repo
82 .get_root_commit(to_branch.as_str())
83 .context("failed to get root commit of the repository")?;
84 // create PR event
85
86 let keys = login::launch(&cli_args.nsec, &cli_args.password)?;
87
88 let pr_event = EventBuilder::new(
89 nostr::event::Kind::Custom(318),
90 format!("{title}\r\n\r\n{description}"),
91 &[Tag::Hashtag(format!("r-{root_commit}"))],
92 // TODO: suggested branch name
93 // Tag::Generic(
94 // TagKind::Custom("suggested-branch-name".to_string()),
95 // vec![],
96 // ),
97 // TODO: add Repo event as root
98 // TODO: people tag maintainers
99 // TODO: add relay tags
100 )
101 .to_event(&keys)
102 .context("failed to create pr event")?;
103
104 let mut patch_events = vec![];
105 for commit in &ahead {
106 let commit_parent = git_repo
107 .get_commit_parent(commit)
108 .context("failed to create patch event")?;
109 patch_events.push(
110 EventBuilder::new(
111 nostr::event::Kind::Custom(317),
112 git_repo
113 .make_patch_from_commit(commit)
114 .context(format!("cannot make patch for commit {commit}"))?,
115 &[
116 Tag::Hashtag(format!("r-{root_commit}")),
117 Tag::Hashtag(commit.to_string()),
118 Tag::Hashtag(commit_parent.to_string()),
119 Tag::Event(
120 pr_event.id,
121 None, // TODO: add relay
122 Some(Marker::Root),
123 ),
124 Tag::Generic(
125 TagKind::Custom("commit".to_string()),
126 vec![commit.to_string()],
127 ),
128 Tag::Generic(
129 TagKind::Custom("parent-commit".to_string()),
130 vec![commit_parent.to_string()],
131 ),
132 // TODO: add Repo event tags
133 // TODO: people tag maintainers
134 // TODO: add relay tags
135 ],
136 )
137 .to_event(&keys),
138 );
139 }
140
141 // TODO check if there is already a similarly named PR
142 // TODO connect to relays and post
143
144 Ok(())
145}
146
147// TODO
148// - find profile
149// - file relays
150// - find repo events
151// -
152
153/**
154 * returns `(from_branch,to_branch,ahead,behind)`
155 */
156fn identify_ahead_behind(
157 git_repo: &Repo,
158 from_branch: &Option<String>,
159 to_branch: &Option<String>,
160) -> Result<(String, String, Vec<Sha1Hash>, Vec<Sha1Hash>)> {
161 let (from_branch, from_tip) = match from_branch {
162 Some(name) => (
163 name.to_string(),
164 git_repo
165 .get_tip_of_local_branch(name)
166 .context(format!("cannot find from_branch '{name}'"))?,
167 ),
168 None => (
169 "head".to_string(),
170 git_repo
171 .get_head_commit()
172 .context("failed to get head commit")
173 .context(
174 "checkout a commit or specify a from_branch. head does not reveal a commit",
175 )?,
176 ),
177 };
178
179 let (to_branch, to_tip) = match to_branch {
180 Some(name) => (
181 name.to_string(),
182 git_repo
183 .get_tip_of_local_branch(name)
184 .context(format!("cannot find to_branch '{name}'"))?,
185 ),
186 None => {
187 let (name, commit) = git_repo
188 .get_main_or_master_branch()
189 .context("a destination branch (to_branch) is not specified and the defaults (main or master) do not exist")?;
190 (name.to_string(), commit)
191 }
192 };
193
194 match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) {
195 Err(e) => {
196 if e.to_string().contains("is not an ancestor of") {
197 return Err(e).context(format!(
198 "'{from_branch}' is not branched from '{to_branch}'"
199 ));
200 }
201 Err(e).context(format!(
202 "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'"
203 ))
204 }
205 Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)),
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use test_utils::git::GitTestRepo;
212
213 use super::*;
214 mod identify_ahead_behind {
215
216 use super::*;
217 use crate::git::oid_to_sha1;
218
219 #[test]
220 fn when_from_branch_doesnt_exist_return_error() -> Result<()> {
221 let test_repo = GitTestRepo::default();
222 let git_repo = Repo::from_path(&test_repo.dir)?;
223
224 test_repo.populate()?;
225 let branch_name = "doesnt_exist";
226 assert_eq!(
227 identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None)
228 .unwrap_err()
229 .to_string(),
230 format!("cannot find from_branch '{}'", &branch_name),
231 );
232 Ok(())
233 }
234
235 #[test]
236 fn when_to_branch_doesnt_exist_return_error() -> Result<()> {
237 let test_repo = GitTestRepo::default();
238 let git_repo = Repo::from_path(&test_repo.dir)?;
239
240 test_repo.populate()?;
241 let branch_name = "doesnt_exist";
242 assert_eq!(
243 identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string()))
244 .unwrap_err()
245 .to_string(),
246 format!("cannot find to_branch '{}'", &branch_name),
247 );
248 Ok(())
249 }
250
251 #[test]
252 fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> {
253 let test_repo = GitTestRepo::new("notmain")?;
254 let git_repo = Repo::from_path(&test_repo.dir)?;
255
256 test_repo.populate()?;
257
258 assert_eq!(
259 identify_ahead_behind(&git_repo, &None, &None)
260 .unwrap_err()
261 .to_string(),
262 "a destination branch (to_branch) is not specified and the defaults (main or master) do not exist",
263 );
264 Ok(())
265 }
266
267 #[test]
268 fn when_from_branch_is_none_return_as_head() -> Result<()> {
269 let test_repo = GitTestRepo::default();
270 let git_repo = Repo::from_path(&test_repo.dir)?;
271
272 test_repo.populate()?;
273 // create feature branch with 1 commit ahead
274 test_repo.create_branch("feature")?;
275 test_repo.checkout("feature")?;
276 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
277 let head_oid = test_repo.stage_and_commit("add t3.md")?;
278
279 // make feature branch 1 commit behind
280 test_repo.checkout("main")?;
281 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
282 let main_oid = test_repo.stage_and_commit("add t4.md")?;
283 // checkout feature
284 test_repo.checkout("feature")?;
285
286 let (from_branch, to_branch, ahead, behind) =
287 identify_ahead_behind(&git_repo, &None, &None)?;
288
289 assert_eq!(from_branch, "head");
290 assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]);
291 assert_eq!(to_branch, "main");
292 assert_eq!(behind, vec![oid_to_sha1(&main_oid)]);
293 Ok(())
294 }
295
296 #[test]
297 fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> {
298 let test_repo = GitTestRepo::default();
299 let git_repo = Repo::from_path(&test_repo.dir)?;
300
301 test_repo.populate()?;
302 // create feature branch with 1 commit ahead
303 test_repo.create_branch("feature")?;
304 test_repo.checkout("feature")?;
305 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
306 let head_oid = test_repo.stage_and_commit("add t3.md")?;
307
308 // make feature branch 1 commit behind
309 test_repo.checkout("main")?;
310 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
311 let main_oid = test_repo.stage_and_commit("add t4.md")?;
312
313 let (from_branch, to_branch, ahead, behind) =
314 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
315
316 assert_eq!(from_branch, "feature");
317 assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]);
318 assert_eq!(to_branch, "main");
319 assert_eq!(behind, vec![oid_to_sha1(&main_oid)]);
320 Ok(())
321 }
322
323 #[test]
324 fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> {
325 let test_repo = GitTestRepo::default();
326 let git_repo = Repo::from_path(&test_repo.dir)?;
327
328 test_repo.populate()?;
329 // create dev branch with 1 commit ahead
330 test_repo.create_branch("dev")?;
331 test_repo.checkout("dev")?;
332 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
333 let dev_oid_first = test_repo.stage_and_commit("add t3.md")?;
334
335 // create feature branch with 1 commit ahead of dev
336 test_repo.create_branch("feature")?;
337 test_repo.checkout("feature")?;
338 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
339 let feature_oid = test_repo.stage_and_commit("add t4.md")?;
340
341 // make feature branch 1 behind
342 test_repo.checkout("dev")?;
343 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
344 let dev_oid = test_repo.stage_and_commit("add t3.md")?;
345
346 let (from_branch, to_branch, ahead, behind) = identify_ahead_behind(
347 &git_repo,
348 &Some("feature".to_string()),
349 &Some("dev".to_string()),
350 )?;
351
352 assert_eq!(from_branch, "feature");
353 assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]);
354 assert_eq!(to_branch, "dev");
355 assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]);
356
357 let (from_branch, to_branch, ahead, behind) =
358 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
359
360 assert_eq!(from_branch, "feature");
361 assert_eq!(
362 ahead,
363 vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)]
364 );
365 assert_eq!(to_branch, "main");
366 assert_eq!(behind, vec![]);
367
368 Ok(())
369 }
370 }
371}
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 @@
1use anyhow::Result;
2use clap::Subcommand;
3
4use crate::Cli;
5pub mod create;
6
7#[derive(clap::Parser)]
8pub struct SubCommandArgs {
9 #[command(subcommand)]
10 pub prs_command: Commands,
11}
12
13#[derive(Debug, Subcommand)]
14pub enum Commands {
15 Create(create::SubCommandArgs),
16}
17
18pub fn launch(cli_args: &Cli, pr_args: &SubCommandArgs) -> Result<()> {
19 match &pr_args.prs_command {
20 Commands::Create(args) => create::launch(cli_args, pr_args, args),
21 }
22}
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"
8assert_cmd = "2.0.12" 8assert_cmd = "2.0.12"
9dialoguer = "0.10.4" 9dialoguer = "0.10.4"
10directories = "5.0.1" 10directories = "5.0.1"
11git2 = "0.18.1"
11nostr = "0.23.0" 12nostr = "0.23.0"
12once_cell = "1.18.0" 13once_cell = "1.18.0"
14rand = "0.8"
13rexpect = { git = "https://github.com/phaer/rexpect.git", branch= "skip-ansi-escape-codes" } 15rexpect = { git = "https://github.com/phaer/rexpect.git", branch= "skip-ansi-escape-codes" }
14strip-ansi-escapes = "0.2.0" 16strip-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 @@
1//create
2
3// implement drop?
4use std::{env::current_dir, fs, path::PathBuf};
5
6use anyhow::Result;
7use git2::{Oid, RepositoryInitOptions, Signature, Time};
8
9pub struct GitTestRepo {
10 pub dir: PathBuf,
11 pub git_repo: git2::Repository,
12}
13
14impl Default for GitTestRepo {
15 fn default() -> Self {
16 Self::new("main").unwrap()
17 }
18}
19impl GitTestRepo {
20 pub fn new(main_branch_name: &str) -> Result<Self> {
21 let path = current_dir()?.join(format!("tmpgit-{}", rand::random::<u64>()));
22 let git_repo = git2::Repository::init_opts(
23 &path,
24 RepositoryInitOptions::new()
25 .initial_head(main_branch_name)
26 .mkpath(true),
27 )?;
28 Ok(Self {
29 dir: path,
30 git_repo,
31 })
32 }
33
34 pub fn initial_commit(&self) -> Result<Oid> {
35 let oid = self.git_repo.index()?.write_tree()?;
36 let tree = self.git_repo.find_tree(oid)?;
37 let commit_oid = self.git_repo.commit(
38 Some("HEAD"),
39 &joe_signature(),
40 &joe_signature(),
41 "Initial commit",
42 &tree,
43 &[],
44 )?;
45 Ok(commit_oid)
46 }
47
48 pub fn populate(&self) -> Result<Oid> {
49 self.initial_commit()?;
50 fs::write(self.dir.join("t1.md"), "some content")?;
51 self.stage_and_commit("add t1.md")?;
52 fs::write(self.dir.join("t2.md"), "some content1")?;
53 self.stage_and_commit("add t2.md")
54 }
55
56 pub fn stage_and_commit(&self, message: &str) -> Result<Oid> {
57 let prev_oid = self.git_repo.head().unwrap().peel_to_commit()?;
58
59 let mut index = self.git_repo.index()?;
60 index.add_all(["."], git2::IndexAddOption::DEFAULT, None)?;
61 index.write()?;
62
63 let oid = self.git_repo.commit(
64 Some("HEAD"),
65 &joe_signature(),
66 &joe_signature(),
67 message,
68 &self.git_repo.find_tree(index.write_tree()?)?,
69 &[&prev_oid],
70 )?;
71
72 Ok(oid)
73 }
74
75 pub fn create_branch(&self, branch_name: &str) -> Result<()> {
76 self.git_repo
77 .branch(branch_name, &self.git_repo.head()?.peel_to_commit()?, false)?;
78 Ok(())
79 }
80
81 pub fn checkout(&self, ref_name: &str) -> Result<Oid> {
82 let (object, reference) = self.git_repo.revparse_ext(ref_name)?;
83
84 self.git_repo.checkout_tree(&object, None)?;
85
86 match reference {
87 // gref is an actual reference like branches or tags
88 Some(gref) => self.git_repo.set_head(gref.name().unwrap()),
89 // this is a commit, not a reference
90 None => self.git_repo.set_head_detached(object.id()),
91 }?;
92 let oid = self.git_repo.head()?.peel_to_commit()?.id();
93 Ok(oid)
94 }
95}
96
97impl Drop for GitTestRepo {
98 fn drop(&mut self) {
99 let _ = fs::remove_dir_all(&self.dir);
100 }
101}
102pub fn joe_signature() -> Signature<'static> {
103 Signature::new("Joe Bloggs", "joe.bloggs@pm.me", &Time::new(0, 0)).unwrap()
104}
105
106#[cfg(test)]
107mod tests {
108
109 use super::*;
110
111 #[test]
112 fn methods_do_not_throw() -> Result<()> {
113 let repo = GitTestRepo::new("main")?;
114
115 repo.populate()?;
116 repo.create_branch("feature")?;
117 repo.checkout("feature")?;
118 fs::write(repo.dir.join("t3.md"), "some content")?;
119 repo.stage_and_commit("add t3.md")?;
120
121 Ok(())
122 }
123}
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 @@
1use std::ffi::OsStr; 1use std::{ffi::OsStr, path::PathBuf};
2 2
3use anyhow::{ensure, Context, Result}; 3use anyhow::{ensure, Context, Result};
4use dialoguer::theme::{ColorfulTheme, Theme}; 4use dialoguer::theme::{ColorfulTheme, Theme};
@@ -8,6 +8,8 @@ use once_cell::sync::Lazy;
8use rexpect::session::{Options, PtySession}; 8use rexpect::session::{Options, PtySession};
9use strip_ansi_escapes::strip_str; 9use strip_ansi_escapes::strip_str;
10 10
11pub mod git;
12
11pub static TEST_KEY_1_NSEC: &str = 13pub static TEST_KEY_1_NSEC: &str =
12 "nsec1ppsg5sm2aexq06juxmu9evtutr6jkwkhp98exxxvwamhru9lyx9s3rwseq"; 14 "nsec1ppsg5sm2aexq06juxmu9evtutr6jkwkhp98exxxvwamhru9lyx9s3rwseq";
13pub static TEST_KEY_1_SK_HEX: &str = 15pub static TEST_KEY_1_SK_HEX: &str =
@@ -75,6 +77,34 @@ impl CliTester {
75 i.prompt().context("initial password prompt")?; 77 i.prompt().context("initial password prompt")?;
76 Ok(i) 78 Ok(i)
77 } 79 }
80
81 pub fn expect_confirm(
82 &mut self,
83 prompt: &str,
84 default: Option<bool>,
85 ) -> Result<CliTesterConfirmPrompt> {
86 let mut i = CliTesterConfirmPrompt {
87 tester: self,
88 prompt: prompt.to_string(),
89 default,
90 };
91 i.prompt(false, default).context("initial confirm prompt")?;
92 Ok(i)
93 }
94
95 pub fn expect_confirm_eventually(
96 &mut self,
97 prompt: &str,
98 default: Option<bool>,
99 ) -> Result<CliTesterConfirmPrompt> {
100 let mut i = CliTesterConfirmPrompt {
101 tester: self,
102 prompt: prompt.to_string(),
103 default,
104 };
105 i.prompt(true, default).context("initial confirm prompt")?;
106 Ok(i)
107 }
78} 108}
79 109
80pub struct CliTesterInputPrompt<'a> { 110pub struct CliTesterInputPrompt<'a> {
@@ -199,6 +229,71 @@ impl CliTesterPasswordPrompt<'_> {
199 } 229 }
200} 230}
201 231
232pub struct CliTesterConfirmPrompt<'a> {
233 tester: &'a mut CliTester,
234 prompt: String,
235 default: Option<bool>,
236}
237
238impl CliTesterConfirmPrompt<'_> {
239 fn prompt(&mut self, eventually: bool, default: Option<bool>) -> Result<&mut Self> {
240 let mut s = String::new();
241 self.tester
242 .formatter
243 .format_confirm_prompt(&mut s, self.prompt.as_str(), default)
244 .expect("diagluer theme formatter should succeed");
245 ensure!(
246 s.contains(self.prompt.as_str()),
247 "dialoguer must be broken as formatted prompt success doesnt contain prompt"
248 );
249
250 if eventually {
251 self.tester
252 .expect_eventually(sanatize(s).as_str())
253 .context("expect input prompt eventually")?;
254 } else {
255 self.tester
256 .expect(sanatize(s).as_str())
257 .context("expect confirm prompt")?;
258 }
259
260 Ok(self)
261 }
262
263 pub fn succeeds_with(&mut self, input: Option<bool>) -> Result<&mut Self> {
264 self.tester.send_line(match input {
265 None => "",
266 Some(true) => "y",
267 Some(false) => "n",
268 })?;
269 self.tester
270 .expect("\r")
271 .context("expect new line after confirm input to be printed")?;
272
273 let mut s = String::new();
274 self.tester
275 .formatter
276 .format_confirm_prompt_selection(
277 &mut s,
278 self.prompt.as_str(),
279 match input {
280 None => self.default,
281 Some(_) => input,
282 },
283 )
284 .expect("diagluer theme formatter should succeed");
285 if !s.contains(self.prompt.as_str()) {
286 panic!("dialoguer must be broken as formatted prompt success doesnt contain prompt");
287 }
288 let formatted_success = format!("{}\r\n", sanatize(s));
289
290 self.tester
291 .expect(formatted_success.as_str())
292 .context("expect immediate prompt success")?;
293 Ok(self)
294 }
295}
296
202impl CliTester { 297impl CliTester {
203 pub fn new<I, S>(args: I) -> Self 298 pub fn new<I, S>(args: I) -> Self
204 where 299 where
@@ -210,6 +305,17 @@ impl CliTester {
210 formatter: ColorfulTheme::default(), 305 formatter: ColorfulTheme::default(),
211 } 306 }
212 } 307 }
308 pub fn new_from_dir<I, S>(dir: &PathBuf, args: I) -> Self
309 where
310 I: IntoIterator<Item = S>,
311 S: AsRef<OsStr>,
312 {
313 Self {
314 rexpect_session: rexpect_with_from_dir(dir, args, 2000)
315 .expect("rexpect to spawn new process"),
316 formatter: ColorfulTheme::default(),
317 }
318 }
213 pub fn new_with_timeout<I, S>(timeout_ms: u64, args: I) -> Self 319 pub fn new_with_timeout<I, S>(timeout_ms: u64, args: I) -> Self
214 where 320 where
215 I: IntoIterator<Item = S>, 321 I: IntoIterator<Item = S>,
@@ -338,6 +444,28 @@ where
338 ) 444 )
339} 445}
340 446
447pub fn rexpect_with_from_dir<I, S>(
448 dir: &PathBuf,
449 args: I,
450 timeout_ms: u64,
451) -> Result<PtySession, rexpect::error::Error>
452where
453 I: IntoIterator<Item = S>,
454 S: AsRef<std::ffi::OsStr>,
455{
456 let mut cmd = std::process::Command::new(assert_cmd::cargo::cargo_bin("ngit"));
457 cmd.current_dir(dir);
458 cmd.args(args);
459 // using branch for PR https://github.com/rust-cli/rexpect/pull/103 to strip ansi escape codes
460 rexpect::session::spawn_with_options(
461 cmd,
462 Options {
463 timeout_ms: Some(timeout_ms),
464 strip_ansi_escape_codes: true,
465 },
466 )
467}
468
341/// backup and remove application config and data 469/// backup and remove application config and data
342pub fn before() -> Result<()> { 470pub fn before() -> Result<()> {
343 backup_existing_config() 471 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 @@
1use anyhow::Result;
2use serial_test::serial;
3use test_utils::{git::GitTestRepo, *};
4
5#[test]
6fn when_to_branch_doesnt_exist_return_error() -> Result<()> {
7 let test_repo = GitTestRepo::default();
8 test_repo.populate()?;
9 let mut p = CliTester::new_from_dir(
10 &test_repo.dir,
11 ["prs", "create", "--to-branch", "nonexistant"],
12 );
13 p.expect("Error: cannot find to_branch 'nonexistant'")?;
14 Ok(())
15}
16
17#[test]
18fn when_no_to_branch_specified_and_no_main_or_master_branch_return_error() -> Result<()> {
19 let test_repo = GitTestRepo::new("notmain")?;
20 test_repo.populate()?;
21 let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]);
22 p.expect("Error: a destination branch (to_branch) is not specified and the defaults (main or master) do not exist")?;
23 Ok(())
24}
25
26#[test]
27fn when_from_branch_doesnt_exist_return_error() -> Result<()> {
28 let test_repo = GitTestRepo::default();
29 test_repo.populate()?;
30 let mut p = CliTester::new_from_dir(
31 &test_repo.dir,
32 ["prs", "create", "--from-branch", "nonexistant"],
33 );
34 p.expect("Error: cannot find from_branch 'nonexistant'")?;
35 Ok(())
36}
37
38#[test]
39fn when_no_commits_ahead_of_main_return_error() -> Result<()> {
40 let test_repo = GitTestRepo::default();
41 test_repo.populate()?;
42 // create feature branch with 1 commit ahead
43 test_repo.create_branch("feature")?;
44 test_repo.checkout("feature")?;
45
46 let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]);
47 p.expect("Error: 'head' is 0 commits ahead of 'main' so no patches were created")?;
48 Ok(())
49}
50
51mod when_commits_behind_ask_to_proceed {
52 use super::*;
53
54 fn prep_test_repo() -> Result<GitTestRepo> {
55 let test_repo = GitTestRepo::default();
56 test_repo.populate()?;
57 // create feature branch with 2 commit ahead
58 test_repo.create_branch("feature")?;
59 test_repo.checkout("feature")?;
60 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
61 test_repo.stage_and_commit("add t3.md")?;
62 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
63 test_repo.stage_and_commit("add t4.md")?;
64 // checkout main and add 1 commit
65 test_repo.checkout("main")?;
66 std::fs::write(test_repo.dir.join("t5.md"), "some content")?;
67 test_repo.stage_and_commit("add t5.md")?;
68 // checkout feature branch
69 test_repo.checkout("feature")?;
70 Ok(test_repo)
71 }
72 static BEHIND_LEN: u8 = 1;
73 static AHEAD_LEN: u8 = 2;
74
75 fn expect_confirm_prompt(
76 p: &mut CliTester,
77 behind: u8,
78 ahead: u8,
79 ) -> Result<CliTesterConfirmPrompt> {
80 p.expect_confirm(
81 format!("'head' is {behind} commits behind 'main' and {ahead} ahead. Consider rebasing before sending patches. Proceed anyway?").as_str(),
82 Some(false),
83 )
84 }
85
86 #[test]
87 fn asked_with_default_no() -> Result<()> {
88 let test_repo = prep_test_repo()?;
89
90 let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]);
91 expect_confirm_prompt(&mut p, BEHIND_LEN, AHEAD_LEN)?;
92 p.exit()?;
93 Ok(())
94 }
95
96 #[test]
97 fn when_response_is_false_aborts() -> Result<()> {
98 let test_repo = prep_test_repo()?;
99
100 let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]);
101
102 expect_confirm_prompt(&mut p, BEHIND_LEN, AHEAD_LEN)?.succeeds_with(Some(false))?;
103
104 p.expect_end_with("Error: aborting so branch can be rebased\r\n")?;
105
106 Ok(())
107 }
108 #[test]
109 #[serial]
110 fn when_response_is_true_proceeds() -> Result<()> {
111 let test_repo = prep_test_repo()?;
112
113 let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]);
114 expect_confirm_prompt(&mut p, BEHIND_LEN, AHEAD_LEN)?.succeeds_with(Some(true))?;
115 p.expect(
116 format!("creating patch for {AHEAD_LEN} commits from 'head' that are {BEHIND_LEN} behind 'main'",)
117 .as_str(),
118 )?;
119 p.exit()?;
120 Ok(())
121 }
122}
123
124mod when_no_commits_behind {
125 use super::*;
126
127 #[test]
128 #[serial]
129 fn message_for_creating_patches() -> Result<()> {
130 let test_repo = GitTestRepo::default();
131 test_repo.populate()?;
132 // create feature branch with 2 commit ahead
133 test_repo.create_branch("feature")?;
134 test_repo.checkout("feature")?;
135 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
136 test_repo.stage_and_commit("add t3.md")?;
137 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
138 test_repo.stage_and_commit("add t4.md")?;
139
140 let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]);
141
142 p.expect("creating patch for 2 commits from 'head' that can be merged into 'main'")?;
143 p.exit()?;
144 Ok(())
145 }
146}
147
148// #[test]
149// #[serial]
150// fn succeeds_with_text_logged_in_as_npub() -> Result<()> {
151// with_fresh_config(|| {
152// let mut p = CliTester::new(["login"]);
153
154// p.expect_input(EXPECTED_NSEC_PROMPT)?
155// .succeeds_with(TEST_KEY_1_NSEC)?;
156
157// p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)?
158// .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)?
159// .succeeds_with(TEST_PASSWORD)?;
160
161// p.expect_end_with(format!("logged in as {}\r\n",
162// TEST_KEY_1_NPUB).as_str()) })
163// }