upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin/ngit/sub_commands/list.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/bin/ngit/sub_commands/list.rs')
-rw-r--r--src/bin/ngit/sub_commands/list.rs906
1 files changed, 906 insertions, 0 deletions
diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs
new file mode 100644
index 0000000..ac1f4ab
--- /dev/null
+++ b/src/bin/ngit/sub_commands/list.rs
@@ -0,0 +1,906 @@
1use std::{collections::HashSet, io::Write, ops::Add, path::Path};
2
3use anyhow::{bail, Context, Result};
4use nostr::nips::nip01::Coordinate;
5use nostr_sdk::{Kind, PublicKey};
6
7use super::send::event_is_patch_set_root;
8#[cfg(test)]
9use crate::client::MockConnect;
10#[cfg(not(test))]
11use crate::client::{Client, Connect};
12use crate::{
13 cli_interactor::{Interactor, InteractorPrompt, PromptChoiceParms, PromptConfirmParms},
14 client::{fetching_with_report, get_events_from_cache, get_repo_ref_from_cache},
15 git::{str_to_sha1, Repo, RepoActions},
16 repo_ref::{get_repo_coordinates, RepoRef},
17 sub_commands::send::{
18 commit_msg_from_patch_oneliner, event_is_cover_letter, event_is_revision_root,
19 event_to_cover_letter, patch_supports_commit_ids,
20 },
21};
22
23#[allow(clippy::too_many_lines)]
24pub async fn launch() -> Result<()> {
25 let git_repo = Repo::discover().context("cannot find a git repository")?;
26 let git_repo_path = git_repo.get_path()?;
27
28 // TODO: check for empty repo
29 // TODO: check for existing maintaiers file
30 // TODO: check for other claims
31
32 #[cfg(not(test))]
33 let client = Client::default();
34 #[cfg(test)]
35 let client = <MockConnect as std::default::Default>::default();
36
37 let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?;
38
39 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
40
41 let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?;
42
43 let proposals_and_revisions: Vec<nostr::Event> =
44 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?;
45 if proposals_and_revisions.is_empty() {
46 println!("no proposals found... create one? try `ngit send`");
47 return Ok(());
48 }
49
50 let statuses: Vec<nostr::Event> = {
51 let mut statuses = get_events_from_cache(
52 git_repo_path,
53 vec![
54 nostr::Filter::default()
55 .kinds(status_kinds().clone())
56 .events(proposals_and_revisions.iter().map(nostr::Event::id)),
57 ],
58 )
59 .await?;
60 statuses.sort_by_key(|e| e.created_at);
61 statuses.reverse();
62 statuses
63 };
64
65 let mut open_proposals: Vec<&nostr::Event> = vec![];
66 let mut draft_proposals: Vec<&nostr::Event> = vec![];
67 let mut closed_proposals: Vec<&nostr::Event> = vec![];
68 let mut applied_proposals: Vec<&nostr::Event> = vec![];
69
70 let proposals: Vec<nostr::Event> = proposals_and_revisions
71 .iter()
72 .filter(|e| !event_is_revision_root(e))
73 .cloned()
74 .collect();
75
76 for proposal in &proposals {
77 let status = if let Some(e) = statuses
78 .iter()
79 .filter(|e| {
80 status_kinds().contains(&e.kind())
81 && e.tags()
82 .iter()
83 .any(|t| t.as_vec()[1].eq(&proposal.id.to_string()))
84 })
85 .collect::<Vec<&nostr::Event>>()
86 .first()
87 {
88 e.kind()
89 } else {
90 Kind::GitStatusOpen
91 };
92 if status.eq(&Kind::GitStatusOpen) {
93 open_proposals.push(proposal);
94 } else if status.eq(&Kind::GitStatusClosed) {
95 closed_proposals.push(proposal);
96 } else if status.eq(&Kind::GitStatusDraft) {
97 draft_proposals.push(proposal);
98 } else if status.eq(&Kind::GitStatusApplied) {
99 applied_proposals.push(proposal);
100 }
101 }
102
103 let mut selected_status = Kind::GitStatusOpen;
104
105 loop {
106 let proposals_for_status = if selected_status == Kind::GitStatusOpen {
107 &open_proposals
108 } else if selected_status == Kind::GitStatusDraft {
109 &draft_proposals
110 } else if selected_status == Kind::GitStatusClosed {
111 &closed_proposals
112 } else if selected_status == Kind::GitStatusApplied {
113 &applied_proposals
114 } else {
115 &open_proposals
116 };
117
118 let prompt = if proposals.len().eq(&open_proposals.len()) {
119 "all proposals"
120 } else if selected_status == Kind::GitStatusOpen {
121 if open_proposals.is_empty() {
122 "proposals menu"
123 } else {
124 "open proposals"
125 }
126 } else if selected_status == Kind::GitStatusDraft {
127 "draft proposals"
128 } else if selected_status == Kind::GitStatusClosed {
129 "closed proposals"
130 } else {
131 "applied proposals"
132 };
133
134 let mut choices: Vec<String> = proposals_for_status
135 .iter()
136 .map(|e| {
137 if let Ok(cl) = event_to_cover_letter(e) {
138 cl.title
139 } else if let Ok(msg) = tag_value(e, "description") {
140 msg.split('\n').collect::<Vec<&str>>()[0].to_string()
141 } else {
142 e.id.to_string()
143 }
144 })
145 .collect();
146
147 if !selected_status.eq(&Kind::GitStatusOpen) && open_proposals.len().gt(&0) {
148 choices.push(format!("({}) Open proposals...", open_proposals.len()));
149 }
150 if !selected_status.eq(&Kind::GitStatusDraft) && draft_proposals.len().gt(&0) {
151 choices.push(format!("({}) Draft proposals...", draft_proposals.len()));
152 }
153 if !selected_status.eq(&Kind::GitStatusClosed) && closed_proposals.len().gt(&0) {
154 choices.push(format!("({}) Closed proposals...", closed_proposals.len()));
155 }
156 if !selected_status.eq(&Kind::GitStatusApplied) && applied_proposals.len().gt(&0) {
157 choices.push(format!(
158 "({}) Applied proposals...",
159 applied_proposals.len()
160 ));
161 }
162
163 let selected_index = Interactor::default().choice(
164 PromptChoiceParms::default()
165 .with_prompt(prompt)
166 .with_choices(choices.clone()),
167 )?;
168
169 if (selected_index + 1).gt(&proposals_for_status.len()) {
170 if choices[selected_index].contains("Open") {
171 selected_status = Kind::GitStatusOpen;
172 } else if choices[selected_index].contains("Draft") {
173 selected_status = Kind::GitStatusDraft;
174 } else if choices[selected_index].contains("Closed") {
175 selected_status = Kind::GitStatusClosed;
176 } else if choices[selected_index].contains("Applied") {
177 selected_status = Kind::GitStatusApplied;
178 }
179 continue;
180 }
181
182 let cover_letter = event_to_cover_letter(proposals_for_status[selected_index])
183 .context("cannot extract proposal details from proposal root event")?;
184
185 let commits_events: Vec<nostr::Event> = get_all_proposal_patch_events_from_cache(
186 git_repo_path,
187 &repo_ref,
188 &proposals_for_status[selected_index].id(),
189 )
190 .await?;
191
192 let Ok(most_recent_proposal_patch_chain) =
193 get_most_recent_patch_with_ancestors(commits_events.clone())
194 else {
195 if Interactor::default().confirm(
196 PromptConfirmParms::default()
197 .with_default(true)
198 .with_prompt(
199 "cannot find any patches on this proposal. choose another proposal?",
200 ),
201 )? {
202 continue;
203 }
204 return Ok(());
205 };
206 // for commit in &most_recent_proposal_patch_chain {
207 // println!("recent_event: {:?}", commit.as_json());
208 // }
209
210 let binding_patch_text_ref = format!("{} commits", most_recent_proposal_patch_chain.len());
211 let patch_text_ref = if most_recent_proposal_patch_chain.len().gt(&1) {
212 binding_patch_text_ref.as_str()
213 } else {
214 "1 commit"
215 };
216
217 let no_support_for_patches_as_branch = most_recent_proposal_patch_chain
218 .iter()
219 .any(|event| !patch_supports_commit_ids(event));
220
221 if no_support_for_patches_as_branch {
222 println!("{patch_text_ref}");
223 return match Interactor::default().choice(
224 PromptChoiceParms::default()
225 .with_default(0)
226 .with_choices(vec![
227 "learn why 'patch only' proposals can't be checked out".to_string(),
228 format!("apply to current branch with `git am`"),
229 format!("download to ./patches"),
230 "back".to_string(),
231 ]),
232 )? {
233 0 => {
234 println!("Some proposals are posted as 'patch only'\n");
235 println!(
236 "they are not anchored against a particular state of the code base like a standard proposal or a GitHub Pull Request can be\n"
237 );
238 println!(
239 "they are designed to reviewed by studying the diff (in a tool like gitworkshop.dev) and if acceptable by a maintainer, applied to the latest version of master with any conflicts resolved as the do so\n"
240 );
241 println!(
242 "this has proven to be a smoother workflow for large scale projects with a high frequency of changes, even when patches are exchanged via email\n"
243 );
244 println!(
245 "by default ngit posts proposals that support both the branch and patch model so either workflow can be used"
246 );
247 Interactor::default().choice(
248 PromptChoiceParms::default()
249 .with_default(0)
250 .with_choices(vec!["back".to_string()]),
251 )?;
252 continue;
253 }
254 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain),
255 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo),
256 3 => continue,
257 _ => {
258 bail!("unexpected choice")
259 }
260 };
261 }
262
263 let branch_exists = git_repo
264 .get_local_branch_names()
265 .context("gitlib2 will not show a list of local branch names")?
266 .iter()
267 .any(|n| n.eq(&cover_letter.get_branch_name().unwrap()));
268
269 let checked_out_proposal_branch = git_repo
270 .get_checked_out_branch_name()?
271 .eq(&cover_letter.get_branch_name()?);
272
273 let proposal_base_commit = str_to_sha1(&tag_value(
274 most_recent_proposal_patch_chain.last().context(
275 "there should be at least one patch as we have already checked for this",
276 )?,
277 "parent-commit",
278 )?)
279 .context("cannot get valid parent commit id from patch")?;
280
281 let (main_branch_name, master_tip) = git_repo.get_main_or_master_branch()?;
282
283 if !git_repo.does_commit_exist(&proposal_base_commit.to_string())? {
284 println!("your '{main_branch_name}' branch may not be up-to-date.");
285 println!("the proposal parent commit doesnt exist in your local repository.");
286 return match Interactor::default().choice(PromptChoiceParms::default().with_default(0).with_choices(
287 vec![
288 format!(
289 "manually run `git pull` on '{main_branch_name}' and select proposal again"
290 ),
291 format!("apply to current branch with `git am`"),
292 format!("download to ./patches"),
293 "back".to_string(),
294 ],
295 ))? {
296 0 | 3 => continue,
297 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain),
298 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo),
299 _ => {
300 bail!("unexpected choice")
301 }
302 };
303 }
304
305 let proposal_tip = str_to_sha1(
306 &get_commit_id_from_patch(most_recent_proposal_patch_chain.first().context(
307 "there should be at least one patch as we have already checked for this",
308 )?)
309 .context("cannot get valid commit_id from patch")?,
310 )
311 .context("cannot get valid commit_id from patch")?;
312
313 let (_, proposal_behind_main) =
314 git_repo.get_commits_ahead_behind(&master_tip, &proposal_base_commit)?;
315
316 // branch doesnt exist
317 if !branch_exists {
318 return match Interactor::default()
319 .choice(PromptChoiceParms::default().with_default(0).with_choices(vec![
320 format!(
321 "create and checkout proposal branch ({} ahead {} behind '{main_branch_name}')",
322 most_recent_proposal_patch_chain.len(),
323 proposal_behind_main.len(),
324 ),
325 format!("apply to current branch with `git am`"),
326 format!("download to ./patches"),
327 "back".to_string(),
328 ]))? {
329 0 => {
330 check_clean(&git_repo)?;
331 let _ = git_repo
332 .apply_patch_chain(
333 &cover_letter.get_branch_name()?,
334 most_recent_proposal_patch_chain,
335 )
336 .context("cannot apply patch chain")?;
337
338 println!(
339 "checked out proposal as '{}' branch",
340 cover_letter.get_branch_name()?
341 );
342 Ok(())
343 }
344 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain),
345 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo),
346 3 => continue,
347 _ => {
348 bail!("unexpected choice")
349 }
350 };
351 }
352
353 let local_branch_tip = git_repo.get_tip_of_branch(&cover_letter.get_branch_name()?)?;
354
355 // up-to-date
356 if proposal_tip.eq(&local_branch_tip) {
357 if checked_out_proposal_branch {
358 println!("branch checked out and up-to-date");
359 return match Interactor::default().choice(
360 PromptChoiceParms::default()
361 .with_default(0)
362 .with_choices(vec!["exit".to_string(), "back".to_string()]),
363 )? {
364 0 => Ok(()),
365 1 => continue,
366 _ => {
367 bail!("unexpected choice")
368 }
369 };
370 }
371
372 return match Interactor::default().choice(
373 PromptChoiceParms::default()
374 .with_default(0)
375 .with_choices(vec![
376 format!(
377 "checkout proposal branch ({} ahead {} behind '{main_branch_name}')",
378 most_recent_proposal_patch_chain.len(),
379 proposal_behind_main.len(),
380 ),
381 format!("apply to current branch with `git am`"),
382 format!("download to ./patches"),
383 "back".to_string(),
384 ]),
385 )? {
386 0 => {
387 check_clean(&git_repo)?;
388 git_repo.checkout(&cover_letter.get_branch_name()?)?;
389 println!(
390 "checked out proposal as '{}' branch",
391 cover_letter.get_branch_name()?
392 );
393 Ok(())
394 }
395 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain),
396 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo),
397 3 => continue,
398 _ => {
399 bail!("unexpected choice")
400 }
401 };
402 }
403
404 let (local_ahead_of_main, local_beind_main) =
405 git_repo.get_commits_ahead_behind(&master_tip, &local_branch_tip)?;
406
407 // new appendments to proposal
408 if let Some(index) = most_recent_proposal_patch_chain.iter().position(|patch| {
409 get_commit_id_from_patch(patch)
410 .unwrap_or_default()
411 .eq(&local_branch_tip.to_string())
412 }) {
413 return match Interactor::default().choice(
414 PromptChoiceParms::default()
415 .with_default(0)
416 .with_choices(vec![
417 format!("checkout proposal branch and apply {} appendments", &index,),
418 format!("apply to current branch with `git am`"),
419 format!("download to ./patches"),
420 "back".to_string(),
421 ]),
422 )? {
423 0 => {
424 check_clean(&git_repo)?;
425 git_repo.checkout(&cover_letter.get_branch_name()?)?;
426 let _ = git_repo
427 .apply_patch_chain(
428 &cover_letter.get_branch_name()?,
429 most_recent_proposal_patch_chain,
430 )
431 .context("cannot apply patch chain")?;
432 println!(
433 "checked out proposal branch and applied {} appendments ({} ahead {} behind '{main_branch_name}')",
434 &index,
435 local_ahead_of_main.len().add(&index),
436 local_beind_main.len(),
437 );
438 Ok(())
439 }
440 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain),
441 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo),
442 3 => continue,
443 _ => {
444 bail!("unexpected choice")
445 }
446 };
447 }
448
449 // new proposal revision / rebase
450 // tip of local in proposal history (new, amended or rebased version but no
451 // local changes)
452 if commits_events.iter().any(|patch| {
453 get_commit_id_from_patch(patch)
454 .unwrap_or_default()
455 .eq(&local_branch_tip.to_string())
456 }) {
457 println!(
458 "updated proposal available ({} ahead {} behind '{main_branch_name}'). existing version is {} ahead {} behind '{main_branch_name}'",
459 most_recent_proposal_patch_chain.len(),
460 proposal_behind_main.len(),
461 local_ahead_of_main.len(),
462 local_beind_main.len(),
463 );
464 return match Interactor::default().choice(
465 PromptChoiceParms::default()
466 .with_default(0)
467 .with_choices(vec![
468 format!("checkout and overwrite existing proposal branch"),
469 format!("checkout existing outdated proposal branch"),
470 format!("apply to current branch with `git am`"),
471 format!("download to ./patches"),
472 "back".to_string(),
473 ]),
474 )? {
475 0 => {
476 check_clean(&git_repo)?;
477 git_repo.create_branch_at_commit(
478 &cover_letter.get_branch_name()?,
479 &proposal_base_commit.to_string(),
480 )?;
481 git_repo.checkout(&cover_letter.get_branch_name()?)?;
482 let chain_length = most_recent_proposal_patch_chain.len();
483 let _ = git_repo
484 .apply_patch_chain(
485 &cover_letter.get_branch_name()?,
486 most_recent_proposal_patch_chain,
487 )
488 .context("cannot apply patch chain")?;
489 println!(
490 "checked out new version of proposal ({} ahead {} behind '{main_branch_name}'), replacing old version ({} ahead {} behind '{main_branch_name}')",
491 chain_length,
492 proposal_behind_main.len(),
493 local_ahead_of_main.len(),
494 local_beind_main.len(),
495 );
496 Ok(())
497 }
498 1 => {
499 check_clean(&git_repo)?;
500 git_repo.checkout(&cover_letter.get_branch_name()?)?;
501 println!(
502 "checked out old proposal in existing branch ({} ahead {} behind '{main_branch_name}')",
503 local_ahead_of_main.len(),
504 local_beind_main.len(),
505 );
506 Ok(())
507 }
508 2 => launch_git_am_with_patches(most_recent_proposal_patch_chain),
509 3 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo),
510 4 => continue,
511 _ => {
512 bail!("unexpected choice")
513 }
514 };
515 }
516 // tip of proposal in branch in history (local appendments made to up-to-date
517 // proposal)
518 else if git_repo.ancestor_of(&local_branch_tip, &proposal_tip)? {
519 let (local_ahead_of_proposal, _) = git_repo
520 .get_commits_ahead_behind(&proposal_tip, &local_branch_tip)
521 .context("cannot get commits ahead behind for propsal_top and local_branch_tip")?;
522
523 println!(
524 "local proposal branch exists with {} unpublished commits on top of the most up-to-date version of the proposal ({} ahead {} behind '{main_branch_name}')",
525 local_ahead_of_proposal.len(),
526 local_ahead_of_main.len(),
527 proposal_behind_main.len(),
528 );
529 return match Interactor::default().choice(
530 PromptChoiceParms::default()
531 .with_default(0)
532 .with_choices(vec![
533 format!(
534 "checkout proposal branch with {} unpublished commits",
535 local_ahead_of_proposal.len(),
536 ),
537 "back".to_string(),
538 ]),
539 )? {
540 0 => {
541 git_repo.checkout(&cover_letter.get_branch_name()?)?;
542 println!(
543 "checked out proposal branch with {} unpublished commits ({} ahead {} behind '{main_branch_name}')",
544 local_ahead_of_proposal.len(),
545 local_ahead_of_main.len(),
546 proposal_behind_main.len(),
547 );
548 Ok(())
549 }
550 1 => continue,
551 _ => {
552 bail!("unexpected choice")
553 }
554 };
555 }
556
557 println!("you have an amended/rebase version the proposal that is unpublished");
558 // user probably has a unpublished amended or rebase version of the latest
559 // proposal version
560 // if tip of proposal commits exist (were once part of branch but have been
561 // amended and git clean up job hasn't removed them)
562 if git_repo.does_commit_exist(&proposal_tip.to_string())? {
563 println!(
564 "you have previously applied the latest version of the proposal ({} ahead {} behind '{main_branch_name}') but your local proposal branch has amended or rebased it ({} ahead {} behind '{main_branch_name}')",
565 most_recent_proposal_patch_chain.len(),
566 proposal_behind_main.len(),
567 local_ahead_of_main.len(),
568 local_beind_main.len(),
569 );
570 }
571 // user probably has a unpublished amended or rebase version of an older
572 // proposal version
573 else {
574 println!(
575 "your local proposal branch ({} ahead {} behind '{main_branch_name}') has conflicting changes with the latest published proposal ({} ahead {} behind '{main_branch_name}')",
576 local_ahead_of_main.len(),
577 local_beind_main.len(),
578 most_recent_proposal_patch_chain.len(),
579 proposal_behind_main.len(),
580 );
581
582 println!(
583 "its likely that you have rebased / amended an old proposal version because git has no record of the latest proposal commit."
584 );
585 println!(
586 "it is possible that you have been working off the latest version and git has delete this commit as part of a clean up"
587 );
588 }
589 println!("to view the latest proposal but retain your changes:");
590 println!(" 1) create a new branch off the tip commit of this one to store your changes");
591 println!(" 2) run `ngit list` and checkout the latest published version of this proposal");
592
593 println!("if you are confident in your changes consider running `ngit push --force`");
594
595 return match Interactor::default().choice(
596 PromptChoiceParms::default()
597 .with_default(0)
598 .with_choices(vec![
599 format!("checkout local branch with unpublished changes"),
600 format!("discard unpublished changes and checkout new revision",),
601 format!("apply to current branch with `git am`"),
602 format!("download to ./patches"),
603 "back".to_string(),
604 ]),
605 )? {
606 0 => {
607 check_clean(&git_repo)?;
608 git_repo.checkout(&cover_letter.get_branch_name()?)?;
609 println!(
610 "checked out old proposal in existing branch ({} ahead {} behind '{main_branch_name}')",
611 local_ahead_of_main.len(),
612 local_beind_main.len(),
613 );
614 Ok(())
615 }
616 1 => {
617 check_clean(&git_repo)?;
618 git_repo.create_branch_at_commit(
619 &cover_letter.get_branch_name()?,
620 &proposal_base_commit.to_string(),
621 )?;
622 let chain_length = most_recent_proposal_patch_chain.len();
623 let _ = git_repo
624 .apply_patch_chain(
625 &cover_letter.get_branch_name()?,
626 most_recent_proposal_patch_chain,
627 )
628 .context("cannot apply patch chain")?;
629
630 git_repo.checkout(&cover_letter.get_branch_name()?)?;
631 println!(
632 "checked out latest version of proposal ({} ahead {} behind '{main_branch_name}'), replacing unpublished version ({} ahead {} behind '{main_branch_name}')",
633 chain_length,
634 proposal_behind_main.len(),
635 local_ahead_of_main.len(),
636 local_beind_main.len(),
637 );
638 Ok(())
639 }
640 2 => launch_git_am_with_patches(most_recent_proposal_patch_chain),
641 3 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo),
642 4 => continue,
643 _ => {
644 bail!("unexpected choice")
645 }
646 };
647 }
648}
649
650fn launch_git_am_with_patches(mut patches: Vec<nostr::Event>) -> Result<()> {
651 println!("applying to current branch with `git am`");
652 // TODO: add PATCH x/n to appended patches
653 patches.reverse();
654
655 let mut am = std::process::Command::new("git")
656 .arg("am")
657 .stdin(std::process::Stdio::piped())
658 .stdout(std::process::Stdio::inherit())
659 .stderr(std::process::Stdio::inherit())
660 .spawn()
661 .context("failed to spawn git am")?;
662
663 let stdin = am
664 .stdin
665 .as_mut()
666 .context("git am process failed to take stdin")?;
667
668 for patch in patches {
669 stdin
670 .write(format!("{}\n\n", patch.content).as_bytes())
671 .context("failed to write patch content into git am stdin buffer")?;
672 }
673 stdin.flush()?;
674 let output = am
675 .wait_with_output()
676 .context("failed to read git am stdout")?;
677 print!("{:?}", output.stdout);
678 Ok(())
679}
680
681fn event_id_extra_shorthand(event: &nostr::Event) -> String {
682 event.id.to_string()[..5].to_string()
683}
684
685fn save_patches_to_dir(mut patches: Vec<nostr::Event>, git_repo: &Repo) -> Result<()> {
686 // TODO: add PATCH x/n to appended patches
687 patches.reverse();
688 let path = git_repo.get_path()?.join("patches");
689 std::fs::create_dir_all(&path)?;
690 let id = event_id_extra_shorthand(
691 patches
692 .first()
693 .context("there must be at least one patch to save")?,
694 );
695 for (i, patch) in patches.iter().enumerate() {
696 let path = path.join(format!(
697 "{}-{:0>4}-{}.patch",
698 &id,
699 i.add(&1),
700 commit_msg_from_patch_oneliner(patch)?
701 ));
702 let mut file = std::fs::OpenOptions::new()
703 .create(true)
704 .write(true)
705 .truncate(true)
706 .open(path)
707 .context("open new patch file with write and truncate options")?;
708 file.write_all(patch.content().as_bytes())?;
709 file.write_all("\n\n".as_bytes())?;
710 file.flush()?;
711 }
712 println!("created {} patch files in ./patches/{id}-*", patches.len());
713 Ok(())
714}
715
716fn check_clean(git_repo: &Repo) -> Result<()> {
717 if git_repo.has_outstanding_changes()? {
718 bail!(
719 "cannot pull proposal branch when repository is not clean. discard or stash (un)staged changes and try again."
720 );
721 }
722 Ok(())
723}
724
725pub fn tag_value(event: &nostr::Event, tag_name: &str) -> Result<String> {
726 Ok(event
727 .tags
728 .iter()
729 .find(|t| t.as_vec()[0].eq(tag_name))
730 .context(format!("tag '{tag_name}'not present"))?
731 .as_vec()[1]
732 .clone())
733}
734
735pub fn get_commit_id_from_patch(event: &nostr::Event) -> Result<String> {
736 let value = tag_value(event, "commit");
737
738 if value.is_ok() {
739 value
740 } else if event.content.starts_with("From ") && event.content.len().gt(&45) {
741 Ok(event.content[5..45].to_string())
742 } else {
743 bail!("event is not a patch")
744 }
745}
746
747fn get_event_parent_id(event: &nostr::Event) -> Result<String> {
748 Ok(if let Some(reply_tag) = event
749 .tags
750 .iter()
751 .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("reply"))
752 {
753 reply_tag
754 } else {
755 event
756 .tags
757 .iter()
758 .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("root"))
759 .context("no reply or root e tag present".to_string())?
760 }
761 .as_vec()[1]
762 .clone())
763}
764
765pub fn get_most_recent_patch_with_ancestors(
766 mut patches: Vec<nostr::Event>,
767) -> Result<Vec<nostr::Event>> {
768 patches.sort_by_key(|e| e.created_at);
769
770 let youngest_patch = patches.last().context("no patches found")?;
771
772 let patches_with_youngest_created_at: Vec<&nostr::Event> = patches
773 .iter()
774 .filter(|p| p.created_at.eq(&youngest_patch.created_at))
775 .collect();
776
777 let mut res = vec![];
778
779 let mut event_id_to_search = patches_with_youngest_created_at
780 .clone()
781 .iter()
782 .find(|p| {
783 !patches_with_youngest_created_at.iter().any(|p2| {
784 if let Ok(reply_to) = get_event_parent_id(p2) {
785 reply_to.eq(&p.id.to_string())
786 } else {
787 false
788 }
789 })
790 })
791 .context("cannot find patches_with_youngest_created_at")?
792 .id
793 .to_string();
794
795 while let Some(event) = patches
796 .iter()
797 .find(|e| e.id.to_string().eq(&event_id_to_search))
798 {
799 res.push(event.clone());
800 if event_is_patch_set_root(event) {
801 break;
802 }
803 event_id_to_search = get_event_parent_id(event).unwrap_or_default();
804 }
805 Ok(res)
806}
807
808pub fn status_kinds() -> Vec<nostr::Kind> {
809 vec![
810 nostr::Kind::GitStatusOpen,
811 nostr::Kind::GitStatusApplied,
812 nostr::Kind::GitStatusClosed,
813 nostr::Kind::GitStatusDraft,
814 ]
815}
816
817pub async fn get_proposals_and_revisions_from_cache(
818 git_repo_path: &Path,
819 repo_coordinates: HashSet<Coordinate>,
820) -> Result<Vec<nostr::Event>> {
821 let mut proposals = get_events_from_cache(
822 git_repo_path,
823 vec![
824 nostr::Filter::default()
825 .kind(nostr::Kind::GitPatch)
826 .custom_tag(
827 nostr::SingleLetterTag::lowercase(nostr_sdk::Alphabet::A),
828 repo_coordinates
829 .iter()
830 .map(std::string::ToString::to_string)
831 .collect::<Vec<String>>(),
832 ),
833 ],
834 )
835 .await?
836 .iter()
837 .filter(|e| event_is_patch_set_root(e))
838 .cloned()
839 .collect::<Vec<nostr::Event>>();
840 proposals.sort_by_key(|e| e.created_at);
841 proposals.reverse();
842 Ok(proposals)
843}
844
845pub async fn get_all_proposal_patch_events_from_cache(
846 git_repo_path: &Path,
847 repo_ref: &RepoRef,
848 proposal_id: &nostr::EventId,
849) -> Result<Vec<nostr::Event>> {
850 let mut commit_events = get_events_from_cache(
851 git_repo_path,
852 vec![
853 nostr::Filter::default()
854 .kind(nostr::Kind::GitPatch)
855 .event(*proposal_id),
856 nostr::Filter::default()
857 .kind(nostr::Kind::GitPatch)
858 .id(*proposal_id),
859 ],
860 )
861 .await?;
862
863 let permissioned_users: HashSet<PublicKey> = [
864 repo_ref.maintainers.clone(),
865 vec![
866 commit_events
867 .iter()
868 .find(|e| e.id().eq(proposal_id))
869 .context("proposal not in cache")?
870 .author(),
871 ],
872 ]
873 .concat()
874 .iter()
875 .copied()
876 .collect();
877 commit_events.retain(|e| permissioned_users.contains(&e.author()));
878
879 let revision_roots: HashSet<nostr::EventId> = commit_events
880 .iter()
881 .filter(|e| event_is_revision_root(e))
882 .map(nostr::Event::id)
883 .collect();
884
885 if !revision_roots.is_empty() {
886 for event in get_events_from_cache(
887 git_repo_path,
888 vec![
889 nostr::Filter::default()
890 .kind(nostr::Kind::GitPatch)
891 .events(revision_roots)
892 .authors(permissioned_users.clone()),
893 ],
894 )
895 .await?
896 {
897 commit_events.push(event);
898 }
899 }
900
901 Ok(commit_events
902 .iter()
903 .filter(|e| !event_is_cover_letter(e) && permissioned_users.contains(&e.author()))
904 .cloned()
905 .collect())
906}