upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/lib/list.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/list.rs')
-rw-r--r--src/lib/list.rs378
1 files changed, 328 insertions, 50 deletions
diff --git a/src/lib/list.rs b/src/lib/list.rs
index 08b197c..69da792 100644
--- a/src/lib/list.rs
+++ b/src/lib/list.rs
@@ -1,7 +1,19 @@
1use std::{collections::HashMap, path::PathBuf, str::FromStr}; 1use std::{
2 collections::HashMap,
3 path::PathBuf,
4 str::FromStr,
5 sync::{
6 Arc,
7 atomic::{AtomicU64, Ordering},
8 },
9 time::Duration,
10};
2 11
3use anyhow::{Result, anyhow}; 12use anyhow::{Result, anyhow};
4use auth_git2::GitAuthenticator; 13use auth_git2::GitAuthenticator;
14use console::Style;
15use futures::stream::{self, StreamExt};
16use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle};
5use nostr::hashes::sha1::Hash as Sha1Hash; 17use nostr::hashes::sha1::Hash as Sha1Hash;
6 18
7use crate::{ 19use crate::{
@@ -44,34 +56,321 @@ impl RemoteIssues {
44 } 56 }
45} 57}
46 58
47pub fn list_from_remotes( 59static GIT_SERVER_SUCCESS_THRESHOLD: f64 = 0.5; // 50% of servers must succeed to switch to short timeout
60
61fn git_server_long_timeout() -> u64 {
62 if std::env::var("NGITTEST").is_ok() {
63 1
64 } else {
65 60
66 }
67}
68
69fn git_server_short_timeout() -> u64 {
70 if std::env::var("NGITTEST").is_ok() {
71 1
72 } else {
73 5
74 }
75}
76
77fn git_server_pb_style(current_timeout: Arc<AtomicU64>) -> Result<ProgressStyle> {
78 Ok(ProgressStyle::with_template(" {spinner} {prefix} {msg}")?
79 .with_key(
80 "timeout_display",
81 move |_state: &ProgressState, w: &mut dyn std::fmt::Write| {
82 write!(w, "{}s", current_timeout.load(Ordering::Relaxed)).unwrap();
83 },
84 )
85 .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "))
86}
87
88fn git_server_pb_after_style(succeed: bool) -> ProgressStyle {
89 let symbol = if succeed {
90 console::style("✔".to_string())
91 .for_stderr()
92 .green()
93 .to_string()
94 } else {
95 console::style("✘".to_string())
96 .for_stderr()
97 .red()
98 .to_string()
99 };
100 ProgressStyle::with_template(&format!(" {symbol} {{prefix}} {{msg}}"))
101 .unwrap_or_else(|_| ProgressStyle::default_bar())
102}
103
104pub async fn list_from_remotes(
48 term: &console::Term, 105 term: &console::Term,
49 git_repo: &Repo, 106 git_repo: &Repo,
50 git_servers: &Vec<String>, 107 git_servers: &[String],
51 decoded_nostr_url: &NostrUrlDecoded, 108 decoded_nostr_url: &NostrUrlDecoded,
109 nostr_state: Option<&RepoState>,
52) -> HashMap<String, (HashMap<String, String>, bool)> { 110) -> HashMap<String, (HashMap<String, String>, bool)> {
111 if git_servers.is_empty() {
112 return HashMap::new();
113 }
114
115 let progress_reporter = if std::env::var("NGITTEST").is_err() {
116 MultiProgress::new()
117 } else {
118 MultiProgress::with_draw_target(indicatif::ProgressDrawTarget::hidden())
119 };
120
121 // Track successful servers for adaptive timeout
122 let success_count = Arc::new(AtomicU64::new(0));
123 let current_timeout = Arc::new(AtomicU64::new(git_server_long_timeout()));
124 let total_servers = git_servers.len() as u64;
125
126 // Calculate column width for alignment
127 let server_column_width = git_servers
128 .iter()
129 .map(|s| get_short_git_server_name(s).chars().count())
130 .max()
131 .unwrap_or(20)
132 + 2;
133
134 let futures: Vec<_> = git_servers
135 .iter()
136 .map(|url| {
137 let url = url.clone();
138 let is_grasp_server = is_grasp_server_clone_url(&url);
139 let success_count_clone = success_count.clone();
140 let current_timeout_clone = current_timeout.clone();
141 let progress_reporter_clone = progress_reporter.clone();
142 let decoded_nostr_url = decoded_nostr_url.clone();
143
144 async move {
145 let dim = Style::new().color256(247);
146 let server_name = get_short_git_server_name(&url);
147
148 let pb = if std::env::var("NGITTEST").is_err() {
149 match git_server_pb_style(current_timeout_clone.clone()) {
150 Ok(style) => {
151 let pb = progress_reporter_clone.add(
152 ProgressBar::new(1)
153 .with_prefix(
154 dim.apply_to(format!(
155 "{: <server_column_width$} connecting",
156 &server_name
157 ))
158 .to_string(),
159 )
160 .with_style(style),
161 );
162 pb.enable_steady_tick(Duration::from_millis(300));
163 Some(pb)
164 }
165 Err(_) => None,
166 }
167 } else {
168 None
169 };
170
171 fn update_progress_bar_with_error(
172 server_column_width: usize,
173 server_name: &str,
174 pb: Option<ProgressBar>,
175 error: &anyhow::Error,
176 ) {
177 if let Some(pb) = pb {
178 pb.set_style(git_server_pb_after_style(false));
179 pb.set_prefix(
180 Style::new()
181 .color256(247)
182 .apply_to(format!("{: <server_column_width$}", server_name))
183 .to_string(),
184 );
185 pb.finish_with_message(
186 console::style(error.to_string())
187 .for_stderr()
188 .red()
189 .to_string(),
190 );
191 }
192 }
193
194 // Create the list operation future - spawn_blocking to avoid blocking async runtime
195 let git_repo_path = git_repo.get_path().ok().map(|p| p.to_path_buf());
196 let url_clone = url.clone();
197 let decoded_nostr_url_clone = decoded_nostr_url.clone();
198 let pb_clone = pb.clone();
199
200 let list_future = async move {
201 match tokio::task::spawn_blocking(move || {
202 // Re-open repo in blocking thread (git2::Repository is not Send)
203 let git_repo = match git_repo_path {
204 Some(path) => Repo::from_path(&path).ok(),
205 None => None,
206 };
207
208 match git_repo {
209 Some(ref repo) => list_from_remote_sync(
210 repo,
211 &url_clone,
212 &decoded_nostr_url_clone,
213 is_grasp_server,
214 pb_clone.as_ref(),
215 ),
216 None => Err(anyhow!("failed to open git repository")),
217 }
218 })
219 .await
220 {
221 Ok(result) => result,
222 Err(e) => Err(anyhow!("task join error: {}", e)),
223 }
224 };
225
226 let timeout_future = async {
227 let check_interval = Duration::from_millis(100);
228 let long_timeout_end = tokio::time::Instant::now()
229 + Duration::from_secs(git_server_long_timeout());
230
231 loop {
232 let current_success_count = success_count_clone.load(Ordering::Relaxed);
233 let threshold = (total_servers as f64 * GIT_SERVER_SUCCESS_THRESHOLD).ceil() as u64;
234
235 if current_success_count >= threshold {
236 tokio::time::sleep(Duration::from_secs(git_server_short_timeout())).await;
237 return "short";
238 }
239
240 if tokio::time::Instant::now() >= long_timeout_end {
241 return "long";
242 }
243
244 tokio::time::sleep(check_interval).await;
245 }
246 };
247
248 let result = tokio::select! {
249 result = list_future => {
250 if result.is_ok() {
251 let new_count = success_count_clone.fetch_add(1, Ordering::Relaxed) + 1;
252 let threshold = (total_servers as f64 * GIT_SERVER_SUCCESS_THRESHOLD).ceil() as u64;
253
254 if new_count >= threshold {
255 current_timeout_clone.store(git_server_short_timeout(), Ordering::Relaxed);
256 }
257 }
258 result
259 }
260 timeout_type = timeout_future => {
261 Err(anyhow!("timeout after {}s",
262 if timeout_type == "long" { git_server_long_timeout() } else { git_server_short_timeout() }))
263 }
264 };
265
266 match result {
267 Err(error) => {
268 update_progress_bar_with_error(
269 server_column_width,
270 &server_name,
271 pb,
272 &error,
273 );
274 Err((url, error))
275 }
276 Ok(state) => {
277 // Determine sync status message and styling using existing functions
278 let status_msg = if state.is_empty() {
279 "empty repository".to_string()
280 } else if let Some(nostr_state) = nostr_state {
281 // Use existing generate_remote_sync_warnings to get detailed status
282 let mut temp_states = HashMap::new();
283 temp_states.insert(url.clone(), (state.clone(), is_grasp_server));
284 let remote_issues = identify_remote_sync_issues(git_repo, nostr_state, &temp_states);
285 let warnings = generate_remote_sync_warnings(&remote_issues, &temp_states);
286
287 if warnings.is_empty() {
288 "in sync".to_string()
289 } else {
290 // Extract the message after "WARNING: <server> "
291 let warning = &warnings[0];
292 let server_name = get_short_git_server_name(&url);
293 let prefix = format!("WARNING: {} ", server_name);
294 warning.strip_prefix(&prefix)
295 .unwrap_or(warning)
296 .to_string()
297 }
298 } else {
299 // No nostr state to compare against
300 "success".to_string()
301 };
302
303 let message_style = if status_msg == "empty repository" {
304 console::style(&status_msg).for_stderr().red()
305 } else if status_msg == "in sync" || status_msg == "success" {
306 console::style(&status_msg).for_stderr().green()
307 } else {
308 console::style(&status_msg).for_stderr().yellow()
309 };
310
311 let is_success = status_msg != "empty repository";
312
313 if let Some(pb) = pb {
314 pb.set_style(git_server_pb_after_style(is_success));
315 pb.set_prefix(
316 Style::new()
317 .color256(247)
318 .apply_to(format!("{: <server_column_width$}", &server_name))
319 .to_string(),
320 );
321 pb.finish_with_message(message_style.to_string());
322 }
323 Ok((url, state, is_grasp_server))
324 }
325 }
326 }
327 })
328 .collect();
329
330 let results = stream::iter(futures)
331 .buffer_unordered(15)
332 .collect::<Vec<Result<(String, HashMap<String, String>, bool), (String, anyhow::Error)>>>()
333 .await;
334
53 let mut remote_states = HashMap::new(); 335 let mut remote_states = HashMap::new();
54 let mut errors = HashMap::new(); 336 for result in results {
55 for url in git_servers { 337 match result {
56 let is_grasp_server = is_grasp_server_clone_url(url); 338 Ok((url, state, is_grasp_server)) => {
57 match list_from_remote(term, git_repo, url, decoded_nostr_url, is_grasp_server) { 339 remote_states.insert(url, (state, is_grasp_server));
58 Err(error) => {
59 errors.insert(url, error);
60 } 340 }
61 Ok(state) => { 341 Err((url, error)) => {
62 remote_states.insert(url.to_string(), (state, is_grasp_server)); 342 // Errors are already displayed in progress bars
343 let _ = term.write_line(&format!("failed to list from {}: {}", url, error));
63 } 344 }
64 } 345 }
65 } 346 }
347
66 remote_states 348 remote_states
67} 349}
68 350
351// Backward-compatible synchronous wrapper for use in non-async contexts
69pub fn list_from_remote( 352pub fn list_from_remote(
70 term: &console::Term, 353 _term: &console::Term,
354 git_repo: &Repo,
355 git_server_url: &str,
356 decoded_nostr_url: &NostrUrlDecoded,
357 is_grasp_server: bool,
358) -> Result<HashMap<String, String>> {
359 list_from_remote_sync(
360 git_repo,
361 git_server_url,
362 decoded_nostr_url,
363 is_grasp_server,
364 None,
365 )
366}
367
368fn list_from_remote_sync(
71 git_repo: &Repo, 369 git_repo: &Repo,
72 git_server_url: &str, 370 git_server_url: &str,
73 decoded_nostr_url: &NostrUrlDecoded, 371 decoded_nostr_url: &NostrUrlDecoded,
74 is_grasp_server: bool, 372 is_grasp_server: bool,
373 pb: Option<&ProgressBar>,
75) -> Result<HashMap<String, String>> { 374) -> Result<HashMap<String, String>> {
76 let server_url = git_server_url.parse::<CloneUrl>()?; 375 let server_url = git_server_url.parse::<CloneUrl>()?;
77 let protocols_to_attempt = 376 let protocols_to_attempt =
@@ -81,13 +380,15 @@ pub fn list_from_remote(
81 let mut remote_state: Option<HashMap<String, String>> = None; 380 let mut remote_state: Option<HashMap<String, String>> = None;
82 381
83 for protocol in &protocols_to_attempt { 382 for protocol in &protocols_to_attempt {
84 term.write_line( 383 if let Some(pb) = pb {
85 format!( 384 // Only show protocol for non-grasp servers as they can failover to other
86 "fetching {} ref list over {protocol}...", 385 // protocols
87 server_url.short_name(), 386 if is_grasp_server {
88 ) 387 pb.set_message("".to_string());
89 .as_str(), 388 } else {
90 )?; 389 pb.set_message(format!("via {protocol}"));
390 }
391 }
91 392
92 let formatted_url = server_url.format_as(protocol)?; 393 let formatted_url = server_url.format_as(protocol)?;
93 394
@@ -96,46 +397,29 @@ pub fn list_from_remote(
96 &formatted_url, 397 &formatted_url,
97 decoded_nostr_url.ssh_key_file_path().as_ref(), 398 decoded_nostr_url.ssh_key_file_path().as_ref(),
98 [ServerProtocol::UnauthHttps, ServerProtocol::UnauthHttp].contains(protocol), 399 [ServerProtocol::UnauthHttps, ServerProtocol::UnauthHttp].contains(protocol),
99 term,
100 ); 400 );
101 401
102 match res { 402 match res {
103 Ok(state) => { 403 Ok(state) => {
104 remote_state = Some(state); 404 remote_state = Some(state);
105 if !is_grasp_server && !failed_protocols.is_empty() { 405 if !is_grasp_server && !failed_protocols.is_empty() {
106 term.write_line(
107 format!(
108 "list: succeeded over {protocol} from {}",
109 server_url.short_name(),
110 )
111 .as_str(),
112 )?;
113 let _ = 406 let _ =
114 set_protocol_preference(git_repo, protocol, &server_url, &Direction::Fetch); 407 set_protocol_preference(git_repo, protocol, &server_url, &Direction::Fetch);
115 } 408 }
116 break; 409 break;
117 } 410 }
118 Err(error) => { 411 Err(error) => {
119 if is_grasp_server {
120 term.write_line(&format!("list: failed: {error}"))?;
121 } else {
122 term.write_line(&format!(
123 "list: {formatted_url} failed over {protocol}{}: {error}",
124 if protocol == &ServerProtocol::Ssh {
125 if let Some(ssh_key_file) = &decoded_nostr_url.ssh_key_file_path() {
126 format!(" with ssh key from {ssh_key_file}")
127 } else {
128 String::new()
129 }
130 } else {
131 String::new()
132 }
133 ))?;
134 }
135 failed_protocols.push(protocol); 412 failed_protocols.push(protocol);
413 if failed_protocols.len() == protocols_to_attempt.len() {
414 // All protocols failed
415 if let Some(pb) = pb {
416 pb.set_message(format!("all protocols failed: {}", error));
417 }
418 }
136 } 419 }
137 } 420 }
138 } 421 }
422
139 if let Some(remote_state) = remote_state { 423 if let Some(remote_state) = remote_state {
140 Ok(remote_state) 424 Ok(remote_state)
141 } else { 425 } else {
@@ -149,9 +433,6 @@ pub fn list_from_remote(
149 "" 433 ""
150 }, 434 },
151 ); 435 );
152 if !is_grasp_server {
153 term.write_line(format!("list: {error}").as_str())?;
154 }
155 Err(error) 436 Err(error)
156 } 437 }
157} 438}
@@ -161,7 +442,6 @@ fn list_from_remote_url(
161 git_server_remote_url: &str, 442 git_server_remote_url: &str,
162 ssh_key_file: Option<&String>, 443 ssh_key_file: Option<&String>,
163 dont_authenticate: bool, 444 dont_authenticate: bool,
164 term: &console::Term,
165) -> Result<HashMap<String, String>> { 445) -> Result<HashMap<String, String>> {
166 let git_config = git_repo.git_repo.config()?; 446 let git_config = git_repo.git_repo.config()?;
167 447
@@ -185,9 +465,7 @@ fn list_from_remote_url(
185 if !dont_authenticate { 465 if !dont_authenticate {
186 remote_callbacks.credentials(auth.credentials(&git_config)); 466 remote_callbacks.credentials(auth.credentials(&git_config));
187 } 467 }
188 term.write_line("list: connecting...")?;
189 git_server_remote.connect_auth(git2::Direction::Fetch, Some(remote_callbacks), None)?; 468 git_server_remote.connect_auth(git2::Direction::Fetch, Some(remote_callbacks), None)?;
190 term.clear_last_lines(1)?;
191 let mut state = HashMap::new(); 469 let mut state = HashMap::new();
192 for head in git_server_remote.list()? { 470 for head in git_server_remote.list()? {
193 if let Some(symbolic_reference) = head.symref_target() { 471 if let Some(symbolic_reference) = head.symref_target() {
@@ -435,7 +713,7 @@ pub fn generate_remote_sync_warnings(
435 if let Some(state) = remote_state { 713 if let Some(state) = remote_state {
436 // Check if remote is completely empty 714 // Check if remote is completely empty
437 if state.is_empty() { 715 if state.is_empty() {
438 warnings.push(format!("WARNING: {remote_name} has no data.")); 716 warnings.push(format!("WARNING: {remote_name} has empty repository."));
439 continue; 717 continue;
440 } 718 }
441 719