From 8f1a1743bd4e85e922ec0cc1f050911a28af4cf0 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 20 Feb 2026 21:54:00 +0000 Subject: add `ngit repo` subcommand group - `ngit repo` (no subcommand): show repository info including maintainer tree, per-maintainer infrastructure attribution, and a note explaining the union-vs-personal field model and recursive maintainer sets - `ngit repo init`: alias for `ngit init` - `ngit repo edit`: same as init but signals intent to update an existing repository announcement - `ngit repo accept`: scoped command for co-maintainers to publish their announcement; errors with clear messages for all other states (trusted maintainer, already accepted, not invited, no repo found) --- src/bin/ngit/sub_commands/repo/mod.rs | 358 ++++++++++++++++++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 src/bin/ngit/sub_commands/repo/mod.rs (limited to 'src/bin/ngit/sub_commands/repo/mod.rs') diff --git a/src/bin/ngit/sub_commands/repo/mod.rs b/src/bin/ngit/sub_commands/repo/mod.rs new file mode 100644 index 0000000..62fe766 --- /dev/null +++ b/src/bin/ngit/sub_commands/repo/mod.rs @@ -0,0 +1,358 @@ +pub mod accept; + +use anyhow::{Context, Result}; +use ngit::{ + client::{Params, fetching_with_report, get_repo_ref_from_cache}, + repo_ref::{RepoRef, extract_npub, is_grasp_server_clone_url}, +}; +use nostr::{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, +}; + +pub async fn launch(cli_args: &Cli, repo_command: Option<&RepoCommands>) -> Result<()> { + match repo_command { + Some(RepoCommands::Init(args) | RepoCommands::Edit(args)) => { + init::launch(cli_args, args).await + } + Some(RepoCommands::Accept(args)) => accept::launch(cli_args, args).await, + None => show_info(cli_args).await, + } +} + +// --------------------------------------------------------------------------- +// `ngit repo` (no subcommand) — show repository info +// --------------------------------------------------------------------------- + +async fn show_info(cli_args: &Cli) -> Result<()> { + let git_repo = Repo::discover().context("failed to find a git repository")?; + 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( + &Some(&git_repo), + &extract_signer_cli_arguments(cli_args).unwrap_or(None), + &cli_args.password, + Some(&client), + false, + ) + .await?; + + let repo_coordinate = (try_and_get_repo_coordinates_when_remote_unknown(&git_repo).await).ok(); + + let Some(repo_coordinate) = repo_coordinate else { + println!("no nostr repository found"); + println!(); + println!("use `ngit repo init` to publish this repository to nostr"); + return Ok(()); + }; + + // Fetch latest data from relays + fetching_with_report(git_repo_path, &client, &repo_coordinate).await?; + + let Some(repo_ref) = + (get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinate).await).ok() + else { + println!( + "coordinate found ({}) but no announcement on relays", + repo_coordinate.identifier + ); + println!(); + println!("if you created this repository, run `ngit repo init` to publish an announcement"); + println!("if you are a co-maintainer, run `ngit repo accept` to publish your announcement"); + return Ok(()); + }; + + print_repo_info(&repo_ref, &user_ref.public_key, &repo_coordinate); + Ok(()) +} + +#[allow(clippy::too_many_lines)] +fn print_repo_info(repo_ref: &RepoRef, my_pubkey: &PublicKey, coordinate: &Nip19Coordinate) { + // --- Basic metadata --- + println!("Repository: {}", repo_ref.name); + if !repo_ref.description.is_empty() { + println!("Description: {}", repo_ref.description); + } + if !repo_ref.web.is_empty() { + for url in &repo_ref.web { + println!("Web: {url}"); + } + } + if !repo_ref.hashtags.is_empty() { + println!("Hashtags: {}", repo_ref.hashtags.join(", ")); + } + println!(); + + // --- 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)); + + // 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() + .filter(|m| *m != trusted) + .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(); + 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)); + } + + // 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(", ")); + } + 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) + ); + } + } + } + + // 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(", ")); + } + } + + // --- 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`]" + } + } 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`]" + } + } else { + "not a maintainer" + }; + println!("Your status: {my_status}"); + 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}"); + } + 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}"); + } + 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."); +} + +/// 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. +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, + public_key: *trusted, + identifier: repo_ref.identifier.clone(), + }, + relays: vec![], + }; + if let Some(event) = repo_ref.events.get(&trusted_coord) { + // Parse the event's maintainers tag + let listed_in_trusted: Vec = event + .tags + .iter() + .filter_map(|t| { + if let Some(TagStandard::PublicKey { public_key, .. }) = t.as_standardized() { + Some(*public_key) + } else { + None + } + }) + .collect(); + if listed_in_trusted.contains(target) { + return None; // directly listed by trusted maintainer + } + } + + // 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 + .tags + .iter() + .filter_map(|t| { + if let Some(TagStandard::PublicKey { public_key, .. }) = t.as_standardized() { + Some(*public_key) + } else { + None + } + }) + .collect(); + if maintainers_listed.contains(target) { + let lister_npub = lister.to_bech32().unwrap_or_else(|_| lister.to_hex()); + return Some(lister_npub); + } + } + + 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( + repo_ref: &RepoRef, + server_url: &str, + _coordinate: &Nip19Coordinate, +) -> Vec { + let mut owners = Vec::new(); + for (coord, event) in &repo_ref.events { + if let Ok(event_ref) = RepoRef::try_from((event.clone(), None)) { + if event_ref + .git_server + .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)); + } + } + } + owners +} + +fn find_relay_owners( + repo_ref: &RepoRef, + relay_url: &str, + _coordinate: &Nip19Coordinate, +) -> Vec { + let mut owners = Vec::new(); + for (coord, event) in &repo_ref.events { + if let Ok(event_ref) = RepoRef::try_from((event.clone(), None)) { + if event_ref + .relays + .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)); + } + } + } + owners +} + +/// Shorten an npub for display: show first 8 + "..." + last 4 chars. +fn short_npub(npub: &str) -> String { + if npub.len() <= 16 { + return npub.to_string(); + } + format!("{}...{}", &npub[..12], &npub[npub.len() - 4..]) +} -- cgit v1.2.3