upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/bin/git_remote_nostr/fetch.rs526
-rw-r--r--src/bin/git_remote_nostr/list.rs6
-rw-r--r--src/bin/ngit/sub_commands/list.rs129
-rw-r--r--src/lib/fetch.rs492
-rw-r--r--src/lib/git_events.rs33
-rw-r--r--src/lib/mod.rs1
6 files changed, 638 insertions, 549 deletions
diff --git a/src/bin/git_remote_nostr/fetch.rs b/src/bin/git_remote_nostr/fetch.rs
index 2cc87da..b0d6cfe 100644
--- a/src/bin/git_remote_nostr/fetch.rs
+++ b/src/bin/git_remote_nostr/fetch.rs
@@ -2,27 +2,21 @@ use core::str;
2use std::{ 2use std::{
3 collections::{HashMap, HashSet}, 3 collections::{HashMap, HashSet},
4 io::Stdin, 4 io::Stdin,
5 sync::{Arc, Mutex},
6 time::Instant,
7}; 5};
8 6
9use anyhow::{Context, Result, anyhow, bail}; 7use anyhow::{Context, Result, bail};
10use auth_git2::GitAuthenticator;
11use git2::{Progress, Repository};
12use ngit::{ 8use ngit::{
13 cli_interactor::count_lines_per_msg_vec, 9 fetch::fetch_from_git_server,
14 git::{ 10 git::{Repo, RepoActions},
15 Repo, RepoActions, 11 git_events::{
16 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol}, 12 KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE,
17 utils::check_ssh_keys, 13 identify_clone_urls_for_oids_from_pr_pr_update_events, tag_value,
18 }, 14 },
19 git_events::{KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, tag_value},
20 login::get_curent_user, 15 login::get_curent_user,
21 repo_ref::{RepoRef, is_grasp_server_in_list}, 16 repo_ref::{RepoRef, is_grasp_server_in_list},
22 utils::{ 17 utils::{
23 Direction, find_proposal_and_patches_by_branch_name, get_oids_from_fetch_batch, 18 find_proposal_and_patches_by_branch_name, get_oids_from_fetch_batch,
24 get_open_or_draft_proposals, get_read_protocols_to_try, join_with_and, 19 get_open_or_draft_proposals,
25 set_protocol_preference,
26 }, 20 },
27}; 21};
28use nostr::nips::nip19; 22use nostr::nips::nip19;
@@ -126,36 +120,6 @@ pub async fn run_fetch(
126 Ok(()) 120 Ok(())
127} 121}
128 122
129async fn identify_clone_urls_for_oids_from_pr_pr_update_events(
130 oids: Vec<&String>,
131 git_repo: &Repo,
132 repo_ref: &RepoRef,
133) -> Result<HashMap<String, Vec<String>>> {
134 let mut map: HashMap<String, Vec<String>> = HashMap::new();
135
136 let open_and_draft_proposals = get_open_or_draft_proposals(git_repo, repo_ref).await?;
137
138 for (_, (_, events)) in open_and_draft_proposals {
139 for event in events {
140 if [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&event.kind) {
141 if let Ok(c) = tag_value(&event, "c") {
142 if oids.contains(&&c) {
143 for tag in event.tags.as_slice() {
144 if tag.kind().eq(&nostr::event::TagKind::Clone) {
145 for clone_url in tag.as_slice().iter().skip(1) {
146 map.entry(c.clone()).or_default().push(clone_url.clone());
147 }
148 }
149 }
150 }
151 }
152 }
153 }
154 }
155
156 Ok(map)
157}
158
159pub fn make_commits_for_proposal( 123pub fn make_commits_for_proposal(
160 git_repo: &Repo, 124 git_repo: &Repo,
161 repo_ref: &RepoRef, 125 repo_ref: &RepoRef,
@@ -235,477 +199,3 @@ async fn fetch_open_or_draft_proposals_from_patches(
235 } 199 }
236 Ok(()) 200 Ok(())
237} 201}
238
239pub fn fetch_from_git_server(
240 git_repo: &Repo,
241 oids: &[String],
242 git_server_url: &str,
243 decoded_nostr_url: &NostrUrlDecoded,
244 term: &console::Term,
245 is_grasp_server: bool,
246) -> Result<()> {
247 let already_have_oids = oids
248 .iter()
249 .all(|oid| git_repo.does_commit_exist(oid).is_ok_and(|outcome| outcome));
250 if already_have_oids {
251 return Ok(());
252 }
253
254 let server_url = git_server_url.parse::<CloneUrl>()?;
255
256 let protocols_to_attempt =
257 get_read_protocols_to_try(git_repo, &server_url, decoded_nostr_url, is_grasp_server);
258
259 let mut failed_protocols = vec![];
260 let mut success = false;
261 for protocol in &protocols_to_attempt {
262 term.write_line(
263 format!("fetching {} over {protocol}...", server_url.short_name(),).as_str(),
264 )?;
265
266 let formatted_url = server_url.format_as(protocol, &decoded_nostr_url.user)?;
267 let res = fetch_from_git_server_url(
268 &git_repo.git_repo,
269 oids,
270 &formatted_url,
271 [ServerProtocol::UnauthHttps, ServerProtocol::UnauthHttp].contains(protocol),
272 term,
273 );
274 if let Err(error) = res {
275 term.write_line(
276 format!("fetch: {formatted_url} failed over {protocol}: {error}").as_str(),
277 )?;
278 failed_protocols.push(protocol);
279 } else {
280 success = true;
281 if !failed_protocols.is_empty() {
282 term.write_line(format!("fetch: succeeded over {protocol}").as_str())?;
283 let _ = set_protocol_preference(git_repo, protocol, &server_url, &Direction::Push);
284 }
285 break;
286 }
287 }
288 if success {
289 Ok(())
290 } else {
291 let error = anyhow!(
292 "{} failed over {}{}",
293 server_url.short_name(),
294 join_with_and(&failed_protocols),
295 if decoded_nostr_url.protocol.is_some() {
296 " and nostr url contains protocol override so no other protocols were attempted"
297 } else {
298 ""
299 },
300 );
301 term.write_line(format!("fetch: {error}").as_str())?;
302 Err(error)
303 }
304}
305
306#[allow(clippy::cast_precision_loss)]
307#[allow(clippy::float_cmp)]
308#[allow(clippy::needless_pass_by_value)]
309fn report_on_transfer_progress(
310 progress_stats: &Progress<'_>,
311 start_time: &Instant,
312 end_time: Option<&Instant>,
313) -> Vec<String> {
314 let mut report = vec![];
315 let total = progress_stats.total_objects() as f64;
316 if total == 0.0 {
317 return report;
318 }
319 let received = progress_stats.received_objects() as f64;
320 let percentage = ((received / total) * 100.0)
321 // always round down because 100% complete is misleading when its not complete
322 .floor();
323
324 let received_bytes = progress_stats.received_bytes() as f64;
325
326 let (size, unit) = if received_bytes >= (1024.0 * 1024.0) {
327 (received_bytes / (1024.0 * 1024.0), "MiB")
328 } else {
329 (received_bytes / 1024.0, "KiB")
330 };
331
332 let speed = {
333 let duration = if let Some(end_time) = end_time {
334 (*end_time - *start_time).as_millis() as f64
335 } else {
336 start_time.elapsed().as_millis() as f64
337 };
338
339 if duration > 0.0 {
340 (received_bytes / (1024.0 * 1024.0)) / (duration / 1000.0) // Convert bytes to MiB and milliseconds to seconds
341 } else {
342 0.0
343 }
344 };
345
346 // Format the output for receiving objects
347 report.push(format!(
348 "Receiving objects: {percentage}% ({received}/{total}) {size:.2} {unit} | {speed:.2} MiB/s{}",
349 if received == total {
350 ", done."
351 } else { ""},
352 ));
353 if received == total {
354 let indexed_deltas = progress_stats.indexed_deltas() as f64;
355 let total_deltas = progress_stats.total_deltas() as f64;
356 let percentage = ((indexed_deltas / total_deltas) * 100.0)
357 // always round down because 100% complete is misleading when its not complete
358 .floor();
359 if total_deltas > 0.0 {
360 report.push(format!(
361 "Resolving deltas: {percentage}% ({indexed_deltas}/{total_deltas}){}",
362 if indexed_deltas == total_deltas {
363 ", done."
364 } else {
365 ""
366 },
367 ));
368 }
369 }
370 report
371}
372
373struct FetchReporter<'a> {
374 remote_msgs: Vec<String>,
375 transfer_progress_msgs: Vec<String>,
376 term: &'a console::Term,
377 start_time: Option<Instant>,
378 end_time: Option<Instant>,
379}
380impl<'a> FetchReporter<'a> {
381 fn new(term: &'a console::Term) -> Self {
382 Self {
383 remote_msgs: vec![],
384 transfer_progress_msgs: vec![],
385 term,
386 start_time: None,
387 end_time: None,
388 }
389 }
390 fn write_all(&self, lines_to_clear: usize) {
391 let _ = self.term.clear_last_lines(lines_to_clear);
392 for msg in &self.remote_msgs {
393 let _ = self.term.write_line(format!("remote: {msg}").as_str());
394 }
395 for msg in &self.transfer_progress_msgs {
396 let _ = self.term.write_line(msg);
397 }
398 }
399 fn count_all_existing_lines(&self) -> usize {
400 let width = self.term.size().1;
401 count_lines_per_msg_vec(width, &self.remote_msgs, "remote: ".len())
402 + count_lines_per_msg_vec(width, &self.transfer_progress_msgs, 0)
403 }
404 fn just_write_transfer_progress(&self, lines_to_clear: usize) {
405 let _ = self.term.clear_last_lines(lines_to_clear);
406 for msg in &self.transfer_progress_msgs {
407 let _ = self.term.write_line(msg);
408 }
409 }
410 fn just_count_transfer_progress(&self) -> usize {
411 let width = self.term.size().1;
412 count_lines_per_msg_vec(width, &self.transfer_progress_msgs, 0)
413 }
414 fn process_remote_msg(&mut self, data: &[u8]) {
415 if let Ok(data) = str::from_utf8(data) {
416 let data = data
417 .split(['\n', '\r'])
418 .map(str::trim)
419 .filter(|line| !line.trim().is_empty())
420 .collect::<Vec<&str>>();
421 for data in data {
422 let existing_lines = self.count_all_existing_lines();
423 let msg = data.to_string();
424 if let Some(last) = self.remote_msgs.last() {
425 // if previous line begins with x but doesnt finish with y then its part of the
426 // same msg
427 if (last.starts_with("Enume") && !last.ends_with(", done."))
428 || ((last.starts_with("Compre") || last.starts_with("Count"))
429 && !last.contains(')'))
430 {
431 let last = self.remote_msgs.pop().unwrap();
432 self.remote_msgs.push(format!("{last}{msg}"));
433 // if previous msg contains % and its not 100% then it
434 // should be overwritten
435 } else if (last.contains('%') && !last.contains("100%"))
436 // but also if the next message is identical with "", done." appended
437 || last == &msg.replace(", done.", "")
438 {
439 self.remote_msgs.pop();
440 self.remote_msgs.push(msg);
441 } else {
442 self.remote_msgs.push(msg);
443 }
444 } else {
445 self.remote_msgs.push(msg);
446 }
447 self.write_all(existing_lines);
448 }
449 }
450 }
451 fn process_transfer_progress_update(&mut self, progress_stats: &git2::Progress<'_>) {
452 if self.start_time.is_none() {
453 self.start_time = Some(Instant::now());
454 }
455 let existing_lines = self.just_count_transfer_progress();
456 let updated = report_on_transfer_progress(
457 progress_stats,
458 &self.start_time.unwrap(),
459 self.end_time.as_ref(),
460 );
461 if self.transfer_progress_msgs.len() <= updated.len() {
462 if self.end_time.is_none() && updated.first().is_some_and(|f| f.contains("100%")) {
463 self.end_time = Some(Instant::now());
464 }
465 // once "Resolving Deltas" is complete, deltas get reset to 0 and it stops
466 // reporting on it so we want to keep the old report
467 self.transfer_progress_msgs = updated;
468 }
469 self.just_write_transfer_progress(existing_lines);
470 }
471}
472
473fn fetch_from_git_server_url(
474 git_repo: &Repository,
475 oids: &[String],
476 git_server_url: &str,
477 dont_authenticate: bool,
478 term: &console::Term,
479) -> Result<()> {
480 if git_server_url.parse::<CloneUrl>()?.protocol() == ServerProtocol::Ssh && !check_ssh_keys() {
481 bail!("no ssh keys found");
482 }
483 let git_config = git_repo.config()?;
484 let mut git_server_remote = git_repo.remote_anonymous(git_server_url)?;
485 let auth = GitAuthenticator::default();
486 let mut fetch_options = git2::FetchOptions::new();
487 let mut remote_callbacks = git2::RemoteCallbacks::new();
488 let fetch_reporter = Arc::new(Mutex::new(FetchReporter::new(term)));
489 remote_callbacks.sideband_progress({
490 let fetch_reporter = Arc::clone(&fetch_reporter);
491 move |data| {
492 let mut reporter = fetch_reporter.lock().unwrap();
493 reporter.process_remote_msg(data);
494 true
495 }
496 });
497 remote_callbacks.transfer_progress({
498 let fetch_reporter = Arc::clone(&fetch_reporter);
499 move |stats| {
500 let mut reporter = fetch_reporter.lock().unwrap();
501 reporter.process_transfer_progress_update(&stats);
502 true
503 }
504 });
505
506 if !dont_authenticate {
507 remote_callbacks.credentials(auth.credentials(&git_config));
508 }
509 fetch_options.remote_callbacks(remote_callbacks);
510
511 git_server_remote.download(oids, Some(&mut fetch_options))?;
512
513 git_server_remote.disconnect()?;
514 Ok(())
515}
516
517#[cfg(test)]
518mod tests {
519
520 use super::*;
521
522 fn pass_through_fetch_reporter_proces_remote_msg(msgs: Vec<&str>) -> Vec<String> {
523 let term = console::Term::stdout();
524 let mut reporter = FetchReporter::new(&term);
525 for msg in msgs {
526 reporter.process_remote_msg(msg.as_bytes());
527 }
528 reporter.remote_msgs
529 }
530
531 #[test]
532 fn logs_single_msg() {
533 assert_eq!(
534 pass_through_fetch_reporter_proces_remote_msg(vec![
535 "Enumerating objects: 23716, done.",
536 ]),
537 vec!["Enumerating objects: 23716, done."]
538 );
539 }
540
541 #[test]
542 fn logs_multiple_msgs() {
543 assert_eq!(
544 pass_through_fetch_reporter_proces_remote_msg(vec![
545 "Enumerating objects: 23716, done.",
546 "Counting objects: 0% (1/2195)",
547 ]),
548 vec![
549 "Enumerating objects: 23716, done.",
550 "Counting objects: 0% (1/2195)",
551 ]
552 );
553 }
554
555 mod ignores {
556 use super::*;
557
558 #[test]
559 fn empty_msgs() {
560 assert_eq!(
561 pass_through_fetch_reporter_proces_remote_msg(vec![
562 "Enumerating objects: 23716, done.",
563 "",
564 "Counting objects: 0% (1/2195)",
565 "",
566 ]),
567 vec![
568 "Enumerating objects: 23716, done.",
569 "Counting objects: 0% (1/2195)",
570 ]
571 );
572 }
573
574 #[test]
575 fn whitespace_msgs() {
576 assert_eq!(
577 pass_through_fetch_reporter_proces_remote_msg(vec![
578 "Enumerating objects: 23716, done.",
579 " ",
580 "Counting objects: 0% (1/2195)",
581 " \r\n \r",
582 ]),
583 vec![
584 "Enumerating objects: 23716, done.",
585 "Counting objects: 0% (1/2195)",
586 ]
587 );
588 }
589 }
590
591 mod splits {
592 use super::*;
593
594 #[test]
595 fn multiple_lines_in_single_msg() {
596 assert_eq!(
597 pass_through_fetch_reporter_proces_remote_msg(vec![
598 "Enumerating objects: 23716, done.\r\nCounting objects: 0% (1/2195)",
599 "",
600 ]),
601 vec![
602 "Enumerating objects: 23716, done.",
603 "Counting objects: 0% (1/2195)",
604 ]
605 );
606 }
607 }
608
609 mod joins_lines_sent_over_multiple_msgs {
610 use super::*;
611
612 #[test]
613 fn enumerating() {
614 assert_eq!(
615 pass_through_fetch_reporter_proces_remote_msg(vec![
616 "Enumerat",
617 "ing objec",
618 "ts: 23716, done.",
619 "Counting objects: 0% (1/2195)",
620 ]),
621 vec![
622 "Enumerating objects: 23716, done.",
623 "Counting objects: 0% (1/2195)",
624 ]
625 );
626 }
627 #[test]
628 fn counting() {
629 assert_eq!(
630 pass_through_fetch_reporter_proces_remote_msg(vec![
631 "Enumerating objects: 23716, done.",
632 "Counting obj",
633 "ects: 0% (1/2195)",
634 "Count",
635 "ing objects: 1% (22/",
636 "2195)",
637 ]),
638 vec![
639 "Enumerating objects: 23716, done.",
640 "Counting objects: 1% (22/2195)",
641 ]
642 );
643 }
644 #[test]
645 fn compressing() {
646 assert_eq!(
647 pass_through_fetch_reporter_proces_remote_msg(vec![
648 "Compress",
649 "ing obj",
650 "ect",
651 "s: 0% (1/56",
652 "0)"
653 ]),
654 vec!["Compressing objects: 0% (1/560)"]
655 );
656 }
657 }
658
659 #[test]
660 fn msgs_with_pc_and_not_100pc_are_replaced() {
661 assert_eq!(
662 pass_through_fetch_reporter_proces_remote_msg(vec![
663 "Enumerating objects: 23716, done.",
664 "Counting objects: 0% (1/2195)",
665 "Counting objects: 1% (22/2195)",
666 ]),
667 vec![
668 "Enumerating objects: 23716, done.",
669 "Counting objects: 1% (22/2195)",
670 ]
671 );
672 }
673 mod msgs_with_pc_100pc_are_not_replaced {
674 use super::*;
675
676 #[test]
677 fn when_next_msg_is_not_identical_but_with_done() {
678 assert_eq!(
679 pass_through_fetch_reporter_proces_remote_msg(vec![
680 "Enumerating objects: 23716, done.",
681 "Counting objects: 0% (1/2195)",
682 "Counting objects: 1% (22/2195)",
683 "Counting objects: 100% (2195/2195)",
684 "Compressing objects: 0% (1/560)"
685 ]),
686 vec![
687 "Enumerating objects: 23716, done.",
688 "Counting objects: 100% (2195/2195)",
689 "Compressing objects: 0% (1/560)"
690 ]
691 );
692 }
693
694 #[test]
695 fn but_is_when_next_msg_is_identical_but_with_done_appended() {
696 assert_eq!(
697 pass_through_fetch_reporter_proces_remote_msg(vec![
698 "Enumerating objects: 23716, done.",
699 "Counting objects: 0% (1/2195)",
700 "Counting objects: 1% (22/2195)",
701 "Counting objects: 100% (2195/2195)",
702 "Counting objects: 100% (2195/2195), done.",
703 ]),
704 vec![
705 "Enumerating objects: 23716, done.",
706 "Counting objects: 100% (2195/2195), done.",
707 ]
708 );
709 }
710 }
711}
diff --git a/src/bin/git_remote_nostr/list.rs b/src/bin/git_remote_nostr/list.rs
index f5eaee4..be83991 100644
--- a/src/bin/git_remote_nostr/list.rs
+++ b/src/bin/git_remote_nostr/list.rs
@@ -5,6 +5,7 @@ use client::get_state_from_cache;
5use git::RepoActions; 5use git::RepoActions;
6use ngit::{ 6use ngit::{
7 client, 7 client,
8 fetch::fetch_from_git_server,
8 git::{self}, 9 git::{self},
9 git_events::{KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_to_cover_letter, tag_value}, 10 git_events::{KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_to_cover_letter, tag_value},
10 list::{get_ahead_behind, list_from_remotes}, 11 list::{get_ahead_behind, list_from_remotes},
@@ -14,10 +15,7 @@ use ngit::{
14}; 15};
15use repo_ref::RepoRef; 16use repo_ref::RepoRef;
16 17
17use crate::{ 18use crate::{fetch::make_commits_for_proposal, git::Repo};
18 fetch::{fetch_from_git_server, make_commits_for_proposal},
19 git::Repo,
20};
21 19
22pub async fn run_list( 20pub async fn run_list(
23 git_repo: &Repo, 21 git_repo: &Repo,
diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs
index 0083c91..2de3151 100644
--- a/src/bin/ngit/sub_commands/list.rs
+++ b/src/bin/ngit/sub_commands/list.rs
@@ -1,4 +1,4 @@
1use std::{io::Write, ops::Add}; 1use std::{collections::HashSet, io::Write, ops::Add};
2 2
3use anyhow::{Context, Result, bail}; 3use anyhow::{Context, Result, bail};
4use ngit::{ 4use ngit::{
@@ -6,10 +6,12 @@ use ngit::{
6 Params, get_all_proposal_patch_pr_pr_update_events_from_cache, 6 Params, get_all_proposal_patch_pr_pr_update_events_from_cache,
7 get_proposals_and_revisions_from_cache, 7 get_proposals_and_revisions_from_cache,
8 }, 8 },
9 fetch::fetch_from_git_server,
9 git_events::{ 10 git_events::{
10 KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, get_commit_id_from_patch, 11 KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, get_commit_id_from_patch,
11 get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, status_kinds, tag_value, 12 get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, status_kinds, tag_value,
12 }, 13 },
14 repo_ref::{RepoRef, is_grasp_server_in_list},
13}; 15};
14use nostr_sdk::Kind; 16use nostr_sdk::Kind;
15 17
@@ -204,40 +206,65 @@ pub async fn launch() -> Result<()> {
204 .iter() 206 .iter()
205 .any(|e| [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&e.kind)) 207 .any(|e| [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&e.kind))
206 { 208 {
209 let branch_name = cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?;
210 let local_branch_tip = git_repo.get_tip_of_branch(&branch_name).ok();
211 let proposal_tip_event = most_recent_proposal_patch_chain_or_pr_or_pr_update
212 .first()
213 .context("most_recent_proposal_patch_chain_or_pr_or_pr_update will always contain a event with c tag")?;
214 let proposal_tip = tag_value(proposal_tip_event, "c")?;
215
207 match Interactor::default().choice( 216 match Interactor::default().choice(
208 PromptChoiceParms::default() 217 PromptChoiceParms::default()
209 .with_prompt(
210 "this is new PR event kind which isn't supported in `ngit list` yet",
211 )
212 .with_default(0) 218 .with_default(0)
213 .with_choices( 219 .with_choices(vec![
214 if [Kind::GitStatusOpen, Kind::GitStatusDraft].contains(&selected_status) 220 if let Some(local_branch_tip) = local_branch_tip {
215 && git_repo 221 if local_branch_tip.to_string() == proposal_tip {
216 .get_first_nostr_remote_when_in_ngit_binary() 222 format!("checkout up-to-date proposal branch '{branch_name}'")
217 .await 223 } else {
218 .is_ok_and(|r| r.is_some()) 224 format!("checkout proposal branch and pull changes '{branch_name}'")
219 { 225 }
220 vec![
221 format!(
222 "I'll manually checkout the proposal at remote branch '{}'",
223 cover_letter
224 .get_branch_name_with_pr_prefix_and_shorthand_id()
225 .unwrap()
226 ),
227 // TODO fetch oids and follow similar logic for dealing with
228 // conflcts as with patches below
229 "back to proposals".to_string(),
230 ]
231 } else { 226 } else {
232 vec!["back to proposals".to_string()] 227 format!("create and checkout as branch '{branch_name}'")
233 }, 228 },
234 ), 229 "back to proposals".to_string(),
230 ]),
235 )? { 231 )? {
236 0 => continue, 232 0 => {
233 if let Some(local_branch_tip) = local_branch_tip {
234 git_repo
235 .checkout(&branch_name)
236 .context("cannot checkout existing proposal branch")?;
237 if local_branch_tip.to_string() == proposal_tip {
238 println!("checked out up-to-date proposal branch '{branch_name}'");
239 return Ok(());
240 }
241 if git_repo.does_commit_exist(&proposal_tip)? {
242 println!("checked out proposal branch and updated tip '{branch_name}'");
243 return Ok(());
244 }
245 }
246 fetch_oid_for_from_servers_for_pr(
247 &proposal_tip,
248 &git_repo,
249 &repo_ref,
250 proposal_tip_event,
251 )?;
252 git_repo.create_branch_at_commit(&branch_name, &proposal_tip)?;
253 git_repo.checkout(&branch_name)?;
254 if local_branch_tip.is_some() {
255 println!("created and checked out proposal branch '{branch_name}'");
256 } else {
257 println!("checked out proposal branch and pulled updates '{branch_name}'");
258 }
259 return Ok(());
260 }
261 1 => {
262 continue;
263 }
237 _ => { 264 _ => {
238 bail!("unexpected choice") 265 bail!("unexpected choice")
239 } 266 }
240 }; 267 }
241 } 268 }
242 269
243 let binding_patch_text_ref = format!( 270 let binding_patch_text_ref = format!(
@@ -739,6 +766,56 @@ pub async fn launch() -> Result<()> {
739 } 766 }
740} 767}
741 768
769fn fetch_oid_for_from_servers_for_pr(
770 oid: &str,
771 git_repo: &Repo,
772 repo_ref: &RepoRef,
773 pr_or_pr_update_event: &nostr::Event,
774) -> Result<()> {
775 let git_servers = {
776 let mut seen: HashSet<String> = HashSet::new();
777 let mut out: Vec<String> = vec![];
778 for tag in pr_or_pr_update_event.tags.as_slice() {
779 if tag.kind().eq(&nostr::event::TagKind::Clone) {
780 for clone_url in tag.as_slice().iter().skip(1) {
781 seen.insert(clone_url.clone());
782 }
783 }
784 }
785 for server in &repo_ref.git_server {
786 if seen.insert(server.clone()) {
787 out.push(server.clone());
788 }
789 }
790 out
791 };
792
793 let mut errors = vec![];
794 let term = console::Term::stderr();
795
796 for git_server_url in &git_servers {
797 if let Err(error) = fetch_from_git_server(
798 git_repo,
799 &[oid.to_string()],
800 git_server_url,
801 &repo_ref.to_nostr_git_url(&None),
802 &term,
803 is_grasp_server_in_list(git_server_url, &repo_ref.grasp_servers()),
804 ) {
805 errors.push(error);
806 } else {
807 println!("fetched proposal git data from {git_server_url}");
808 break;
809 }
810 }
811 if !git_repo.does_commit_exist(oid)? {
812 bail!(
813 "cannot find proposal git data from proposal git server hint or repository git servers"
814 )
815 }
816 Ok(())
817}
818
742fn launch_git_am_with_patches(mut patches: Vec<nostr::Event>) -> Result<()> { 819fn launch_git_am_with_patches(mut patches: Vec<nostr::Event>) -> Result<()> {
743 println!("applying to current branch with `git am`"); 820 println!("applying to current branch with `git am`");
744 // TODO: add PATCH x/n to appended patches 821 // TODO: add PATCH x/n to appended patches
diff --git a/src/lib/fetch.rs b/src/lib/fetch.rs
new file mode 100644
index 0000000..89001d4
--- /dev/null
+++ b/src/lib/fetch.rs
@@ -0,0 +1,492 @@
1use std::{
2 sync::{Arc, Mutex},
3 time::Instant,
4};
5
6use anyhow::{Result, anyhow, bail};
7use auth_git2::GitAuthenticator;
8use git2::{Progress, Repository};
9
10use crate::{
11 cli_interactor::count_lines_per_msg_vec,
12 git::{
13 Repo, RepoActions,
14 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol},
15 utils::check_ssh_keys,
16 },
17 utils::{Direction, get_read_protocols_to_try, join_with_and, set_protocol_preference},
18};
19
20pub fn fetch_from_git_server(
21 git_repo: &Repo,
22 oids: &[String],
23 git_server_url: &str,
24 decoded_nostr_url: &NostrUrlDecoded,
25 term: &console::Term,
26 is_grasp_server: bool,
27) -> Result<()> {
28 let already_have_oids = oids
29 .iter()
30 .all(|oid| git_repo.does_commit_exist(oid).is_ok_and(|outcome| outcome));
31 if already_have_oids {
32 return Ok(());
33 }
34
35 let server_url = git_server_url.parse::<CloneUrl>()?;
36
37 let protocols_to_attempt =
38 get_read_protocols_to_try(git_repo, &server_url, decoded_nostr_url, is_grasp_server);
39
40 let mut failed_protocols = vec![];
41 let mut success = false;
42 for protocol in &protocols_to_attempt {
43 term.write_line(
44 format!("fetching {} over {protocol}...", server_url.short_name(),).as_str(),
45 )?;
46
47 let formatted_url = server_url.format_as(protocol, &decoded_nostr_url.user)?;
48 let res = fetch_from_git_server_url(
49 &git_repo.git_repo,
50 oids,
51 &formatted_url,
52 [ServerProtocol::UnauthHttps, ServerProtocol::UnauthHttp].contains(protocol),
53 term,
54 );
55 if let Err(error) = res {
56 term.write_line(
57 format!("fetch: {formatted_url} failed over {protocol}: {error}").as_str(),
58 )?;
59 failed_protocols.push(protocol);
60 } else {
61 success = true;
62 if !failed_protocols.is_empty() {
63 term.write_line(format!("fetch: succeeded over {protocol}").as_str())?;
64 let _ = set_protocol_preference(git_repo, protocol, &server_url, &Direction::Push);
65 }
66 break;
67 }
68 }
69 if success {
70 Ok(())
71 } else {
72 let error = anyhow!(
73 "{} failed over {}{}",
74 server_url.short_name(),
75 join_with_and(&failed_protocols),
76 if decoded_nostr_url.protocol.is_some() {
77 " and nostr url contains protocol override so no other protocols were attempted"
78 } else {
79 ""
80 },
81 );
82 term.write_line(format!("fetch: {error}").as_str())?;
83 Err(error)
84 }
85}
86
87fn fetch_from_git_server_url(
88 git_repo: &Repository,
89 oids: &[String],
90 git_server_url: &str,
91 dont_authenticate: bool,
92 term: &console::Term,
93) -> Result<()> {
94 if git_server_url.parse::<CloneUrl>()?.protocol() == ServerProtocol::Ssh && !check_ssh_keys() {
95 bail!("no ssh keys found");
96 }
97 let git_config = git_repo.config()?;
98 let mut git_server_remote = git_repo.remote_anonymous(git_server_url)?;
99 let auth = GitAuthenticator::default();
100 let mut fetch_options = git2::FetchOptions::new();
101 let mut remote_callbacks = git2::RemoteCallbacks::new();
102 let fetch_reporter = Arc::new(Mutex::new(FetchReporter::new(term)));
103 remote_callbacks.sideband_progress({
104 let fetch_reporter = Arc::clone(&fetch_reporter);
105 move |data| {
106 let mut reporter = fetch_reporter.lock().unwrap();
107 reporter.process_remote_msg(data);
108 true
109 }
110 });
111 remote_callbacks.transfer_progress({
112 let fetch_reporter = Arc::clone(&fetch_reporter);
113 move |stats| {
114 let mut reporter = fetch_reporter.lock().unwrap();
115 reporter.process_transfer_progress_update(&stats);
116 true
117 }
118 });
119
120 if !dont_authenticate {
121 remote_callbacks.credentials(auth.credentials(&git_config));
122 }
123 fetch_options.remote_callbacks(remote_callbacks);
124
125 git_server_remote.download(oids, Some(&mut fetch_options))?;
126
127 git_server_remote.disconnect()?;
128 Ok(())
129}
130
131struct FetchReporter<'a> {
132 remote_msgs: Vec<String>,
133 transfer_progress_msgs: Vec<String>,
134 term: &'a console::Term,
135 start_time: Option<Instant>,
136 end_time: Option<Instant>,
137}
138impl<'a> FetchReporter<'a> {
139 fn new(term: &'a console::Term) -> Self {
140 Self {
141 remote_msgs: vec![],
142 transfer_progress_msgs: vec![],
143 term,
144 start_time: None,
145 end_time: None,
146 }
147 }
148 fn write_all(&self, lines_to_clear: usize) {
149 let _ = self.term.clear_last_lines(lines_to_clear);
150 for msg in &self.remote_msgs {
151 let _ = self.term.write_line(format!("remote: {msg}").as_str());
152 }
153 for msg in &self.transfer_progress_msgs {
154 let _ = self.term.write_line(msg);
155 }
156 }
157 fn count_all_existing_lines(&self) -> usize {
158 let width = self.term.size().1;
159 count_lines_per_msg_vec(width, &self.remote_msgs, "remote: ".len())
160 + count_lines_per_msg_vec(width, &self.transfer_progress_msgs, 0)
161 }
162 fn just_write_transfer_progress(&self, lines_to_clear: usize) {
163 let _ = self.term.clear_last_lines(lines_to_clear);
164 for msg in &self.transfer_progress_msgs {
165 let _ = self.term.write_line(msg);
166 }
167 }
168 fn just_count_transfer_progress(&self) -> usize {
169 let width = self.term.size().1;
170 count_lines_per_msg_vec(width, &self.transfer_progress_msgs, 0)
171 }
172 fn process_remote_msg(&mut self, data: &[u8]) {
173 if let Ok(data) = str::from_utf8(data) {
174 let data = data
175 .split(['\n', '\r'])
176 .map(str::trim)
177 .filter(|line| !line.trim().is_empty())
178 .collect::<Vec<&str>>();
179 for data in data {
180 let existing_lines = self.count_all_existing_lines();
181 let msg = data.to_string();
182 if let Some(last) = self.remote_msgs.last() {
183 // if previous line begins with x but doesnt finish with y then its part of the
184 // same msg
185 if (last.starts_with("Enume") && !last.ends_with(", done."))
186 || ((last.starts_with("Compre") || last.starts_with("Count"))
187 && !last.contains(')'))
188 {
189 let last = self.remote_msgs.pop().unwrap();
190 self.remote_msgs.push(format!("{last}{msg}"));
191 // if previous msg contains % and its not 100% then it
192 // should be overwritten
193 } else if (last.contains('%') && !last.contains("100%"))
194 // but also if the next message is identical with "", done." appended
195 || last == &msg.replace(", done.", "")
196 {
197 self.remote_msgs.pop();
198 self.remote_msgs.push(msg);
199 } else {
200 self.remote_msgs.push(msg);
201 }
202 } else {
203 self.remote_msgs.push(msg);
204 }
205 self.write_all(existing_lines);
206 }
207 }
208 }
209 fn process_transfer_progress_update(&mut self, progress_stats: &git2::Progress<'_>) {
210 if self.start_time.is_none() {
211 self.start_time = Some(Instant::now());
212 }
213 let existing_lines = self.just_count_transfer_progress();
214 let updated = report_on_transfer_progress(
215 progress_stats,
216 &self.start_time.unwrap(),
217 self.end_time.as_ref(),
218 );
219 if self.transfer_progress_msgs.len() <= updated.len() {
220 if self.end_time.is_none() && updated.first().is_some_and(|f| f.contains("100%")) {
221 self.end_time = Some(Instant::now());
222 }
223 // once "Resolving Deltas" is complete, deltas get reset to 0 and it stops
224 // reporting on it so we want to keep the old report
225 self.transfer_progress_msgs = updated;
226 }
227 self.just_write_transfer_progress(existing_lines);
228 }
229}
230
231#[allow(clippy::cast_precision_loss)]
232#[allow(clippy::float_cmp)]
233#[allow(clippy::needless_pass_by_value)]
234fn report_on_transfer_progress(
235 progress_stats: &Progress<'_>,
236 start_time: &Instant,
237 end_time: Option<&Instant>,
238) -> Vec<String> {
239 let mut report = vec![];
240 let total = progress_stats.total_objects() as f64;
241 if total == 0.0 {
242 return report;
243 }
244 let received = progress_stats.received_objects() as f64;
245 let percentage = ((received / total) * 100.0)
246 // always round down because 100% complete is misleading when its not complete
247 .floor();
248
249 let received_bytes = progress_stats.received_bytes() as f64;
250
251 let (size, unit) = if received_bytes >= (1024.0 * 1024.0) {
252 (received_bytes / (1024.0 * 1024.0), "MiB")
253 } else {
254 (received_bytes / 1024.0, "KiB")
255 };
256
257 let speed = {
258 let duration = if let Some(end_time) = end_time {
259 (*end_time - *start_time).as_millis() as f64
260 } else {
261 start_time.elapsed().as_millis() as f64
262 };
263
264 if duration > 0.0 {
265 (received_bytes / (1024.0 * 1024.0)) / (duration / 1000.0) // Convert bytes to MiB and milliseconds to seconds
266 } else {
267 0.0
268 }
269 };
270
271 // Format the output for receiving objects
272 report.push(format!(
273 "Receiving objects: {percentage}% ({received}/{total}) {size:.2} {unit} | {speed:.2} MiB/s{}",
274 if received == total {
275 ", done."
276 } else { ""},
277 ));
278 if received == total {
279 let indexed_deltas = progress_stats.indexed_deltas() as f64;
280 let total_deltas = progress_stats.total_deltas() as f64;
281 let percentage = ((indexed_deltas / total_deltas) * 100.0)
282 // always round down because 100% complete is misleading when its not complete
283 .floor();
284 if total_deltas > 0.0 {
285 report.push(format!(
286 "Resolving deltas: {percentage}% ({indexed_deltas}/{total_deltas}){}",
287 if indexed_deltas == total_deltas {
288 ", done."
289 } else {
290 ""
291 },
292 ));
293 }
294 }
295 report
296}
297
298#[cfg(test)]
299mod tests {
300
301 use super::*;
302
303 fn pass_through_fetch_reporter_proces_remote_msg(msgs: Vec<&str>) -> Vec<String> {
304 let term = console::Term::stdout();
305 let mut reporter = FetchReporter::new(&term);
306 for msg in msgs {
307 reporter.process_remote_msg(msg.as_bytes());
308 }
309 reporter.remote_msgs
310 }
311
312 #[test]
313 fn logs_single_msg() {
314 assert_eq!(
315 pass_through_fetch_reporter_proces_remote_msg(vec![
316 "Enumerating objects: 23716, done.",
317 ]),
318 vec!["Enumerating objects: 23716, done."]
319 );
320 }
321
322 #[test]
323 fn logs_multiple_msgs() {
324 assert_eq!(
325 pass_through_fetch_reporter_proces_remote_msg(vec![
326 "Enumerating objects: 23716, done.",
327 "Counting objects: 0% (1/2195)",
328 ]),
329 vec![
330 "Enumerating objects: 23716, done.",
331 "Counting objects: 0% (1/2195)",
332 ]
333 );
334 }
335
336 mod ignores {
337 use super::*;
338
339 #[test]
340 fn empty_msgs() {
341 assert_eq!(
342 pass_through_fetch_reporter_proces_remote_msg(vec![
343 "Enumerating objects: 23716, done.",
344 "",
345 "Counting objects: 0% (1/2195)",
346 "",
347 ]),
348 vec![
349 "Enumerating objects: 23716, done.",
350 "Counting objects: 0% (1/2195)",
351 ]
352 );
353 }
354
355 #[test]
356 fn whitespace_msgs() {
357 assert_eq!(
358 pass_through_fetch_reporter_proces_remote_msg(vec![
359 "Enumerating objects: 23716, done.",
360 " ",
361 "Counting objects: 0% (1/2195)",
362 " \r\n \r",
363 ]),
364 vec![
365 "Enumerating objects: 23716, done.",
366 "Counting objects: 0% (1/2195)",
367 ]
368 );
369 }
370 }
371
372 mod splits {
373 use super::*;
374
375 #[test]
376 fn multiple_lines_in_single_msg() {
377 assert_eq!(
378 pass_through_fetch_reporter_proces_remote_msg(vec![
379 "Enumerating objects: 23716, done.\r\nCounting objects: 0% (1/2195)",
380 "",
381 ]),
382 vec![
383 "Enumerating objects: 23716, done.",
384 "Counting objects: 0% (1/2195)",
385 ]
386 );
387 }
388 }
389
390 mod joins_lines_sent_over_multiple_msgs {
391 use super::*;
392
393 #[test]
394 fn enumerating() {
395 assert_eq!(
396 pass_through_fetch_reporter_proces_remote_msg(vec![
397 "Enumerat",
398 "ing objec",
399 "ts: 23716, done.",
400 "Counting objects: 0% (1/2195)",
401 ]),
402 vec![
403 "Enumerating objects: 23716, done.",
404 "Counting objects: 0% (1/2195)",
405 ]
406 );
407 }
408 #[test]
409 fn counting() {
410 assert_eq!(
411 pass_through_fetch_reporter_proces_remote_msg(vec![
412 "Enumerating objects: 23716, done.",
413 "Counting obj",
414 "ects: 0% (1/2195)",
415 "Count",
416 "ing objects: 1% (22/",
417 "2195)",
418 ]),
419 vec![
420 "Enumerating objects: 23716, done.",
421 "Counting objects: 1% (22/2195)",
422 ]
423 );
424 }
425 #[test]
426 fn compressing() {
427 assert_eq!(
428 pass_through_fetch_reporter_proces_remote_msg(vec![
429 "Compress",
430 "ing obj",
431 "ect",
432 "s: 0% (1/56",
433 "0)"
434 ]),
435 vec!["Compressing objects: 0% (1/560)"]
436 );
437 }
438 }
439
440 #[test]
441 fn msgs_with_pc_and_not_100pc_are_replaced() {
442 assert_eq!(
443 pass_through_fetch_reporter_proces_remote_msg(vec![
444 "Enumerating objects: 23716, done.",
445 "Counting objects: 0% (1/2195)",
446 "Counting objects: 1% (22/2195)",
447 ]),
448 vec![
449 "Enumerating objects: 23716, done.",
450 "Counting objects: 1% (22/2195)",
451 ]
452 );
453 }
454 mod msgs_with_pc_100pc_are_not_replaced {
455 use super::*;
456
457 #[test]
458 fn when_next_msg_is_not_identical_but_with_done() {
459 assert_eq!(
460 pass_through_fetch_reporter_proces_remote_msg(vec![
461 "Enumerating objects: 23716, done.",
462 "Counting objects: 0% (1/2195)",
463 "Counting objects: 1% (22/2195)",
464 "Counting objects: 100% (2195/2195)",
465 "Compressing objects: 0% (1/560)"
466 ]),
467 vec![
468 "Enumerating objects: 23716, done.",
469 "Counting objects: 100% (2195/2195)",
470 "Compressing objects: 0% (1/560)"
471 ]
472 );
473 }
474
475 #[test]
476 fn but_is_when_next_msg_is_identical_but_with_done_appended() {
477 assert_eq!(
478 pass_through_fetch_reporter_proces_remote_msg(vec![
479 "Enumerating objects: 23716, done.",
480 "Counting objects: 0% (1/2195)",
481 "Counting objects: 1% (22/2195)",
482 "Counting objects: 100% (2195/2195)",
483 "Counting objects: 100% (2195/2195), done.",
484 ]),
485 vec![
486 "Enumerating objects: 23716, done.",
487 "Counting objects: 100% (2195/2195), done.",
488 ]
489 );
490 }
491 }
492}
diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs
index 5ea630a..56ebcef 100644
--- a/src/lib/git_events.rs
+++ b/src/lib/git_events.rs
@@ -1,4 +1,4 @@
1use std::{str::FromStr, sync::Arc}; 1use std::{collections::HashMap, str::FromStr, sync::Arc};
2 2
3use anyhow::{Context, Result, bail}; 3use anyhow::{Context, Result, bail};
4use nostr::{ 4use nostr::{
@@ -15,6 +15,7 @@ use crate::{
15 client::sign_event, 15 client::sign_event,
16 git::{Repo, RepoActions}, 16 git::{Repo, RepoActions},
17 repo_ref::RepoRef, 17 repo_ref::RepoRef,
18 utils::get_open_or_draft_proposals,
18}; 19};
19 20
20pub fn tag_value(event: &Event, tag_name: &str) -> Result<String> { 21pub fn tag_value(event: &Event, tag_name: &str) -> Result<String> {
@@ -925,6 +926,36 @@ pub fn get_status(
925 } 926 }
926} 927}
927 928
929pub async fn identify_clone_urls_for_oids_from_pr_pr_update_events(
930 oids: Vec<&String>,
931 git_repo: &Repo,
932 repo_ref: &RepoRef,
933) -> Result<HashMap<String, Vec<String>>> {
934 let mut map: HashMap<String, Vec<String>> = HashMap::new();
935
936 let open_and_draft_proposals = get_open_or_draft_proposals(git_repo, repo_ref).await?;
937
938 for (_, (_, events)) in open_and_draft_proposals {
939 for event in events {
940 if [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&event.kind) {
941 if let Ok(c) = tag_value(&event, "c") {
942 if oids.contains(&&c) {
943 for tag in event.tags.as_slice() {
944 if tag.kind().eq(&nostr::event::TagKind::Clone) {
945 for clone_url in tag.as_slice().iter().skip(1) {
946 map.entry(c.clone()).or_default().push(clone_url.clone());
947 }
948 }
949 }
950 }
951 }
952 }
953 }
954 }
955
956 Ok(map)
957}
958
928#[cfg(test)] 959#[cfg(test)]
929mod tests { 960mod tests {
930 use super::*; 961 use super::*;
diff --git a/src/lib/mod.rs b/src/lib/mod.rs
index 265dd6b..a09f866 100644
--- a/src/lib/mod.rs
+++ b/src/lib/mod.rs
@@ -1,5 +1,6 @@
1pub mod cli_interactor; 1pub mod cli_interactor;
2pub mod client; 2pub mod client;
3pub mod fetch;
3pub mod git; 4pub mod git;
4pub mod git_events; 5pub mod git_events;
5pub mod list; 6pub mod list;