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.rs584
1 files changed, 583 insertions, 1 deletions
diff --git a/src/lib/list.rs b/src/lib/list.rs
index 733936a..b4d6a5e 100644
--- a/src/lib/list.rs
+++ b/src/lib/list.rs
@@ -10,9 +10,40 @@ use crate::{
10 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol}, 10 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol},
11 }, 11 },
12 repo_ref::is_grasp_server_clone_url, 12 repo_ref::is_grasp_server_clone_url,
13 utils::{Direction, get_read_protocols_to_try, join_with_and, set_protocol_preference}, 13 repo_state::RepoState,
14 utils::{
15 Direction, get_read_protocols_to_try, get_short_git_server_name, join_with_and,
16 set_protocol_preference,
17 },
14}; 18};
15 19
20/// Sync issues identified for a single remote
21#[derive(Default, Debug, Clone)]
22pub struct RemoteIssues {
23 pub branches_out_of_sync: Vec<(String, Option<(usize, usize)>)>, // (ref, (ahead, behind))
24 pub branches_missing: Vec<String>,
25 pub tags_out_of_sync: Vec<String>,
26 pub tags_missing: Vec<String>,
27}
28
29impl RemoteIssues {
30 /// Returns true if there are no issues
31 pub fn is_empty(&self) -> bool {
32 self.branches_out_of_sync.is_empty()
33 && self.branches_missing.is_empty()
34 && self.tags_out_of_sync.is_empty()
35 && self.tags_missing.is_empty()
36 }
37
38 /// Returns the total count of all issues
39 pub fn total_count(&self) -> usize {
40 self.branches_out_of_sync.len()
41 + self.branches_missing.len()
42 + self.tags_out_of_sync.len()
43 + self.tags_missing.len()
44 }
45}
46
16pub fn list_from_remotes( 47pub fn list_from_remotes(
17 term: &console::Term, 48 term: &console::Term,
18 git_repo: &Repo, 49 git_repo: &Repo,
@@ -182,3 +213,554 @@ pub fn get_ahead_behind(
182 let latest = git_repo.get_commit_or_tip_of_reference(latest_ref_or_oid)?; 213 let latest = git_repo.get_commit_or_tip_of_reference(latest_ref_or_oid)?;
183 git_repo.get_commits_ahead_behind(&base, &latest) 214 git_repo.get_commits_ahead_behind(&base, &latest)
184} 215}
216
217/// Identify sync discrepancies between nostr state and remote git servers
218///
219/// This function analyzes the differences between the expected state (from
220/// nostr) and the actual state on each remote git server, categorizing issues
221/// by type (branches/tags, out of sync/missing).
222///
223/// # Arguments
224/// * `git_repo` - The local git repository
225/// * `nostr_state` - The expected state from nostr
226/// * `remote_states` - Map of remote URLs to their states and whether they're
227/// grasp servers
228///
229/// # Returns
230/// A HashMap mapping remote names to their identified sync issues
231pub fn identify_remote_sync_issues(
232 git_repo: &Repo,
233 nostr_state: &RepoState,
234 remote_states: &HashMap<String, (HashMap<String, String>, bool)>,
235) -> HashMap<String, RemoteIssues> {
236 let mut remote_issues: HashMap<String, RemoteIssues> = HashMap::new();
237
238 for (name, value) in &nostr_state.state {
239 for (url, (remote_state, _is_grasp_server)) in remote_states {
240 let remote_name = get_short_git_server_name(git_repo, url);
241 let issues = remote_issues.entry(remote_name.clone()).or_default();
242
243 let is_branch = name.starts_with("refs/heads/");
244 let is_tag = name.starts_with("refs/tags/");
245
246 if let Some(remote_value) = remote_state.get(name) {
247 if value.ne(remote_value) {
248 if is_branch {
249 // Calculate ahead/behind for branches
250 let ahead_behind = get_ahead_behind(git_repo, value, remote_value)
251 .ok()
252 .map(|(ahead, behind)| (ahead.len(), behind.len()));
253 issues
254 .branches_out_of_sync
255 .push((name.clone(), ahead_behind));
256 } else if is_tag {
257 issues.tags_out_of_sync.push(name.clone());
258 }
259 }
260 } else if is_branch {
261 issues.branches_missing.push(name.clone());
262 } else if is_tag {
263 issues.tags_missing.push(name.clone());
264 }
265 }
266 }
267
268 remote_issues
269}
270
271/// Format a list of refs with ahead/behind information into a user-friendly
272/// issue summary
273///
274/// # Arguments
275/// * `refs` - List of refs with optional ahead/behind counts
276/// * `singular` - Singular form of the ref type (e.g., "branch")
277/// * `plural` - Plural form of the ref type (e.g., "branches")
278/// * `status` - Status description (e.g., "out of sync", "missing")
279/// * `is_branch` - Whether these are branches (affects formatting)
280///
281/// # Returns
282/// A formatted string describing the issue
283pub fn format_ref_issue(
284 refs: &[(String, Option<(usize, usize)>)],
285 _singular: &str,
286 plural: &str,
287 status: &str,
288 is_branch: bool,
289) -> String {
290 let count = refs.len();
291
292 /// Helper to format branch name with ahead/behind info
293 fn format_branch_with_sync(name: &str, ahead_behind: &Option<(usize, usize)>) -> String {
294 if let Some((ahead, behind)) = ahead_behind {
295 if *ahead > 0 && *behind > 0 {
296 format!("{} ({} behind, {} ahead)", name, behind, ahead)
297 } else if *behind > 0 {
298 format!("{} ({} behind)", name, behind)
299 } else if *ahead > 0 {
300 format!("{} ({} ahead)", name, ahead)
301 } else {
302 name.to_string()
303 }
304 } else {
305 name.to_string()
306 }
307 }
308
309 if count == 1 {
310 // Single item: name the ref with ahead/behind info
311 let clean_ref = refs[0]
312 .0
313 .strip_prefix("refs/heads/")
314 .or_else(|| refs[0].0.strip_prefix("refs/tags/"))
315 .unwrap_or(&refs[0].0);
316 let formatted = if is_branch {
317 format_branch_with_sync(clean_ref, &refs[0].1)
318 } else {
319 clean_ref.to_string()
320 };
321 format!("{} {}", formatted, status)
322 } else if is_branch && count <= 3 {
323 // For branches: list up to 3 names with ahead/behind info
324 let names: Vec<_> = refs
325 .iter()
326 .map(|(r, ab)| {
327 let clean = r.strip_prefix("refs/heads/").unwrap_or(r);
328 format_branch_with_sync(clean, ab)
329 })
330 .collect();
331 if count == 2 {
332 format!("{} and {} {}", names[0], names[1], status)
333 } else {
334 format!("{}, {} and {} {}", names[0], names[1], names[2], status)
335 }
336 } else if is_branch && count > 3 {
337 // For many branches: list first 2 with ahead/behind and count others
338 let names: Vec<_> = refs
339 .iter()
340 .take(2)
341 .map(|(r, ab)| {
342 let clean = r.strip_prefix("refs/heads/").unwrap_or(r);
343 format_branch_with_sync(clean, ab)
344 })
345 .collect();
346 let other_count = count - 2;
347 let other = if other_count == 1 {
348 "1 other".to_string()
349 } else {
350 format!("{} others", other_count)
351 };
352 format!("{}, {} and {} {}", names[0], names[1], other, status)
353 } else {
354 // For tags: just count
355 format!("{} {} {}", count, plural, status)
356 }
357}
358
359/// Format a list of refs (String only) into a user-friendly issue summary
360///
361/// # Arguments
362/// * `refs` - List of ref names
363/// * `singular` - Singular form of the ref type (e.g., "branch")
364/// * `plural` - Plural form of the ref type (e.g., "branches")
365/// * `status` - Status description (e.g., "out of sync", "missing")
366/// * `is_branch` - Whether these are branches (affects formatting)
367///
368/// # Returns
369/// A formatted string describing the issue
370pub fn format_ref_issue_simple(
371 refs: &[String],
372 _singular: &str,
373 plural: &str,
374 status: &str,
375 is_branch: bool,
376) -> String {
377 let count = refs.len();
378
379 if count == 1 {
380 // Single item: name the ref
381 let clean_ref = refs[0]
382 .strip_prefix("refs/heads/")
383 .or_else(|| refs[0].strip_prefix("refs/tags/"))
384 .unwrap_or(&refs[0]);
385 format!("{} {}", clean_ref, status)
386 } else if is_branch && count <= 3 {
387 // For branches: list up to 3 names
388 let names: Vec<_> = refs
389 .iter()
390 .map(|r| r.strip_prefix("refs/heads/").unwrap_or(r))
391 .collect();
392 if count == 2 {
393 format!("{} and {} {}", names[0], names[1], status)
394 } else {
395 format!("{}, {} and {} {}", names[0], names[1], names[2], status)
396 }
397 } else if is_branch && count > 3 {
398 // For many branches: list first 2 and count others
399 let names: Vec<_> = refs
400 .iter()
401 .take(2)
402 .map(|r| r.strip_prefix("refs/heads/").unwrap_or(r))
403 .collect();
404 let other_count = count - 2;
405 let other = if other_count == 1 {
406 "1 other".to_string()
407 } else {
408 format!("{} others", other_count)
409 };
410 format!("{}, {} and {} {}", names[0], names[1], other, status)
411 } else {
412 // For tags: just count
413 format!("{} {} {}", count, plural, status)
414 }
415}
416
417/// Generate warning messages for remote sync issues
418pub fn generate_remote_sync_warnings(
419 git_repo: &Repo,
420 remote_issues: &HashMap<String, RemoteIssues>,
421 remote_states: &HashMap<String, (HashMap<String, String>, bool)>,
422) -> Vec<String> {
423 let mut warnings = Vec::new();
424
425 for (remote_name, issues) in remote_issues {
426 if issues.is_empty() {
427 continue;
428 }
429
430 // Find remote state for this remote
431 let remote_state = remote_states
432 .iter()
433 .find(|(url, _)| &get_short_git_server_name(git_repo, url) == remote_name)
434 .map(|(_, (state, _))| state);
435
436 if let Some(state) = remote_state {
437 // Check if remote is completely empty
438 if state.is_empty() {
439 warnings.push(format!("WARNING: {remote_name} has no data."));
440 continue;
441 }
442
443 // Check if remote only has a few branches and missing many
444 let remote_branches: Vec<_> = state
445 .keys()
446 .filter(|k| k.starts_with("refs/heads/"))
447 .map(|b| b.strip_prefix("refs/heads/").unwrap_or(b))
448 .collect();
449
450 if remote_branches.len() <= 3 && issues.branches_missing.len() >= 5 {
451 let sync_status = if issues.branches_out_of_sync.is_empty() {
452 ""
453 } else {
454 " and they are out of sync"
455 };
456
457 warnings.push(format!(
458 "WARNING: {remote_name} only has {} branches{}",
459 remote_branches.join(", "),
460 sync_status
461 ));
462 continue;
463 }
464 }
465
466 // Build summary message parts
467 let mut parts = Vec::new();
468
469 if !issues.branches_out_of_sync.is_empty() {
470 parts.push(format_ref_issue(
471 &issues.branches_out_of_sync,
472 "branch",
473 "branches",
474 "out of sync",
475 true,
476 ));
477 }
478
479 if !issues.branches_missing.is_empty() {
480 parts.push(format_ref_issue_simple(
481 &issues.branches_missing,
482 "branch",
483 "branches",
484 "missing",
485 true,
486 ));
487 }
488
489 if !issues.tags_out_of_sync.is_empty() {
490 parts.push(format_ref_issue_simple(
491 &issues.tags_out_of_sync,
492 "tag",
493 "tags",
494 "out of sync",
495 false,
496 ));
497 }
498
499 if !issues.tags_missing.is_empty() {
500 parts.push(format_ref_issue_simple(
501 &issues.tags_missing,
502 "tag",
503 "tags",
504 "missing",
505 false,
506 ));
507 }
508
509 if !parts.is_empty() {
510 warnings.push(format!(
511 "WARNING: {remote_name} is out of sync. {}",
512 parts.join(". ")
513 ));
514 }
515 }
516
517 warnings
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 #[test]
525 fn test_format_ref_issue_single_branch_with_ahead_behind() {
526 let refs = vec![("refs/heads/main".to_string(), Some((5, 3)))];
527 assert_eq!(
528 format_ref_issue(&refs, "branch", "branches", "out of sync", true),
529 "main (3 behind, 5 ahead) out of sync"
530 );
531 }
532
533 #[test]
534 fn test_format_ref_issue_single_branch_only_behind() {
535 let refs = vec![("refs/heads/feature".to_string(), Some((0, 7)))];
536 assert_eq!(
537 format_ref_issue(&refs, "branch", "branches", "out of sync", true),
538 "feature (7 behind) out of sync"
539 );
540 }
541
542 #[test]
543 fn test_format_ref_issue_single_branch_only_ahead() {
544 let refs = vec![("refs/heads/dev".to_string(), Some((4, 0)))];
545 assert_eq!(
546 format_ref_issue(&refs, "branch", "branches", "out of sync", true),
547 "dev (4 ahead) out of sync"
548 );
549 }
550
551 #[test]
552 fn test_format_ref_issue_single_branch_no_diff() {
553 let refs = vec![("refs/heads/main".to_string(), Some((0, 0)))];
554 assert_eq!(
555 format_ref_issue(&refs, "branch", "branches", "out of sync", true),
556 "main out of sync"
557 );
558 }
559
560 #[test]
561 fn test_format_ref_issue_single_branch_no_ahead_behind_info() {
562 let refs = vec![("refs/heads/main".to_string(), None)];
563 assert_eq!(
564 format_ref_issue(&refs, "branch", "branches", "out of sync", true),
565 "main out of sync"
566 );
567 }
568
569 #[test]
570 fn test_format_ref_issue_two_branches() {
571 let refs = vec![
572 ("refs/heads/main".to_string(), Some((2, 1))),
573 ("refs/heads/dev".to_string(), Some((0, 3))),
574 ];
575 assert_eq!(
576 format_ref_issue(&refs, "branch", "branches", "out of sync", true),
577 "main (1 behind, 2 ahead) and dev (3 behind) out of sync"
578 );
579 }
580
581 #[test]
582 fn test_format_ref_issue_three_branches() {
583 let refs = vec![
584 ("refs/heads/main".to_string(), Some((1, 0))),
585 ("refs/heads/dev".to_string(), Some((0, 2))),
586 ("refs/heads/feature".to_string(), None),
587 ];
588 assert_eq!(
589 format_ref_issue(&refs, "branch", "branches", "out of sync", true),
590 "main (1 ahead), dev (2 behind) and feature out of sync"
591 );
592 }
593
594 #[test]
595 fn test_format_ref_issue_many_branches() {
596 let refs = vec![
597 ("refs/heads/main".to_string(), Some((5, 3))),
598 ("refs/heads/dev".to_string(), Some((0, 1))),
599 ("refs/heads/feature1".to_string(), None),
600 ("refs/heads/feature2".to_string(), Some((2, 0))),
601 ];
602 assert_eq!(
603 format_ref_issue(&refs, "branch", "branches", "out of sync", true),
604 "main (3 behind, 5 ahead), dev (1 behind) and 2 others out of sync"
605 );
606 }
607
608 #[test]
609 fn test_format_ref_issue_many_branches_singular_other() {
610 let refs = vec![
611 ("refs/heads/main".to_string(), Some((1, 1))),
612 ("refs/heads/dev".to_string(), Some((2, 2))),
613 ("refs/heads/feature".to_string(), None),
614 ];
615 // With 3 branches, it should list all 3
616 assert_eq!(
617 format_ref_issue(&refs, "branch", "branches", "out of sync", true),
618 "main (1 behind, 1 ahead), dev (2 behind, 2 ahead) and feature out of sync"
619 );
620
621 // With 4 branches (show 2, then "2 others")
622 let refs = vec![
623 ("refs/heads/main".to_string(), Some((1, 1))),
624 ("refs/heads/dev".to_string(), Some((2, 2))),
625 ("refs/heads/feature1".to_string(), None),
626 ("refs/heads/feature2".to_string(), None),
627 ];
628 assert_eq!(
629 format_ref_issue(&refs, "branch", "branches", "out of sync", true),
630 "main (1 behind, 1 ahead), dev (2 behind, 2 ahead) and 2 others out of sync"
631 );
632 }
633
634 #[test]
635 fn test_format_ref_issue_single_tag() {
636 let refs = vec![("refs/tags/v1.0.0".to_string(), None)];
637 assert_eq!(
638 format_ref_issue(&refs, "tag", "tags", "out of sync", false),
639 "v1.0.0 out of sync"
640 );
641 }
642
643 #[test]
644 fn test_format_ref_issue_multiple_tags() {
645 let refs = vec![
646 ("refs/tags/v1.0.0".to_string(), None),
647 ("refs/tags/v1.0.1".to_string(), None),
648 ("refs/tags/v2.0.0".to_string(), None),
649 ];
650 assert_eq!(
651 format_ref_issue(&refs, "tag", "tags", "out of sync", false),
652 "3 tags out of sync"
653 );
654 }
655
656 #[test]
657 fn test_format_ref_issue_simple_single_branch() {
658 let refs = vec!["refs/heads/main".to_string()];
659 assert_eq!(
660 format_ref_issue_simple(&refs, "branch", "branches", "missing", true),
661 "main missing"
662 );
663 }
664
665 #[test]
666 fn test_format_ref_issue_simple_two_branches() {
667 let refs = vec!["refs/heads/main".to_string(), "refs/heads/dev".to_string()];
668 assert_eq!(
669 format_ref_issue_simple(&refs, "branch", "branches", "missing", true),
670 "main and dev missing"
671 );
672 }
673
674 #[test]
675 fn test_format_ref_issue_simple_three_branches() {
676 let refs = vec![
677 "refs/heads/main".to_string(),
678 "refs/heads/dev".to_string(),
679 "refs/heads/feature".to_string(),
680 ];
681 assert_eq!(
682 format_ref_issue_simple(&refs, "branch", "branches", "missing", true),
683 "main, dev and feature missing"
684 );
685 }
686
687 #[test]
688 fn test_format_ref_issue_simple_many_branches() {
689 let refs = vec![
690 "refs/heads/main".to_string(),
691 "refs/heads/dev".to_string(),
692 "refs/heads/feature1".to_string(),
693 "refs/heads/feature2".to_string(),
694 ];
695 assert_eq!(
696 format_ref_issue_simple(&refs, "branch", "branches", "missing", true),
697 "main, dev and 2 others missing"
698 );
699 }
700
701 #[test]
702 fn test_format_ref_issue_simple_many_branches_singular_other() {
703 let refs = vec![
704 "refs/heads/main".to_string(),
705 "refs/heads/dev".to_string(),
706 "refs/heads/feature".to_string(),
707 "refs/heads/hotfix".to_string(),
708 ];
709 assert_eq!(
710 format_ref_issue_simple(&refs, "branch", "branches", "missing", true),
711 "main, dev and 2 others missing"
712 );
713
714 // Test with exactly 4 branches (2 shown + 2 others)
715 let refs = vec![
716 "refs/heads/main".to_string(),
717 "refs/heads/dev".to_string(),
718 "refs/heads/feature".to_string(),
719 ];
720 // With 3 branches, all should be shown
721 assert_eq!(
722 format_ref_issue_simple(&refs, "branch", "branches", "missing", true),
723 "main, dev and feature missing"
724 );
725 }
726
727 #[test]
728 fn test_format_ref_issue_simple_single_tag() {
729 let refs = vec!["refs/tags/v1.0.0".to_string()];
730 assert_eq!(
731 format_ref_issue_simple(&refs, "tag", "tags", "missing", false),
732 "v1.0.0 missing"
733 );
734 }
735
736 #[test]
737 fn test_format_ref_issue_simple_multiple_tags() {
738 let refs = vec![
739 "refs/tags/v1.0.0".to_string(),
740 "refs/tags/v1.0.1".to_string(),
741 "refs/tags/v2.0.0".to_string(),
742 ];
743 assert_eq!(
744 format_ref_issue_simple(&refs, "tag", "tags", "missing", false),
745 "3 tags missing"
746 );
747 }
748
749 #[test]
750 fn test_format_ref_issue_without_refs_prefix() {
751 let refs = vec![("main".to_string(), Some((1, 0)))];
752 assert_eq!(
753 format_ref_issue(&refs, "branch", "branches", "out of sync", true),
754 "main (1 ahead) out of sync"
755 );
756 }
757
758 #[test]
759 fn test_format_ref_issue_simple_without_refs_prefix() {
760 let refs = vec!["main".to_string()];
761 assert_eq!(
762 format_ref_issue_simple(&refs, "branch", "branches", "missing", true),
763 "main missing"
764 );
765 }
766}