upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2024-03-08 14:37:56 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2024-03-08 14:39:21 +0000
commit6b3aecbcbde669859533716225e9c3bbfd2023b2 (patch)
tree06258c93893c57ae1ef3b3d709c364f00365fdb5 /src
parent5622e384447fba354548aca0e39dcee3d95951c3 (diff)
feat(send): select commits from a list
when since_or_range isn't specified adds resilience as assuming master..HEAD can cause some issues eg when master is not up-to-date with origin/master
Diffstat (limited to 'src')
-rw-r--r--src/cli_interactor.rs44
-rw-r--r--src/git.rs74
-rw-r--r--src/sub_commands/push.rs2
-rw-r--r--src/sub_commands/send.rs216
4 files changed, 282 insertions, 54 deletions
diff --git a/src/cli_interactor.rs b/src/cli_interactor.rs
index dc15c87..4cf6357 100644
--- a/src/cli_interactor.rs
+++ b/src/cli_interactor.rs
@@ -14,6 +14,7 @@ pub trait InteractorPrompt {
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 fn confirm(&self, params: PromptConfirmParms) -> Result<bool>;
16 fn choice(&self, params: PromptChoiceParms) -> Result<usize>; 16 fn choice(&self, params: PromptChoiceParms) -> Result<usize>;
17 fn multi_choice(&self, params: PromptMultiChoiceParms) -> Result<Vec<usize>>;
17} 18}
18impl InteractorPrompt for Interactor { 19impl InteractorPrompt for Interactor {
19 fn input(&self, parms: PromptInputParms) -> Result<String> { 20 fn input(&self, parms: PromptInputParms) -> Result<String> {
@@ -53,6 +54,18 @@ impl InteractorPrompt for Interactor {
53 } 54 }
54 choice.interact().context("failed to get choice") 55 choice.interact().context("failed to get choice")
55 } 56 }
57 fn multi_choice(&self, parms: PromptMultiChoiceParms) -> Result<Vec<usize>> {
58 // the colorful theme is not very clear so falling back to default
59 let mut choice = dialoguer::MultiSelect::default();
60 choice
61 .with_prompt(parms.prompt)
62 .report(parms.report)
63 .items(&parms.choices);
64 if let Some(defaults) = parms.defaults {
65 choice.defaults(&defaults);
66 }
67 choice.interact().context("failed to get choice")
68 }
56} 69}
57 70
58#[derive(Default)] 71#[derive(Default)]
@@ -140,3 +153,34 @@ impl PromptChoiceParms {
140 self 153 self
141 } 154 }
142} 155}
156
157#[derive(Default)]
158pub struct PromptMultiChoiceParms {
159 pub prompt: String,
160 pub choices: Vec<String>,
161 pub defaults: Option<Vec<bool>>,
162 pub report: bool,
163}
164
165impl PromptMultiChoiceParms {
166 pub fn with_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
167 self.prompt = prompt.into();
168 self.report = true;
169 self
170 }
171
172 pub fn dont_report(mut self) -> Self {
173 self.report = false;
174 self
175 }
176
177 pub fn with_choices(mut self, choices: Vec<String>) -> Self {
178 self.choices = choices;
179 self
180 }
181
182 pub fn with_defaults(mut self, defaults: Vec<bool>) -> Self {
183 self.defaults = Some(defaults);
184 self
185 }
186}
diff --git a/src/git.rs b/src/git.rs
index ef14ab1..42297be 100644
--- a/src/git.rs
+++ b/src/git.rs
@@ -41,6 +41,7 @@ pub trait RepoActions {
41 fn get_head_commit(&self) -> Result<Sha1Hash>; 41 fn get_head_commit(&self) -> Result<Sha1Hash>;
42 fn get_commit_parent(&self, commit: &Sha1Hash) -> Result<Sha1Hash>; 42 fn get_commit_parent(&self, commit: &Sha1Hash) -> Result<Sha1Hash>;
43 fn get_commit_message(&self, commit: &Sha1Hash) -> Result<String>; 43 fn get_commit_message(&self, commit: &Sha1Hash) -> Result<String>;
44 fn get_commit_message_summary(&self, commit: &Sha1Hash) -> Result<String>;
44 /// returns vector ["name", "email", "unixtime", "offset"] 45 /// returns vector ["name", "email", "unixtime", "offset"]
45 /// eg ["joe bloggs", "joe@pm.me", "12176","-300"] 46 /// eg ["joe bloggs", "joe@pm.me", "12176","-300"]
46 fn get_commit_author(&self, commit: &Sha1Hash) -> Result<Vec<String>>; 47 fn get_commit_author(&self, commit: &Sha1Hash) -> Result<Vec<String>>;
@@ -52,6 +53,7 @@ pub trait RepoActions {
52 base_commit: &Sha1Hash, 53 base_commit: &Sha1Hash,
53 latest_commit: &Sha1Hash, 54 latest_commit: &Sha1Hash,
54 ) -> Result<(Vec<Sha1Hash>, Vec<Sha1Hash>)>; 55 ) -> Result<(Vec<Sha1Hash>, Vec<Sha1Hash>)>;
56 fn get_refs(&self, commit: &Sha1Hash) -> Result<Vec<String>>;
55 // including (un)staged changes and (un)tracked files 57 // including (un)staged changes and (un)tracked files
56 fn has_outstanding_changes(&self) -> Result<bool>; 58 fn has_outstanding_changes(&self) -> Result<bool>;
57 fn make_patch_from_commit( 59 fn make_patch_from_commit(
@@ -199,6 +201,22 @@ impl RepoActions for Repo {
199 .to_string()) 201 .to_string())
200 } 202 }
201 203
204 fn get_commit_message_summary(&self, commit: &Sha1Hash) -> Result<String> {
205 Ok(self
206 .git_repo
207 .find_commit(sha1_to_oid(commit)?)
208 .context(format!("could not find commit {commit}"))?
209 .message_raw()
210 .context("commit message has unusual characters in (not valid utf-8)")?
211 .split('\r')
212 .collect::<Vec<&str>>()[0]
213 .split('\n')
214 .collect::<Vec<&str>>()[0]
215 .to_string()
216 .trim()
217 .to_string())
218 }
219
202 fn get_commit_author(&self, commit: &Sha1Hash) -> Result<Vec<String>> { 220 fn get_commit_author(&self, commit: &Sha1Hash) -> Result<Vec<String>> {
203 let commit = self 221 let commit = self
204 .git_repo 222 .git_repo
@@ -217,6 +235,25 @@ impl RepoActions for Repo {
217 Ok(git_sig_to_tag_vec(&sig)) 235 Ok(git_sig_to_tag_vec(&sig))
218 } 236 }
219 237
238 fn get_refs(&self, commit: &Sha1Hash) -> Result<Vec<String>> {
239 Ok(self
240 .git_repo
241 .references()?
242 .filter(|r| {
243 if let Ok(r) = r {
244 if let Ok(ref_tip) = r.peel_to_commit() {
245 ref_tip.id().to_string().eq(&commit.to_string())
246 } else {
247 false
248 }
249 } else {
250 false
251 }
252 })
253 .map(|r| r.unwrap().shorthand().unwrap().to_string())
254 .collect::<Vec<String>>())
255 }
256
220 fn make_patch_from_commit( 257 fn make_patch_from_commit(
221 &self, 258 &self,
222 commit: &Sha1Hash, 259 commit: &Sha1Hash,
@@ -744,6 +781,43 @@ mod tests {
744 } 781 }
745 } 782 }
746 783
784 mod get_commit_message_summary {
785 use super::*;
786 fn run(message: &str, summary: &str) -> Result<()> {
787 let test_repo = GitTestRepo::default();
788 test_repo.populate()?;
789 std::fs::write(test_repo.dir.join("t100.md"), "some content")?;
790 let oid = test_repo.stage_and_commit(message)?;
791
792 let git_repo = Repo::from_path(&test_repo.dir)?;
793
794 assert_eq!(
795 summary,
796 git_repo.get_commit_message_summary(&oid_to_sha1(&oid))?,
797 );
798 Ok(())
799 }
800 #[test]
801 fn one_liner() -> Result<()> {
802 run("add t100.md", "add t100.md")
803 }
804
805 #[test]
806 fn multiline() -> Result<()> {
807 run("add t100.md\r\nanother line\r\nthird line", "add t100.md")
808 }
809
810 #[test]
811 fn trailing_newlines() -> Result<()> {
812 run("add t100.md\r\n\r\n\r\n\r\n\r\n\r\n", "add t100.md")
813 }
814
815 #[test]
816 fn unicode_characters() -> Result<()> {
817 run("add t100.md ❤️", "add t100.md ❤️")
818 }
819 }
820
747 mod get_commit_author { 821 mod get_commit_author {
748 use super::*; 822 use super::*;
749 823
diff --git a/src/sub_commands/push.rs b/src/sub_commands/push.rs
index bcac178..fdaab8e 100644
--- a/src/sub_commands/push.rs
+++ b/src/sub_commands/push.rs
@@ -107,7 +107,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
107 sub_commands::send::launch( 107 sub_commands::send::launch(
108 cli_args, 108 cli_args,
109 &sub_commands::send::SubCommandArgs { 109 &sub_commands::send::SubCommandArgs {
110 since_or_revision_range: String::new(), 110 since_or_range: String::new(),
111 in_reply_to: Some(proposal_root_event.id.to_string()), 111 in_reply_to: Some(proposal_root_event.id.to_string()),
112 title: None, 112 title: None,
113 description: None, 113 description: None,
diff --git a/src/sub_commands/send.rs b/src/sub_commands/send.rs
index 16f10c4..9b44cc3 100644
--- a/src/sub_commands/send.rs
+++ b/src/sub_commands/send.rs
@@ -1,6 +1,7 @@
1use std::{str::FromStr, time::Duration}; 1use std::{str::FromStr, time::Duration};
2 2
3use anyhow::{bail, Context, Result}; 3use anyhow::{bail, Context, Result};
4use console::Style;
4use futures::future::join_all; 5use futures::future::join_all;
5use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; 6use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
6use nostr::{ 7use nostr::{
@@ -14,7 +15,9 @@ use crate::client::Client;
14#[cfg(test)] 15#[cfg(test)]
15use crate::client::MockConnect; 16use crate::client::MockConnect;
16use crate::{ 17use crate::{
17 cli_interactor::{Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms}, 18 cli_interactor::{
19 Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms,
20 },
18 client::Connect, 21 client::Connect,
19 git::{Repo, RepoActions}, 22 git::{Repo, RepoActions},
20 login, 23 login,
@@ -24,9 +27,9 @@ use crate::{
24 27
25#[derive(Debug, clap::Args)] 28#[derive(Debug, clap::Args)]
26pub struct SubCommandArgs { 29pub struct SubCommandArgs {
27 #[arg(default_value = "master..HEAD")] 30 #[arg(default_value = "")]
28 /// commits to send as proposal; like in `git format-patch` 31 /// commits to send as proposal; like in `git format-patch` eg. HEAD~2
29 pub(crate) since_or_revision_range: String, 32 pub(crate) since_or_range: String,
30 #[clap(long)] 33 #[clap(long)]
31 /// nevent or event id of an existing proposal for which this is a new 34 /// nevent or event id of an existing proposal for which this is a new
32 /// version 35 /// version
@@ -46,67 +49,83 @@ pub struct SubCommandArgs {
46pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { 49pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
47 let git_repo = Repo::discover().context("cannot find a git repository")?; 50 let git_repo = Repo::discover().context("cannot find a git repository")?;
48 51
52 if let Some(id) = &args.in_reply_to {
53 println!("creating proposal revision for: {id}");
54 }
55
49 let mut commits: Vec<Sha1Hash> = { 56 let mut commits: Vec<Sha1Hash> = {
50 if args.since_or_revision_range.is_empty() 57 if args.since_or_range.is_empty() {
51 || args.since_or_revision_range.eq("master..HEAD")
52 {
53 let branch_name = git_repo.get_checked_out_branch_name()?; 58 let branch_name = git_repo.get_checked_out_branch_name()?;
54 let (main_branch_name, main_tip) = git_repo 59 let (main_branch_name, main_tip) = git_repo
55 .get_main_or_master_branch() 60 .get_main_or_master_branch()
56 .context("the default branches (main or master) do not exist")?; 61 .context("the default branches (main or master) do not exist")?;
57 if branch_name.eq(main_branch_name) { 62
58 println!("creating 1 patch from latest commit"); 63 let proposed_commits = if branch_name.eq(main_branch_name) {
59 vec![main_tip] 64 vec![main_tip]
60 } else { 65 } else {
61 let (from_branch, to_branch, ahead, behind) = 66 let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?;
62 identify_ahead_behind(&git_repo, &None, &None)?;
63
64 if ahead.is_empty() {
65 bail!(format!(
66 "'{from_branch}' is 0 commits ahead of '{to_branch}' so no patches were created"
67 ));
68 }
69
70 if behind.is_empty() {
71 println!(
72 "creating patch for {} commits from '{from_branch}' that can be merged into '{to_branch}'",
73 ahead.len(),
74 );
75 } else {
76 if !Interactor::default().confirm(
77 PromptConfirmParms::default()
78 .with_prompt(
79 format!(
80 "'{from_branch}' is {} commits behind '{to_branch}' and {} ahead. Consider rebasing before sending patches. Proceed anyway?",
81 behind.len(),
82 ahead.len(),
83 )
84 )
85 .with_default(false)
86 ).context("failed to get confirmation response from interactor confirm")? {
87 bail!("aborting so branch can be rebased");
88 }
89 println!(
90 "creating patch for {} commit{} from '{from_branch}' that {} {} behind '{to_branch}'",
91 ahead.len(),
92 if ahead.len() > 1 { "s" } else { "" },
93 if ahead.len() > 1 { "are" } else { "is" },
94 behind.len(),
95 );
96 }
97 ahead 67 ahead
98 } 68 };
69 choose_commits(&git_repo, proposed_commits)?
99 } else { 70 } else {
100 let ahead = git_repo 71 git_repo
101 .parse_starting_commits(&args.since_or_revision_range) 72 .parse_starting_commits(&args.since_or_range)
102 .context("cannot parse specified starting commit or range")?; 73 .context("cannot parse specified starting commit or range")?
103 println!("creating patch for {} commits", ahead.len(),);
104 ahead
105 } 74 }
106 }; 75 };
107 76
108 if let Some(id) = &args.in_reply_to { 77 if commits.is_empty() {
109 println!("as a revision to proposal: {id}"); 78 bail!("no commits selected");
79 }
80 println!("creating proposal from {} commits:", commits.len());
81
82 let dim = Style::new().color256(247);
83 for commit in &commits {
84 println!(
85 "{} {}",
86 dim.apply_to(commit.to_string().chars().take(7).collect::<String>()),
87 git_repo.get_commit_message_summary(commit)?
88 );
89 }
90
91 let (main_branch_name, main_tip) = git_repo
92 .get_main_or_master_branch()
93 .context("the default branches (main or master) do not exist")?;
94 let (first_commit_ahead, behind) =
95 git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?;
96
97 // check proposal ahead of origin/main
98 if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm(
99 PromptConfirmParms::default()
100 .with_prompt(
101 format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1)
102 )
103 .with_default(false)
104 ).context("failed to get confirmation response from interactor confirm")? {
105 bail!("aborting because selected commits were ahead of origin/master");
106 }
107
108 // check if a selected commit is already in origin
109 if commits.iter().any(|c| c.eq(&main_tip)) {
110 if !Interactor::default().confirm(
111 PromptConfirmParms::default()
112 .with_prompt(
113 format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?")
114 )
115 .with_default(false)
116 ).context("failed to get confirmation response from interactor confirm")? {
117 bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'");
118 }
119 }
120 // check proposal isn't behind origin/main
121 else if !behind.is_empty() && !Interactor::default().confirm(
122 PromptConfirmParms::default()
123 .with_prompt(
124 format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len())
125 )
126 .with_default(false)
127 ).context("failed to get confirmation response from interactor confirm")? {
128 bail!("aborting so commits can be rebased");
110 } 129 }
111 130
112 let title = if args.no_cover_letter { 131 let title = if args.no_cover_letter {
@@ -359,6 +378,97 @@ where
359 (vec1_u, vec2_u, dup, all) 378 (vec1_u, vec2_u, dup, all)
360} 379}
361 380
381fn choose_commits(git_repo: &Repo, proposed_commits: Vec<Sha1Hash>) -> Result<Vec<Sha1Hash>> {
382 let mut proposed_commits = if proposed_commits.len().gt(&10) {
383 vec![]
384 } else {
385 proposed_commits
386 };
387
388 let tip_of_head = git_repo.get_tip_of_local_branch(&git_repo.get_checked_out_branch_name()?)?;
389 let most_recent_commit = proposed_commits.first().unwrap_or(&tip_of_head);
390
391 let mut last_15_commits = vec![*most_recent_commit];
392
393 while last_15_commits.len().lt(&15) {
394 if let Ok(parent_commit) = git_repo.get_commit_parent(last_15_commits.last().unwrap()) {
395 last_15_commits.push(parent_commit);
396 } else {
397 break;
398 }
399 }
400
401 let term = console::Term::stderr();
402 let mut printed_error_line = false;
403
404 let selected_commits = 'outer: loop {
405 let selected = Interactor::default().multi_choice(
406 PromptMultiChoiceParms::default()
407 .with_prompt("select commits for proposal")
408 .dont_report()
409 .with_choices(
410 last_15_commits
411 .iter()
412 .map(|h| summarise_commit_for_selection(git_repo, h).unwrap())
413 .collect(),
414 )
415 .with_defaults(
416 last_15_commits
417 .iter()
418 .map(|h| proposed_commits.iter().any(|c| c.eq(h)))
419 .collect(),
420 ),
421 )?;
422 proposed_commits = selected.iter().map(|i| last_15_commits[*i]).collect();
423
424 if printed_error_line {
425 term.clear_last_lines(1)?;
426 }
427
428 if proposed_commits.is_empty() {
429 term.write_line("no commits selected")?;
430 printed_error_line = true;
431 continue;
432 }
433 for (i, selected_i) in selected.iter().enumerate() {
434 if i.gt(&0) && selected_i.ne(&(selected[i - 1] + 1)) {
435 term.write_line("commits must be consecutive. try again.")?;
436 printed_error_line = true;
437 continue 'outer;
438 }
439 }
440
441 break proposed_commits;
442 };
443 Ok(selected_commits)
444}
445
446fn summarise_commit_for_selection(git_repo: &Repo, commit: &Sha1Hash) -> Result<String> {
447 let references = git_repo.get_refs(commit)?;
448 let dim = Style::new().color256(247);
449 let prefix = format!("({})", git_repo.get_commit_author(commit)?[0],);
450 let references_string = if references.is_empty() {
451 String::new()
452 } else {
453 format!(
454 " {}",
455 references
456 .iter()
457 .map(|r| format!("[{r}]"))
458 .collect::<Vec<String>>()
459 .join(" ")
460 )
461 };
462
463 Ok(format!(
464 "{} {}{} {}",
465 dim.apply_to(prefix),
466 git_repo.get_commit_message_summary(commit)?,
467 Style::new().magenta().apply_to(references_string),
468 dim.apply_to(commit.to_string().chars().take(7).collect::<String>(),),
469 ))
470}
471
362mod tests_unique_and_duplicate { 472mod tests_unique_and_duplicate {
363 473
364 #[test] 474 #[test]