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/cli.rs | 29 +++ src/bin/ngit/main.rs | 3 + src/bin/ngit/sub_commands/mod.rs | 1 + src/bin/ngit/sub_commands/repo/accept.rs | 263 +++++++++++++++++++++++ src/bin/ngit/sub_commands/repo/mod.rs | 358 +++++++++++++++++++++++++++++++ 5 files changed, 654 insertions(+) create mode 100644 src/bin/ngit/sub_commands/repo/accept.rs create mode 100644 src/bin/ngit/sub_commands/repo/mod.rs (limited to 'src/bin') diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs index 037d17f..fa5d906 100644 --- a/src/bin/ngit/cli.rs +++ b/src/bin/ngit/cli.rs @@ -106,6 +106,11 @@ pub enum Commands { /// publish a repository to nostr; signal you are its maintainer accepting /// PRs and issues Init(sub_commands::init::SubCommandArgs), + /// manage repository metadata and maintainership + #[command( + long_about = "manage repository metadata and maintainership\n\nrun without a subcommand to show repository info" + )] + Repo(RepoSubCommandArgs), /// submit PR with advanced options #[command( long_about = "submit PR with advanced options\n\nfor a simpler flow, push a branch with the `pr/` prefix using native git:\n git push -o 'title=My PR' -o 'description=details here' -u origin pr/my-branch" @@ -177,3 +182,27 @@ pub struct AccountSubCommandArgs { #[command(subcommand)] pub account_command: AccountCommands, } + +#[derive(clap::Parser)] +pub struct RepoSubCommandArgs { + #[command(subcommand)] + pub repo_command: Option, +} + +#[derive(Subcommand)] +pub enum RepoCommands { + /// publish a repository to nostr (alias for `ngit init`) + Init(sub_commands::init::SubCommandArgs), + /// update repository metadata on nostr + #[command( + long_about = "update repository metadata on nostr\n\nlike `ngit init` but makes clear you are editing an existing repository" + )] + Edit(sub_commands::init::SubCommandArgs), + /// accept an invitation to co-maintain a repository + #[command(long_about = "accept an invitation to co-maintain a repository\n\n\ + publishes your repository announcement to nostr, confirming your co-maintainership.\n\n\ + This is required because your signed announcement is what ties your git state events\n\ + to a specific repository coordinate chain, preventing scammers from attributing your\n\ + commits to a fake repository. See `ngit repo info` for details on the maintainer model.")] + Accept(sub_commands::repo::accept::SubCommandArgs), +} diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs index c88238a..924a714 100644 --- a/src/bin/ngit/main.rs +++ b/src/bin/ngit/main.rs @@ -49,6 +49,9 @@ async fn main() { } }, Commands::Init(args) => sub_commands::init::launch(&cli, args).await, + Commands::Repo(args) => { + sub_commands::repo::launch(&cli, args.repo_command.as_ref()).await + } Commands::List { status, json, diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs index 6b94248..d132240 100644 --- a/src/bin/ngit/sub_commands/mod.rs +++ b/src/bin/ngit/sub_commands/mod.rs @@ -6,5 +6,6 @@ pub mod init; pub mod list; pub mod login; pub mod logout; +pub mod repo; pub mod send; pub mod sync; diff --git a/src/bin/ngit/sub_commands/repo/accept.rs b/src/bin/ngit/sub_commands/repo/accept.rs new file mode 100644 index 0000000..5564b77 --- /dev/null +++ b/src/bin/ngit/sub_commands/repo/accept.rs @@ -0,0 +1,263 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use ngit::{ + accept_maintainership::{accept_maintainership_with_defaults, wait_for_grasp_servers}, + cli_interactor::cli_error, + client::{Params, fetching_with_report, get_repo_ref_from_cache, send_events}, + repo_ref::{RepoRef, apply_grasp_infrastructure, latest_event_repo_ref}, +}; +use nostr::{ + ToBech32, + nips::{nip01::Coordinate, nip19::Nip19Coordinate}, +}; +use nostr_sdk::{Kind, NostrSigner, RelayUrl}; + +use crate::{ + cli::{Cli, extract_signer_cli_arguments}, + client::{Client, Connect}, + git::{Repo, RepoActions}, + login, + repo_ref::try_and_get_repo_coordinates_when_remote_unknown, +}; + +#[derive(Debug, clap::Args)] +pub struct SubCommandArgs { + #[clap(short, long, value_parser, num_args = 1..)] + /// where your git+nostr data is hosted (optional; uses your saved grasp + /// server list or the trusted maintainer's servers if not specified) + grasp_server: Vec, +} + +pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { + let git_repo = Repo::discover().context("failed to find a git repository")?; + let git_repo_path = git_repo.get_path()?; + let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); + + let (signer, 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 my_pubkey = user_ref.public_key; + + let repo_coordinate = (try_and_get_repo_coordinates_when_remote_unknown(&git_repo).await).ok(); + + let Some(repo_coordinate) = repo_coordinate else { + return Err(cli_error( + "no nostr repository found", + &[], + &["use `ngit repo init` to publish this repository to nostr"], + )); + }; + + // 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 { + return Err(cli_error( + "no announcement found on relays for this repository", + &[], + &[ + "if you created this repository, use `ngit repo init` to publish an announcement", + "if this is a relay or network issue, try again later", + ], + )); + }; + + // Validate state + let trusted = repo_ref.trusted_maintainer; + + if trusted == my_pubkey { + return Err(cli_error( + "you are already the trusted maintainer of this repository", + &[], + &["use `ngit repo edit` to update your announcement"], + )); + } + + let has_announcement = repo_ref + .events + .keys() + .any(|c| c.coordinate.public_key == my_pubkey); + + if has_announcement { + return Err(cli_error( + "you have already published a co-maintainer announcement for this repository", + &[], + &["use `ngit repo edit` to update your announcement"], + )); + } + + if !repo_ref.maintainers.contains(&my_pubkey) { + let trusted_npub = trusted.to_bech32().unwrap_or_else(|_| trusted.to_hex()); + return Err(cli_error( + "you have not been invited as a maintainer of this repository", + &[("trusted maintainer", trusted_npub.as_str())], + &["the trusted maintainer must add your npub to their announcement first"], + )); + } + + // Happy path: CoMaintainer state without an existing announcement + let repo_name = &repo_ref.name; + let trusted_npub = trusted.to_bech32().unwrap_or_else(|_| trusted.to_hex()); + println!("accepting co-maintainership of '{repo_name}' (offered by {trusted_npub})"); + println!("publishing your repository announcement to nostr..."); + + if args.grasp_server.is_empty() { + // Use the existing defaults logic from the library + accept_maintainership_with_defaults(&git_repo, &repo_ref, &user_ref, &mut client, &signer) + .await?; + } else { + // User specified grasp servers explicitly — use them + accept_with_grasp_servers( + &git_repo, + &repo_ref, + &signer, + &user_ref, + &mut client, + &args.grasp_server, + ) + .await?; + } + + println!("co-maintainership accepted."); + println!("your announcement has been published to nostr. you can now push updates."); + println!("run `ngit repo edit` at any time to update your announcement."); + + Ok(()) +} + +/// Accept co-maintainership with explicitly specified grasp servers. +#[allow(clippy::too_many_lines)] +async fn accept_with_grasp_servers( + git_repo: &Repo, + repo_ref: &RepoRef, + signer: &Arc, + user_ref: &ngit::login::user::UserRef, + client: &mut Client, + grasp_servers: &[String], +) -> Result<()> { + let my_pubkey = &user_ref.public_key; + let identifier = &repo_ref.identifier; + + let mut git_servers: Vec = vec![]; + let mut relay_strings: Vec = vec![]; + + apply_grasp_infrastructure( + grasp_servers, + &mut git_servers, + &mut relay_strings, + my_pubkey, + identifier, + )?; + + let relays: Vec = relay_strings + .iter() + .filter_map(|r| RelayUrl::parse(r).ok()) + .collect(); + + let latest = latest_event_repo_ref(repo_ref); + let name = latest + .as_ref() + .map_or_else(|| identifier.clone(), |lr| lr.name.clone()); + let description = latest + .as_ref() + .map(|lr| lr.description.clone()) + .unwrap_or_default(); + let web = latest.as_ref().map(|lr| lr.web.clone()).unwrap_or_default(); + let hashtags = latest + .as_ref() + .map(|lr| lr.hashtags.clone()) + .unwrap_or_default(); + let blossoms = latest + .as_ref() + .map(|lr| lr.blossoms.clone()) + .unwrap_or_default(); + let root_commit = latest + .as_ref() + .map(|lr| lr.root_commit.clone()) + .filter(|c| !c.is_empty()) + .unwrap_or_else(|| repo_ref.root_commit.clone()); + + let mut maintainers = vec![*my_pubkey]; + if repo_ref.trusted_maintainer != *my_pubkey { + maintainers.push(repo_ref.trusted_maintainer); + } + + let my_repo_ref = RepoRef { + identifier: identifier.clone(), + name, + description, + root_commit, + git_server: git_servers, + web, + relays: relays.clone(), + blossoms, + hashtags, + trusted_maintainer: *my_pubkey, + maintainers_without_annoucnement: None, + maintainers, + events: std::collections::HashMap::new(), + nostr_git_url: None, + }; + + let repo_event = my_repo_ref.to_event(signer).await?; + + client.set_signer(signer.clone()).await; + + send_events( + client, + Some(git_repo.get_path()?), + vec![repo_event], + user_ref.relays.write(), + relays.clone(), + true, + false, + ) + .await + .context("failed to publish co-maintainer announcement")?; + + if !grasp_servers.is_empty() { + wait_for_grasp_servers(git_repo, grasp_servers, my_pubkey, identifier).await?; + } + + // Update nostr.repo git config + git_repo + .save_git_config_item( + "nostr.repo", + &Nip19Coordinate { + coordinate: Coordinate { + kind: Kind::GitRepoAnnouncement, + public_key: *my_pubkey, + identifier: identifier.clone(), + }, + relays: vec![], + } + .to_bech32()?, + false, + ) + .context("failed to update nostr.repo git config")?; + + // Update origin remote + let nostr_url = my_repo_ref.to_nostr_git_url(&Some(git_repo)).to_string(); + if git_repo.git_repo.find_remote("origin").is_ok() { + git_repo + .git_repo + .remote_set_url("origin", &nostr_url) + .context("failed to update origin remote")?; + } else { + git_repo + .git_repo + .remote("origin", &nostr_url) + .context("failed to set origin remote")?; + } + + Ok(()) +} 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