upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/lib/push.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-08-07 17:52:22 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2025-08-07 17:52:22 +0100
commit92c2362a9bed1bc1f256e7948e087c4102b7c4f9 (patch)
tree700bf840ba52a8bd576afcfbe532a669f104dfcb /src/lib/push.rs
parent8724af191f520a822214109f75a1851856c74fd2 (diff)
parentfa7adf840ac2d78defee398a61b60888f615622a (diff)
Merge branch 'add-prs-to-ngit-send'
Diffstat (limited to 'src/lib/push.rs')
-rw-r--r--src/lib/push.rs270
1 files changed, 252 insertions, 18 deletions
diff --git a/src/lib/push.rs b/src/lib/push.rs
index 0d0ec93..8cb0212 100644
--- a/src/lib/push.rs
+++ b/src/lib/push.rs
@@ -1,25 +1,38 @@
1use std::{ 1use std::{
2 collections::{HashMap, HashSet},
2 sync::{Arc, Mutex}, 3 sync::{Arc, Mutex},
3 time::Instant, 4 time::Instant,
4}; 5};
5 6
6use anyhow::{Result, anyhow}; 7use anyhow::{Context, Result, anyhow};
7use auth_git2::GitAuthenticator; 8use auth_git2::GitAuthenticator;
8use console::Term; 9use console::Term;
10use nostr::{
11 event::{Event, EventBuilder, Kind, Tag, TagStandard, UnsignedEvent},
12 hashes::sha1::Hash as Sha1Hash,
13 key::PublicKey,
14 nips::nip10::Marker,
15 signer::NostrSigner,
16};
9 17
10use crate::{ 18use crate::{
11 cli_interactor::count_lines_per_msg_vec, 19 cli_interactor::count_lines_per_msg_vec,
20 client::{sign_draft_event, sign_event},
12 git::{ 21 git::{
13 Repo, 22 Repo, RepoActions,
14 nostr_url::{CloneUrl, NostrUrlDecoded}, 23 nostr_url::{CloneUrl, NostrUrlDecoded},
15 oid_to_shorthand_string, 24 oid_to_shorthand_string,
16 }, 25 },
26 git_events::generate_unsigned_pr_or_update_event,
27 login::user::UserRef,
28 repo_ref::{RepoRef, is_grasp_server_clone_url, normalize_grasp_server_url},
17 utils::{ 29 utils::{
18 Direction, get_short_git_server_name, get_write_protocols_to_try, join_with_and, 30 Direction, get_short_git_server_name, get_write_protocols_to_try, join_with_and,
19 set_protocol_preference, 31 set_protocol_preference,
20 }, 32 },
21}; 33};
22 34
35// returns a HashMap of refs responded to and any related cancellation reasons
23pub fn push_to_remote( 36pub fn push_to_remote(
24 git_repo: &Repo, 37 git_repo: &Repo,
25 git_server_url: &str, 38 git_server_url: &str,
@@ -27,35 +40,65 @@ pub fn push_to_remote(
27 remote_refspecs: &[String], 40 remote_refspecs: &[String],
28 term: &Term, 41 term: &Term,
29 is_grasp_server: bool, 42 is_grasp_server: bool,
30) -> Result<()> { 43) -> Result<HashMap<String, Option<String>>> {
31 let server_url = git_server_url.parse::<CloneUrl>()?; 44 let server_url = git_server_url.parse::<CloneUrl>()?;
32 let protocols_to_attempt = 45 let protocols_to_attempt =
33 get_write_protocols_to_try(git_repo, &server_url, decoded_nostr_url, is_grasp_server); 46 get_write_protocols_to_try(git_repo, &server_url, decoded_nostr_url, is_grasp_server);
34 47
35 let mut failed_protocols = vec![]; 48 let mut failed_protocols = vec![];
36 let mut success = false; 49 let mut success = false;
50 let mut ref_updates = HashMap::new();
37 51
38 for protocol in &protocols_to_attempt { 52 for protocol in &protocols_to_attempt {
39 term.write_line(format!("push: {} over {protocol}...", server_url.short_name(),).as_str())?; 53 term.write_line(format!("push: {} over {protocol}...", server_url.short_name(),).as_str())?;
40 54
41 let formatted_url = server_url.format_as(protocol, &decoded_nostr_url.user)?; 55 let formatted_url = server_url.format_as(protocol, &decoded_nostr_url.user)?;
42 56
43 if let Err(error) = push_to_remote_url(git_repo, &formatted_url, remote_refspecs, term) { 57 match push_to_remote_url(git_repo, &formatted_url, remote_refspecs, term) {
44 term.write_line( 58 Err(error) => {
45 format!("push: {formatted_url} failed over {protocol}: {error}").as_str(), 59 term.write_line(
46 )?; 60 format!("push: {formatted_url} failed over {protocol}: {error}").as_str(),
47 failed_protocols.push(protocol); 61 )?;
48 } else { 62 failed_protocols.push(protocol);
49 success = true; 63 }
50 if !failed_protocols.is_empty() { 64 Ok(ref_updates_on_protocol) => {
51 term.write_line(format!("push: succeeded over {protocol}").as_str())?; 65 success = true;
52 let _ = set_protocol_preference(git_repo, protocol, &server_url, &Direction::Push); 66 if ref_updates_on_protocol
67 .values()
68 .all(|error| error.is_none())
69 {
70 if !failed_protocols.is_empty() {
71 term.write_line(format!("push: succeeded over {protocol}").as_str())?;
72 let _ = set_protocol_preference(
73 git_repo,
74 protocol,
75 &server_url,
76 &Direction::Push,
77 );
78 }
79 break;
80 } else {
81 term.write_line(
82 format!(
83 "push: {formatted_url} with {protocol} complete but {}ref{} not accepted:",
84 if remote_refspecs.len() != failed_protocols.len() { "some " } else {""},
85 if remote_refspecs.len() == 1 { "s"} else {""},
86 ).as_str(),
87 )?;
88 for (git_ref, error) in &ref_updates_on_protocol {
89 if let Some(error) = error {
90 term.write_line(format!("push: - {git_ref}: {error}").as_str())?;
91 }
92 }
93 // TODO do we want to report on the refs that weren't responded to?
94 ref_updates = ref_updates_on_protocol;
95 }
96 break;
53 } 97 }
54 break;
55 } 98 }
56 } 99 }
57 if success { 100 if success {
58 Ok(()) 101 Ok(ref_updates)
59 } else { 102 } else {
60 let error = anyhow!( 103 let error = anyhow!(
61 "{} failed over {}{}", 104 "{} failed over {}{}",
@@ -72,12 +115,13 @@ pub fn push_to_remote(
72 } 115 }
73} 116}
74 117
118// returns HashMaps of refspecs responded to and any failure message
75pub fn push_to_remote_url( 119pub fn push_to_remote_url(
76 git_repo: &Repo, 120 git_repo: &Repo,
77 git_server_url: &str, 121 git_server_url: &str,
78 remote_refspecs: &[String], 122 remote_refspecs: &[String],
79 term: &Term, 123 term: &Term,
80) -> Result<()> { 124) -> Result<HashMap<String, Option<String>>> {
81 let git_config = git_repo.git_repo.config()?; 125 let git_config = git_repo.git_repo.config()?;
82 let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_url)?; 126 let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_url)?;
83 let auth = GitAuthenticator::default(); 127 let auth = GitAuthenticator::default();
@@ -91,6 +135,9 @@ pub fn push_to_remote_url(
91 let push_reporter = Arc::clone(&push_reporter); 135 let push_reporter = Arc::clone(&push_reporter);
92 move |name, error| { 136 move |name, error| {
93 let mut reporter = push_reporter.lock().unwrap(); 137 let mut reporter = push_reporter.lock().unwrap();
138 reporter
139 .ref_updates
140 .insert(name.to_string(), error.map(|s| s.to_string()));
94 if let Some(error) = error { 141 if let Some(error) = error {
95 let existing_lines = reporter.count_all_existing_lines(); 142 let existing_lines = reporter.count_all_existing_lines();
96 reporter.update_reference_errors.push(format!( 143 reporter.update_reference_errors.push(format!(
@@ -115,7 +162,11 @@ pub fn push_to_remote_url(
115 .unwrap_or("") 162 .unwrap_or("")
116 .replace("refs/heads/", "") 163 .replace("refs/heads/", "")
117 .replace("refs/tags/", "tags/"); 164 .replace("refs/tags/", "tags/");
118 let msg = if update.dst().is_zero() { 165 let msg = if let Some(Some(_)) =
166 reporter.ref_updates.get(update.dst_refname().unwrap_or(""))
167 {
168 format!("push: - [failed] {dst_refname}")
169 } else if update.dst().is_zero() {
119 format!("push: - [delete] {dst_refname}") 170 format!("push: - [delete] {dst_refname}")
120 } else if update.src().is_zero() { 171 } else if update.src().is_zero() {
121 if update.dst_refname().unwrap_or("").contains("refs/tags") { 172 if update.dst_refname().unwrap_or("").contains("refs/tags") {
@@ -174,7 +225,8 @@ pub fn push_to_remote_url(
174 push_options.remote_callbacks(remote_callbacks); 225 push_options.remote_callbacks(remote_callbacks);
175 git_server_remote.push(remote_refspecs, Some(&mut push_options))?; 226 git_server_remote.push(remote_refspecs, Some(&mut push_options))?;
176 let _ = git_server_remote.disconnect(); 227 let _ = git_server_remote.disconnect();
177 Ok(()) 228 let reporter = push_reporter.lock().unwrap();
229 Ok(reporter.ref_updates.clone())
178} 230}
179 231
180#[allow(clippy::cast_precision_loss)] 232#[allow(clippy::cast_precision_loss)]
@@ -223,6 +275,7 @@ pub struct PushReporter<'a> {
223 negotiation: Vec<String>, 275 negotiation: Vec<String>,
224 transfer_progress_msgs: Vec<String>, 276 transfer_progress_msgs: Vec<String>,
225 update_reference_errors: Vec<String>, 277 update_reference_errors: Vec<String>,
278 ref_updates: HashMap<String, Option<String>>,
226 term: &'a console::Term, 279 term: &'a console::Term,
227 start_time: Option<Instant>, 280 start_time: Option<Instant>,
228 end_time: Option<Instant>, 281 end_time: Option<Instant>,
@@ -234,6 +287,7 @@ impl<'a> PushReporter<'a> {
234 negotiation: vec![], 287 negotiation: vec![],
235 transfer_progress_msgs: vec![], 288 transfer_progress_msgs: vec![],
236 update_reference_errors: vec![], 289 update_reference_errors: vec![],
290 ref_updates: HashMap::new(),
237 term, 291 term,
238 start_time: None, 292 start_time: None,
239 end_time: None, 293 end_time: None,
@@ -308,3 +362,183 @@ impl<'a> PushReporter<'a> {
308 } 362 }
309 } 363 }
310} 364}
365
366#[allow(clippy::too_many_arguments)]
367pub async fn push_refs_and_generate_pr_or_pr_update_event(
368 git_repo: &Repo,
369 repo_ref: &RepoRef,
370 tip: &Sha1Hash,
371 user_ref: &UserRef,
372 root_proposal: Option<&Event>,
373 title_description_overide: &Option<(String, String)>,
374 servers: &[String],
375 git_ref: Option<String>,
376 signer: &Arc<dyn NostrSigner>,
377 term: &Term,
378) -> Result<(Option<Vec<Event>>, Vec<(String, Result<()>)>)> {
379 let mut responses: Vec<(String, Result<()>)> = vec![];
380
381 let mut unsigned_pr_event: Option<UnsignedEvent> = None;
382 for clone_url in servers {
383 let mut draft_pr_event = if let Some(ref unsigned_pr_event) = unsigned_pr_event {
384 unsigned_pr_event.clone()
385 } else {
386 generate_unsigned_pr_or_update_event(
387 git_repo,
388 repo_ref,
389 &user_ref.public_key,
390 root_proposal,
391 title_description_overide,
392 tip,
393 &[clone_url],
394 &[],
395 )?
396 };
397
398 let git_ref_used = git_ref
399 .clone()
400 .unwrap_or("refs/nostr/<event-id>".to_string())
401 .replace("<event-id>", &draft_pr_event.id().to_string());
402
403 let refspec = format!("{tip}:{git_ref_used}");
404
405 let res = if is_grasp_server_clone_url(clone_url) {
406 push_to_remote_url(git_repo, clone_url, &[refspec], term)
407 } else {
408 // anticipated only when pushing to user's own repo or a personal-fork with
409 // non-grasp git servers. this is used to extract prefered protocols / ssh
410 // details from nostr url
411 let decoded_nostr_url = {
412 if let Ok(Some((_, decoded_nostr_url))) = git_repo
413 .get_first_nostr_remote_when_in_ngit_binary()
414 .await.context("failed to list git remotes")
415 .context("no `nostr://` remote detected. `ngit sync` must be run from a repo with a nostr remote") {
416 decoded_nostr_url
417 } else {
418 repo_ref.to_nostr_git_url(&Some(git_repo))
419 }
420 };
421 push_to_remote(
422 git_repo,
423 clone_url,
424 &decoded_nostr_url,
425 &[refspec],
426 term,
427 false,
428 )
429 };
430
431 match res {
432 Err(error) => {
433 let normalized_url = normalize_grasp_server_url(clone_url)?;
434 term.write_line(&format!(
435 "push: error sending commit data to {normalized_url}: {error}"
436 ))?;
437 responses.push((clone_url.clone(), Err(anyhow!(error))));
438 }
439 Ok(ref_updates) => {
440 let normalized_url = normalize_grasp_server_url(clone_url)?;
441 if let Some((_, Some(error))) = ref_updates.iter().next() {
442 term.write_line(&format!(
443 "push: error sending commit data to {normalized_url}: {error}"
444 ))?;
445 responses.push((clone_url.clone(), Err(anyhow!(error.clone()))));
446 } else {
447 responses.push((clone_url.clone(), Ok(())));
448 term.write_line(&format!("push: commit data sent to {normalized_url}"))?;
449 unsigned_pr_event = Some(draft_pr_event);
450 }
451 }
452 }
453 }
454 if let Some(unsigned_pr_event) = unsigned_pr_event {
455 let pr_event = sign_draft_event(
456 unsigned_pr_event,
457 signer,
458 if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) {
459 "Pull Request Replacing Original Patch"
460 } else if root_proposal.is_some() {
461 "Pull Request Update"
462 } else {
463 "Pull Request"
464 }
465 .to_string(),
466 )
467 .await?;
468 if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) {
469 Ok((
470 Some(vec![
471 pr_event,
472 create_close_status_for_original_patch(
473 signer,
474 repo_ref,
475 root_proposal.unwrap(),
476 )
477 .await?,
478 ]),
479 responses,
480 ))
481 } else {
482 Ok((Some(vec![pr_event]), responses))
483 }
484 } else {
485 Ok((None, responses))
486 }
487}
488
489async fn create_close_status_for_original_patch(
490 signer: &Arc<dyn NostrSigner>,
491 repo_ref: &RepoRef,
492 proposal: &Event,
493) -> Result<Event> {
494 let mut public_keys = repo_ref
495 .maintainers
496 .iter()
497 .copied()
498 .collect::<HashSet<PublicKey>>();
499 public_keys.insert(proposal.pubkey);
500
501 sign_event(
502 EventBuilder::new(nostr::event::Kind::GitStatusClosed, String::new()).tags(
503 [
504 vec![
505 Tag::custom(
506 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
507 vec![
508 "Git patch closed as forthcoming update is too large. Replacing with Pull Request"
509 .to_string(),
510 ],
511 ),
512 Tag::from_standardized(nostr::TagStandard::Event {
513 event_id: proposal.id,
514 relay_url: repo_ref.relays.first().cloned(),
515 marker: Some(Marker::Root),
516 public_key: None,
517 uppercase: false,
518 }),
519 ],
520 public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(),
521 repo_ref
522 .coordinates()
523 .iter()
524 .map(|c| {
525 Tag::from_standardized(TagStandard::Coordinate {
526 coordinate: c.coordinate.clone(),
527 relay_url: c.relays.first().cloned(),
528 uppercase: false,
529 })
530 })
531 .collect::<Vec<Tag>>(),
532 vec![
533 Tag::from_standardized(nostr::TagStandard::Reference(
534 repo_ref.root_commit.to_string(),
535 )),
536 ],
537 ]
538 .concat(),
539 ),
540 signer,
541 "close status for original patch".to_string(),
542 )
543 .await
544}