upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2024-09-04 08:04:48 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2024-09-04 13:30:59 +0100
commit949c6459aa7683453a7160423b689ceadb08954b (patch)
tree230c26ecb11b99916e5570e548673eb09ecf0a36 /src/bin
parenta825311f2c55661aaab3a163bda9109295c96044 (diff)
refactor: organise into lib and bin structure
the make the code more readable this commit just moves the files, the next commit should fix the imports
Diffstat (limited to 'src/bin')
-rw-r--r--src/bin/git_remote_nostr/main.rs1897
-rw-r--r--src/bin/ngit/cli.rs44
-rw-r--r--src/bin/ngit/main.rs26
-rw-r--r--src/bin/ngit/sub_commands/fetch.rs44
-rw-r--r--src/bin/ngit/sub_commands/init.rs385
-rw-r--r--src/bin/ngit/sub_commands/list.rs906
-rw-r--r--src/bin/ngit/sub_commands/login.rs52
-rw-r--r--src/bin/ngit/sub_commands/mod.rs7
-rw-r--r--src/bin/ngit/sub_commands/pull.rs209
-rw-r--r--src/bin/ngit/sub_commands/push.rs223
-rw-r--r--src/bin/ngit/sub_commands/send.rs1363
11 files changed, 5156 insertions, 0 deletions
diff --git a/src/bin/git_remote_nostr/main.rs b/src/bin/git_remote_nostr/main.rs
new file mode 100644
index 0000000..a5244bf
--- /dev/null
+++ b/src/bin/git_remote_nostr/main.rs
@@ -0,0 +1,1897 @@
1#![cfg_attr(not(test), warn(clippy::pedantic))]
2#![allow(clippy::large_futures)]
3// better solution to dead_code error on multiple binaries than https://stackoverflow.com/a/66196291
4#![allow(dead_code)]
5#![cfg_attr(not(test), warn(clippy::expect_used))]
6
7use core::str;
8use std::{
9 collections::{HashMap, HashSet},
10 env,
11 io::{self, Stdin},
12 path::{Path, PathBuf},
13};
14
15use anyhow::{anyhow, bail, Context, Result};
16use auth_git2::GitAuthenticator;
17use client::{
18 consolidate_fetch_reports, get_events_from_cache, get_repo_ref_from_cache,
19 get_state_from_cache, sign_event, Connect, STATE_KIND,
20};
21use console::Term;
22use git::{sha1_to_oid, NostrUrlDecoded, RepoActions};
23use git2::{Oid, Repository};
24use nostr::nips::{nip01::Coordinate, nip10::Marker};
25use nostr_sdk::{
26 hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, EventId, Kind, PublicKey, Tag, Url,
27};
28use nostr_signer::NostrSigner;
29use repo_ref::RepoRef;
30use repo_state::RepoState;
31use sub_commands::{
32 list::{
33 get_all_proposal_patch_events_from_cache, get_commit_id_from_patch,
34 get_most_recent_patch_with_ancestors, get_proposals_and_revisions_from_cache, status_kinds,
35 tag_value,
36 },
37 send::{
38 event_is_revision_root, event_to_cover_letter, generate_cover_letter_and_patch_events,
39 generate_patch_event, send_events,
40 },
41};
42
43#[cfg(not(test))]
44use crate::client::Client;
45#[cfg(test)]
46use crate::client::MockConnect;
47use crate::git::Repo;
48
49mod cli;
50mod cli_interactor;
51mod client;
52mod config;
53mod git;
54mod key_handling;
55mod login;
56mod repo_ref;
57mod repo_state;
58mod sub_commands;
59
60#[tokio::main]
61async fn main() -> Result<()> {
62 let args = env::args();
63 let args = args.skip(1).take(2).collect::<Vec<_>>();
64
65 let ([_, nostr_remote_url] | [nostr_remote_url]) = args.as_slice() else {
66 bail!("invalid arguments - no url");
67 };
68 if env::args().nth(1).as_deref() == Some("--version") {
69 const VERSION: &str = env!("CARGO_PKG_VERSION");
70 println!("v{VERSION}");
71 return Ok(());
72 }
73
74 let git_repo = Repo::from_path(&PathBuf::from(
75 std::env::var("GIT_DIR").context("git should set GIT_DIR when remote helper is called")?,
76 ))?;
77 let git_repo_path = git_repo.get_path()?;
78
79 #[cfg(not(test))]
80 let client = Client::default();
81 #[cfg(test)]
82 let client = <MockConnect as std::default::Default>::default();
83
84 let decoded_nostr_url =
85 NostrUrlDecoded::from_str(nostr_remote_url).context("invalid nostr url")?;
86
87 fetching_with_report_for_helper(git_repo_path, &client, &decoded_nostr_url.coordinates).await?;
88
89 let repo_ref = get_repo_ref_from_cache(git_repo_path, &decoded_nostr_url.coordinates).await?;
90
91 let stdin = io::stdin();
92 let mut line = String::new();
93
94 let mut list_outputs = None;
95 loop {
96 let tokens = read_line(&stdin, &mut line)?;
97
98 match tokens.as_slice() {
99 ["capabilities"] => {
100 println!("option");
101 println!("push");
102 println!("fetch");
103 println!();
104 }
105 ["option", "verbosity"] => {
106 println!("ok");
107 }
108 ["option", ..] => {
109 println!("unsupported");
110 }
111 ["fetch", oid, refstr] => {
112 fetch(&git_repo, &repo_ref, &stdin, oid, refstr).await?;
113 }
114 ["push", refspec] => {
115 push(
116 &git_repo,
117 &repo_ref,
118 nostr_remote_url,
119 &stdin,
120 refspec,
121 &client,
122 list_outputs.clone(),
123 )
124 .await?;
125 }
126 ["list"] => {
127 list_outputs = Some(list(&git_repo, &repo_ref, false).await?);
128 }
129 ["list", "for-push"] => {
130 list_outputs = Some(list(&git_repo, &repo_ref, true).await?);
131 }
132 [] => {
133 return Ok(());
134 }
135 _ => {
136 bail!(format!("unknown command: {}", line.trim().to_owned()));
137 }
138 }
139 }
140}
141
142/// Read one line from stdin, and split it into tokens.
143pub(crate) fn read_line<'a>(stdin: &io::Stdin, line: &'a mut String) -> io::Result<Vec<&'a str>> {
144 line.clear();
145
146 let read = stdin.read_line(line)?;
147 if read == 0 {
148 return Ok(vec![]);
149 }
150 let line = line.trim();
151 let tokens = line.split(' ').filter(|t| !t.is_empty()).collect();
152
153 Ok(tokens)
154}
155
156async fn fetching_with_report_for_helper(
157 git_repo_path: &Path,
158 #[cfg(test)] client: &crate::client::MockConnect,
159 #[cfg(not(test))] client: &Client,
160 repo_coordinates: &HashSet<Coordinate>,
161) -> Result<()> {
162 let term = console::Term::stderr();
163 term.write_line("nostr: fetching...")?;
164 let (relay_reports, progress_reporter) = client
165 .fetch_all(git_repo_path, repo_coordinates, &HashSet::new())
166 .await?;
167 if !relay_reports.iter().any(std::result::Result::is_err) {
168 let _ = progress_reporter.clear();
169 term.clear_last_lines(1)?;
170 }
171 let report = consolidate_fetch_reports(relay_reports);
172 if report.to_string().is_empty() {
173 term.write_line("nostr: no updates")?;
174 } else {
175 term.write_line(&format!("nostr updates: {report}"))?;
176 }
177 Ok(())
178}
179
180async fn list(
181 git_repo: &Repo,
182 repo_ref: &RepoRef,
183 for_push: bool,
184) -> Result<HashMap<String, HashMap<String, String>>> {
185 let nostr_state =
186 if let Ok(nostr_state) = get_state_from_cache(git_repo.get_path()?, repo_ref).await {
187 Some(nostr_state)
188 } else {
189 None
190 };
191
192 let term = console::Term::stderr();
193
194 let remote_states = list_from_remotes(&term, git_repo, &repo_ref.git_server)?;
195
196 let mut state = if let Some(nostr_state) = nostr_state {
197 for (name, value) in &nostr_state.state {
198 for (url, remote_state) in &remote_states {
199 let remote_name = get_short_git_server_name(git_repo, url);
200 if let Some(remote_value) = remote_state.get(name) {
201 if value.ne(remote_value) {
202 term.write_line(
203 format!(
204 "WARNING: {remote_name} {name} is {} nostr ",
205 if let Ok((ahead, behind)) =
206 get_ahead_behind(git_repo, value, remote_value)
207 {
208 format!("{} ahead {} behind", ahead.len(), behind.len())
209 } else {
210 "out of sync with".to_string()
211 }
212 )
213 .as_str(),
214 )?;
215 }
216 } else {
217 term.write_line(
218 format!("WARNING: {remote_name} {name} is missing but tracked on nostr")
219 .as_str(),
220 )?;
221 }
222 }
223 }
224 nostr_state.state
225 } else {
226 repo_ref
227 .git_server
228 .iter()
229 .filter_map(|server| remote_states.get(server))
230 .cloned()
231 .collect::<Vec<HashMap<String, String>>>()
232 .first()
233 .context("failed to get refs from git server")?
234 .clone()
235 };
236
237 state.retain(|k, _| !k.starts_with("refs/heads/pr/"));
238
239 let open_proposals = get_open_proposals(git_repo, repo_ref).await?;
240 let current_user = get_curent_user(git_repo)?;
241 for (_, (proposal, patches)) in open_proposals {
242 if let Ok(cl) = event_to_cover_letter(&proposal) {
243 if let Ok(mut branch_name) = cl.get_branch_name() {
244 branch_name = if let Some(public_key) = current_user {
245 if proposal.author().eq(&public_key) {
246 cl.branch_name.to_string()
247 } else {
248 branch_name
249 }
250 } else {
251 branch_name
252 };
253 if let Some(patch) = patches.first() {
254 // TODO this isn't resilient because the commit id stated may not be correct
255 // we will need to check whether the commit id exists in the repo or apply the
256 // proposal and each patch to check
257 if let Ok(commit_id) = get_commit_id_from_patch(patch) {
258 state.insert(format!("refs/heads/{branch_name}"), commit_id);
259 }
260 }
261 }
262 }
263 }
264
265 // TODO 'for push' should we check with the git servers to see if any of them
266 // allow push from the user?
267 for (name, value) in state {
268 if value.starts_with("ref: ") {
269 if !for_push {
270 println!("{} {name}", value.replace("ref: ", "@"));
271 }
272 } else {
273 println!("{value} {name}");
274 }
275 }
276
277 println!();
278 Ok(remote_states)
279}
280
281fn list_from_remotes(
282 term: &console::Term,
283 git_repo: &Repo,
284 git_servers: &Vec<String>,
285) -> Result<HashMap<String, HashMap<String, String>>> {
286 let mut remote_states = HashMap::new();
287 for url in git_servers {
288 let short_name = get_short_git_server_name(git_repo, url);
289 term.write_line(format!("fetching refs list: {short_name}...").as_str())?;
290 match list_from_remote(git_repo, url) {
291 Ok(remote_state) => {
292 remote_states.insert(url.clone(), remote_state);
293 }
294 Err(error1) => {
295 if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(url) {
296 match list_from_remote(git_repo, &alternative_url) {
297 Ok(remote_state) => {
298 remote_states.insert(url.clone(), remote_state);
299 }
300 Err(error2) => {
301 term.write_line(
302 format!("WARNING: {short_name} failed to list refs error: {error1}\r\nand alternative protocol {alternative_url}: {error2}").as_str(),
303 )?;
304 }
305 }
306 } else {
307 term.write_line(
308 format!("WARNING: {short_name} failed to list refs error: {error1}",)
309 .as_str(),
310 )?;
311 }
312 }
313 }
314 term.clear_last_lines(1)?;
315 }
316 Ok(remote_states)
317}
318
319fn switch_clone_url_between_ssh_and_https(url: &str) -> Result<String> {
320 if url.starts_with("https://") {
321 // Convert HTTPS to git@ syntax
322 let parts: Vec<&str> = url.trim_start_matches("https://").split('/').collect();
323 if parts.len() >= 2 {
324 // Construct the git@ URL
325 Ok(format!("git@{}:{}", parts[0], parts[1..].join("/")))
326 } else {
327 // If the format is unexpected, return an error
328 bail!("Invalid HTTPS URL format: {}", url);
329 }
330 } else if url.starts_with("ssh://") {
331 // Convert SSH to git@ syntax
332 let parts: Vec<&str> = url.trim_start_matches("ssh://").split('/').collect();
333 if parts.len() >= 2 {
334 // Construct the git@ URL
335 Ok(format!("git@{}:{}", parts[0], parts[1..].join("/")))
336 } else {
337 // If the format is unexpected, return an error
338 bail!("Invalid SSH URL format: {}", url);
339 }
340 } else if url.starts_with("git@") {
341 // Convert git@ syntax to HTTPS
342 let parts: Vec<&str> = url.split(':').collect();
343 if parts.len() == 2 {
344 // Construct the HTTPS URL
345 Ok(format!(
346 "https://{}/{}",
347 parts[0].trim_end_matches('@'),
348 parts[1]
349 ))
350 } else {
351 // If the format is unexpected, return an error
352 bail!("Invalid git@ URL format: {}", url);
353 }
354 } else {
355 // If the URL is neither HTTPS, SSH, nor git@, return an error
356 bail!("Unsupported URL protocol: {}", url);
357 }
358}
359
360fn list_from_remote(
361 git_repo: &Repo,
362 git_server_remote_url: &str,
363) -> Result<HashMap<String, String>> {
364 let git_config = git_repo.git_repo.config()?;
365
366 let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_remote_url)?;
367 // authentication may be required
368 let auth = GitAuthenticator::default();
369 let mut remote_callbacks = git2::RemoteCallbacks::new();
370 remote_callbacks.credentials(auth.credentials(&git_config));
371 git_server_remote.connect_auth(git2::Direction::Fetch, Some(remote_callbacks), None)?;
372 let mut state = HashMap::new();
373 for head in git_server_remote.list()? {
374 if let Some(symbolic_reference) = head.symref_target() {
375 state.insert(
376 head.name().to_string(),
377 format!("ref: {symbolic_reference}"),
378 );
379 } else {
380 state.insert(head.name().to_string(), head.oid().to_string());
381 }
382 }
383 git_server_remote.disconnect()?;
384 Ok(state)
385}
386
387fn get_ahead_behind(
388 git_repo: &Repo,
389 base_ref_or_oid: &str,
390 latest_ref_or_oid: &str,
391) -> Result<(Vec<Sha1Hash>, Vec<Sha1Hash>)> {
392 let base = git_repo.get_commit_or_tip_of_reference(base_ref_or_oid)?;
393 let latest = git_repo.get_commit_or_tip_of_reference(latest_ref_or_oid)?;
394 git_repo.get_commits_ahead_behind(&base, &latest)
395}
396
397async fn get_open_proposals(
398 git_repo: &Repo,
399 repo_ref: &RepoRef,
400) -> Result<HashMap<EventId, (Event, Vec<Event>)>> {
401 let git_repo_path = git_repo.get_path()?;
402 let proposals: Vec<nostr::Event> =
403 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates())
404 .await?
405 .iter()
406 .filter(|e| !event_is_revision_root(e))
407 .cloned()
408 .collect();
409
410 let statuses: Vec<nostr::Event> = {
411 let mut statuses = get_events_from_cache(
412 git_repo_path,
413 vec![
414 nostr::Filter::default()
415 .kinds(status_kinds().clone())
416 .events(proposals.iter().map(nostr::Event::id)),
417 ],
418 )
419 .await?;
420 statuses.sort_by_key(|e| e.created_at);
421 statuses.reverse();
422 statuses
423 };
424 let mut open_proposals = HashMap::new();
425
426 for proposal in proposals {
427 let status = if let Some(e) = statuses
428 .iter()
429 .filter(|e| {
430 status_kinds().contains(&e.kind())
431 && e.tags()
432 .iter()
433 .any(|t| t.as_vec()[1].eq(&proposal.id.to_string()))
434 })
435 .collect::<Vec<&nostr::Event>>()
436 .first()
437 {
438 e.kind()
439 } else {
440 Kind::GitStatusOpen
441 };
442 if status.eq(&Kind::GitStatusOpen) {
443 if let Ok(commits_events) =
444 get_all_proposal_patch_events_from_cache(git_repo_path, repo_ref, &proposal.id)
445 .await
446 {
447 if let Ok(most_recent_proposal_patch_chain) =
448 get_most_recent_patch_with_ancestors(commits_events.clone())
449 {
450 open_proposals
451 .insert(proposal.id(), (proposal, most_recent_proposal_patch_chain));
452 }
453 }
454 }
455 }
456 Ok(open_proposals)
457}
458
459fn get_curent_user(git_repo: &Repo) -> Result<Option<PublicKey>> {
460 Ok(
461 if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? {
462 if let Ok(public_key) = PublicKey::parse(npub) {
463 Some(public_key)
464 } else {
465 None
466 }
467 } else {
468 None
469 },
470 )
471}
472
473async fn get_all_proposals(
474 git_repo: &Repo,
475 repo_ref: &RepoRef,
476) -> Result<HashMap<EventId, (Event, Vec<Event>)>> {
477 let git_repo_path = git_repo.get_path()?;
478 let proposals: Vec<nostr::Event> =
479 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates())
480 .await?
481 .iter()
482 .filter(|e| !event_is_revision_root(e))
483 .cloned()
484 .collect();
485
486 let mut all_proposals = HashMap::new();
487
488 for proposal in proposals {
489 if let Ok(commits_events) =
490 get_all_proposal_patch_events_from_cache(git_repo_path, repo_ref, &proposal.id).await
491 {
492 if let Ok(most_recent_proposal_patch_chain) =
493 get_most_recent_patch_with_ancestors(commits_events.clone())
494 {
495 all_proposals.insert(proposal.id(), (proposal, most_recent_proposal_patch_chain));
496 }
497 }
498 }
499 Ok(all_proposals)
500}
501
502async fn fetch(
503 git_repo: &Repo,
504 repo_ref: &RepoRef,
505 stdin: &Stdin,
506 oid: &str,
507 refstr: &str,
508) -> Result<()> {
509 let mut fetch_batch = get_oids_from_fetch_batch(stdin, oid, refstr)?;
510
511 let oids_from_git_servers = fetch_batch
512 .iter()
513 .filter(|(refstr, _)| !refstr.contains("refs/heads/pr/"))
514 .map(|(_, oid)| oid.clone())
515 .collect::<Vec<String>>();
516
517 let mut errors = HashMap::new();
518 let term = console::Term::stderr();
519
520 for git_server_url in &repo_ref.git_server {
521 let term = console::Term::stderr();
522 let short_name = get_short_git_server_name(git_repo, git_server_url);
523 term.write_line(format!("fetching from {short_name}...").as_str())?;
524 let res = fetch_from_git_server(&git_repo.git_repo, &oids_from_git_servers, git_server_url);
525 term.clear_last_lines(1)?;
526 if let Err(error1) = res {
527 if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(git_server_url) {
528 let res2 = fetch_from_git_server(
529 &git_repo.git_repo,
530 &oids_from_git_servers,
531 &alternative_url,
532 );
533 if let Err(error2) = res2 {
534 term.write_line(
535 format!(
536 "WARNING: failed to fetch from {short_name} error:{error1}\r\nand using alternative protocol {alternative_url}: {error2}"
537 ).as_str()
538 )?;
539 errors.insert(
540 short_name.to_string(),
541 anyhow!(
542 "{error1} and using alternative protocol {alternative_url}: {error2}"
543 ),
544 );
545 } else {
546 break;
547 }
548 } else {
549 term.write_line(
550 format!("WARNING: failed to fetch from {short_name} error:{error1}").as_str(),
551 )?;
552 errors.insert(short_name.to_string(), error1);
553 }
554 } else {
555 break;
556 }
557 }
558
559 if oids_from_git_servers
560 .iter()
561 .any(|oid| !git_repo.does_commit_exist(oid).unwrap())
562 && !errors.is_empty()
563 {
564 bail!(
565 "failed to fetch objects in nostr state event from:\r\n{}",
566 errors
567 .iter()
568 .map(|(url, error)| format!("{url}: {error}"))
569 .collect::<Vec<String>>()
570 .join("\r\n")
571 );
572 }
573
574 fetch_batch.retain(|refstr, _| refstr.contains("refs/heads/pr/"));
575
576 if !fetch_batch.is_empty() {
577 let open_proposals = get_open_proposals(git_repo, repo_ref).await?;
578
579 let current_user = get_curent_user(git_repo)?;
580
581 for (refstr, oid) in fetch_batch {
582 if let Some((_, (_, patches))) =
583 find_proposal_and_patches_by_branch_name(&refstr, &open_proposals, &current_user)
584 {
585 if !git_repo.does_commit_exist(&oid)? {
586 let mut patches_ancestor_first = patches.clone();
587 patches_ancestor_first.reverse();
588 if git_repo.does_commit_exist(&tag_value(
589 patches_ancestor_first.first().unwrap(),
590 "parent-commit",
591 )?)? {
592 for patch in &patches_ancestor_first {
593 git_repo.create_commit_from_patch(patch)?;
594 }
595 } else {
596 term.write_line(
597 format!("WARNING: cannot find parent commit for {refstr}").as_str(),
598 )?;
599 }
600 }
601 } else {
602 term.write_line(format!("WARNING: cannot find proposal for {refstr}").as_str())?;
603 }
604 }
605 }
606
607 term.flush()?;
608 println!();
609 Ok(())
610}
611
612fn find_proposal_and_patches_by_branch_name<'a>(
613 refstr: &'a str,
614 open_proposals: &'a HashMap<EventId, (Event, Vec<Event>)>,
615 current_user: &Option<PublicKey>,
616) -> Option<(&'a EventId, &'a (Event, Vec<Event>))> {
617 open_proposals.iter().find(|(_, (proposal, _))| {
618 if let Ok(cl) = event_to_cover_letter(proposal) {
619 if let Ok(mut branch_name) = cl.get_branch_name() {
620 branch_name = if let Some(public_key) = current_user {
621 if proposal.author().eq(public_key) {
622 cl.branch_name.to_string()
623 } else {
624 branch_name
625 }
626 } else {
627 branch_name
628 };
629 branch_name.eq(&refstr.replace("refs/heads/", ""))
630 } else {
631 false
632 }
633 } else {
634 false
635 }
636 })
637}
638
639fn fetch_from_git_server(
640 git_repo: &Repository,
641 oids: &[String],
642 git_server_url: &str,
643) -> Result<()> {
644 let git_config = git_repo.config()?;
645
646 let mut git_server_remote = git_repo.remote_anonymous(git_server_url)?;
647 // authentication may be required (and will be requird if clone url is ssh)
648 let auth = GitAuthenticator::default();
649 let mut fetch_options = git2::FetchOptions::new();
650 let mut remote_callbacks = git2::RemoteCallbacks::new();
651 remote_callbacks.credentials(auth.credentials(&git_config));
652 fetch_options.remote_callbacks(remote_callbacks);
653 git_server_remote.download(oids, Some(&mut fetch_options))?;
654 git_server_remote.disconnect()?;
655 Ok(())
656}
657
658#[allow(clippy::too_many_lines)]
659async fn push(
660 git_repo: &Repo,
661 repo_ref: &RepoRef,
662 nostr_remote_url: &str,
663 stdin: &Stdin,
664 initial_refspec: &str,
665 #[cfg(test)] client: &crate::client::MockConnect,
666 #[cfg(not(test))] client: &Client,
667 list_outputs: Option<HashMap<String, HashMap<String, String>>>,
668) -> Result<()> {
669 let refspecs = get_refspecs_from_push_batch(stdin, initial_refspec)?;
670
671 let proposal_refspecs = refspecs
672 .iter()
673 .filter(|r| r.contains("refs/heads/pr/"))
674 .cloned()
675 .collect::<Vec<String>>();
676
677 let mut git_server_refspecs = refspecs
678 .iter()
679 .filter(|r| !r.contains("refs/heads/pr/"))
680 .cloned()
681 .collect::<Vec<String>>();
682
683 let term = console::Term::stderr();
684
685 let list_outputs = match list_outputs {
686 Some(outputs) => outputs,
687 _ => list_from_remotes(&term, git_repo, &repo_ref.git_server)?,
688 };
689
690 let nostr_state = get_state_from_cache(git_repo.get_path()?, repo_ref).await;
691
692 let existing_state = {
693 // if no state events - create from first git server listed
694 if let Ok(nostr_state) = &nostr_state {
695 nostr_state.state.clone()
696 } else if let Some(url) = repo_ref
697 .git_server
698 .iter()
699 .find(|&url| list_outputs.contains_key(url))
700 {
701 list_outputs.get(url).unwrap().to_owned()
702 } else {
703 bail!(
704 "cannot connect to git servers: {}",
705 repo_ref.git_server.join(" ")
706 );
707 }
708 };
709
710 let (rejected_refspecs, remote_refspecs) = create_rejected_refspecs_and_remotes_refspecs(
711 &term,
712 git_repo,
713 &git_server_refspecs,
714 &existing_state,
715 &list_outputs,
716 )?;
717
718 git_server_refspecs.retain(|refspec| {
719 if let Some(rejected) = rejected_refspecs.get(&refspec.to_string()) {
720 let (_, to) = refspec_to_from_to(refspec).unwrap();
721 println!("error {to} {} out of sync with nostr", rejected.join(" "));
722 false
723 } else {
724 true
725 }
726 });
727
728 let mut events = vec![];
729
730 if git_server_refspecs.is_empty() && proposal_refspecs.is_empty() {
731 // all refspecs rejected
732 println!();
733 return Ok(());
734 }
735
736 let (signer, user_ref) = login::launch(
737 git_repo,
738 &None,
739 &None,
740 &None,
741 &None,
742 Some(client),
743 false,
744 true,
745 )
746 .await?;
747
748 if !repo_ref.maintainers.contains(&user_ref.public_key) {
749 for refspec in &git_server_refspecs {
750 let (_, to) = refspec_to_from_to(refspec).unwrap();
751 println!(
752 "error {to} your nostr account {} isn't listed as a maintainer of the repo",
753 user_ref.metadata.name
754 );
755 }
756 git_server_refspecs.clear();
757 if proposal_refspecs.is_empty() {
758 println!();
759 return Ok(());
760 }
761 }
762
763 if !git_server_refspecs.is_empty() {
764 let new_state = generate_updated_state(git_repo, &existing_state, &git_server_refspecs)?;
765
766 let new_repo_state =
767 RepoState::build(repo_ref.identifier.clone(), new_state, &signer).await?;
768
769 events.push(new_repo_state.event);
770
771 for event in get_merged_status_events(
772 &term,
773 repo_ref,
774 git_repo,
775 nostr_remote_url,
776 &signer,
777 &git_server_refspecs,
778 )
779 .await?
780 {
781 events.push(event);
782 }
783 }
784
785 let mut rejected_proposal_refspecs = vec![];
786 if !proposal_refspecs.is_empty() {
787 let all_proposals = get_all_proposals(git_repo, repo_ref).await?;
788 let current_user = get_curent_user(git_repo)?;
789
790 for refspec in &proposal_refspecs {
791 let (from, to) = refspec_to_from_to(refspec).unwrap();
792 let tip_of_pushed_branch = git_repo.get_commit_or_tip_of_reference(from)?;
793
794 if let Some((_, (proposal, patches))) =
795 find_proposal_and_patches_by_branch_name(to, &all_proposals, &current_user)
796 {
797 if [repo_ref.maintainers.clone(), vec![proposal.author()]]
798 .concat()
799 .contains(&user_ref.public_key)
800 {
801 if refspec.starts_with('+') {
802 // force push
803 let (_, main_tip) = git_repo.get_main_or_master_branch()?;
804 let (mut ahead, _) =
805 git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?;
806 ahead.reverse();
807 for patch in generate_cover_letter_and_patch_events(
808 None,
809 git_repo,
810 &ahead,
811 &signer,
812 repo_ref,
813 &Some(proposal.id().to_string()),
814 &[],
815 )
816 .await?
817 {
818 events.push(patch);
819 }
820 } else {
821 // fast forward push
822 let tip_patch = patches.first().unwrap();
823 let tip_of_proposal = get_commit_id_from_patch(tip_patch)?;
824 let tip_of_proposal_commit =
825 git_repo.get_commit_or_tip_of_reference(&tip_of_proposal)?;
826
827 let (mut ahead, behind) = git_repo.get_commits_ahead_behind(
828 &tip_of_proposal_commit,
829 &tip_of_pushed_branch,
830 )?;
831 if behind.is_empty() {
832 let thread_id = if let Ok(root_event_id) = get_event_root(tip_patch) {
833 root_event_id
834 } else {
835 // tip patch is the root proposal
836 tip_patch.id()
837 };
838 let mut parent_patch = tip_patch.clone();
839 ahead.reverse();
840 for (i, commit) in ahead.iter().enumerate() {
841 let new_patch = generate_patch_event(
842 git_repo,
843 &git_repo.get_root_commit()?,
844 commit,
845 Some(thread_id),
846 &signer,
847 repo_ref,
848 Some(parent_patch.id()),
849 Some((
850 (patches.len() + i + 1).try_into().unwrap(),
851 (patches.len() + ahead.len()).try_into().unwrap(),
852 )),
853 None,
854 &None,
855 &[],
856 )
857 .await
858 .context("cannot make patch event from commit")?;
859 events.push(new_patch.clone());
860 parent_patch = new_patch;
861 }
862 } else {
863 // we shouldn't get here
864 term.write_line(
865 format!(
866 "WARNING: failed to push {from} as nostr proposal. Try and force push ",
867 )
868 .as_str(),
869 )
870 .unwrap();
871 println!(
872 "error {to} cannot fastforward as newer patches found on proposal"
873 );
874 rejected_proposal_refspecs.push(refspec.to_string());
875 }
876 }
877 } else {
878 println!(
879 "error {to} permission denied. you are not the proposal author or a repo maintainer"
880 );
881 rejected_proposal_refspecs.push(refspec.to_string());
882 }
883 } else {
884 // TODO new proposal / couldn't find exisiting proposal
885 let (_, main_tip) = git_repo.get_main_or_master_branch()?;
886 let (mut ahead, _) =
887 git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?;
888 ahead.reverse();
889 for patch in generate_cover_letter_and_patch_events(
890 None,
891 git_repo,
892 &ahead,
893 &signer,
894 repo_ref,
895 &None,
896 &[],
897 )
898 .await?
899 {
900 events.push(patch);
901 }
902 }
903 }
904 }
905
906 // TODO check whether tip of each branch pushed is on at least one git server
907 // before broadcasting the nostr state
908 if !events.is_empty() {
909 send_events(
910 client,
911 git_repo.get_path()?,
912 events,
913 user_ref.relays.write(),
914 repo_ref.relays.clone(),
915 false,
916 true,
917 )
918 .await?;
919 }
920
921 for refspec in &[git_server_refspecs.clone(), proposal_refspecs.clone()].concat() {
922 if rejected_proposal_refspecs.contains(refspec) {
923 continue;
924 }
925 let (_, to) = refspec_to_from_to(refspec)?;
926 println!("ok {to}");
927 update_remote_refs_pushed(&git_repo.git_repo, refspec, nostr_remote_url)
928 .context("could not update remote_ref locally")?;
929 }
930
931 // TODO make async - check gitlib2 callbacks work async
932 for (git_server_url, remote_refspecs) in remote_refspecs {
933 let remote_refspecs = remote_refspecs
934 .iter()
935 .filter(|refspec| git_server_refspecs.contains(refspec))
936 .cloned()
937 .collect::<Vec<String>>();
938 if !refspecs.is_empty()
939 && push_to_remote(git_repo, &git_server_url, &remote_refspecs, &term).is_err()
940 {
941 if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(&git_server_url) {
942 if push_to_remote(git_repo, &alternative_url, &remote_refspecs, &term).is_err() {
943 // errors get printed as part of callback
944 // TODO prevent 2 warning messages and instead use one
945 // to say it didnt work over either https or ssh
946 } else {
947 term.write_line(
948 format!("but succeed over alterantive protocol {alternative_url}",)
949 .as_str(),
950 )?;
951 }
952 }
953 }
954 }
955 println!();
956 Ok(())
957}
958
959fn push_to_remote(
960 git_repo: &Repo,
961 git_server_url: &str,
962 remote_refspecs: &[String],
963 term: &Term,
964) -> Result<()> {
965 let git_config = git_repo.git_repo.config()?;
966 let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_url)?;
967 let auth = GitAuthenticator::default();
968 let mut push_options = git2::PushOptions::new();
969 let mut remote_callbacks = git2::RemoteCallbacks::new();
970 remote_callbacks.credentials(auth.credentials(&git_config));
971 remote_callbacks.push_update_reference(|name, error| {
972 if let Some(error) = error {
973 term.write_line(
974 format!(
975 "WARNING: {} failed to push {name} error: {error}",
976 get_short_git_server_name(git_repo, git_server_url),
977 )
978 .as_str(),
979 )
980 .unwrap();
981 }
982 Ok(())
983 });
984 push_options.remote_callbacks(remote_callbacks);
985 git_server_remote.push(remote_refspecs, Some(&mut push_options))?;
986 let _ = git_server_remote.disconnect();
987 Ok(())
988}
989
990fn get_event_root(event: &nostr::Event) -> Result<EventId> {
991 Ok(EventId::parse(
992 event
993 .tags()
994 .iter()
995 .find(|t| t.is_root())
996 .context("no thread root in event")?
997 .as_vec()
998 .get(1)
999 .unwrap(),
1000 )?)
1001}
1002
1003type HashMapUrlRefspecs = HashMap<String, Vec<String>>;
1004
1005#[allow(clippy::too_many_lines)]
1006fn create_rejected_refspecs_and_remotes_refspecs(
1007 term: &console::Term,
1008 git_repo: &Repo,
1009 refspecs: &Vec<String>,
1010 nostr_state: &HashMap<String, String>,
1011 list_outputs: &HashMap<String, HashMap<String, String>>,
1012) -> Result<(HashMapUrlRefspecs, HashMapUrlRefspecs)> {
1013 let mut refspecs_for_remotes = HashMap::new();
1014
1015 let mut rejected_refspecs: HashMapUrlRefspecs = HashMap::new();
1016
1017 for (url, remote_state) in list_outputs {
1018 let short_name = get_short_git_server_name(git_repo, url);
1019 let mut refspecs_for_remote = vec![];
1020 for refspec in refspecs {
1021 let (from, to) = refspec_to_from_to(refspec)?;
1022 let nostr_value = nostr_state.get(to);
1023 let remote_value = remote_state.get(to);
1024 if from.is_empty() {
1025 if remote_value.is_some() {
1026 // delete remote branch
1027 refspecs_for_remote.push(refspec.clone());
1028 }
1029 continue;
1030 }
1031 let from_tip = git_repo.get_commit_or_tip_of_reference(from)?;
1032 if let Some(nostr_value) = nostr_value {
1033 if let Some(remote_value) = remote_value {
1034 if nostr_value.eq(remote_value) {
1035 // in sync - existing branch at same state
1036 let is_remote_tip_ancestor_of_commit = if let Ok(remote_value_tip) =
1037 git_repo.get_commit_or_tip_of_reference(remote_value)
1038 {
1039 if let Ok((_, behind)) =
1040 git_repo.get_commits_ahead_behind(&remote_value_tip, &from_tip)
1041 {
1042 behind.is_empty()
1043 } else {
1044 false
1045 }
1046 } else {
1047 false
1048 };
1049 if is_remote_tip_ancestor_of_commit {
1050 refspecs_for_remote.push(refspec.clone());
1051 } else {
1052 // this is a force push so we need to force push to git server too
1053 if refspec.starts_with('+') {
1054 refspecs_for_remote.push(refspec.clone());
1055 } else {
1056 refspecs_for_remote.push(format!("+{refspec}"));
1057 }
1058 }
1059 } else if let Ok(remote_value_tip) =
1060 git_repo.get_commit_or_tip_of_reference(remote_value)
1061 {
1062 if from_tip.eq(&remote_value_tip) {
1063 // remote already at correct state
1064 term.write_line(
1065 format!("{short_name} {to} already up-to-date").as_str(),
1066 )?;
1067 }
1068 let (ahead_of_local, behind_local) =
1069 git_repo.get_commits_ahead_behind(&from_tip, &remote_value_tip)?;
1070 if ahead_of_local.is_empty() {
1071 // can soft push
1072 refspecs_for_remote.push(refspec.clone());
1073 } else {
1074 // cant soft push
1075 let (ahead_of_nostr, behind_nostr) = git_repo
1076 .get_commits_ahead_behind(
1077 &git_repo.get_commit_or_tip_of_reference(nostr_value)?,
1078 &remote_value_tip,
1079 )?;
1080 if ahead_of_nostr.is_empty() {
1081 // ancestor of nostr and we are force pushing anyway...
1082 refspecs_for_remote.push(refspec.clone());
1083 } else {
1084 rejected_refspecs
1085 .entry(refspec.to_string())
1086 .and_modify(|a| a.push(url.to_string()))
1087 .or_insert(vec![url.to_string()]);
1088 term.write_line(
1089 format!(
1090 "ERROR: {short_name} {to} conflicts with nostr ({} ahead {} behind) and local ({} ahead {} behind). either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote",
1091 ahead_of_nostr.len(),
1092 behind_nostr.len(),
1093 ahead_of_local.len(),
1094 behind_local.len(),
1095 ).as_str(),
1096 )?;
1097 }
1098 };
1099 } else {
1100 // remote_value oid is not present locally
1101 // TODO can we download the remote reference?
1102
1103 // cant soft push
1104 rejected_refspecs
1105 .entry(refspec.to_string())
1106 .and_modify(|a| a.push(url.to_string()))
1107 .or_insert(vec![url.to_string()]);
1108 term.write_line(
1109 format!("ERROR: {short_name} {to} conflicts with nostr and is not an ancestor of local branch. either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote").as_str(),
1110 )?;
1111 }
1112 } else {
1113 // existing nostr branch not on remote
1114 // report - creating new branch
1115 term.write_line(
1116 format!(
1117 "{short_name} {to} doesn't exist and will be added as a new branch"
1118 )
1119 .as_str(),
1120 )?;
1121 refspecs_for_remote.push(refspec.clone());
1122 }
1123 } else if let Some(remote_value) = remote_value {
1124 // new to nostr but on remote
1125 if let Ok(remote_value_tip) = git_repo.get_commit_or_tip_of_reference(remote_value)
1126 {
1127 let (ahead, behind) =
1128 git_repo.get_commits_ahead_behind(&from_tip, &remote_value_tip)?;
1129 if behind.is_empty() {
1130 // can soft push
1131 refspecs_for_remote.push(refspec.clone());
1132 } else {
1133 // cant soft push
1134 rejected_refspecs
1135 .entry(refspec.to_string())
1136 .and_modify(|a| a.push(url.to_string()))
1137 .or_insert(vec![url.to_string()]);
1138 term.write_line(
1139 format!(
1140 "ERROR: {short_name} already contains {to} {} ahead and {} behind local branch. either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote",
1141 ahead.len(),
1142 behind.len(),
1143 ).as_str(),
1144 )?;
1145 }
1146 } else {
1147 // havn't fetched oid from remote
1148 // TODO fetch oid from remote
1149 // cant soft push
1150 rejected_refspecs
1151 .entry(refspec.to_string())
1152 .and_modify(|a| a.push(url.to_string()))
1153 .or_insert(vec![url.to_string()]);
1154 term.write_line(
1155 format!("ERROR: {short_name} already contains {to} at {remote_value} which is not an ancestor of local branch. either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote").as_str(),
1156 )?;
1157 }
1158 } else {
1159 // in sync - new branch
1160 refspecs_for_remote.push(refspec.clone());
1161 }
1162 }
1163 if !refspecs_for_remote.is_empty() {
1164 refspecs_for_remotes.insert(url.to_string(), refspecs_for_remote);
1165 }
1166 }
1167
1168 // remove rejected refspecs so they dont get pushed to some remotes
1169 let mut remotes_refspecs_without_rejected = HashMap::new();
1170 for (url, value) in &refspecs_for_remotes {
1171 remotes_refspecs_without_rejected.insert(
1172 url.to_string(),
1173 value
1174 .iter()
1175 .filter(|refspec| !rejected_refspecs.contains_key(*refspec))
1176 .cloned()
1177 .collect(),
1178 );
1179 }
1180 Ok((rejected_refspecs, remotes_refspecs_without_rejected))
1181}
1182
1183fn generate_updated_state(
1184 git_repo: &Repo,
1185 existing_state: &HashMap<String, String>,
1186 refspecs: &Vec<String>,
1187) -> Result<HashMap<String, String>> {
1188 let mut new_state = existing_state.clone();
1189
1190 for refspec in refspecs {
1191 let (from, to) = refspec_to_from_to(refspec)?;
1192 if from.is_empty() {
1193 // delete
1194 new_state.remove(to);
1195 if to.contains("refs/tags") {
1196 new_state.remove(&format!("{to}{}", "^{}"));
1197 }
1198 } else if to.contains("refs/tags") {
1199 new_state.insert(
1200 format!("{to}{}", "^{}"),
1201 git_repo
1202 .get_commit_or_tip_of_reference(from)
1203 .unwrap()
1204 .to_string(),
1205 );
1206 new_state.insert(
1207 to.to_string(),
1208 git_repo
1209 .git_repo
1210 .find_reference(to)
1211 .unwrap()
1212 .peel(git2::ObjectType::Tag)
1213 .unwrap()
1214 .id()
1215 .to_string(),
1216 );
1217 } else {
1218 // add or update
1219 new_state.insert(
1220 to.to_string(),
1221 git_repo
1222 .get_commit_or_tip_of_reference(from)
1223 .unwrap()
1224 .to_string(),
1225 );
1226 }
1227 }
1228 Ok(new_state)
1229}
1230
1231async fn get_merged_status_events(
1232 term: &console::Term,
1233 repo_ref: &RepoRef,
1234 git_repo: &Repo,
1235 remote_nostr_url: &str,
1236 signer: &NostrSigner,
1237 refspecs_to_git_server: &Vec<String>,
1238) -> Result<Vec<Event>> {
1239 let mut events = vec![];
1240 for refspec in refspecs_to_git_server {
1241 let (from, to) = refspec_to_from_to(refspec)?;
1242 if to.eq("refs/heads/main") || to.eq("refs/heads/master") {
1243 let tip_of_pushed_branch = git_repo.get_commit_or_tip_of_reference(from)?;
1244 let Ok(tip_of_remote_branch) = git_repo.get_commit_or_tip_of_reference(
1245 &refspec_remote_ref_name(&git_repo.git_repo, refspec, remote_nostr_url)?,
1246 ) else {
1247 // branch not on remote
1248 continue;
1249 };
1250 let (ahead, _) =
1251 git_repo.get_commits_ahead_behind(&tip_of_remote_branch, &tip_of_pushed_branch)?;
1252 for commit_hash in ahead {
1253 let commit = git_repo.git_repo.find_commit(sha1_to_oid(&commit_hash)?)?;
1254 if commit.parent_count() > 1 {
1255 // merge commit
1256 for parent in commit.parents() {
1257 // lookup parent id
1258 let commit_events = get_events_from_cache(
1259 git_repo.get_path()?,
1260 vec![
1261 nostr::Filter::default()
1262 .kind(nostr::Kind::GitPatch)
1263 .reference(parent.id().to_string()),
1264 ],
1265 )
1266 .await?;
1267 if let Some(commit_event) = commit_events.iter().find(|e| {
1268 e.tags.iter().any(|t| {
1269 t.as_vec()[0].eq("commit")
1270 && t.as_vec()[1].eq(&parent.id().to_string())
1271 })
1272 }) {
1273 let (proposal_id, revision_id) =
1274 get_proposal_and_revision_root_from_patch(git_repo, commit_event)
1275 .await?;
1276 term.write_line(
1277 format!(
1278 "merge commit {}: create nostr proposal status event",
1279 &commit.id().to_string()[..7],
1280 )
1281 .as_str(),
1282 )?;
1283
1284 events.push(
1285 create_merge_status(
1286 signer,
1287 repo_ref,
1288 &get_event_from_cache_by_id(git_repo, &proposal_id).await?,
1289 &if let Some(revision_id) = revision_id {
1290 Some(
1291 get_event_from_cache_by_id(git_repo, &revision_id)
1292 .await?,
1293 )
1294 } else {
1295 None
1296 },
1297 &commit_hash,
1298 commit_event.id(),
1299 )
1300 .await?,
1301 );
1302 }
1303 }
1304 }
1305 }
1306 }
1307 }
1308 Ok(events)
1309}
1310
1311async fn get_event_from_cache_by_id(git_repo: &Repo, event_id: &EventId) -> Result<Event> {
1312 Ok(get_events_from_cache(
1313 git_repo.get_path()?,
1314 vec![nostr::Filter::default().id(*event_id)],
1315 )
1316 .await?
1317 .first()
1318 .context("cannot find event in cache")?
1319 .clone())
1320}
1321
1322async fn create_merge_status(
1323 signer: &NostrSigner,
1324 repo_ref: &RepoRef,
1325 proposal: &Event,
1326 revision: &Option<Event>,
1327 merge_commit: &Sha1Hash,
1328 merged_patch: EventId,
1329) -> Result<Event> {
1330 let mut public_keys = repo_ref
1331 .maintainers
1332 .iter()
1333 .copied()
1334 .collect::<HashSet<PublicKey>>();
1335 public_keys.insert(proposal.author());
1336 if let Some(revision) = revision {
1337 public_keys.insert(revision.author());
1338 }
1339 sign_event(
1340 EventBuilder::new(
1341 nostr::event::Kind::GitStatusApplied,
1342 String::new(),
1343 [
1344 vec![
1345 Tag::custom(
1346 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
1347 vec!["git proposal merged / applied".to_string()],
1348 ),
1349 Tag::from_standardized(nostr::TagStandard::Event {
1350 event_id: proposal.id(),
1351 relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new),
1352 marker: Some(Marker::Root),
1353 public_key: None,
1354 }),
1355 Tag::from_standardized(nostr::TagStandard::Event {
1356 event_id: merged_patch,
1357 relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new),
1358 marker: Some(Marker::Mention),
1359 public_key: None,
1360 }),
1361 ],
1362 if let Some(revision) = revision {
1363 vec![Tag::from_standardized(nostr::TagStandard::Event {
1364 event_id: revision.id(),
1365 relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new),
1366 marker: Some(Marker::Root),
1367 public_key: None,
1368 })]
1369 } else {
1370 vec![]
1371 },
1372 public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(),
1373 repo_ref
1374 .coordinates()
1375 .iter()
1376 .map(|c| Tag::coordinate(c.clone()))
1377 .collect::<Vec<Tag>>(),
1378 vec![
1379 Tag::from_standardized(nostr::TagStandard::Reference(
1380 repo_ref.root_commit.to_string(),
1381 )),
1382 Tag::from_standardized(nostr::TagStandard::Reference(format!(
1383 "{merge_commit}"
1384 ))),
1385 Tag::custom(
1386 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("merge-commit-id")),
1387 vec![format!("{merge_commit}")],
1388 ),
1389 ],
1390 ]
1391 .concat(),
1392 ),
1393 signer,
1394 )
1395 .await
1396}
1397
1398async fn get_proposal_and_revision_root_from_patch(
1399 git_repo: &Repo,
1400 patch: &Event,
1401) -> Result<(EventId, Option<EventId>)> {
1402 let proposal_or_revision = if patch.tags.iter().any(|t| t.as_vec()[1].eq("root")) {
1403 patch.clone()
1404 } else {
1405 let proposal_or_revision_id = EventId::parse(
1406 if let Some(t) = patch.tags.iter().find(|t| t.is_root()) {
1407 t.clone()
1408 } else if let Some(t) = patch.tags.iter().find(|t| t.is_reply()) {
1409 t.clone()
1410 } else {
1411 Tag::event(patch.id())
1412 }
1413 .as_vec()[1]
1414 .clone(),
1415 )?;
1416
1417 get_events_from_cache(
1418 git_repo.get_path()?,
1419 vec![nostr::Filter::default().id(proposal_or_revision_id)],
1420 )
1421 .await?
1422 .first()
1423 .unwrap()
1424 .clone()
1425 };
1426
1427 if !proposal_or_revision.kind().eq(&Kind::GitPatch) {
1428 bail!("thread root is not a git patch");
1429 }
1430
1431 if proposal_or_revision
1432 .tags
1433 .iter()
1434 .any(|t| t.as_vec()[1].eq("revision-root"))
1435 {
1436 Ok((
1437 EventId::parse(
1438 proposal_or_revision
1439 .tags
1440 .iter()
1441 .find(|t| t.is_reply())
1442 .unwrap()
1443 .as_vec()[1]
1444 .clone(),
1445 )?,
1446 Some(proposal_or_revision.id()),
1447 ))
1448 } else {
1449 Ok((proposal_or_revision.id(), None))
1450 }
1451}
1452
1453fn update_remote_refs_pushed(
1454 git_repo: &Repository,
1455 refspec: &str,
1456 nostr_remote_url: &str,
1457) -> Result<()> {
1458 let (from, _) = refspec_to_from_to(refspec)?;
1459
1460 let target_ref_name = refspec_remote_ref_name(git_repo, refspec, nostr_remote_url)?;
1461
1462 if from.is_empty() {
1463 if let Ok(mut remote_ref) = git_repo.find_reference(&target_ref_name) {
1464 remote_ref.delete()?;
1465 }
1466 } else {
1467 let commit = reference_to_commit(git_repo, from)
1468 .context(format!("cannot get commit of reference {from}"))?;
1469 if let Ok(mut remote_ref) = git_repo.find_reference(&target_ref_name) {
1470 remote_ref.set_target(commit, "updated by nostr remote helper")?;
1471 } else {
1472 git_repo.reference(
1473 &target_ref_name,
1474 commit,
1475 false,
1476 "created by nostr remote helper",
1477 )?;
1478 }
1479 }
1480 Ok(())
1481}
1482
1483fn refspec_to_from_to(refspec: &str) -> Result<(&str, &str)> {
1484 if !refspec.contains(':') {
1485 bail!(
1486 "refspec should contain a colon (:) but consists of: {}",
1487 refspec
1488 );
1489 }
1490 let parts = refspec.split(':').collect::<Vec<&str>>();
1491 Ok((
1492 if parts.first().unwrap().starts_with('+') {
1493 &parts.first().unwrap()[1..]
1494 } else {
1495 parts.first().unwrap()
1496 },
1497 parts.get(1).unwrap(),
1498 ))
1499}
1500
1501fn refspec_remote_ref_name(
1502 git_repo: &Repository,
1503 refspec: &str,
1504 nostr_remote_url: &str,
1505) -> Result<String> {
1506 let (_, to) = refspec_to_from_to(refspec)?;
1507 let nostr_remote = git_repo
1508 .find_remote(&get_remote_name_by_url(git_repo, nostr_remote_url)?)
1509 .context("we should have just located this remote")?;
1510 Ok(format!(
1511 "refs/remotes/{}/{}",
1512 nostr_remote.name().context("remote should have a name")?,
1513 to.replace("refs/heads/", ""), /* TODO only replace if it begins with this
1514 * TODO what about tags? */
1515 ))
1516}
1517
1518fn reference_to_commit(git_repo: &Repository, reference: &str) -> Result<Oid> {
1519 Ok(git_repo
1520 .find_reference(reference)
1521 .context(format!("cannot find reference: {reference}"))?
1522 .peel_to_commit()
1523 .context(format!("cannot get commit from reference: {reference}"))?
1524 .id())
1525}
1526
1527// this maybe a commit id or a ref: pointer
1528fn reference_to_ref_value(git_repo: &Repository, reference: &str) -> Result<String> {
1529 let reference_obj = git_repo
1530 .find_reference(reference)
1531 .context(format!("cannot find reference: {reference}"))?;
1532 if let Some(symref) = reference_obj.symbolic_target() {
1533 Ok(symref.to_string())
1534 } else {
1535 Ok(reference_obj
1536 .peel_to_commit()
1537 .context(format!("cannot get commit from reference: {reference}"))?
1538 .id()
1539 .to_string())
1540 }
1541}
1542
1543fn get_remote_name_by_url(git_repo: &Repository, url: &str) -> Result<String> {
1544 let remotes = git_repo.remotes()?;
1545 Ok(remotes
1546 .iter()
1547 .find(|r| {
1548 if let Some(name) = r {
1549 if let Some(remote_url) = git_repo.find_remote(name).unwrap().url() {
1550 url == remote_url
1551 } else {
1552 false
1553 }
1554 } else {
1555 false
1556 }
1557 })
1558 .context("could not find remote with matching url")?
1559 .context("remote with matching url must be named")?
1560 .to_string())
1561}
1562
1563fn get_short_git_server_name(git_repo: &Repo, url: &str) -> std::string::String {
1564 if let Ok(name) = get_remote_name_by_url(&git_repo.git_repo, url) {
1565 return name;
1566 }
1567 if let Ok(url) = Url::parse(url) {
1568 if let Some(domain) = url.domain() {
1569 return domain.to_string();
1570 }
1571 }
1572 url.to_string()
1573}
1574
1575fn get_oids_from_fetch_batch(
1576 stdin: &Stdin,
1577 initial_oid: &str,
1578 initial_refstr: &str,
1579) -> Result<HashMap<String, String>> {
1580 let mut line = String::new();
1581 let mut batch = HashMap::new();
1582 batch.insert(initial_refstr.to_string(), initial_oid.to_string());
1583 loop {
1584 let tokens = read_line(stdin, &mut line)?;
1585 match tokens.as_slice() {
1586 ["fetch", oid, refstr] => {
1587 batch.insert((*refstr).to_string(), (*oid).to_string());
1588 }
1589 [] => break,
1590 _ => bail!(
1591 "after a `fetch` command we are only expecting another fetch or an empty line"
1592 ),
1593 }
1594 }
1595 Ok(batch)
1596}
1597
1598fn get_refspecs_from_push_batch(stdin: &Stdin, initial_refspec: &str) -> Result<Vec<String>> {
1599 let mut line = String::new();
1600 let mut refspecs = vec![initial_refspec.to_string()];
1601 loop {
1602 let tokens = read_line(stdin, &mut line)?;
1603 match tokens.as_slice() {
1604 ["push", spec] => {
1605 refspecs.push((*spec).to_string());
1606 }
1607 [] => break,
1608 _ => {
1609 bail!("after a `push` command we are only expecting another push or an empty line")
1610 }
1611 }
1612 }
1613 Ok(refspecs)
1614}
1615
1616impl RepoState {
1617 pub async fn build(
1618 identifier: String,
1619 state: HashMap<String, String>,
1620 signer: &NostrSigner,
1621 ) -> Result<RepoState> {
1622 let mut tags = vec![Tag::identifier(identifier.clone())];
1623 for (name, value) in &state {
1624 tags.push(Tag::custom(
1625 nostr_sdk::TagKind::Custom(name.into()),
1626 vec![value.clone()],
1627 ));
1628 }
1629 let event = sign_event(EventBuilder::new(STATE_KIND, "", tags), signer).await?;
1630 Ok(RepoState {
1631 identifier,
1632 state,
1633 event,
1634 })
1635 }
1636}
1637
1638#[cfg(test)]
1639mod tests {
1640 use super::*;
1641
1642 mod nostr_git_url_paramemters_from_str {
1643 use git::ServerProtocol;
1644 use nostr_sdk::PublicKey;
1645
1646 use super::*;
1647
1648 fn get_model_coordinate(relays: bool) -> Coordinate {
1649 Coordinate {
1650 identifier: "ngit".to_string(),
1651 public_key: PublicKey::parse(
1652 "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr",
1653 )
1654 .unwrap(),
1655 kind: nostr_sdk::Kind::GitRepoAnnouncement,
1656 relays: if relays {
1657 vec!["wss://nos.lol/".to_string()]
1658 } else {
1659 vec![]
1660 },
1661 }
1662 }
1663
1664 #[test]
1665 fn from_naddr() -> Result<()> {
1666 assert_eq!(
1667 NostrUrlDecoded::from_str(
1668 "nostr://naddr1qqzxuemfwsqs6amnwvaz7tmwdaejumr0dspzpgqgmmc409hm4xsdd74sf68a2uyf9pwel4g9mfdg8l5244t6x4jdqvzqqqrhnym0k2qj"
1669 )?,
1670 NostrUrlDecoded {
1671 coordinates: HashSet::from([Coordinate {
1672 identifier: "ngit".to_string(),
1673 public_key: PublicKey::parse(
1674 "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr",
1675 )
1676 .unwrap(),
1677 kind: nostr_sdk::Kind::GitRepoAnnouncement,
1678 relays: vec!["wss://nos.lol".to_string()], // wont add the slash
1679 }]),
1680 protocol: None,
1681 user: None,
1682 },
1683 );
1684 Ok(())
1685 }
1686 mod from_npub_slash_identifier {
1687 use super::*;
1688
1689 #[test]
1690 fn without_relay() -> Result<()> {
1691 assert_eq!(
1692 NostrUrlDecoded::from_str(
1693 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit"
1694 )?,
1695 NostrUrlDecoded {
1696 coordinates: HashSet::from([get_model_coordinate(false)]),
1697 protocol: None,
1698 user: None,
1699 },
1700 );
1701 Ok(())
1702 }
1703
1704 mod with_url_parameters {
1705
1706 use super::*;
1707
1708 #[test]
1709 fn with_relay_without_scheme_defaults_to_wss() -> Result<()> {
1710 assert_eq!(
1711 NostrUrlDecoded::from_str(
1712 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay=nos.lol"
1713 )?,
1714 NostrUrlDecoded {
1715 coordinates: HashSet::from([get_model_coordinate(true)]),
1716 protocol: None,
1717 user: None,
1718 },
1719 );
1720 Ok(())
1721 }
1722
1723 #[test]
1724 fn with_encoded_relay() -> Result<()> {
1725 assert_eq!(
1726 NostrUrlDecoded::from_str(&format!(
1727 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay={}",
1728 urlencoding::encode("wss://nos.lol")
1729 ))?,
1730 NostrUrlDecoded {
1731 coordinates: HashSet::from([get_model_coordinate(true)]),
1732 protocol: None,
1733 user: None,
1734 },
1735 );
1736 Ok(())
1737 }
1738 #[test]
1739 fn with_multiple_encoded_relays() -> Result<()> {
1740 assert_eq!(
1741 NostrUrlDecoded::from_str(&format!(
1742 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay={}&relay1={}",
1743 urlencoding::encode("wss://nos.lol"),
1744 urlencoding::encode("wss://relay.damus.io"),
1745 ))?,
1746 NostrUrlDecoded {
1747 coordinates: HashSet::from([Coordinate {
1748 identifier: "ngit".to_string(),
1749 public_key: PublicKey::parse(
1750 "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr",
1751 )
1752 .unwrap(),
1753 kind: nostr_sdk::Kind::GitRepoAnnouncement,
1754 relays: vec![
1755 "wss://nos.lol/".to_string(),
1756 "wss://relay.damus.io/".to_string(),
1757 ],
1758 }]),
1759 protocol: None,
1760 user: None,
1761 },
1762 );
1763 Ok(())
1764 }
1765
1766 #[test]
1767 fn with_server_protocol() -> Result<()> {
1768 assert_eq!(
1769 NostrUrlDecoded::from_str(
1770 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?protocol=ssh"
1771 )?,
1772 NostrUrlDecoded {
1773 coordinates: HashSet::from([get_model_coordinate(false)]),
1774 protocol: Some(ServerProtocol::Ssh),
1775 user: None,
1776 },
1777 );
1778 Ok(())
1779 }
1780 #[test]
1781 fn with_server_protocol_and_user() -> Result<()> {
1782 assert_eq!(
1783 NostrUrlDecoded::from_str(
1784 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?protocol=ssh&user=fred"
1785 )?,
1786 NostrUrlDecoded {
1787 coordinates: HashSet::from([get_model_coordinate(false)]),
1788 protocol: Some(ServerProtocol::Ssh),
1789 user: Some("fred".to_string()),
1790 },
1791 );
1792 Ok(())
1793 }
1794 }
1795 mod with_parameters_embedded_with_slashes {
1796 use super::*;
1797
1798 #[test]
1799 fn with_relay_without_scheme_defaults_to_wss() -> Result<()> {
1800 assert_eq!(
1801 NostrUrlDecoded::from_str(
1802 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/nos.lol/ngit"
1803 )?,
1804 NostrUrlDecoded {
1805 coordinates: HashSet::from([get_model_coordinate(true)]),
1806 protocol: None,
1807 user: None,
1808 },
1809 );
1810 Ok(())
1811 }
1812
1813 #[test]
1814 fn with_encoded_relay() -> Result<()> {
1815 assert_eq!(
1816 NostrUrlDecoded::from_str(&format!(
1817 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/{}/ngit",
1818 urlencoding::encode("wss://nos.lol")
1819 ))?,
1820 NostrUrlDecoded {
1821 coordinates: HashSet::from([get_model_coordinate(true)]),
1822 protocol: None,
1823 user: None,
1824 },
1825 );
1826 Ok(())
1827 }
1828 #[test]
1829 fn with_multiple_encoded_relays() -> Result<()> {
1830 assert_eq!(
1831 NostrUrlDecoded::from_str(&format!(
1832 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/{}/{}/ngit",
1833 urlencoding::encode("wss://nos.lol"),
1834 urlencoding::encode("wss://relay.damus.io"),
1835 ))?,
1836 NostrUrlDecoded {
1837 coordinates: HashSet::from([Coordinate {
1838 identifier: "ngit".to_string(),
1839 public_key: PublicKey::parse(
1840 "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr",
1841 )
1842 .unwrap(),
1843 kind: nostr_sdk::Kind::GitRepoAnnouncement,
1844 relays: vec![
1845 "wss://nos.lol/".to_string(),
1846 "wss://relay.damus.io/".to_string(),
1847 ],
1848 }]),
1849 protocol: None,
1850 user: None,
1851 },
1852 );
1853 Ok(())
1854 }
1855
1856 #[test]
1857 fn with_server_protocol() -> Result<()> {
1858 assert_eq!(
1859 NostrUrlDecoded::from_str(
1860 "nostr://ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit"
1861 )?,
1862 NostrUrlDecoded {
1863 coordinates: HashSet::from([get_model_coordinate(false)]),
1864 protocol: Some(ServerProtocol::Ssh),
1865 user: None,
1866 },
1867 );
1868 Ok(())
1869 }
1870 #[test]
1871 fn with_server_protocol_and_user() -> Result<()> {
1872 assert_eq!(
1873 NostrUrlDecoded::from_str(
1874 "nostr://fred@ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit"
1875 )?,
1876 NostrUrlDecoded {
1877 coordinates: HashSet::from([get_model_coordinate(false)]),
1878 protocol: Some(ServerProtocol::Ssh),
1879 user: Some("fred".to_string()),
1880 },
1881 );
1882 Ok(())
1883 }
1884 }
1885 }
1886 }
1887
1888 mod refspec_to_from_to {
1889 use super::*;
1890
1891 #[test]
1892 fn trailing_plus_stripped() {
1893 let (from, _) = refspec_to_from_to("+testing:testingb").unwrap();
1894 assert_eq!(from, "testing");
1895 }
1896 }
1897}
diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs
new file mode 100644
index 0000000..d0f934e
--- /dev/null
+++ b/src/bin/ngit/cli.rs
@@ -0,0 +1,44 @@
1use clap::{Parser, Subcommand};
2
3use crate::sub_commands;
4
5#[derive(Parser)]
6#[command(author, version, about, long_about = None)]
7#[command(propagate_version = true)]
8pub struct Cli {
9 #[command(subcommand)]
10 pub command: Commands,
11 /// remote signer address
12 #[arg(long, global = true)]
13 pub bunker_uri: Option<String>,
14 /// remote signer app secret key
15 #[arg(long, global = true)]
16 pub bunker_app_key: Option<String>,
17 /// nsec or hex private key
18 #[arg(short, long, global = true)]
19 pub nsec: Option<String>,
20 /// password to decrypt nsec
21 #[arg(short, long, global = true)]
22 pub password: Option<String>,
23 /// disable spinner animations
24 #[arg(long, action)]
25 pub disable_cli_spinners: bool,
26}
27
28#[derive(Subcommand)]
29pub enum Commands {
30 /// update cache with latest updates from nostr
31 Fetch(sub_commands::fetch::SubCommandArgs),
32 /// signal you are this repo's maintainer accepting proposals via nostr
33 Init(sub_commands::init::SubCommandArgs),
34 /// issue commits as a proposal
35 Send(sub_commands::send::SubCommandArgs),
36 /// list proposals; checkout, apply or download selected
37 List,
38 /// send proposal revision
39 Push(sub_commands::push::SubCommandArgs),
40 /// fetch and apply new proposal commits / revisions linked to branch
41 Pull,
42 /// run with --nsec flag to change npub
43 Login(sub_commands::login::SubCommandArgs),
44}
diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs
new file mode 100644
index 0000000..97e5981
--- /dev/null
+++ b/src/bin/ngit/main.rs
@@ -0,0 +1,26 @@
1#![cfg_attr(not(test), warn(clippy::pedantic))]
2#![allow(clippy::large_futures)]
3#![cfg_attr(not(test), warn(clippy::expect_used))]
4
5use anyhow::Result;
6use clap::Parser;
7use cli::{Cli, Commands};
8
9mod cli;
10use ngit::*;
11
12mod sub_commands;
13
14#[tokio::main]
15async fn main() -> Result<()> {
16 let cli = Cli::parse();
17 match &cli.command {
18 Commands::Fetch(args) => sub_commands::fetch::launch(&cli, args).await,
19 Commands::Login(args) => sub_commands::login::launch(&cli, args).await,
20 Commands::Init(args) => sub_commands::init::launch(&cli, args).await,
21 Commands::Send(args) => sub_commands::send::launch(&cli, args, false).await,
22 Commands::List => sub_commands::list::launch().await,
23 Commands::Pull => sub_commands::pull::launch().await,
24 Commands::Push(args) => sub_commands::push::launch(&cli, args).await,
25 }
26}
diff --git a/src/bin/ngit/sub_commands/fetch.rs b/src/bin/ngit/sub_commands/fetch.rs
new file mode 100644
index 0000000..b1e83c5
--- /dev/null
+++ b/src/bin/ngit/sub_commands/fetch.rs
@@ -0,0 +1,44 @@
1use std::collections::HashSet;
2
3use anyhow::{Context, Result};
4use clap;
5use nostr::nips::nip01::Coordinate;
6
7#[cfg(not(test))]
8use crate::client::Client;
9#[cfg(test)]
10use crate::client::MockConnect;
11use crate::{
12 cli::Cli,
13 client::{fetching_with_report, Connect},
14 git::{Repo, RepoActions},
15 repo_ref::get_repo_coordinates,
16};
17
18#[derive(clap::Args)]
19pub struct SubCommandArgs {
20 /// address pointer to repo announcement
21 #[arg(long, action)]
22 repo: Vec<String>,
23}
24
25pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> {
26 let _ = args;
27 let git_repo = Repo::discover().context("cannot find a git repository")?;
28 #[cfg(not(test))]
29 let client = Client::default();
30 #[cfg(test)]
31 let client = <MockConnect as std::default::Default>::default();
32 let repo_coordinates = if command_args.repo.is_empty() {
33 get_repo_coordinates(&git_repo, &client).await?
34 } else {
35 let mut repo_coordinates = HashSet::new();
36 for repo in &command_args.repo {
37 repo_coordinates.insert(Coordinate::parse(repo.clone())?);
38 }
39 repo_coordinates
40 };
41 fetching_with_report(git_repo.get_path()?, &client, &repo_coordinates).await?;
42 client.disconnect().await?;
43 Ok(())
44}
diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs
new file mode 100644
index 0000000..5b7e03d
--- /dev/null
+++ b/src/bin/ngit/sub_commands/init.rs
@@ -0,0 +1,385 @@
1use std::collections::HashMap;
2
3use anyhow::{Context, Result};
4use nostr::{nips::nip01::Coordinate, FromBech32, PublicKey, ToBech32};
5use nostr_sdk::Kind;
6
7use super::send::send_events;
8#[cfg(not(test))]
9use crate::client::Client;
10#[cfg(test)]
11use crate::client::MockConnect;
12use crate::{
13 cli::Cli,
14 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms},
15 client::{fetching_with_report, get_repo_ref_from_cache, Connect},
16 git::{convert_clone_url_to_https, Repo, RepoActions},
17 login,
18 repo_ref::{
19 extract_pks, get_repo_config_from_yaml, save_repo_config_to_yaml,
20 try_and_get_repo_coordinates, RepoRef,
21 },
22};
23
24#[derive(Debug, clap::Args)]
25pub struct SubCommandArgs {
26 #[clap(short, long)]
27 /// name of repository
28 title: Option<String>,
29 #[clap(short, long)]
30 /// optional description
31 description: Option<String>,
32 #[clap(long)]
33 /// git server url users can clone from
34 clone_url: Vec<String>,
35 #[clap(short, long, value_parser, num_args = 1..)]
36 /// homepage
37 web: Vec<String>,
38 #[clap(short, long, value_parser, num_args = 1..)]
39 /// relays contributors push patches and comments to
40 relays: Vec<String>,
41 #[clap(short, long, value_parser, num_args = 1..)]
42 /// npubs of other maintainers
43 other_maintainers: Vec<String>,
44 #[clap(long)]
45 /// usually root commit but will be more recent commit for forks
46 earliest_unique_commit: Option<String>,
47 #[clap(short, long)]
48 /// shortname with no spaces or special characters
49 identifier: Option<String>,
50}
51
52#[allow(clippy::too_many_lines)]
53pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
54 let git_repo = Repo::discover().context("cannot find a git repository")?;
55 let git_repo_path = git_repo.get_path()?;
56
57 let root_commit = git_repo
58 .get_root_commit()
59 .context("failed to get root commit of the repository")?;
60
61 // TODO: check for empty repo
62 // TODO: check for existing maintaiers file
63
64 #[cfg(not(test))]
65 let mut client = Client::default();
66 #[cfg(test)]
67 let mut client = <MockConnect as std::default::Default>::default();
68
69 let repo_coordinates = if let Ok(repo_coordinates) =
70 try_and_get_repo_coordinates(&git_repo, &client, false).await
71 {
72 Some(repo_coordinates)
73 } else {
74 None
75 };
76
77 let repo_ref = if let Some(repo_coordinates) = repo_coordinates {
78 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
79 Some(get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?)
80 } else {
81 None
82 };
83
84 let (signer, user_ref) = login::launch(
85 &git_repo,
86 &cli_args.bunker_uri,
87 &cli_args.bunker_app_key,
88 &cli_args.nsec,
89 &cli_args.password,
90 Some(&client),
91 false,
92 false,
93 )
94 .await?;
95
96 let repo_config_result = get_repo_config_from_yaml(&git_repo);
97 // TODO: check for other claims
98
99 let name = match &args.title {
100 Some(t) => t.clone(),
101 None => Interactor::default().input(
102 PromptInputParms::default()
103 .with_prompt("name")
104 .with_default(if let Some(repo_ref) = &repo_ref {
105 repo_ref.name.clone()
106 } else {
107 String::new()
108 }),
109 )?,
110 };
111
112 let identifier = match &args.identifier {
113 Some(t) => t.clone(),
114 None => Interactor::default().input(
115 PromptInputParms::default()
116 .with_prompt("identifier")
117 .with_default(if let Some(repo_ref) = &repo_ref {
118 repo_ref.identifier.clone()
119 } else {
120 let fallback = name
121 .clone()
122 .replace(' ', "-")
123 .chars()
124 .map(|c| {
125 if c.is_ascii_alphanumeric() || c.eq(&'/') {
126 c
127 } else {
128 '-'
129 }
130 })
131 .collect();
132 if let Ok(config) = &repo_config_result {
133 if let Some(identifier) = &config.identifier {
134 identifier.to_string()
135 } else {
136 fallback
137 }
138 } else {
139 fallback
140 }
141 }),
142 )?,
143 };
144
145 let description = match &args.description {
146 Some(t) => t.clone(),
147 None => Interactor::default().input(
148 PromptInputParms::default()
149 .with_prompt("description")
150 .with_default(if let Some(repo_ref) = &repo_ref {
151 repo_ref.description.clone()
152 } else {
153 String::new()
154 }),
155 )?,
156 };
157
158 let git_server = if args.clone_url.is_empty() {
159 Interactor::default()
160 .input(
161 PromptInputParms::default()
162 .with_prompt("clone url (for fetch)")
163 .with_default(if let Some(repo_ref) = &repo_ref {
164 repo_ref.git_server.clone().join(" ")
165 } else if let Ok(url) = git_repo.get_origin_url() {
166 if let Ok(fetch_url) = convert_clone_url_to_https(&url) {
167 fetch_url
168 } else {
169 // local repo or custom protocol
170 url
171 }
172 } else {
173 String::new()
174 }),
175 )?
176 .split(' ')
177 .map(std::string::ToString::to_string)
178 .collect()
179 } else {
180 args.clone_url.clone()
181 };
182
183 let web: Vec<String> = if args.web.is_empty() {
184 Interactor::default()
185 .input(
186 PromptInputParms::default()
187 .with_prompt("web")
188 .optional()
189 .with_default(if let Some(repo_ref) = &repo_ref {
190 repo_ref.web.clone().join(" ")
191 } else {
192 format!("https://gitworkshop.dev/repo/{}", &identifier)
193 }),
194 )?
195 .split(' ')
196 .map(std::string::ToString::to_string)
197 .collect()
198 } else {
199 args.web.clone()
200 };
201
202 let maintainers: Vec<PublicKey> = {
203 let mut dont_ask = !args.other_maintainers.is_empty();
204 let mut maintainers_string = if !args.other_maintainers.is_empty() {
205 [args.other_maintainers.clone()].concat().join(" ")
206 } else if repo_ref.is_none() && repo_config_result.is_err() {
207 signer.public_key().await?.to_bech32()?
208 } else {
209 let maintainers = if let Ok(config) = &repo_config_result {
210 config.maintainers.clone()
211 } else if let Some(repo_ref) = &repo_ref {
212 repo_ref
213 .maintainers
214 .clone()
215 .iter()
216 .map(|k| k.to_bech32().unwrap())
217 .collect()
218 } else {
219 //unreachable
220 vec![signer.public_key().await?.to_bech32()?]
221 };
222 // add current user if not present
223 if maintainers.iter().any(|m| {
224 if let Ok(m_pubkey) = PublicKey::from_bech32(m) {
225 user_ref.public_key.eq(&m_pubkey)
226 } else {
227 false
228 }
229 }) {
230 maintainers.join(" ")
231 } else {
232 [maintainers, vec![signer.public_key().await?.to_bech32()?]]
233 .concat()
234 .join(" ")
235 }
236 };
237 'outer: loop {
238 if !dont_ask {
239 println!("{}", &maintainers_string);
240 maintainers_string = Interactor::default().input(
241 PromptInputParms::default()
242 .with_prompt("maintainers")
243 .with_default(maintainers_string),
244 )?;
245 }
246 let mut maintainers: Vec<PublicKey> = vec![];
247 for m in maintainers_string.split(' ') {
248 if let Ok(m_pubkey) = PublicKey::from_bech32(m) {
249 maintainers.push(m_pubkey);
250 } else {
251 println!("not a valid set of npubs seperated by a space");
252 dont_ask = false;
253 continue 'outer;
254 }
255 }
256 // add current user incase removed
257 if !maintainers.iter().any(|m| user_ref.public_key.eq(m)) {
258 maintainers.push(signer.public_key().await?);
259 }
260 break maintainers;
261 }
262 };
263
264 // TODO: check if relays are free to post to so contributors can submit patches
265 // TODO: recommend some reliable free ones
266 let relays: Vec<String> = if args.relays.is_empty() {
267 Interactor::default()
268 .input(
269 PromptInputParms::default()
270 .with_prompt("relays")
271 .with_default(if let Ok(config) = &repo_config_result {
272 config.relays.clone().join(" ")
273 } else if let Some(repo_ref) = &repo_ref {
274 repo_ref.relays.clone().join(" ")
275 } else {
276 user_ref.relays.write().join(" ")
277 }),
278 )?
279 .split(' ')
280 .map(std::string::ToString::to_string)
281 .collect()
282 } else {
283 args.relays.clone()
284 };
285
286 let earliest_unique_commit = match &args.earliest_unique_commit {
287 Some(t) => t.clone(),
288 None => {
289 let mut earliest_unique_commit = if let Some(repo_ref) = &repo_ref {
290 repo_ref.root_commit.clone()
291 } else {
292 root_commit.to_string()
293 };
294 loop {
295 earliest_unique_commit = Interactor::default().input(
296 PromptInputParms::default()
297 .with_prompt("earliest unique commit")
298 .with_default(earliest_unique_commit.clone()),
299 )?;
300 if let Ok(exists) = git_repo.does_commit_exist(&earliest_unique_commit) {
301 if exists {
302 break earliest_unique_commit;
303 }
304 println!("commit does not exist on current repository");
305 } else {
306 println!("commit id not formatted correctly");
307 }
308 if earliest_unique_commit.len().ne(&40) {
309 println!("commit id must be 40 characters long");
310 }
311 }
312 }
313 };
314
315 println!("publishing repostory reference...");
316
317 let repo_ref = RepoRef {
318 identifier: identifier.clone(),
319 name,
320 description,
321 root_commit: earliest_unique_commit,
322 git_server,
323 web,
324 relays: relays.clone(),
325 maintainers: maintainers.clone(),
326 events: HashMap::new(),
327 };
328 let repo_event = repo_ref.to_event(&signer).await?;
329
330 client.set_signer(signer).await;
331
332 send_events(
333 &client,
334 git_repo_path,
335 vec![repo_event],
336 user_ref.relays.write(),
337 relays.clone(),
338 !cli_args.disable_cli_spinners,
339 false,
340 )
341 .await?;
342
343 git_repo.save_git_config_item(
344 "nostr.repo",
345 &Coordinate {
346 kind: Kind::GitRepoAnnouncement,
347 public_key: user_ref.public_key,
348 identifier: identifier.clone(),
349 relays: vec![],
350 }
351 .to_bech32()?,
352 false,
353 )?;
354
355 // if yaml file doesnt exist or needs updating
356 if match &repo_config_result {
357 Ok(config) => {
358 !<std::option::Option<std::string::String> as Clone>::clone(&config.identifier)
359 .unwrap_or_default()
360 .eq(&identifier)
361 || !extract_pks(config.maintainers.clone())?.eq(&maintainers)
362 || !config.relays.eq(&relays)
363 }
364 Err(_) => true,
365 } {
366 save_repo_config_to_yaml(
367 &git_repo,
368 identifier.clone(),
369 maintainers.clone(),
370 relays.clone(),
371 )?;
372 println!(
373 "maintainers.yaml {}. commit and push.",
374 if repo_config_result.is_err() {
375 "created"
376 } else {
377 "updated"
378 }
379 );
380 println!(
381 "this optional file helps in identifying who the maintainers are over time through the commit history"
382 );
383 }
384 Ok(())
385}
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}
diff --git a/src/bin/ngit/sub_commands/login.rs b/src/bin/ngit/sub_commands/login.rs
new file mode 100644
index 0000000..8a3788f
--- /dev/null
+++ b/src/bin/ngit/sub_commands/login.rs
@@ -0,0 +1,52 @@
1use anyhow::{Context, Result};
2use clap;
3
4#[cfg(not(test))]
5use crate::client::Client;
6#[cfg(test)]
7use crate::client::MockConnect;
8use crate::{cli::Cli, client::Connect, git::Repo, login};
9
10#[derive(clap::Args)]
11pub struct SubCommandArgs {
12 /// don't fetch user metadata and relay list from relays
13 #[arg(long, action)]
14 offline: bool,
15}
16
17pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> {
18 let git_repo = Repo::discover().context("cannot find a git repository")?;
19 if command_args.offline {
20 login::launch(
21 &git_repo,
22 &args.bunker_uri,
23 &args.bunker_app_key,
24 &args.nsec,
25 &args.password,
26 None,
27 true,
28 false,
29 )
30 .await?;
31 Ok(())
32 } else {
33 #[cfg(not(test))]
34 let client = Client::default();
35 #[cfg(test)]
36 let client = <MockConnect as std::default::Default>::default();
37
38 login::launch(
39 &git_repo,
40 &args.bunker_uri,
41 &args.bunker_app_key,
42 &args.nsec,
43 &args.password,
44 Some(&client),
45 true,
46 false,
47 )
48 .await?;
49 client.disconnect().await?;
50 Ok(())
51 }
52}
diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs
new file mode 100644
index 0000000..29a60f9
--- /dev/null
+++ b/src/bin/ngit/sub_commands/mod.rs
@@ -0,0 +1,7 @@
1pub mod fetch;
2pub mod init;
3pub mod list;
4pub mod login;
5pub mod pull;
6pub mod push;
7pub mod send;
diff --git a/src/bin/ngit/sub_commands/pull.rs b/src/bin/ngit/sub_commands/pull.rs
new file mode 100644
index 0000000..e33a744
--- /dev/null
+++ b/src/bin/ngit/sub_commands/pull.rs
@@ -0,0 +1,209 @@
1use anyhow::{bail, Context, Result};
2
3use super::{
4 list::{
5 get_all_proposal_patch_events_from_cache, get_commit_id_from_patch,
6 get_proposals_and_revisions_from_cache, tag_value,
7 },
8 send::event_to_cover_letter,
9};
10#[cfg(test)]
11use crate::client::MockConnect;
12#[cfg(not(test))]
13use crate::client::{Client, Connect};
14use crate::{
15 client::{fetching_with_report, get_repo_ref_from_cache},
16 git::{str_to_sha1, Repo, RepoActions},
17 repo_ref::get_repo_coordinates,
18 sub_commands::{list::get_most_recent_patch_with_ancestors, send::event_is_revision_root},
19};
20
21#[allow(clippy::too_many_lines)]
22pub async fn launch() -> Result<()> {
23 let git_repo = Repo::discover().context("cannot find a git repository")?;
24 let git_repo_path = git_repo.get_path()?;
25
26 let (main_or_master_branch_name, _) = git_repo
27 .get_main_or_master_branch()
28 .context("no main or master branch")?;
29
30 let branch_name = git_repo
31 .get_checked_out_branch_name()
32 .context("cannot get checked out branch name")?;
33
34 if branch_name == main_or_master_branch_name {
35 bail!("checkout a branch associated with a proposal first")
36 }
37 #[cfg(not(test))]
38 let client = Client::default();
39 #[cfg(test)]
40 let client = <MockConnect as std::default::Default>::default();
41
42 let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?;
43
44 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
45
46 let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?;
47
48 let proposal_root_event =
49 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates())
50 .await?
51 .iter()
52 .find(|e| {
53 event_to_cover_letter(e)
54 .is_ok_and(|cl| cl.get_branch_name().is_ok_and(|s| s.eq(&branch_name)))
55 && !event_is_revision_root(e)
56 })
57 .context("cannot find proposal that matches the current branch name")?
58 .clone();
59 let commit_events = get_all_proposal_patch_events_from_cache(
60 git_repo_path,
61 &repo_ref,
62 &proposal_root_event.id(),
63 )
64 .await?;
65
66 let most_recent_proposal_patch_chain =
67 get_most_recent_patch_with_ancestors(commit_events.clone())
68 .context("cannot get most recent patch for proposal")?;
69
70 let local_branch_tip = git_repo.get_tip_of_branch(&branch_name)?;
71
72 let (main_branch_name, master_tip) = git_repo.get_main_or_master_branch()?;
73
74 let (local_ahead_of_main, local_beind_main) =
75 git_repo.get_commits_ahead_behind(&master_tip, &local_branch_tip)?;
76
77 let proposal_base_commit = str_to_sha1(&tag_value(
78 most_recent_proposal_patch_chain
79 .last()
80 .context("there should be at least one patch as we have already checked for this")?,
81 "parent-commit",
82 )?)
83 .context("cannot get valid parent commit id from patch")?;
84
85 let (_, proposal_behind_main) =
86 git_repo.get_commits_ahead_behind(&master_tip, &proposal_base_commit)?;
87
88 let proposal_tip =
89 str_to_sha1(
90 &get_commit_id_from_patch(most_recent_proposal_patch_chain.first().context(
91 "there should be at least one patch as we have already checked for this",
92 )?)
93 .context("cannot get valid commit_id from patch")?,
94 )
95 .context("cannot get valid commit_id from patch")?;
96
97 // if uptodate
98 if proposal_tip.eq(&local_branch_tip) {
99 println!("branch already up-to-date");
100 }
101 // if new appendments
102 else if most_recent_proposal_patch_chain.iter().any(|patch| {
103 get_commit_id_from_patch(patch)
104 .unwrap_or_default()
105 .eq(&local_branch_tip.to_string())
106 }) {
107 check_clean(&git_repo)?;
108 let applied = git_repo
109 .apply_patch_chain(&branch_name, most_recent_proposal_patch_chain)
110 .context("cannot apply patch chain")?;
111 println!("applied {} new commits", applied.len(),);
112 }
113 // if parent commit doesnt exist
114 else if !git_repo.does_commit_exist(&proposal_base_commit.to_string())? {
115 println!(
116 "a new version of the proposal has a prant commit that doesnt exist in your local repository."
117 );
118 println!("your '{main_branch_name}' branch may not be up-to-date.");
119 println!("manually run `git pull` on '{main_branch_name}' and try again");
120 }
121 // if new revision and no local changes (tip of local in proposal history)
122 else if commit_events.iter().any(|patch| {
123 get_commit_id_from_patch(patch)
124 .unwrap_or_default()
125 .eq(&local_branch_tip.to_string())
126 }) {
127 check_clean(&git_repo)?;
128
129 git_repo.create_branch_at_commit(&branch_name, &proposal_base_commit.to_string())?;
130 let applied = git_repo
131 .apply_patch_chain(&branch_name, most_recent_proposal_patch_chain)
132 .context("cannot apply patch chain")?;
133
134 println!(
135 "pulled new version of proposal ({} ahead {} behind '{main_branch_name}'), replacing old version ({} ahead {} behind '{main_branch_name}')",
136 applied.len(),
137 proposal_behind_main.len(),
138 local_ahead_of_main.len(),
139 local_beind_main.len(),
140 );
141 }
142 // if tip of proposal in branch in history (local appendments made to up-to-date
143 // proposal)
144 else if git_repo.ancestor_of(&local_branch_tip, &proposal_tip)? {
145 let (local_ahead_of_proposal, _) = git_repo
146 .get_commits_ahead_behind(&proposal_tip, &local_branch_tip)
147 .context("cannot get commits ahead behind for propsal_top and local_branch_tip")?;
148 println!(
149 "local proposal branch exists with {} unpublished commits on top of the most up-to-date version of the proposal",
150 local_ahead_of_proposal.len()
151 );
152 } else {
153 println!("you have an amended/rebase version the proposal that is unpublished");
154 // user probably has a unpublished amended or rebase version of the latest
155 // proposal version
156 // if tip of proposal commits exist (were once part of branch but have been
157 // amended and git clean up job hasn't removed them)
158 if git_repo.does_commit_exist(&proposal_tip.to_string())? {
159 println!(
160 "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}')",
161 most_recent_proposal_patch_chain.len(),
162 proposal_behind_main.len(),
163 local_ahead_of_main.len(),
164 local_beind_main.len(),
165 );
166 }
167 // user probably has a unpublished amended or rebase version of an older
168 // proposal version
169 else {
170 println!(
171 "your local proposal branch ({} ahead {} behind '{main_branch_name}') has conflicting changes with the latest published proposal ({} ahead {} behind '{main_branch_name}')",
172 local_ahead_of_main.len(),
173 local_beind_main.len(),
174 most_recent_proposal_patch_chain.len(),
175 proposal_behind_main.len(),
176 );
177
178 println!(
179 "its likely that you have rebased / amended an old proposal version because git has no record of the latest proposal commit."
180 );
181 println!(
182 "it is possible that you have been working off the latest version and git has delete this commit as part of a clean up"
183 );
184 }
185 println!("to view the latest proposal but retain your changes:");
186 println!(" 1) create a new branch off the tip commit of this one to store your changes");
187 println!(" 2) run `ngit list` and checkout the latest published version of this proposal");
188
189 println!("if you are confident in your changes consider running `ngit push --force`");
190
191 // TODO: this copy could be refined further based on this:
192 // - amended commits in the proposal
193 // - if local_base eq proposal base
194 // - amended an older version of proposal
195 // - if local_base is behind proposal_base
196 // - rebased the proposal
197 // - if local_base is ahead of proposal_base
198 }
199 Ok(())
200}
201
202fn check_clean(git_repo: &Repo) -> Result<()> {
203 if git_repo.has_outstanding_changes()? {
204 bail!(
205 "cannot pull proposal branch when repository is not clean. discard or stash (un)staged changes and try again."
206 );
207 }
208 Ok(())
209}
diff --git a/src/bin/ngit/sub_commands/push.rs b/src/bin/ngit/sub_commands/push.rs
new file mode 100644
index 0000000..7a82c7a
--- /dev/null
+++ b/src/bin/ngit/sub_commands/push.rs
@@ -0,0 +1,223 @@
1use anyhow::{bail, Context, Result};
2
3#[cfg(not(test))]
4use crate::client::Client;
5#[cfg(test)]
6use crate::client::MockConnect;
7use crate::{
8 cli::Cli,
9 client::{fetching_with_report, get_repo_ref_from_cache, Connect},
10 git::{str_to_sha1, Repo, RepoActions},
11 login,
12 repo_ref::get_repo_coordinates,
13 sub_commands::{
14 self,
15 list::{
16 get_all_proposal_patch_events_from_cache, get_commit_id_from_patch,
17 get_most_recent_patch_with_ancestors, get_proposals_and_revisions_from_cache,
18 tag_value,
19 },
20 send::{
21 event_is_revision_root, event_to_cover_letter, generate_patch_event,
22 identify_ahead_behind, send_events,
23 },
24 },
25};
26
27#[derive(Debug, clap::Args)]
28pub struct SubCommandArgs {
29 #[arg(long, action)]
30 /// send proposal revision from checked out proposal branch
31 force: bool,
32}
33
34#[allow(clippy::too_many_lines)]
35pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
36 let git_repo = Repo::discover().context("cannot find a git repository")?;
37 let git_repo_path = git_repo.get_path()?;
38
39 let (main_or_master_branch_name, _) = git_repo
40 .get_main_or_master_branch()
41 .context("no main or master branch")?;
42
43 let root_commit = git_repo
44 .get_root_commit()
45 .context("failed to get root commit of the repository")?;
46
47 let branch_name = git_repo
48 .get_checked_out_branch_name()
49 .context("cannot get checked out branch name")?;
50
51 if branch_name == main_or_master_branch_name {
52 bail!("checkout a branch associated with a proposal first")
53 }
54 #[cfg(not(test))]
55 let mut client = Client::default();
56 #[cfg(test)]
57 let mut client = <MockConnect as std::default::Default>::default();
58
59 let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?;
60
61 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
62
63 let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?;
64
65 let proposal_root_event =
66 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates())
67 .await?
68 .iter()
69 .find(|e| {
70 event_to_cover_letter(e)
71 .is_ok_and(|cl| cl.get_branch_name().is_ok_and(|s| s.eq(&branch_name)))
72 && !event_is_revision_root(e)
73 })
74 .context("cannot find proposal that matches the current branch name")?
75 .clone();
76
77 let commit_events = get_all_proposal_patch_events_from_cache(
78 git_repo_path,
79 &repo_ref,
80 &proposal_root_event.id(),
81 )
82 .await?;
83
84 let most_recent_proposal_patch_chain = get_most_recent_patch_with_ancestors(commit_events)
85 .context("cannot get most recent patch for proposal")?;
86
87 let branch_tip = git_repo.get_tip_of_branch(&branch_name)?;
88
89 let most_recent_patch_commit_id = str_to_sha1(
90 &get_commit_id_from_patch(
91 most_recent_proposal_patch_chain
92 .first()
93 .context("no patches found")?,
94 )
95 .context("latest patch event doesnt have a commit tag")?,
96 )
97 .context("latest patch event commit tag isn't a valid SHA1 hash")?;
98
99 let proposal_base_commit_id = str_to_sha1(
100 &tag_value(
101 most_recent_proposal_patch_chain
102 .last()
103 .context("no patches found")?,
104 "parent-commit",
105 )
106 .context("patch is incorrectly formatted")?,
107 )
108 .context("latest patch event parent-commit tag isn't a valid SHA1 hash")?;
109
110 if most_recent_patch_commit_id.eq(&branch_tip) {
111 bail!("proposal already up-to-date with local branch");
112 }
113
114 if args.force {
115 println!("preparing to force push proposal revision...");
116 sub_commands::send::launch(
117 cli_args,
118 &sub_commands::send::SubCommandArgs {
119 // if not ahead of master prompt, otherwise assume proposal revision is all commits
120 // ahead
121 since_or_range: if let Ok((_, _, ahead, _)) =
122 identify_ahead_behind(&git_repo, &None, &None)
123 {
124 if ahead.is_empty() {
125 String::new()
126 } else {
127 format!("HEAD~{}", ahead.len())
128 }
129 } else {
130 String::new()
131 },
132 in_reply_to: vec![proposal_root_event.id.to_string()],
133 title: None,
134 description: None,
135 no_cover_letter: true,
136 },
137 true,
138 )
139 .await?;
140 println!("force pushed proposal revision");
141 return Ok(());
142 }
143
144 if most_recent_proposal_patch_chain.iter().any(|e| {
145 let c = tag_value(e, "parent-commit").unwrap_or_default();
146 c.eq(&branch_tip.to_string())
147 }) {
148 bail!("proposal is ahead of local branch");
149 }
150
151 let Ok((ahead, behind)) = git_repo
152 .get_commits_ahead_behind(&most_recent_patch_commit_id, &branch_tip)
153 .context("the latest patch in proposal doesnt share an ancestor with your branch.")
154 else {
155 if git_repo.ancestor_of(&proposal_base_commit_id, &branch_tip)? {
156 bail!("local unpublished proposal ammendments. consider force pushing.");
157 }
158 bail!("local unpublished proposal has been rebased. consider force pushing");
159 };
160
161 if !behind.is_empty() {
162 bail!(
163 "your local proposal branch is {} behind patches on nostr. consider rebasing or force pushing",
164 behind.len()
165 )
166 }
167
168 println!(
169 "{} commits ahead. preparing to create creating patch events.",
170 ahead.len()
171 );
172
173 let (signer, user_ref) = login::launch(
174 &git_repo,
175 &cli_args.bunker_uri,
176 &cli_args.bunker_app_key,
177 &cli_args.nsec,
178 &cli_args.password,
179 Some(&client),
180 false,
181 false,
182 )
183 .await?;
184
185 let mut patch_events: Vec<nostr::Event> = vec![];
186 for commit in &ahead {
187 patch_events.push(
188 generate_patch_event(
189 &git_repo,
190 &root_commit,
191 commit,
192 Some(proposal_root_event.id),
193 &signer,
194 &repo_ref,
195 patch_events.last().map(nostr::Event::id),
196 None,
197 None,
198 &None,
199 &[],
200 )
201 .await
202 .context("cannot make patch event from commit")?,
203 );
204 }
205 println!("pushing {} commits", ahead.len());
206
207 client.set_signer(signer).await;
208
209 send_events(
210 &client,
211 git_repo_path,
212 patch_events,
213 user_ref.relays.write(),
214 repo_ref.relays.clone(),
215 !cli_args.disable_cli_spinners,
216 false,
217 )
218 .await?;
219
220 println!("pushed {} commits", ahead.len());
221
222 Ok(())
223}
diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs
new file mode 100644
index 0000000..3c4df9d
--- /dev/null
+++ b/src/bin/ngit/sub_commands/send.rs
@@ -0,0 +1,1363 @@
1use std::{path::Path, str::FromStr, time::Duration};
2
3use anyhow::{bail, Context, Result};
4use console::Style;
5use futures::future::join_all;
6use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
7use nostr::{
8 nips::{
9 nip01::Coordinate,
10 nip10::Marker,
11 nip19::{Nip19, Nip19Event},
12 },
13 EventBuilder, FromBech32, Tag, TagKind, ToBech32, UncheckedUrl,
14};
15use nostr_sdk::{hashes::sha1::Hash as Sha1Hash, Kind, NostrSigner, TagStandard};
16
17use super::list::tag_value;
18#[cfg(not(test))]
19use crate::client::Client;
20#[cfg(test)]
21use crate::client::MockConnect;
22use crate::{
23 cli::Cli,
24 cli_interactor::{
25 Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms,
26 },
27 client::{
28 fetching_with_report, get_events_from_cache, get_repo_ref_from_cache, sign_event, Connect,
29 },
30 git::{Repo, RepoActions},
31 login,
32 repo_ref::{get_repo_coordinates, RepoRef},
33};
34
35#[derive(Debug, clap::Args)]
36pub struct SubCommandArgs {
37 #[arg(default_value = "")]
38 /// commits to send as proposal; like in `git format-patch` eg. HEAD~2
39 pub(crate) since_or_range: String,
40 #[clap(long, value_parser, num_args = 0.., value_delimiter = ' ')]
41 /// references to an existing proposal for which this is a new
42 /// version and/or events / npubs to tag as mentions
43 pub(crate) in_reply_to: Vec<String>,
44 /// don't prompt for a cover letter
45 #[arg(long, action)]
46 pub(crate) no_cover_letter: bool,
47 /// optional cover letter title
48 #[clap(short, long)]
49 pub(crate) title: Option<String>,
50 #[clap(short, long)]
51 /// optional cover letter description
52 pub(crate) description: Option<String>,
53}
54
55#[allow(clippy::too_many_lines)]
56pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Result<()> {
57 let git_repo = Repo::discover().context("cannot find a git repository")?;
58 let git_repo_path = git_repo.get_path()?;
59
60 let (main_branch_name, main_tip) = git_repo
61 .get_main_or_master_branch()
62 .context("the default branches (main or master) do not exist")?;
63
64 #[cfg(not(test))]
65 let mut client = Client::default();
66 #[cfg(test)]
67 let mut client = <MockConnect as std::default::Default>::default();
68
69 let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?;
70
71 if !no_fetch {
72 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
73 }
74
75 let (root_proposal_id, mention_tags) =
76 get_root_proposal_id_and_mentions_from_in_reply_to(git_repo.get_path()?, &args.in_reply_to)
77 .await?;
78
79 if let Some(root_ref) = args.in_reply_to.first() {
80 if root_proposal_id.is_some() {
81 println!("creating proposal revision for: {root_ref}");
82 }
83 }
84
85 let mut commits: Vec<Sha1Hash> = {
86 if args.since_or_range.is_empty() {
87 let branch_name = git_repo.get_checked_out_branch_name()?;
88 let proposed_commits = if branch_name.eq(main_branch_name) {
89 vec![main_tip]
90 } else {
91 let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?;
92 ahead
93 };
94 choose_commits(&git_repo, proposed_commits)?
95 } else {
96 git_repo
97 .parse_starting_commits(&args.since_or_range)
98 .context("cannot parse specified starting commit or range")?
99 }
100 };
101
102 if commits.is_empty() {
103 bail!("no commits selected");
104 }
105 println!("creating proposal from {} commits:", commits.len());
106
107 let dim = Style::new().color256(247);
108 for commit in &commits {
109 println!(
110 "{} {}",
111 dim.apply_to(commit.to_string().chars().take(7).collect::<String>()),
112 git_repo.get_commit_message_summary(commit)?
113 );
114 }
115
116 let (first_commit_ahead, behind) =
117 git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?;
118
119 // check proposal ahead of origin/main
120 if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm(
121 PromptConfirmParms::default()
122 .with_prompt(
123 format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1)
124 )
125 .with_default(false)
126 ).context("failed to get confirmation response from interactor confirm")? {
127 bail!("aborting because selected commits were ahead of origin/master");
128 }
129
130 // check if a selected commit is already in origin
131 if commits.iter().any(|c| c.eq(&main_tip)) {
132 if !Interactor::default().confirm(
133 PromptConfirmParms::default()
134 .with_prompt(
135 format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?")
136 )
137 .with_default(false)
138 ).context("failed to get confirmation response from interactor confirm")? {
139 bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'");
140 }
141 }
142 // check proposal isn't behind origin/main
143 else if !behind.is_empty() && !Interactor::default().confirm(
144 PromptConfirmParms::default()
145 .with_prompt(
146 format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len())
147 )
148 .with_default(false)
149 ).context("failed to get confirmation response from interactor confirm")? {
150 bail!("aborting so commits can be rebased");
151 }
152
153 let title = if args.no_cover_letter {
154 None
155 } else {
156 match &args.title {
157 Some(t) => Some(t.clone()),
158 None => {
159 if Interactor::default().confirm(
160 PromptConfirmParms::default()
161 .with_default(false)
162 .with_prompt("include cover letter?"),
163 )? {
164 Some(
165 Interactor::default()
166 .input(PromptInputParms::default().with_prompt("title"))?
167 .clone(),
168 )
169 } else {
170 None
171 }
172 }
173 }
174 };
175
176 let cover_letter_title_description = if let Some(title) = title {
177 Some((
178 title,
179 if let Some(t) = &args.description {
180 t.clone()
181 } else {
182 Interactor::default()
183 .input(PromptInputParms::default().with_prompt("cover letter description"))?
184 .clone()
185 },
186 ))
187 } else {
188 None
189 };
190 let (signer, user_ref) = login::launch(
191 &git_repo,
192 &cli_args.bunker_uri,
193 &cli_args.bunker_app_key,
194 &cli_args.nsec,
195 &cli_args.password,
196 Some(&client),
197 false,
198 false,
199 )
200 .await?;
201
202 client.set_signer(signer.clone()).await;
203
204 let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?;
205
206 // oldest first
207 commits.reverse();
208
209 let events = generate_cover_letter_and_patch_events(
210 cover_letter_title_description.clone(),
211 &git_repo,
212 &commits,
213 &signer,
214 &repo_ref,
215 &root_proposal_id,
216 &mention_tags,
217 )
218 .await?;
219
220 println!(
221 "posting {} patch{} {} a covering letter...",
222 if cover_letter_title_description.is_none() {
223 events.len()
224 } else {
225 events.len() - 1
226 },
227 if cover_letter_title_description.is_none() && events.len().eq(&1)
228 || cover_letter_title_description.is_some() && events.len().eq(&2)
229 {
230 ""
231 } else {
232 "es"
233 },
234 if cover_letter_title_description.is_none() {
235 "without"
236 } else {
237 "with"
238 }
239 );
240
241 send_events(
242 &client,
243 git_repo_path,
244 events.clone(),
245 user_ref.relays.write(),
246 repo_ref.relays.clone(),
247 !cli_args.disable_cli_spinners,
248 false,
249 )
250 .await?;
251
252 if root_proposal_id.is_none() {
253 if let Some(event) = events.first() {
254 let event_bech32 = if let Some(relay) = repo_ref.relays.first() {
255 Nip19Event::new(event.id(), vec![relay]).to_bech32()?
256 } else {
257 event.id().to_bech32()?
258 };
259 println!(
260 "{}",
261 dim.apply_to(format!(
262 "view in gitworkshop.dev: https://gitworkshop.dev/repo/{}/proposal/{}",
263 repo_ref.coordinate_with_hint().to_bech32()?,
264 &event_bech32,
265 ))
266 );
267 println!(
268 "{}",
269 dim.apply_to(format!(
270 "view in another client: https://njump.me/{}",
271 &event_bech32,
272 ))
273 );
274 }
275 }
276 // TODO check if there is already a similarly named
277 Ok(())
278}
279
280#[allow(clippy::module_name_repetitions)]
281#[allow(clippy::too_many_lines)]
282pub async fn send_events(
283 #[cfg(test)] client: &crate::client::MockConnect,
284 #[cfg(not(test))] client: &Client,
285 git_repo_path: &Path,
286 events: Vec<nostr::Event>,
287 my_write_relays: Vec<String>,
288 repo_read_relays: Vec<String>,
289 animate: bool,
290 silent: bool,
291) -> Result<()> {
292 let fallback = [
293 client.get_fallback_relays().clone(),
294 if events
295 .iter()
296 .any(|e| e.kind().eq(&Kind::GitRepoAnnouncement))
297 {
298 client.get_blaster_relays().clone()
299 } else {
300 vec![]
301 },
302 ]
303 .concat();
304 let mut relays: Vec<&String> = vec![];
305
306 let all = &[
307 repo_read_relays.clone(),
308 my_write_relays.clone(),
309 fallback.clone(),
310 ]
311 .concat();
312 // add duplicates first
313 for r in &repo_read_relays {
314 let r_clean = remove_trailing_slash(r);
315 if !my_write_relays
316 .iter()
317 .filter(|x| r_clean.eq(&remove_trailing_slash(x)))
318 .count()
319 > 1
320 && !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x)))
321 {
322 relays.push(r);
323 }
324 }
325
326 for r in all {
327 let r_clean = remove_trailing_slash(r);
328 if !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) {
329 relays.push(r);
330 }
331 }
332
333 let m = if silent {
334 MultiProgress::with_draw_target(ProgressDrawTarget::hidden())
335 } else {
336 MultiProgress::new()
337 };
338 let pb_style = ProgressStyle::with_template(if animate {
339 " {spinner} {prefix} {bar} {pos}/{len} {msg}"
340 } else {
341 " - {prefix} {bar} {pos}/{len} {msg}"
342 })?
343 .progress_chars("##-");
344
345 let pb_after_style =
346 |symbol| ProgressStyle::with_template(format!(" {symbol} {}", "{prefix} {msg}",).as_str());
347 let pb_after_style_succeeded = pb_after_style(if animate {
348 console::style("✔".to_string())
349 .for_stderr()
350 .green()
351 .to_string()
352 } else {
353 "y".to_string()
354 })?;
355
356 let pb_after_style_failed = pb_after_style(if animate {
357 console::style("✘".to_string())
358 .for_stderr()
359 .red()
360 .to_string()
361 } else {
362 "x".to_string()
363 })?;
364
365 #[allow(clippy::borrow_deref_ref)]
366 join_all(relays.iter().map(|&relay| async {
367 let relay_clean = remove_trailing_slash(&*relay);
368 let details = format!(
369 "{}{}{} {}",
370 if my_write_relays
371 .iter()
372 .any(|r| relay_clean.eq(&remove_trailing_slash(r)))
373 {
374 " [my-relay]"
375 } else {
376 ""
377 },
378 if repo_read_relays
379 .iter()
380 .any(|r| relay_clean.eq(&remove_trailing_slash(r)))
381 {
382 " [repo-relay]"
383 } else {
384 ""
385 },
386 if fallback
387 .iter()
388 .any(|r| relay_clean.eq(&remove_trailing_slash(r)))
389 {
390 " [default]"
391 } else {
392 ""
393 },
394 relay_clean,
395 );
396 let pb = m.add(
397 ProgressBar::new(events.len() as u64)
398 .with_prefix(details.to_string())
399 .with_style(pb_style.clone()),
400 );
401 if animate {
402 pb.enable_steady_tick(Duration::from_millis(300));
403 }
404 pb.inc(0); // need to make pb display intially
405 let mut failed = false;
406 for event in &events {
407 match client
408 .send_event_to(git_repo_path, relay.as_str(), event.clone())
409 .await
410 {
411 Ok(_) => pb.inc(1),
412 Err(e) => {
413 pb.set_style(pb_after_style_failed.clone());
414 pb.finish_with_message(
415 console::style(
416 e.to_string()
417 .replace("relay pool error:", "error:")
418 .replace("event not published: ", "error: "),
419 )
420 .for_stderr()
421 .red()
422 .to_string(),
423 );
424 failed = true;
425 break;
426 }
427 };
428 }
429 if !failed {
430 pb.set_style(pb_after_style_succeeded.clone());
431 pb.finish_with_message("");
432 }
433 }))
434 .await;
435 Ok(())
436}
437
438fn remove_trailing_slash(s: &String) -> String {
439 match s.as_str().strip_suffix('/') {
440 Some(s) => s,
441 None => s,
442 }
443 .to_string()
444}
445
446fn choose_commits(git_repo: &Repo, proposed_commits: Vec<Sha1Hash>) -> Result<Vec<Sha1Hash>> {
447 let mut proposed_commits = if proposed_commits.len().gt(&10) {
448 vec![]
449 } else {
450 proposed_commits
451 };
452
453 let tip_of_head = git_repo.get_tip_of_branch(&git_repo.get_checked_out_branch_name()?)?;
454 let most_recent_commit = proposed_commits.first().unwrap_or(&tip_of_head);
455
456 let mut last_15_commits = vec![*most_recent_commit];
457
458 while last_15_commits.len().lt(&15) {
459 if let Ok(parent_commit) = git_repo.get_commit_parent(last_15_commits.last().unwrap()) {
460 last_15_commits.push(parent_commit);
461 } else {
462 break;
463 }
464 }
465
466 let term = console::Term::stderr();
467 let mut printed_error_line = false;
468
469 let selected_commits = 'outer: loop {
470 let selected = Interactor::default().multi_choice(
471 PromptMultiChoiceParms::default()
472 .with_prompt("select commits for proposal")
473 .dont_report()
474 .with_choices(
475 last_15_commits
476 .iter()
477 .map(|h| summarise_commit_for_selection(git_repo, h).unwrap())
478 .collect(),
479 )
480 .with_defaults(
481 last_15_commits
482 .iter()
483 .map(|h| proposed_commits.iter().any(|c| c.eq(h)))
484 .collect(),
485 ),
486 )?;
487 proposed_commits = selected.iter().map(|i| last_15_commits[*i]).collect();
488
489 if printed_error_line {
490 term.clear_last_lines(1)?;
491 }
492
493 if proposed_commits.is_empty() {
494 term.write_line("no commits selected")?;
495 printed_error_line = true;
496 continue;
497 }
498 for (i, selected_i) in selected.iter().enumerate() {
499 if i.gt(&0) && selected_i.ne(&(selected[i - 1] + 1)) {
500 term.write_line("commits must be consecutive. try again.")?;
501 printed_error_line = true;
502 continue 'outer;
503 }
504 }
505
506 break proposed_commits;
507 };
508 Ok(selected_commits)
509}
510
511fn summarise_commit_for_selection(git_repo: &Repo, commit: &Sha1Hash) -> Result<String> {
512 let references = git_repo.get_refs(commit)?;
513 let dim = Style::new().color256(247);
514 let prefix = format!("({})", git_repo.get_commit_author(commit)?[0],);
515 let references_string = if references.is_empty() {
516 String::new()
517 } else {
518 format!(
519 " {}",
520 references
521 .iter()
522 .map(|r| format!("[{r}]"))
523 .collect::<Vec<String>>()
524 .join(" ")
525 )
526 };
527
528 Ok(format!(
529 "{} {}{} {}",
530 dim.apply_to(prefix),
531 git_repo.get_commit_message_summary(commit)?,
532 Style::new().magenta().apply_to(references_string),
533 dim.apply_to(commit.to_string().chars().take(7).collect::<String>(),),
534 ))
535}
536
537async fn get_root_proposal_id_and_mentions_from_in_reply_to(
538 git_repo_path: &Path,
539 in_reply_to: &[String],
540) -> Result<(Option<String>, Vec<nostr::Tag>)> {
541 let root_proposal_id = if let Some(first) = in_reply_to.first() {
542 match event_tag_from_nip19_or_hex(first, "in-reply-to", Marker::Root, true, false)?
543 .as_standardized()
544 {
545 Some(nostr_sdk::TagStandard::Event {
546 event_id,
547 relay_url: _,
548 marker: _,
549 public_key: _,
550 }) => {
551 let events =
552 get_events_from_cache(git_repo_path, vec![nostr::Filter::new().id(*event_id)])
553 .await?;
554
555 if let Some(first) = events.iter().find(|e| e.id.eq(event_id)) {
556 if event_is_patch_set_root(first) {
557 Some(event_id.to_string())
558 } else {
559 None
560 }
561 } else {
562 None
563 }
564 }
565 _ => None,
566 }
567 } else {
568 return Ok((None, vec![]));
569 };
570
571 let mut mention_tags = vec![];
572 for (i, reply_to) in in_reply_to.iter().enumerate() {
573 if i.ne(&0) || root_proposal_id.is_none() {
574 mention_tags.push(
575 event_tag_from_nip19_or_hex(reply_to, "in-reply-to", Marker::Mention, true, false)
576 .context(format!(
577 "{reply_to} in 'in-reply-to' not a valid nostr reference"
578 ))?,
579 );
580 }
581 }
582
583 Ok((root_proposal_id, mention_tags))
584}
585
586#[allow(clippy::too_many_lines)]
587pub async fn generate_cover_letter_and_patch_events(
588 cover_letter_title_description: Option<(String, String)>,
589 git_repo: &Repo,
590 commits: &[Sha1Hash],
591 signer: &NostrSigner,
592 repo_ref: &RepoRef,
593 root_proposal_id: &Option<String>,
594 mentions: &[nostr::Tag],
595) -> Result<Vec<nostr::Event>> {
596 let root_commit = git_repo
597 .get_root_commit()
598 .context("failed to get root commit of the repository")?;
599
600 let mut events = vec![];
601
602 if let Some((title, description)) = cover_letter_title_description {
603 events.push(sign_event(EventBuilder::new(
604 nostr::event::Kind::GitPatch,
605 format!(
606 "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}",
607 commits.last().unwrap(),
608 commits.len()
609 ),
610 [
611 repo_ref.maintainers.iter().map(|m| Tag::coordinate(Coordinate {
612 kind: nostr::Kind::GitRepoAnnouncement,
613 public_key: *m,
614 identifier: repo_ref.identifier.to_string(),
615 relays: repo_ref.relays.clone(),
616 })).collect::<Vec<Tag>>(),
617 vec![
618 Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))),
619 Tag::hashtag("cover-letter"),
620 Tag::custom(
621 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
622 vec![format!("git patch cover letter: {}", title.clone())],
623 ),
624 ],
625 if let Some(event_ref) = root_proposal_id.clone() {
626 vec![
627 Tag::hashtag("root"),
628 Tag::hashtag("revision-root"),
629 // TODO check if id is for a root proposal (perhaps its for an issue?)
630 event_tag_from_nip19_or_hex(&event_ref,"proposal",Marker::Reply, false, false)?,
631 ]
632 } else {
633 vec![
634 Tag::hashtag("root"),
635 ]
636 },
637 mentions.to_vec(),
638 // this is not strictly needed but makes for prettier branch names
639 // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding
640 // a change like this, or the removal of this tag will require the actual branch name to be tracked
641 // so pulling and pushing still work
642 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
643 if !branch_name.eq("main")
644 && !branch_name.eq("master")
645 && !branch_name.eq("origin/main")
646 && !branch_name.eq("origin/master")
647 {
648 vec![
649 Tag::custom(
650 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
651 vec![if let Some(branch_name) = branch_name.strip_prefix("pr/") {
652 branch_name.to_string()
653 } else {
654 branch_name
655 }],
656 ),
657 ]
658 }
659 else { vec![] }
660 } else {
661 vec![]
662 },
663 repo_ref.maintainers
664 .iter()
665 .map(|pk| Tag::public_key(*pk))
666 .collect(),
667 ].concat(),
668 ), signer).await
669 .context("failed to create cover-letter event")?);
670 }
671
672 for (i, commit) in commits.iter().enumerate() {
673 events.push(
674 generate_patch_event(
675 git_repo,
676 &root_commit,
677 commit,
678 events.first().map(|event| event.id),
679 signer,
680 repo_ref,
681 events.last().map(nostr::Event::id),
682 if events.is_empty() && commits.len().eq(&1) {
683 None
684 } else {
685 Some(((i + 1).try_into()?, commits.len().try_into()?))
686 },
687 if events.is_empty() {
688 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
689 if !branch_name.eq("main")
690 && !branch_name.eq("master")
691 && !branch_name.eq("origin/main")
692 && !branch_name.eq("origin/master")
693 {
694 Some(if let Some(branch_name) = branch_name.strip_prefix("pr/") {
695 branch_name.to_string()
696 } else {
697 branch_name
698 })
699 } else {
700 None
701 }
702 } else {
703 None
704 }
705 } else {
706 None
707 },
708 root_proposal_id,
709 if events.is_empty() { mentions } else { &[] },
710 )
711 .await
712 .context("failed to generate patch event")?,
713 );
714 }
715 Ok(events)
716}
717
718fn event_tag_from_nip19_or_hex(
719 reference: &str,
720 reference_name: &str,
721 marker: Marker,
722 allow_npub_reference: bool,
723 prompt_for_correction: bool,
724) -> Result<nostr::Tag> {
725 let mut bech32 = reference.to_string();
726 loop {
727 if bech32.is_empty() {
728 bech32 = Interactor::default().input(
729 PromptInputParms::default().with_prompt(&format!("{reference_name} reference")),
730 )?;
731 }
732 if let Ok(nip19) = Nip19::from_bech32(bech32.clone()) {
733 match nip19 {
734 Nip19::Event(n) => {
735 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
736 event_id: n.event_id,
737 relay_url: n.relays.first().map(UncheckedUrl::new),
738 marker: Some(marker),
739 public_key: None,
740 }));
741 }
742 Nip19::EventId(id) => {
743 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
744 event_id: id,
745 relay_url: None,
746 marker: Some(marker),
747 public_key: None,
748 }));
749 }
750 Nip19::Coordinate(coordinate) => {
751 break Ok(Tag::coordinate(coordinate));
752 }
753 Nip19::Profile(profile) => {
754 if allow_npub_reference {
755 break Ok(Tag::public_key(profile.public_key));
756 }
757 }
758 Nip19::Pubkey(public_key) => {
759 if allow_npub_reference {
760 break Ok(Tag::public_key(public_key));
761 }
762 }
763 _ => {}
764 }
765 }
766 if let Ok(id) = nostr::EventId::from_str(&bech32) {
767 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
768 event_id: id,
769 relay_url: None,
770 marker: Some(marker),
771 public_key: None,
772 }));
773 }
774 if prompt_for_correction {
775 println!("not a valid {reference_name} event reference");
776 } else {
777 bail!(format!("not a valid {reference_name} event reference"));
778 }
779
780 bech32 = String::new();
781 }
782}
783
784pub struct CoverLetter {
785 pub title: String,
786 pub description: String,
787 pub branch_name: String,
788 pub event_id: Option<nostr::EventId>,
789}
790
791impl CoverLetter {
792 pub fn get_branch_name(&self) -> Result<String> {
793 Ok(format!(
794 "pr/{}({})",
795 self.branch_name,
796 &self
797 .event_id
798 .context("proposal root event_id must be know to get it's branch name")?
799 .to_hex()
800 .as_str()[..8],
801 ))
802 }
803}
804pub fn event_is_cover_letter(event: &nostr::Event) -> bool {
805 // TODO: look for Subject:[ PATCH 0/n ] but watch out for:
806 // [PATCH v1 0/n ] or
807 // [PATCH subsystem v2 0/n ]
808 event.kind.eq(&Kind::GitPatch)
809 && event.tags().iter().any(|t| t.as_vec()[1].eq("root"))
810 && event
811 .tags()
812 .iter()
813 .any(|t| t.as_vec()[1].eq("cover-letter"))
814}
815
816pub fn commit_msg_from_patch(patch: &nostr::Event) -> Result<String> {
817 if let Ok(msg) = tag_value(patch, "description") {
818 Ok(msg)
819 } else {
820 let start_index = patch
821 .content
822 .find("] ")
823 .context("event is not formatted as a patch or cover letter")?
824 + 2;
825 let end_index = patch.content[start_index..]
826 .find("\ndiff --git")
827 .unwrap_or(patch.content.len());
828 Ok(patch.content[start_index..end_index].to_string())
829 }
830}
831
832pub fn commit_msg_from_patch_oneliner(patch: &nostr::Event) -> Result<String> {
833 Ok(commit_msg_from_patch(patch)?
834 .split('\n')
835 .collect::<Vec<&str>>()[0]
836 .to_string())
837}
838
839pub fn event_to_cover_letter(event: &nostr::Event) -> Result<CoverLetter> {
840 if !event_is_patch_set_root(event) {
841 bail!("event is not a patch set root event (root patch or cover letter)")
842 }
843
844 let title = commit_msg_from_patch_oneliner(event)?;
845 let full = commit_msg_from_patch(event)?;
846 let description = full[title.len()..].trim().to_string();
847
848 Ok(CoverLetter {
849 title: title.clone(),
850 description,
851 // TODO should this be prefixed by format!("{}-"e.id.to_string()[..5]?)
852 branch_name: if let Ok(name) = match tag_value(event, "branch-name") {
853 Ok(name) => {
854 if !name.eq("main") && !name.eq("master") {
855 Ok(name)
856 } else {
857 Err(())
858 }
859 }
860 _ => Err(()),
861 } {
862 name
863 } else {
864 let s = title
865 .replace(' ', "-")
866 .chars()
867 .map(|c| {
868 if c.is_ascii_alphanumeric() || c.eq(&'/') {
869 c
870 } else {
871 '-'
872 }
873 })
874 .collect();
875 s
876 },
877 event_id: Some(event.id()),
878 })
879}
880
881pub fn event_is_patch_set_root(event: &nostr::Event) -> bool {
882 event.kind.eq(&Kind::GitPatch) && event.tags().iter().any(|t| t.as_vec()[1].eq("root"))
883}
884
885pub fn event_is_revision_root(event: &nostr::Event) -> bool {
886 event.kind.eq(&Kind::GitPatch)
887 && event
888 .tags()
889 .iter()
890 .any(|t| t.as_vec()[1].eq("revision-root"))
891}
892
893pub fn patch_supports_commit_ids(event: &nostr::Event) -> bool {
894 event.kind.eq(&Kind::GitPatch)
895 && event
896 .tags()
897 .iter()
898 .any(|t| t.as_vec()[0].eq("commit-pgp-sig"))
899}
900
901#[allow(clippy::too_many_arguments)]
902#[allow(clippy::too_many_lines)]
903pub async fn generate_patch_event(
904 git_repo: &Repo,
905 root_commit: &Sha1Hash,
906 commit: &Sha1Hash,
907 thread_event_id: Option<nostr::EventId>,
908 signer: &nostr_sdk::NostrSigner,
909 repo_ref: &RepoRef,
910 parent_patch_event_id: Option<nostr::EventId>,
911 series_count: Option<(u64, u64)>,
912 branch_name: Option<String>,
913 root_proposal_id: &Option<String>,
914 mentions: &[nostr::Tag],
915) -> Result<nostr::Event> {
916 let commit_parent = git_repo
917 .get_commit_parent(commit)
918 .context("failed to get parent commit")?;
919 let relay_hint = repo_ref.relays.first().map(nostr::UncheckedUrl::from);
920
921 sign_event(
922 EventBuilder::new(
923 nostr::event::Kind::GitPatch,
924 git_repo
925 .make_patch_from_commit(commit, &series_count)
926 .context(format!("cannot make patch for commit {commit}"))?,
927 [
928 repo_ref
929 .maintainers
930 .iter()
931 .map(|m| {
932 Tag::coordinate(Coordinate {
933 kind: nostr::Kind::GitRepoAnnouncement,
934 public_key: *m,
935 identifier: repo_ref.identifier.to_string(),
936 relays: repo_ref.relays.clone(),
937 })
938 })
939 .collect::<Vec<Tag>>(),
940 vec![
941 Tag::from_standardized(TagStandard::Reference(root_commit.to_string())),
942 // commit id reference is a trade-off. its now
943 // unclear which one is the root commit id but it
944 // enables easier location of code comments againt
945 // code that makes it into the main branch, assuming
946 // the commit id is correct
947 Tag::from_standardized(TagStandard::Reference(commit.to_string())),
948 Tag::custom(
949 TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
950 vec![format!(
951 "git patch: {}",
952 git_repo
953 .get_commit_message_summary(commit)
954 .unwrap_or_default()
955 )],
956 ),
957 ],
958 if let Some(thread_event_id) = thread_event_id {
959 vec![Tag::from_standardized(nostr_sdk::TagStandard::Event {
960 event_id: thread_event_id,
961 relay_url: relay_hint.clone(),
962 marker: Some(Marker::Root),
963 public_key: None,
964 })]
965 } else if let Some(event_ref) = root_proposal_id.clone() {
966 vec![
967 Tag::hashtag("root"),
968 Tag::hashtag("revision-root"),
969 // TODO check if id is for a root proposal (perhaps its for an issue?)
970 event_tag_from_nip19_or_hex(
971 &event_ref,
972 "proposal",
973 Marker::Reply,
974 false,
975 false,
976 )?,
977 ]
978 } else {
979 vec![Tag::hashtag("root")]
980 },
981 mentions.to_vec(),
982 if let Some(id) = parent_patch_event_id {
983 vec![Tag::from_standardized(nostr_sdk::TagStandard::Event {
984 event_id: id,
985 relay_url: relay_hint.clone(),
986 marker: Some(Marker::Reply),
987 public_key: None,
988 })]
989 } else {
990 vec![]
991 },
992 // see comment on branch names in cover letter event creation
993 if let Some(branch_name) = branch_name {
994 if thread_event_id.is_none() {
995 vec![Tag::custom(
996 TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
997 vec![branch_name.to_string()],
998 )]
999 } else {
1000 vec![]
1001 }
1002 } else {
1003 vec![]
1004 },
1005 // whilst it is in nip34 draft to tag the maintainers
1006 // I'm not sure it is a good idea because if they are
1007 // interested in all patches then their specialised
1008 // client should subscribe to patches tagged with the
1009 // repo reference. maintainers of large repos will not
1010 // be interested in every patch.
1011 repo_ref
1012 .maintainers
1013 .iter()
1014 .map(|pk| Tag::public_key(*pk))
1015 .collect(),
1016 vec![
1017 // a fallback is now in place to extract this from the patch
1018 Tag::custom(
1019 TagKind::Custom(std::borrow::Cow::Borrowed("commit")),
1020 vec![commit.to_string()],
1021 ),
1022 // this is required as patches cannot be relied upon to include the 'base
1023 // commit'
1024 Tag::custom(
1025 TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")),
1026 vec![commit_parent.to_string()],
1027 ),
1028 // this is required to ensure the commit id matches
1029 Tag::custom(
1030 TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")),
1031 vec![
1032 git_repo
1033 .extract_commit_pgp_signature(commit)
1034 .unwrap_or_default(),
1035 ],
1036 ),
1037 // removing description tag will not cause anything to break
1038 Tag::from_standardized(nostr_sdk::TagStandard::Description(
1039 git_repo.get_commit_message(commit)?.to_string(),
1040 )),
1041 Tag::custom(
1042 TagKind::Custom(std::borrow::Cow::Borrowed("author")),
1043 git_repo.get_commit_author(commit)?,
1044 ),
1045 // this is required to ensure the commit id matches
1046 Tag::custom(
1047 TagKind::Custom(std::borrow::Cow::Borrowed("committer")),
1048 git_repo.get_commit_comitter(commit)?,
1049 ),
1050 ],
1051 ]
1052 .concat(),
1053 ),
1054 signer,
1055 )
1056 .await
1057 .context("failed to sign event")
1058}
1059// TODO
1060// - find profile
1061// - file relays
1062// - find repo events
1063// -
1064
1065/**
1066 * returns `(from_branch,to_branch,ahead,behind)`
1067 */
1068pub fn identify_ahead_behind(
1069 git_repo: &Repo,
1070 from_branch: &Option<String>,
1071 to_branch: &Option<String>,
1072) -> Result<(String, String, Vec<Sha1Hash>, Vec<Sha1Hash>)> {
1073 let (from_branch, from_tip) = match from_branch {
1074 Some(name) => (
1075 name.to_string(),
1076 git_repo
1077 .get_tip_of_branch(name)
1078 .context(format!("cannot find from_branch '{name}'"))?,
1079 ),
1080 None => (
1081 if let Ok(name) = git_repo.get_checked_out_branch_name() {
1082 name
1083 } else {
1084 "head".to_string()
1085 },
1086 git_repo
1087 .get_head_commit()
1088 .context("failed to get head commit")
1089 .context(
1090 "checkout a commit or specify a from_branch. head does not reveal a commit",
1091 )?,
1092 ),
1093 };
1094
1095 let (to_branch, to_tip) = match to_branch {
1096 Some(name) => (
1097 name.to_string(),
1098 git_repo
1099 .get_tip_of_branch(name)
1100 .context(format!("cannot find to_branch '{name}'"))?,
1101 ),
1102 None => {
1103 let (name, commit) = git_repo
1104 .get_main_or_master_branch()
1105 .context("the default branches (main or master) do not exist")?;
1106 (name.to_string(), commit)
1107 }
1108 };
1109
1110 match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) {
1111 Err(e) => {
1112 if e.to_string().contains("is not an ancestor of") {
1113 return Err(e).context(format!(
1114 "'{from_branch}' is not branched from '{to_branch}'"
1115 ));
1116 }
1117 Err(e).context(format!(
1118 "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'"
1119 ))
1120 }
1121 Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)),
1122 }
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127 use test_utils::git::GitTestRepo;
1128
1129 use super::*;
1130 mod identify_ahead_behind {
1131
1132 use super::*;
1133 use crate::git::oid_to_sha1;
1134
1135 #[test]
1136 fn when_from_branch_doesnt_exist_return_error() -> Result<()> {
1137 let test_repo = GitTestRepo::default();
1138 let git_repo = Repo::from_path(&test_repo.dir)?;
1139
1140 test_repo.populate()?;
1141 let branch_name = "doesnt_exist";
1142 assert_eq!(
1143 identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None)
1144 .unwrap_err()
1145 .to_string(),
1146 format!("cannot find from_branch '{}'", &branch_name),
1147 );
1148 Ok(())
1149 }
1150
1151 #[test]
1152 fn when_to_branch_doesnt_exist_return_error() -> Result<()> {
1153 let test_repo = GitTestRepo::default();
1154 let git_repo = Repo::from_path(&test_repo.dir)?;
1155
1156 test_repo.populate()?;
1157 let branch_name = "doesnt_exist";
1158 assert_eq!(
1159 identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string()))
1160 .unwrap_err()
1161 .to_string(),
1162 format!("cannot find to_branch '{}'", &branch_name),
1163 );
1164 Ok(())
1165 }
1166
1167 #[test]
1168 fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> {
1169 let test_repo = GitTestRepo::new("notmain")?;
1170 let git_repo = Repo::from_path(&test_repo.dir)?;
1171
1172 test_repo.populate()?;
1173
1174 assert_eq!(
1175 identify_ahead_behind(&git_repo, &None, &None)
1176 .unwrap_err()
1177 .to_string(),
1178 "the default branches (main or master) do not exist",
1179 );
1180 Ok(())
1181 }
1182
1183 #[test]
1184 fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> {
1185 let test_repo = GitTestRepo::default();
1186 let git_repo = Repo::from_path(&test_repo.dir)?;
1187
1188 test_repo.populate()?;
1189 // create feature branch with 1 commit ahead
1190 test_repo.create_branch("feature")?;
1191 test_repo.checkout("feature")?;
1192 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1193 let head_oid = test_repo.stage_and_commit("add t3.md")?;
1194
1195 // make feature branch 1 commit behind
1196 test_repo.checkout("main")?;
1197 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
1198 let main_oid = test_repo.stage_and_commit("add t4.md")?;
1199
1200 let (from_branch, to_branch, ahead, behind) =
1201 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
1202
1203 assert_eq!(from_branch, "feature");
1204 assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]);
1205 assert_eq!(to_branch, "main");
1206 assert_eq!(behind, vec![oid_to_sha1(&main_oid)]);
1207 Ok(())
1208 }
1209
1210 #[test]
1211 fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> {
1212 let test_repo = GitTestRepo::default();
1213 let git_repo = Repo::from_path(&test_repo.dir)?;
1214
1215 test_repo.populate()?;
1216 // create dev branch with 1 commit ahead
1217 test_repo.create_branch("dev")?;
1218 test_repo.checkout("dev")?;
1219 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1220 let dev_oid_first = test_repo.stage_and_commit("add t3.md")?;
1221
1222 // create feature branch with 1 commit ahead of dev
1223 test_repo.create_branch("feature")?;
1224 test_repo.checkout("feature")?;
1225 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
1226 let feature_oid = test_repo.stage_and_commit("add t4.md")?;
1227
1228 // make feature branch 1 behind
1229 test_repo.checkout("dev")?;
1230 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1231 let dev_oid = test_repo.stage_and_commit("add t3.md")?;
1232
1233 let (from_branch, to_branch, ahead, behind) = identify_ahead_behind(
1234 &git_repo,
1235 &Some("feature".to_string()),
1236 &Some("dev".to_string()),
1237 )?;
1238
1239 assert_eq!(from_branch, "feature");
1240 assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]);
1241 assert_eq!(to_branch, "dev");
1242 assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]);
1243
1244 let (from_branch, to_branch, ahead, behind) =
1245 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
1246
1247 assert_eq!(from_branch, "feature");
1248 assert_eq!(
1249 ahead,
1250 vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)]
1251 );
1252 assert_eq!(to_branch, "main");
1253 assert_eq!(behind, vec![]);
1254
1255 Ok(())
1256 }
1257 }
1258
1259 mod event_to_cover_letter {
1260 use super::*;
1261
1262 fn generate_cover_letter(title: &str, description: &str) -> Result<nostr::Event> {
1263 Ok(nostr::event::EventBuilder::new(
1264 nostr::event::Kind::GitPatch,
1265 format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"),
1266 [
1267 Tag::hashtag("cover-letter"),
1268 Tag::hashtag("root"),
1269 ],
1270 )
1271 .to_event(&nostr::Keys::generate())?)
1272 }
1273
1274 #[test]
1275 fn basic_title() -> Result<()> {
1276 assert_eq!(
1277 event_to_cover_letter(&generate_cover_letter("the title", "description here")?)?
1278 .title,
1279 "the title",
1280 );
1281 Ok(())
1282 }
1283
1284 #[test]
1285 fn basic_description() -> Result<()> {
1286 assert_eq!(
1287 event_to_cover_letter(&generate_cover_letter("the title", "description here")?)?
1288 .description,
1289 "description here",
1290 );
1291 Ok(())
1292 }
1293
1294 #[test]
1295 fn description_trimmed() -> Result<()> {
1296 assert_eq!(
1297 event_to_cover_letter(&generate_cover_letter(
1298 "the title",
1299 " \n \ndescription here\n\n "
1300 )?)?
1301 .description,
1302 "description here",
1303 );
1304 Ok(())
1305 }
1306
1307 #[test]
1308 fn multi_line_description() -> Result<()> {
1309 assert_eq!(
1310 event_to_cover_letter(&generate_cover_letter(
1311 "the title",
1312 "description here\n\nmore here\nmore"
1313 )?)?
1314 .description,
1315 "description here\n\nmore here\nmore",
1316 );
1317 Ok(())
1318 }
1319
1320 #[test]
1321 fn new_lines_in_title_forms_part_of_description() -> Result<()> {
1322 assert_eq!(
1323 event_to_cover_letter(&generate_cover_letter(
1324 "the title\nwith new line",
1325 "description here\n\nmore here\nmore"
1326 )?)?
1327 .title,
1328 "the title",
1329 );
1330 assert_eq!(
1331 event_to_cover_letter(&generate_cover_letter(
1332 "the title\nwith new line",
1333 "description here\n\nmore here\nmore"
1334 )?)?
1335 .description,
1336 "with new line\n\ndescription here\n\nmore here\nmore",
1337 );
1338 Ok(())
1339 }
1340
1341 mod blank_description {
1342 use super::*;
1343
1344 #[test]
1345 fn title_correct() -> Result<()> {
1346 assert_eq!(
1347 event_to_cover_letter(&generate_cover_letter("the title", "")?)?.title,
1348 "the title",
1349 );
1350 Ok(())
1351 }
1352
1353 #[test]
1354 fn description_is_empty_string() -> Result<()> {
1355 assert_eq!(
1356 event_to_cover_letter(&generate_cover_letter("the title", "")?)?.description,
1357 "",
1358 );
1359 Ok(())
1360 }
1361 }
1362 }
1363}