upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-08-18 17:25:50 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2025-08-19 07:48:16 +0100
commitb7d4c5a81f0a008524dcc5b4f286f0cf700013c0 (patch)
tree9efc97803252d1021df767fe781c2fc91babf80b /src/lib
parent4d68e64ac4f08274aba6ff225bd89a60eb62e225 (diff)
feat(list): add PR fetch and checkout support
abstracted git remote helper fetch functions added support to `ngit list` to fetch PR data and checkout as proposal branch
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/fetch.rs492
-rw-r--r--src/lib/git_events.rs33
-rw-r--r--src/lib/mod.rs1
3 files changed, 525 insertions, 1 deletions
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;