upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/sub_commands/send.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/sub_commands/send.rs')
-rw-r--r--src/sub_commands/send.rs950
1 files changed, 950 insertions, 0 deletions
diff --git a/src/sub_commands/send.rs b/src/sub_commands/send.rs
new file mode 100644
index 0000000..2c1dec8
--- /dev/null
+++ b/src/sub_commands/send.rs
@@ -0,0 +1,950 @@
1use std::time::Duration;
2
3use anyhow::{bail, Context, Result};
4use futures::future::join_all;
5use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
6use nostr::{prelude::sha1::Hash as Sha1Hash, EventBuilder, Marker, Tag, TagKind};
7
8use super::list::tag_value;
9#[cfg(not(test))]
10use crate::client::Client;
11#[cfg(test)]
12use crate::client::MockConnect;
13use crate::{
14 cli_interactor::{Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms},
15 client::Connect,
16 git::{Repo, RepoActions},
17 login,
18 repo_ref::{self, RepoRef, REPO_REF_KIND},
19 Cli,
20};
21
22#[derive(Debug, clap::Args)]
23pub struct SubCommandArgs {
24 #[clap(short, long)]
25 /// optional cover letter title
26 title: Option<String>,
27 #[clap(short, long)]
28 /// optional cover letter description
29 description: Option<String>,
30 #[clap(long)]
31 /// branch to get changes from (defaults to head)
32 from_branch: Option<String>,
33 #[clap(long)]
34 /// destination branch (defaults to main or master)
35 to_branch: Option<String>,
36 /// don't ask about a cover letter
37 #[arg(long, action)]
38 no_cover_letter: bool,
39}
40
41#[allow(clippy::too_many_lines)]
42pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
43 let git_repo = Repo::discover().context("cannot find a git repository")?;
44
45 let (from_branch, to_branch, mut ahead, behind) =
46 identify_ahead_behind(&git_repo, &args.from_branch, &args.to_branch)?;
47
48 if ahead.is_empty() {
49 bail!(format!(
50 "'{from_branch}' is 0 commits ahead of '{to_branch}' so no patches were created"
51 ));
52 }
53
54 if behind.is_empty() {
55 println!(
56 "creating patch for {} commits from '{from_branch}' that can be merged into '{to_branch}'",
57 ahead.len(),
58 );
59 } else {
60 if !Interactor::default().confirm(
61 PromptConfirmParms::default()
62 .with_prompt(
63 format!(
64 "'{from_branch}' is {} commits behind '{to_branch}' and {} ahead. Consider rebasing before sending patches. Proceed anyway?",
65 behind.len(),
66 ahead.len(),
67 )
68 )
69 .with_default(false)
70 ).context("failed to get confirmation response from interactor confirm")? {
71 bail!("aborting so branch can be rebased");
72 }
73 println!(
74 "creating patch for {} commit{} from '{from_branch}' that {} {} behind '{to_branch}'",
75 ahead.len(),
76 if ahead.len() > 1 { "s" } else { "" },
77 if ahead.len() > 1 { "are" } else { "is" },
78 behind.len(),
79 );
80 }
81
82 let title = if args.no_cover_letter {
83 None
84 } else {
85 match &args.title {
86 Some(t) => Some(t.clone()),
87 None => {
88 if Interactor::default().confirm(
89 PromptConfirmParms::default()
90 .with_default(false)
91 .with_prompt("include cover letter?"),
92 )? {
93 Some(
94 Interactor::default()
95 .input(PromptInputParms::default().with_prompt("title"))?
96 .clone(),
97 )
98 } else {
99 None
100 }
101 }
102 }
103 };
104
105 let cover_letter_title_description = if let Some(title) = title {
106 Some((
107 title,
108 if let Some(t) = &args.description {
109 t.clone()
110 } else {
111 Interactor::default()
112 .input(PromptInputParms::default().with_prompt("cover letter description"))?
113 .clone()
114 },
115 ))
116 } else {
117 None
118 };
119
120 #[cfg(not(test))]
121 let mut client = Client::default();
122 #[cfg(test)]
123 let mut client = <MockConnect as std::default::Default>::default();
124
125 let (keys, user_ref) = login::launch(&cli_args.nsec, &cli_args.password, Some(&client)).await?;
126
127 client.set_keys(&keys).await;
128
129 let repo_ref = repo_ref::fetch(
130 &git_repo,
131 git_repo
132 .get_root_commit()
133 .context("failed to get root commit of the repository")?
134 .to_string(),
135 &client,
136 user_ref.relays.write(),
137 )
138 .await?;
139
140 // oldest first
141 ahead.reverse();
142
143 let events = generate_pr_and_patch_events(
144 cover_letter_title_description.clone(),
145 &git_repo,
146 &ahead,
147 &keys,
148 &repo_ref,
149 )?;
150
151 println!(
152 "posting {} patches {} a covering letter...",
153 if cover_letter_title_description.is_none() {
154 events.len()
155 } else {
156 events.len() - 1
157 },
158 if cover_letter_title_description.is_none() {
159 "without"
160 } else {
161 "with"
162 }
163 );
164
165 send_events(
166 &client,
167 events,
168 user_ref.relays.write(),
169 repo_ref.relays.clone(),
170 !cli_args.disable_cli_spinners,
171 )
172 .await?;
173 // TODO check if there is already a similarly named
174 Ok(())
175}
176
177#[allow(clippy::module_name_repetitions)]
178pub async fn send_events(
179 #[cfg(test)] client: &crate::client::MockConnect,
180 #[cfg(not(test))] client: &Client,
181 events: Vec<nostr::Event>,
182 my_write_relays: Vec<String>,
183 repo_read_relays: Vec<String>,
184 animate: bool,
185) -> Result<()> {
186 let (_, _, _, all) = unique_and_duplicate_all(&my_write_relays, &repo_read_relays);
187
188 let m = MultiProgress::new();
189 let pb_style = ProgressStyle::with_template(if animate {
190 " {spinner} {prefix} {bar} {pos}/{len} {msg}"
191 } else {
192 " - {prefix} {bar} {pos}/{len} {msg}"
193 })?
194 .progress_chars("##-");
195
196 let pb_after_style =
197 |symbol| ProgressStyle::with_template(format!(" {symbol} {}", "{prefix} {msg}",).as_str());
198 let pb_after_style_succeeded = pb_after_style(if animate {
199 console::style("✔".to_string())
200 .for_stderr()
201 .green()
202 .to_string()
203 } else {
204 "y".to_string()
205 })?;
206
207 let pb_after_style_failed = pb_after_style(if animate {
208 console::style("✘".to_string())
209 .for_stderr()
210 .red()
211 .to_string()
212 } else {
213 "x".to_string()
214 })?;
215
216 join_all(all.iter().map(|&relay| async {
217 let details = format!(
218 "{}{} {}",
219 if my_write_relays.iter().any(|r| relay.eq(r)) {
220 " [my-relay]"
221 } else {
222 ""
223 },
224 if repo_read_relays.iter().any(|r| relay.eq(r)) {
225 " [repo-relay]"
226 } else {
227 ""
228 },
229 *relay,
230 );
231 let pb = m.add(
232 ProgressBar::new(events.len() as u64)
233 .with_prefix(details.to_string())
234 .with_style(pb_style.clone()),
235 );
236 if animate {
237 pb.enable_steady_tick(Duration::from_millis(300));
238 }
239 pb.inc(0); // need to make pb display intially
240 let mut failed = false;
241 for event in &events {
242 match client.send_event_to(relay.as_str(), event.clone()).await {
243 Ok(_) => pb.inc(1),
244 Err(e) => {
245 pb.set_style(pb_after_style_failed.clone());
246 pb.finish_with_message(
247 console::style(
248 e.to_string()
249 .replace("relay pool error:", "error:")
250 .replace("event not published: ", ""),
251 )
252 .for_stderr()
253 .red()
254 .to_string(),
255 );
256 failed = true;
257 break;
258 }
259 };
260 }
261 if !failed {
262 pb.set_style(pb_after_style_succeeded.clone());
263 pb.finish_with_message("");
264 }
265 }))
266 .await;
267 Ok(())
268}
269
270/// returns `(unique_vec1, unique_vec2, duplicates, all)`
271fn unique_and_duplicate_all<'a, S>(
272 vec1: &'a Vec<S>,
273 vec2: &'a Vec<S>,
274) -> (Vec<&'a S>, Vec<&'a S>, Vec<&'a S>, Vec<&'a S>)
275where
276 S: PartialEq,
277{
278 let mut vec1_u = vec![];
279 let mut vec2_u = vec![];
280 let mut dup = vec![];
281 let mut all = vec![];
282 for s1 in vec1 {
283 if vec2.iter().any(|s2| s1.eq(s2)) {
284 dup.push(s1);
285 } else {
286 vec1_u.push(s1);
287 }
288 }
289 for s2 in vec2 {
290 if !vec1.iter().any(|s1| s2.eq(s1)) {
291 vec2_u.push(s2);
292 }
293 }
294 for a in [&dup, &vec1_u, &vec2_u] {
295 for e in a {
296 all.push(&**e);
297 }
298 }
299 (vec1_u, vec2_u, dup, all)
300}
301
302mod tests_unique_and_duplicate {
303
304 #[test]
305 fn correct_number_of_unique_and_duplicate_items() {
306 let v1 = vec![
307 "t1".to_string(),
308 "t2".to_string(),
309 "t3".to_string(),
310 "t4".to_string(),
311 "t5".to_string(),
312 ];
313 let v2 = vec![
314 "t3".to_string(),
315 "t4".to_string(),
316 "t5".to_string(),
317 "t6".to_string(),
318 ];
319
320 let (v1_u, v2_u, d, a) = super::unique_and_duplicate_all(&v1, &v2);
321
322 assert_eq!(v1_u.len(), 2);
323 assert_eq!(v2_u.len(), 1);
324 assert_eq!(d.len(), 3);
325 assert_eq!(a.len(), 6);
326 }
327 #[test]
328 fn all_begins_with_duplicates() {
329 let v1 = vec![
330 "t1".to_string(),
331 "t2".to_string(),
332 "t3".to_string(),
333 "t4".to_string(),
334 "t5".to_string(),
335 ];
336 let v2 = vec![
337 "t3".to_string(),
338 "t4".to_string(),
339 "t5".to_string(),
340 "t6".to_string(),
341 ];
342
343 let (_, _, d, a) = super::unique_and_duplicate_all(&v1, &v2);
344
345 assert_eq!(a[0], d[0]);
346 }
347}
348
349pub static PATCH_KIND: u64 = 1617;
350
351pub fn generate_pr_and_patch_events(
352 cover_letter_title_description: Option<(String, String)>,
353 git_repo: &Repo,
354 commits: &Vec<Sha1Hash>,
355 keys: &nostr::Keys,
356 repo_ref: &RepoRef,
357) -> Result<Vec<nostr::Event>> {
358 let root_commit = git_repo
359 .get_root_commit()
360 .context("failed to get root commit of the repository")?;
361
362 let mut events = vec![];
363
364 if let Some((title, description)) = cover_letter_title_description {
365 events.push(EventBuilder::new(
366 nostr::event::Kind::Custom(PATCH_KIND),
367 format!(
368 "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}",
369 commits.last().unwrap(),
370 commits.len()
371 ),
372 [
373 vec![
374 // TODO: why not tag all maintainer identifiers?
375 Tag::A {
376 kind: nostr::Kind::Custom(REPO_REF_KIND),
377 public_key: *repo_ref.maintainers.first()
378 .context("repo reference should always have at least one maintainer - the issuer of the repo event")
379 ?,
380 identifier: repo_ref.identifier.to_string(),
381 relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::from).clone(),
382 },
383 Tag::Reference(format!("{root_commit}")),
384 Tag::Hashtag("cover-letter".to_string()),
385 Tag::Hashtag("root".to_string()),
386 ],
387 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
388 vec![Tag::Generic(
389 TagKind::Custom("branch-name".to_string()),
390 vec![branch_name],
391 )]
392 } else {
393 vec![]
394 },
395 repo_ref.maintainers
396 .iter()
397 .map(|pk| Tag::public_key(*pk))
398 .collect(),
399 ].concat(),
400 )
401 .to_event(keys)
402 .context("failed to create cover-letter event")?);
403 }
404
405 for (i, commit) in commits.iter().enumerate() {
406 events.push(
407 generate_patch_event(
408 git_repo,
409 &root_commit,
410 commit,
411 events.first().map(|event| event.id),
412 keys,
413 repo_ref,
414 events.last().map(nostr::Event::id),
415 if events.is_empty() {
416 None
417 } else {
418 Some(((i + 1).try_into()?, commits.len().try_into()?))
419 },
420 if events.is_empty() {
421 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
422 Some(branch_name)
423 } else {
424 None
425 }
426 } else {
427 None
428 },
429 )
430 .context("failed to generate patch event")?,
431 );
432 }
433 Ok(events)
434}
435
436pub struct CoverLetter {
437 pub title: String,
438 pub description: String,
439 pub branch_name: String,
440}
441
442pub fn event_is_cover_letter(event: &nostr::Event) -> bool {
443 // TODO: look for Subject:[ PATCH 0/n ] but watch out for:
444 // [PATCH v1 0/n ] or
445 // [PATCH subsystem v2 0/n ]
446 event.kind.as_u64().eq(&PATCH_KIND)
447 && event.iter_tags().any(|t| t.as_vec()[1].eq("root"))
448 && event.iter_tags().any(|t| t.as_vec()[1].eq("cover-letter"))
449}
450pub fn event_to_cover_letter(event: &nostr::Event) -> Result<CoverLetter> {
451 if !event_is_patch_set_root(event) {
452 bail!("event is not a patch set root event (root patch or cover letter)")
453 }
454 let title_index = event
455 .content
456 .find("] ")
457 .context("event is not formatted as a patch or cover letter")?
458 + 2;
459 let description_index = event.content[title_index..]
460 .find('\n')
461 .unwrap_or(event.content.len() - 1 - title_index)
462 + title_index;
463
464 let title = if let Ok(msg) = tag_value(event, "description") {
465 msg.split('\n').collect::<Vec<&str>>()[0].to_string()
466 } else {
467 event.content[title_index..description_index].to_string()
468 };
469
470 // note: if the description field is removed from patch events like in gitstr,
471 // then this will show entire patch. I'm not sure it is ever displayed though
472 let description = if let Ok(msg) = tag_value(event, "description") {
473 if let Some((_before, after)) = msg.split_once('\n') {
474 after.trim().to_string()
475 } else {
476 String::new()
477 }
478 } else {
479 event.content[description_index..].trim().to_string()
480 };
481
482 Ok(CoverLetter {
483 title: title.clone(),
484 description,
485 // TODO should this be prefixed by format!("{}-"e.id.to_string()[..5]?)
486 branch_name: if let Ok(name) = tag_value(event, "branch-name") {
487 name
488 } else {
489 let s = title
490 .replace(' ', "-")
491 .chars()
492 .map(|c| {
493 if c.is_ascii_alphanumeric() || c.eq(&'/') {
494 c
495 } else {
496 '-'
497 }
498 })
499 .collect();
500 s
501 },
502 })
503}
504
505pub fn event_is_patch_set_root(event: &nostr::Event) -> bool {
506 event.kind.as_u64().eq(&PATCH_KIND) && event.iter_tags().any(|t| t.as_vec()[1].eq("root"))
507}
508
509#[allow(clippy::too_many_arguments)]
510pub fn generate_patch_event(
511 git_repo: &Repo,
512 root_commit: &Sha1Hash,
513 commit: &Sha1Hash,
514 thread_event_id: Option<nostr::EventId>,
515 keys: &nostr::Keys,
516 repo_ref: &RepoRef,
517 parent_patch_event_id: Option<nostr::EventId>,
518 series_count: Option<(u64, u64)>,
519 branch_name: Option<String>,
520) -> Result<nostr::Event> {
521 let commit_parent = git_repo
522 .get_commit_parent(commit)
523 .context("failed to get parent commit")?;
524 let relay_hint = repo_ref.relays.first().map(nostr::UncheckedUrl::from);
525 EventBuilder::new(
526 nostr::event::Kind::Custom(PATCH_KIND),
527 git_repo
528 .make_patch_from_commit(commit,&series_count)
529 .context(format!("cannot make patch for commit {commit}"))?,
530 [
531 vec![
532 Tag::A {
533 kind: nostr::Kind::Custom(REPO_REF_KIND),
534 public_key: *repo_ref.maintainers.first()
535 .context("repo reference should always have at least one maintainer - the issuer of the repo event")
536 ?,
537 identifier: repo_ref.identifier.to_string(),
538 relay_url: relay_hint.clone(),
539 },
540 Tag::Reference(format!("{root_commit}")),
541 // commit id reference is a trade-off. its now
542 // unclear which one is the root commit id but it
543 // enables easier location of code comments againt
544 // code that makes it into the main branch, assuming
545 // the commit id is correct
546 Tag::Reference(commit.to_string()),
547
548 if let Some(thread_event_id) = thread_event_id { Tag::Event {
549 event_id: thread_event_id,
550 relay_url: relay_hint.clone(),
551 marker: Some(Marker::Root),
552 } }
553 else {
554 Tag::Hashtag("root".to_string())
555 },
556 ],
557 if let Some(id) = parent_patch_event_id {
558 vec![Tag::Event {
559 event_id: id,
560 relay_url: relay_hint.clone(),
561 marker: Some(Marker::Reply),
562 }]
563 } else {
564 vec![]
565 },
566 if let Some(branch_name) = branch_name {
567 if thread_event_id.is_none() {
568 vec![
569 Tag::Generic(
570 TagKind::Custom("branch-name".to_string()),
571 vec![branch_name.to_string()],
572 )
573 ]
574 }
575 else { vec![]}
576 }
577 else { vec![]},
578 // whilst it is in nip34 draft to tag the maintainers
579 // I'm not sure it is a good idea because if they are
580 // interested in all patches then their specialised
581 // client should subscribe to patches tagged with the
582 // repo reference. maintainers of large repos will not
583 // be interested in every patch.
584 repo_ref.maintainers
585 .iter()
586 .map(|pk| Tag::public_key(*pk))
587 .collect(),
588 vec![
589 Tag::Generic(
590 TagKind::Custom("commit".to_string()),
591 vec![commit.to_string()],
592 ),
593 Tag::Generic(
594 TagKind::Custom("parent-commit".to_string()),
595 vec![commit_parent.to_string()],
596 ),
597 Tag::Generic(
598 TagKind::Custom("commit-pgp-sig".to_string()),
599 vec![
600 git_repo
601 .extract_commit_pgp_signature(commit)
602 .unwrap_or_default(),
603 ],
604 ),
605 Tag::Description(git_repo.get_commit_message(commit)?.to_string()),
606 Tag::Generic(
607 TagKind::Custom("author".to_string()),
608 git_repo.get_commit_author(commit)?,
609 ),
610 Tag::Generic(
611 TagKind::Custom("committer".to_string()),
612 git_repo.get_commit_comitter(commit)?,
613 ),
614 ],
615 ]
616 .concat(),
617 )
618 .to_event(keys)
619 .context("failed to sign event")
620}
621// TODO
622// - find profile
623// - file relays
624// - find repo events
625// -
626
627/**
628 * returns `(from_branch,to_branch,ahead,behind)`
629 */
630fn identify_ahead_behind(
631 git_repo: &Repo,
632 from_branch: &Option<String>,
633 to_branch: &Option<String>,
634) -> Result<(String, String, Vec<Sha1Hash>, Vec<Sha1Hash>)> {
635 let (from_branch, from_tip) = match from_branch {
636 Some(name) => (
637 name.to_string(),
638 git_repo
639 .get_tip_of_local_branch(name)
640 .context(format!("cannot find from_branch '{name}'"))?,
641 ),
642 None => (
643 "head".to_string(),
644 git_repo
645 .get_head_commit()
646 .context("failed to get head commit")
647 .context(
648 "checkout a commit or specify a from_branch. head does not reveal a commit",
649 )?,
650 ),
651 };
652
653 let (to_branch, to_tip) = match to_branch {
654 Some(name) => (
655 name.to_string(),
656 git_repo
657 .get_tip_of_local_branch(name)
658 .context(format!("cannot find to_branch '{name}'"))?,
659 ),
660 None => {
661 let (name, commit) = git_repo
662 .get_main_or_master_branch()
663 .context("a destination branch (to_branch) is not specified and the defaults (main or master) do not exist")?;
664 (name.to_string(), commit)
665 }
666 };
667
668 match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) {
669 Err(e) => {
670 if e.to_string().contains("is not an ancestor of") {
671 return Err(e).context(format!(
672 "'{from_branch}' is not branched from '{to_branch}'"
673 ));
674 }
675 Err(e).context(format!(
676 "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'"
677 ))
678 }
679 Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)),
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 use test_utils::git::GitTestRepo;
686
687 use super::*;
688 mod identify_ahead_behind {
689
690 use super::*;
691 use crate::git::oid_to_sha1;
692
693 #[test]
694 fn when_from_branch_doesnt_exist_return_error() -> Result<()> {
695 let test_repo = GitTestRepo::default();
696 let git_repo = Repo::from_path(&test_repo.dir)?;
697
698 test_repo.populate()?;
699 let branch_name = "doesnt_exist";
700 assert_eq!(
701 identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None)
702 .unwrap_err()
703 .to_string(),
704 format!("cannot find from_branch '{}'", &branch_name),
705 );
706 Ok(())
707 }
708
709 #[test]
710 fn when_to_branch_doesnt_exist_return_error() -> Result<()> {
711 let test_repo = GitTestRepo::default();
712 let git_repo = Repo::from_path(&test_repo.dir)?;
713
714 test_repo.populate()?;
715 let branch_name = "doesnt_exist";
716 assert_eq!(
717 identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string()))
718 .unwrap_err()
719 .to_string(),
720 format!("cannot find to_branch '{}'", &branch_name),
721 );
722 Ok(())
723 }
724
725 #[test]
726 fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> {
727 let test_repo = GitTestRepo::new("notmain")?;
728 let git_repo = Repo::from_path(&test_repo.dir)?;
729
730 test_repo.populate()?;
731
732 assert_eq!(
733 identify_ahead_behind(&git_repo, &None, &None)
734 .unwrap_err()
735 .to_string(),
736 "a destination branch (to_branch) is not specified and the defaults (main or master) do not exist",
737 );
738 Ok(())
739 }
740
741 #[test]
742 fn when_from_branch_is_none_return_as_head() -> Result<()> {
743 let test_repo = GitTestRepo::default();
744 let git_repo = Repo::from_path(&test_repo.dir)?;
745
746 test_repo.populate()?;
747 // create feature branch with 1 commit ahead
748 test_repo.create_branch("feature")?;
749 test_repo.checkout("feature")?;
750 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
751 let head_oid = test_repo.stage_and_commit("add t3.md")?;
752
753 // make feature branch 1 commit behind
754 test_repo.checkout("main")?;
755 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
756 let main_oid = test_repo.stage_and_commit("add t4.md")?;
757 // checkout feature
758 test_repo.checkout("feature")?;
759
760 let (from_branch, to_branch, ahead, behind) =
761 identify_ahead_behind(&git_repo, &None, &None)?;
762
763 assert_eq!(from_branch, "head");
764 assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]);
765 assert_eq!(to_branch, "main");
766 assert_eq!(behind, vec![oid_to_sha1(&main_oid)]);
767 Ok(())
768 }
769
770 #[test]
771 fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> {
772 let test_repo = GitTestRepo::default();
773 let git_repo = Repo::from_path(&test_repo.dir)?;
774
775 test_repo.populate()?;
776 // create feature branch with 1 commit ahead
777 test_repo.create_branch("feature")?;
778 test_repo.checkout("feature")?;
779 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
780 let head_oid = test_repo.stage_and_commit("add t3.md")?;
781
782 // make feature branch 1 commit behind
783 test_repo.checkout("main")?;
784 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
785 let main_oid = test_repo.stage_and_commit("add t4.md")?;
786
787 let (from_branch, to_branch, ahead, behind) =
788 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
789
790 assert_eq!(from_branch, "feature");
791 assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]);
792 assert_eq!(to_branch, "main");
793 assert_eq!(behind, vec![oid_to_sha1(&main_oid)]);
794 Ok(())
795 }
796
797 #[test]
798 fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> {
799 let test_repo = GitTestRepo::default();
800 let git_repo = Repo::from_path(&test_repo.dir)?;
801
802 test_repo.populate()?;
803 // create dev branch with 1 commit ahead
804 test_repo.create_branch("dev")?;
805 test_repo.checkout("dev")?;
806 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
807 let dev_oid_first = test_repo.stage_and_commit("add t3.md")?;
808
809 // create feature branch with 1 commit ahead of dev
810 test_repo.create_branch("feature")?;
811 test_repo.checkout("feature")?;
812 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
813 let feature_oid = test_repo.stage_and_commit("add t4.md")?;
814
815 // make feature branch 1 behind
816 test_repo.checkout("dev")?;
817 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
818 let dev_oid = test_repo.stage_and_commit("add t3.md")?;
819
820 let (from_branch, to_branch, ahead, behind) = identify_ahead_behind(
821 &git_repo,
822 &Some("feature".to_string()),
823 &Some("dev".to_string()),
824 )?;
825
826 assert_eq!(from_branch, "feature");
827 assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]);
828 assert_eq!(to_branch, "dev");
829 assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]);
830
831 let (from_branch, to_branch, ahead, behind) =
832 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
833
834 assert_eq!(from_branch, "feature");
835 assert_eq!(
836 ahead,
837 vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)]
838 );
839 assert_eq!(to_branch, "main");
840 assert_eq!(behind, vec![]);
841
842 Ok(())
843 }
844 }
845
846 mod event_to_cover_letter {
847 use super::*;
848
849 fn generate_cover_letter(title: &str, description: &str) -> Result<nostr::Event> {
850 Ok(nostr::event::EventBuilder::new(
851 nostr::event::Kind::Custom(PATCH_KIND),
852 format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"),
853 [
854 Tag::Hashtag("cover-letter".to_string()),
855 Tag::Hashtag("root".to_string()),
856 ],
857 )
858 .to_event(&nostr::Keys::generate())?)
859 }
860
861 #[test]
862 fn basic_title() -> Result<()> {
863 assert_eq!(
864 event_to_cover_letter(&generate_cover_letter("the title", "description here")?)?
865 .title,
866 "the title",
867 );
868 Ok(())
869 }
870
871 #[test]
872 fn basic_description() -> Result<()> {
873 assert_eq!(
874 event_to_cover_letter(&generate_cover_letter("the title", "description here")?)?
875 .description,
876 "description here",
877 );
878 Ok(())
879 }
880
881 #[test]
882 fn description_trimmed() -> Result<()> {
883 assert_eq!(
884 event_to_cover_letter(&generate_cover_letter(
885 "the title",
886 " \n \ndescription here\n\n "
887 )?)?
888 .description,
889 "description here",
890 );
891 Ok(())
892 }
893
894 #[test]
895 fn multi_line_description() -> Result<()> {
896 assert_eq!(
897 event_to_cover_letter(&generate_cover_letter(
898 "the title",
899 "description here\n\nmore here\nmore"
900 )?)?
901 .description,
902 "description here\n\nmore here\nmore",
903 );
904 Ok(())
905 }
906
907 #[test]
908 fn new_lines_in_title_forms_part_of_description() -> Result<()> {
909 assert_eq!(
910 event_to_cover_letter(&generate_cover_letter(
911 "the title\nwith new line",
912 "description here\n\nmore here\nmore"
913 )?)?
914 .title,
915 "the title",
916 );
917 assert_eq!(
918 event_to_cover_letter(&generate_cover_letter(
919 "the title\nwith new line",
920 "description here\n\nmore here\nmore"
921 )?)?
922 .description,
923 "with new line\n\ndescription here\n\nmore here\nmore",
924 );
925 Ok(())
926 }
927
928 mod blank_description {
929 use super::*;
930
931 #[test]
932 fn title_correct() -> Result<()> {
933 assert_eq!(
934 event_to_cover_letter(&generate_cover_letter("the title", "")?)?.title,
935 "the title",
936 );
937 Ok(())
938 }
939
940 #[test]
941 fn description_is_empty_string() -> Result<()> {
942 assert_eq!(
943 event_to_cover_letter(&generate_cover_letter("the title", "")?)?.description,
944 "",
945 );
946 Ok(())
947 }
948 }
949 }
950}