From 68779f91f051822270f156a4185350fb3c4b5017 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 20 Feb 2026 22:41:50 +0000 Subject: improve `ngit repo` output formatting - suppress fetch summary (no updates / updates: X) - write blank line to stderr after relay errors for clear separation - show identifier below title only when it differs from name - show earliest unique commit (root_commit) in metadata - restructure infrastructure into grasp servers / additional git servers / additional relays sections - display grasp servers by domain only (strip scheme, npub, repo path) - strip wss:// prefix from relay display - show maintainer names from metadata cache; fall back to short npub - append (you) next to the current user's name wherever it appears - show [name] attribution and the maintainer model note only when there is more than one maintainer --- src/bin/ngit/sub_commands/repo/mod.rs | 423 +++++++++++++++++++++------------- src/lib/client.rs | 36 +++ 2 files changed, 300 insertions(+), 159 deletions(-) (limited to 'src') diff --git a/src/bin/ngit/sub_commands/repo/mod.rs b/src/bin/ngit/sub_commands/repo/mod.rs index 62fe766..7914e1d 100644 --- a/src/bin/ngit/sub_commands/repo/mod.rs +++ b/src/bin/ngit/sub_commands/repo/mod.rs @@ -1,17 +1,24 @@ pub mod accept; +use std::path::Path; + use anyhow::{Context, Result}; +use console::Style; use ngit::{ - client::{Params, fetching_with_report, get_repo_ref_from_cache}, - repo_ref::{RepoRef, extract_npub, is_grasp_server_clone_url}, + client::{Params, fetching_quietly, get_repo_ref_from_cache}, + login::{existing::load_existing_login, user::get_user_ref_from_cache}, + repo_ref::{ + RepoRef, extract_npub, format_grasp_server_url_as_relay_url, is_grasp_server_clone_url, + normalize_grasp_server_url, + }, + utils::get_short_git_server_name, }; -use nostr::{PublicKey, TagStandard, ToBech32, nips::nip19::Nip19Coordinate}; +use nostr::{FromBech32, PublicKey, TagStandard, ToBech32, nips::nip19::Nip19Coordinate}; use crate::{ cli::{Cli, RepoCommands, extract_signer_cli_arguments}, client::{Client, Connect}, git::{Repo, RepoActions}, - login, repo_ref::try_and_get_repo_coordinates_when_remote_unknown, sub_commands::init, }; @@ -35,14 +42,20 @@ async fn show_info(cli_args: &Cli) -> Result<()> { let git_repo_path = git_repo.get_path()?; let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); - let (_, user_ref, _) = login::login_or_signup( + // Attempt a silent login — don't prompt if not logged in. + let my_pubkey: Option = load_existing_login( &Some(&git_repo), &extract_signer_cli_arguments(cli_args).unwrap_or(None), &cli_args.password, + &None, Some(&client), - false, + true, // silent + false, // don't prompt for password + false, // don't fetch profile updates ) - .await?; + .await + .ok() + .map(|(_, user_ref, _)| user_ref.public_key); let repo_coordinate = (try_and_get_repo_coordinates_when_remote_unknown(&git_repo).await).ok(); @@ -53,8 +66,10 @@ async fn show_info(cli_args: &Cli) -> Result<()> { return Ok(()); }; - // Fetch latest data from relays - fetching_with_report(git_repo_path, &client, &repo_coordinate).await?; + // Fetch latest data from relays — suppress the summary line. + // fetching_quietly writes a blank line to stderr after errors so there + // is clear separation before the repo info below. + let _ = fetching_quietly(git_repo_path, &client, &repo_coordinate).await; let Some(repo_ref) = (get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinate).await).ok() @@ -69,34 +84,67 @@ async fn show_info(cli_args: &Cli) -> Result<()> { return Ok(()); }; - print_repo_info(&repo_ref, &user_ref.public_key, &repo_coordinate); + print_repo_info(&repo_ref, my_pubkey.as_ref(), &repo_coordinate, git_repo_path).await; Ok(()) } #[allow(clippy::too_many_lines)] -fn print_repo_info(repo_ref: &RepoRef, my_pubkey: &PublicKey, coordinate: &Nip19Coordinate) { +async fn print_repo_info( + repo_ref: &RepoRef, + my_pubkey: Option<&PublicKey>, + coordinate: &Nip19Coordinate, + git_repo_path: &Path, +) { + let heading = Style::new().bold(); + let dim = Style::new().dim(); + + let multi_maintainer = repo_ref.maintainers.len() > 1 + || repo_ref + .maintainers_without_annoucnement + .as_ref() + .is_some_and(|v| !v.is_empty()); + // --- Basic metadata --- - println!("Repository: {}", repo_ref.name); + println!("{}", heading.apply_to(&repo_ref.name)); + + // Show identifier only when it differs from the name + let identifier_slug = repo_ref.identifier.to_lowercase().replace(' ', "-"); + let name_slug = repo_ref.name.to_lowercase().replace(' ', "-"); + if identifier_slug != name_slug { + println!( + "{}", + dim.apply_to(format!("identifier: {}", repo_ref.identifier)) + ); + } + if !repo_ref.description.is_empty() { - println!("Description: {}", repo_ref.description); + println!("{}", repo_ref.description); } if !repo_ref.web.is_empty() { for url in &repo_ref.web { - println!("Web: {url}"); + println!("{}", dim.apply_to(url)); } } if !repo_ref.hashtags.is_empty() { - println!("Hashtags: {}", repo_ref.hashtags.join(", ")); + println!("{}", dim.apply_to(repo_ref.hashtags.join(" "))); + } + if !repo_ref.root_commit.is_empty() { + println!( + "{}", + dim.apply_to(format!( + "earliest unique commit: {}", + &repo_ref.root_commit[..7.min(repo_ref.root_commit.len())] + )) + ); } println!(); // --- Maintainers --- + println!("{}", heading.apply_to("Maintainers")); let trusted = &repo_ref.trusted_maintainer; - let trusted_npub = trusted.to_bech32().unwrap_or_else(|_| trusted.to_hex()); - println!("Trusted maintainer: {}", short_npub(&trusted_npub)); + let trusted_name = display_name_for(trusted, my_pubkey, git_repo_path).await; + println!(" trusted: {trusted_name}"); - // Build a map: pubkey → who listed them (for recursive display) - // We walk the events map to find each maintainer's "lister" let co_maintainers: Vec<&PublicKey> = repo_ref .maintainers .iter() @@ -104,113 +152,213 @@ fn print_repo_info(repo_ref: &RepoRef, my_pubkey: &PublicKey, coordinate: &Nip19 .collect(); if !co_maintainers.is_empty() { - // For each co-maintainer, find who listed them by inspecting events - let mut listed_by: Vec<(String, Option)> = Vec::new(); + let mut direct_names: Vec = Vec::new(); + let mut indirect: Vec<(String, String)> = Vec::new(); // (name, lister_name) + for co in &co_maintainers { - let co_npub = co.to_bech32().unwrap_or_else(|_| co.to_hex()); - // Find which maintainer's event lists this co-maintainer - let lister = find_lister(repo_ref, co, trusted); - listed_by.push((co_npub, lister)); + let co_name = display_name_for(co, my_pubkey, git_repo_path).await; + match find_lister(repo_ref, co, trusted) { + None => direct_names.push(co_name), + Some(lister_hex) => { + let lister_name = if let Ok(pk) = PublicKey::from_hex(&lister_hex) { + display_name_for(&pk, my_pubkey, git_repo_path).await + } else { + short_npub(&lister_hex) + }; + indirect.push((co_name, lister_name)); + } + } } - // Print directly-listed co-maintainers first, then indirectly-listed - let direct: Vec<_> = listed_by - .iter() - .filter(|(_, lister)| lister.is_none()) - .collect(); - let indirect: Vec<_> = listed_by - .iter() - .filter(|(_, lister)| lister.is_some()) - .collect(); - - if !direct.is_empty() { - let names: Vec = direct.iter().map(|(npub, _)| short_npub(npub)).collect(); - println!("Co-maintainers: {}", names.join(", ")); + if !direct_names.is_empty() { + println!(" co-maintainers: {}", direct_names.join(", ")); } - for (npub, lister) in &indirect { - if let Some(lister_npub) = lister { - println!( - " └─ {} is listed by {}, not directly by the trusted maintainer", - short_npub(npub), - short_npub(lister_npub) - ); - } + for (name, lister_name) in &indirect { + println!( + " {} {}", + name, + dim.apply_to(format!( + "(listed by {lister_name}, not directly by trusted maintainer)" + )) + ); } } - // Maintainers without announcements if let Some(without) = &repo_ref.maintainers_without_annoucnement { if !without.is_empty() { - let names: Vec = without - .iter() - .map(|pk| { - let npub = pk.to_bech32().unwrap_or_else(|_| pk.to_hex()); - short_npub(&npub) - }) - .collect(); - println!(" (invited, no announcement yet: {})", names.join(", ")); + let mut names = Vec::new(); + for pk in without { + names.push(display_name_for(pk, my_pubkey, git_repo_path).await); + } + println!( + " {}", + dim.apply_to(format!( + "invited, no announcement yet: {}", + names.join(", ") + )) + ); } } + println!(); + + // --- Infrastructure --- + // Split into three groups: + // 1. Grasp servers (each bundles a git server + relay) + // 2. Additional git servers (non-grasp) + // 3. Additional relays (not covered by a grasp server) + + // Relay URLs that grasp servers already cover (for deduplication) + let grasp_relay_urls: Vec = repo_ref + .git_server + .iter() + .filter(|s| is_grasp_server_clone_url(s)) + .filter_map(|s| format_grasp_server_url_as_relay_url(s).ok()) + .collect(); + + let grasp_servers: Vec<&String> = repo_ref + .git_server + .iter() + .filter(|s| is_grasp_server_clone_url(s)) + .collect(); + + let extra_git_servers: Vec<&String> = repo_ref + .git_server + .iter() + .filter(|s| !is_grasp_server_clone_url(s)) + .collect(); + + let extra_relays: Vec<_> = repo_ref + .relays + .iter() + .filter(|r| { + let r_str = r.as_str().trim_end_matches('/'); + !grasp_relay_urls + .iter() + .any(|g| g.trim_end_matches('/') == r_str) + }) + .collect(); - // --- My status --- - let my_status = if my_pubkey == trusted { - let has_announcement = repo_ref - .events - .keys() - .any(|c| c.coordinate.public_key == *my_pubkey); - if has_announcement { - "trusted maintainer [announcement published ✓]" - } else { - "trusted maintainer [no announcement — run `ngit repo init`]" + if !grasp_servers.is_empty() { + println!("{}", heading.apply_to("Grasp servers")); + for server in &grasp_servers { + // Display just the domain (strip scheme, npub path, and repo path) + let short = normalize_grasp_server_url(server) + .unwrap_or_else(|_| get_short_git_server_name(server)); + + if multi_maintainer { + // Owner is encoded in the URL path (the npub) + let owner_label = if let Ok(npub) = extract_npub(server) { + if let Ok(pk) = PublicKey::from_bech32(npub) { + let name = display_name_for(&pk, my_pubkey, git_repo_path).await; + format!("[{name}]") + } else { + format!("[{}]", short_npub(npub)) + } + } else { + String::new() + }; + if owner_label.is_empty() { + println!(" {short}"); + } else { + println!(" {short} {}", dim.apply_to(&owner_label)); + } + } else { + println!(" {short}"); + } } - } else if repo_ref.maintainers.contains(my_pubkey) { - let has_announcement = repo_ref - .events - .keys() - .any(|c| c.coordinate.public_key == *my_pubkey); - if has_announcement { - "co-maintainer [announcement published ✓]" - } else { - "co-maintainer [no announcement — run `ngit repo accept`]" + println!(); + } + + if !extra_git_servers.is_empty() { + println!("{}", heading.apply_to("Additional git servers")); + for server in &extra_git_servers { + let short = get_short_git_server_name(server); + if multi_maintainer { + let owners = find_server_owners(repo_ref, server, coordinate, my_pubkey, git_repo_path).await; + if owners.is_empty() { + println!(" {short}"); + } else { + println!( + " {short} {}", + dim.apply_to(format!("[{}]", owners.join(", "))) + ); + } + } else { + println!(" {short}"); + } } - } else { - "not a maintainer" - }; - println!("Your status: {my_status}"); - println!(); + println!(); + } - // --- Infrastructure (with per-maintainer attribution) --- - println!("Git servers (union across all maintainers — any maintainer can add a mirror):"); - for server in &repo_ref.git_server { - let attribution = attribute_server_to_maintainer(repo_ref, server, coordinate); - println!(" {server} {attribution}"); + if !extra_relays.is_empty() { + println!("{}", heading.apply_to("Additional relays")); + for relay in &extra_relays { + // Strip the wss:// / ws:// prefix for display + let display = relay + .as_str() + .trim_start_matches("wss://") + .trim_start_matches("ws://") + .trim_end_matches('/'); + if multi_maintainer { + let owners = + find_relay_owners(repo_ref, relay.as_str(), coordinate, my_pubkey, git_repo_path).await; + if owners.is_empty() { + println!(" {display}"); + } else { + println!( + " {display} {}", + dim.apply_to(format!("[{}]", owners.join(", "))) + ); + } + } else { + println!(" {display}"); + } + } + println!(); } - println!(); - println!("Relays (union across all maintainers — any maintainer can add a relay):"); - for relay in &repo_ref.relays { - let attribution = attribute_relay_to_maintainer(repo_ref, relay.as_str(), coordinate); - println!(" {relay} {attribution}"); + // --- Maintainer model note (only relevant when there are multiple maintainers) --- + if multi_maintainer { + println!( + "{}", + dim.apply_to( + "Note: git servers and relays are pooled from all maintainers' announcements.\n\ + Name, description, web, and hashtags come from the most recently updated announcement.\n\ + Each maintainer independently decides who they list as co-maintainers;\n\ + if Alice lists Bob and Bob lists Carol, all three are in the maintainer set." + ) + ); } - println!(); +} - // --- Maintainer model note --- - println!("Note: git servers and relays are pooled from all maintainers' announcements."); - println!( - " Name, description, web, and hashtags come from the most recently updated announcement." - ); - println!(" Each maintainer independently decides who they list as co-maintainers;"); - println!(" if Alice lists Bob and Bob lists Carol, all three are in the maintainer set."); +/// Resolve a display name for a public key from the local metadata cache. +/// Appends " (you)" when `pk` matches `my_pubkey`. +/// Falls back to a short npub if no metadata is cached. +async fn display_name_for( + pk: &PublicKey, + my_pubkey: Option<&PublicKey>, + git_repo_path: &Path, +) -> String { + let name = if let Ok(user_ref) = get_user_ref_from_cache(Some(git_repo_path), pk).await { + user_ref.metadata.name + } else { + let npub = pk.to_bech32().unwrap_or_else(|_| pk.to_hex()); + short_npub(&npub) + }; + if my_pubkey == Some(pk) { + format!("{name} (you)") + } else { + name + } } /// Find which maintainer's event lists `target` as a maintainer. /// Returns `None` if listed directly by the trusted maintainer, -/// or `Some(lister_npub)` if listed by a co-maintainer. +/// or `Some(lister_pubkey_hex)` if listed by a co-maintainer. fn find_lister(repo_ref: &RepoRef, target: &PublicKey, trusted: &PublicKey) -> Option { use nostr::nips::nip01::Coordinate; use nostr_sdk::Kind; - // Check if the trusted maintainer's event lists this target directly let trusted_coord = nostr::nips::nip19::Nip19Coordinate { coordinate: Coordinate { kind: Kind::GitRepoAnnouncement, @@ -220,8 +368,7 @@ fn find_lister(repo_ref: &RepoRef, target: &PublicKey, trusted: &PublicKey) -> O relays: vec![], }; if let Some(event) = repo_ref.events.get(&trusted_coord) { - // Parse the event's maintainers tag - let listed_in_trusted: Vec = event + let listed: Vec = event .tags .iter() .filter_map(|t| { @@ -232,18 +379,17 @@ fn find_lister(repo_ref: &RepoRef, target: &PublicKey, trusted: &PublicKey) -> O } }) .collect(); - if listed_in_trusted.contains(target) { - return None; // directly listed by trusted maintainer + if listed.contains(target) { + return None; } } - // Otherwise find which co-maintainer lists them for (coord, event) in &repo_ref.events { if coord.coordinate.public_key == *trusted { continue; } let lister = coord.coordinate.public_key; - let maintainers_listed: Vec = event + let lister_listed: Vec = event .tags .iter() .filter_map(|t| { @@ -254,55 +400,20 @@ fn find_lister(repo_ref: &RepoRef, target: &PublicKey, trusted: &PublicKey) -> O } }) .collect(); - if maintainers_listed.contains(target) { - let lister_npub = lister.to_bech32().unwrap_or_else(|_| lister.to_hex()); - return Some(lister_npub); + if lister_listed.contains(target) { + return Some(lister.to_hex()); } } None } -/// Find which maintainer(s) contribute a given git server URL. -fn attribute_server_to_maintainer( - repo_ref: &RepoRef, - server_url: &str, - coordinate: &Nip19Coordinate, -) -> String { - // For grasp-format URLs, the npub in the path tells us the owner - if is_grasp_server_clone_url(server_url) { - if let Ok(npub) = extract_npub(server_url) { - return format!("[{}]", short_npub(npub)); - } - } - - // For non-grasp URLs, find which maintainer's event lists it - let owners = find_server_owners(repo_ref, server_url, coordinate); - if owners.is_empty() { - String::new() - } else { - format!("[{}]", owners.join(", ")) - } -} - -/// Find which maintainer(s) contribute a given relay URL. -fn attribute_relay_to_maintainer( - repo_ref: &RepoRef, - relay_url: &str, - coordinate: &Nip19Coordinate, -) -> String { - let owners = find_relay_owners(repo_ref, relay_url, coordinate); - if owners.is_empty() { - String::new() - } else { - format!("[{}]", owners.join(", ")) - } -} - -fn find_server_owners( +async fn find_server_owners( repo_ref: &RepoRef, server_url: &str, _coordinate: &Nip19Coordinate, + my_pubkey: Option<&PublicKey>, + git_repo_path: &Path, ) -> Vec { let mut owners = Vec::new(); for (coord, event) in &repo_ref.events { @@ -312,22 +423,20 @@ fn find_server_owners( .iter() .any(|s| s.trim_end_matches('/') == server_url.trim_end_matches('/')) { - let npub = coord - .coordinate - .public_key - .to_bech32() - .unwrap_or_else(|_| coord.coordinate.public_key.to_hex()); - owners.push(short_npub(&npub)); + let pk = coord.coordinate.public_key; + owners.push(display_name_for(&pk, my_pubkey, git_repo_path).await); } } } owners } -fn find_relay_owners( +async fn find_relay_owners( repo_ref: &RepoRef, relay_url: &str, _coordinate: &Nip19Coordinate, + my_pubkey: Option<&PublicKey>, + git_repo_path: &Path, ) -> Vec { let mut owners = Vec::new(); for (coord, event) in &repo_ref.events { @@ -337,19 +446,15 @@ fn find_relay_owners( .iter() .any(|r| r.as_str().trim_end_matches('/') == relay_url.trim_end_matches('/')) { - let npub = coord - .coordinate - .public_key - .to_bech32() - .unwrap_or_else(|_| coord.coordinate.public_key.to_hex()); - owners.push(short_npub(&npub)); + let pk = coord.coordinate.public_key; + owners.push(display_name_for(&pk, my_pubkey, git_repo_path).await); } } } owners } -/// Shorten an npub for display: show first 8 + "..." + last 4 chars. +/// Shorten an npub for display: show first 12 + "..." + last 4 chars. fn short_npub(npub: &str) -> String { if npub.len() <= 16 { return npub.to_string(); diff --git a/src/lib/client.rs b/src/lib/client.rs index 68b7e1c..32c2d37 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs @@ -2265,6 +2265,42 @@ pub async fn fetching_with_report( Ok(report) } +/// Like `fetching_with_report` but suppresses the "no updates" / "updates: X" +/// summary line. Returns `true` if any relay reported an error (so the caller +/// can print a blank line to visually separate relay-error output from +/// subsequent content). +pub async fn fetching_quietly( + git_repo_path: &Path, + #[cfg(test)] client: &crate::client::MockConnect, + #[cfg(not(test))] client: &Client, + trusted_maintainer_coordinate: &Nip19Coordinate, +) -> Result<(FetchReport, bool)> { + let verbose = is_verbose(); + if verbose { + let term = console::Term::stderr(); + term.write_line("Checking nostr relays...")?; + } + let (relay_reports, progress_reporter) = client + .fetch_all( + Some(git_repo_path), + Some(trusted_maintainer_coordinate), + &HashSet::new(), + ) + .await?; + let had_errors = relay_reports.iter().any(std::result::Result::is_err); + if !had_errors { + let _ = progress_reporter.clear(); + } + // Drop the MultiProgress now so all buffered stderr output is flushed + // before we write the separator blank line. + drop(progress_reporter); + if had_errors { + let _ = console::Term::stderr().write_line(""); + } + let report = consolidate_fetch_reports(relay_reports); + Ok((report, had_errors)) +} + pub async fn get_proposals_and_revisions_from_cache( git_repo_path: &Path, repo_coordinates: HashSet, -- cgit v1.2.3