upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin/ngit/sub_commands/repo/mod.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-20 21:54:00 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-20 21:54:00 +0000
commit8f1a1743bd4e85e922ec0cc1f050911a28af4cf0 (patch)
tree2d9eb4954395b62c66d1cc937c6e78a060129f3f /src/bin/ngit/sub_commands/repo/mod.rs
parent67f09343c15e6a0e3622811d3eb5e513a8205eda (diff)
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)
Diffstat (limited to 'src/bin/ngit/sub_commands/repo/mod.rs')
-rw-r--r--src/bin/ngit/sub_commands/repo/mod.rs358
1 files changed, 358 insertions, 0 deletions
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 @@
1pub mod accept;
2
3use anyhow::{Context, Result};
4use ngit::{
5 client::{Params, fetching_with_report, get_repo_ref_from_cache},
6 repo_ref::{RepoRef, extract_npub, is_grasp_server_clone_url},
7};
8use nostr::{PublicKey, TagStandard, ToBech32, nips::nip19::Nip19Coordinate};
9
10use crate::{
11 cli::{Cli, RepoCommands, extract_signer_cli_arguments},
12 client::{Client, Connect},
13 git::{Repo, RepoActions},
14 login,
15 repo_ref::try_and_get_repo_coordinates_when_remote_unknown,
16 sub_commands::init,
17};
18
19pub async fn launch(cli_args: &Cli, repo_command: Option<&RepoCommands>) -> Result<()> {
20 match repo_command {
21 Some(RepoCommands::Init(args) | RepoCommands::Edit(args)) => {
22 init::launch(cli_args, args).await
23 }
24 Some(RepoCommands::Accept(args)) => accept::launch(cli_args, args).await,
25 None => show_info(cli_args).await,
26 }
27}
28
29// ---------------------------------------------------------------------------
30// `ngit repo` (no subcommand) — show repository info
31// ---------------------------------------------------------------------------
32
33async fn show_info(cli_args: &Cli) -> Result<()> {
34 let git_repo = Repo::discover().context("failed to find a git repository")?;
35 let git_repo_path = git_repo.get_path()?;
36 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
37
38 let (_, user_ref, _) = login::login_or_signup(
39 &Some(&git_repo),
40 &extract_signer_cli_arguments(cli_args).unwrap_or(None),
41 &cli_args.password,
42 Some(&client),
43 false,
44 )
45 .await?;
46
47 let repo_coordinate = (try_and_get_repo_coordinates_when_remote_unknown(&git_repo).await).ok();
48
49 let Some(repo_coordinate) = repo_coordinate else {
50 println!("no nostr repository found");
51 println!();
52 println!("use `ngit repo init` to publish this repository to nostr");
53 return Ok(());
54 };
55
56 // Fetch latest data from relays
57 fetching_with_report(git_repo_path, &client, &repo_coordinate).await?;
58
59 let Some(repo_ref) =
60 (get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinate).await).ok()
61 else {
62 println!(
63 "coordinate found ({}) but no announcement on relays",
64 repo_coordinate.identifier
65 );
66 println!();
67 println!("if you created this repository, run `ngit repo init` to publish an announcement");
68 println!("if you are a co-maintainer, run `ngit repo accept` to publish your announcement");
69 return Ok(());
70 };
71
72 print_repo_info(&repo_ref, &user_ref.public_key, &repo_coordinate);
73 Ok(())
74}
75
76#[allow(clippy::too_many_lines)]
77fn print_repo_info(repo_ref: &RepoRef, my_pubkey: &PublicKey, coordinate: &Nip19Coordinate) {
78 // --- Basic metadata ---
79 println!("Repository: {}", repo_ref.name);
80 if !repo_ref.description.is_empty() {
81 println!("Description: {}", repo_ref.description);
82 }
83 if !repo_ref.web.is_empty() {
84 for url in &repo_ref.web {
85 println!("Web: {url}");
86 }
87 }
88 if !repo_ref.hashtags.is_empty() {
89 println!("Hashtags: {}", repo_ref.hashtags.join(", "));
90 }
91 println!();
92
93 // --- Maintainers ---
94 let trusted = &repo_ref.trusted_maintainer;
95 let trusted_npub = trusted.to_bech32().unwrap_or_else(|_| trusted.to_hex());
96 println!("Trusted maintainer: {}", short_npub(&trusted_npub));
97
98 // Build a map: pubkey → who listed them (for recursive display)
99 // We walk the events map to find each maintainer's "lister"
100 let co_maintainers: Vec<&PublicKey> = repo_ref
101 .maintainers
102 .iter()
103 .filter(|m| *m != trusted)
104 .collect();
105
106 if !co_maintainers.is_empty() {
107 // For each co-maintainer, find who listed them by inspecting events
108 let mut listed_by: Vec<(String, Option<String>)> = Vec::new();
109 for co in &co_maintainers {
110 let co_npub = co.to_bech32().unwrap_or_else(|_| co.to_hex());
111 // Find which maintainer's event lists this co-maintainer
112 let lister = find_lister(repo_ref, co, trusted);
113 listed_by.push((co_npub, lister));
114 }
115
116 // Print directly-listed co-maintainers first, then indirectly-listed
117 let direct: Vec<_> = listed_by
118 .iter()
119 .filter(|(_, lister)| lister.is_none())
120 .collect();
121 let indirect: Vec<_> = listed_by
122 .iter()
123 .filter(|(_, lister)| lister.is_some())
124 .collect();
125
126 if !direct.is_empty() {
127 let names: Vec<String> = direct.iter().map(|(npub, _)| short_npub(npub)).collect();
128 println!("Co-maintainers: {}", names.join(", "));
129 }
130 for (npub, lister) in &indirect {
131 if let Some(lister_npub) = lister {
132 println!(
133 " └─ {} is listed by {}, not directly by the trusted maintainer",
134 short_npub(npub),
135 short_npub(lister_npub)
136 );
137 }
138 }
139 }
140
141 // Maintainers without announcements
142 if let Some(without) = &repo_ref.maintainers_without_annoucnement {
143 if !without.is_empty() {
144 let names: Vec<String> = without
145 .iter()
146 .map(|pk| {
147 let npub = pk.to_bech32().unwrap_or_else(|_| pk.to_hex());
148 short_npub(&npub)
149 })
150 .collect();
151 println!(" (invited, no announcement yet: {})", names.join(", "));
152 }
153 }
154
155 // --- My status ---
156 let my_status = if my_pubkey == trusted {
157 let has_announcement = repo_ref
158 .events
159 .keys()
160 .any(|c| c.coordinate.public_key == *my_pubkey);
161 if has_announcement {
162 "trusted maintainer [announcement published ✓]"
163 } else {
164 "trusted maintainer [no announcement — run `ngit repo init`]"
165 }
166 } else if repo_ref.maintainers.contains(my_pubkey) {
167 let has_announcement = repo_ref
168 .events
169 .keys()
170 .any(|c| c.coordinate.public_key == *my_pubkey);
171 if has_announcement {
172 "co-maintainer [announcement published ✓]"
173 } else {
174 "co-maintainer [no announcement — run `ngit repo accept`]"
175 }
176 } else {
177 "not a maintainer"
178 };
179 println!("Your status: {my_status}");
180 println!();
181
182 // --- Infrastructure (with per-maintainer attribution) ---
183 println!("Git servers (union across all maintainers — any maintainer can add a mirror):");
184 for server in &repo_ref.git_server {
185 let attribution = attribute_server_to_maintainer(repo_ref, server, coordinate);
186 println!(" {server} {attribution}");
187 }
188 println!();
189
190 println!("Relays (union across all maintainers — any maintainer can add a relay):");
191 for relay in &repo_ref.relays {
192 let attribution = attribute_relay_to_maintainer(repo_ref, relay.as_str(), coordinate);
193 println!(" {relay} {attribution}");
194 }
195 println!();
196
197 // --- Maintainer model note ---
198 println!("Note: git servers and relays are pooled from all maintainers' announcements.");
199 println!(
200 " Name, description, web, and hashtags come from the most recently updated announcement."
201 );
202 println!(" Each maintainer independently decides who they list as co-maintainers;");
203 println!(" if Alice lists Bob and Bob lists Carol, all three are in the maintainer set.");
204}
205
206/// Find which maintainer's event lists `target` as a maintainer.
207/// Returns `None` if listed directly by the trusted maintainer,
208/// or `Some(lister_npub)` if listed by a co-maintainer.
209fn find_lister(repo_ref: &RepoRef, target: &PublicKey, trusted: &PublicKey) -> Option<String> {
210 use nostr::nips::nip01::Coordinate;
211 use nostr_sdk::Kind;
212
213 // Check if the trusted maintainer's event lists this target directly
214 let trusted_coord = nostr::nips::nip19::Nip19Coordinate {
215 coordinate: Coordinate {
216 kind: Kind::GitRepoAnnouncement,
217 public_key: *trusted,
218 identifier: repo_ref.identifier.clone(),
219 },
220 relays: vec![],
221 };
222 if let Some(event) = repo_ref.events.get(&trusted_coord) {
223 // Parse the event's maintainers tag
224 let listed_in_trusted: Vec<PublicKey> = event
225 .tags
226 .iter()
227 .filter_map(|t| {
228 if let Some(TagStandard::PublicKey { public_key, .. }) = t.as_standardized() {
229 Some(*public_key)
230 } else {
231 None
232 }
233 })
234 .collect();
235 if listed_in_trusted.contains(target) {
236 return None; // directly listed by trusted maintainer
237 }
238 }
239
240 // Otherwise find which co-maintainer lists them
241 for (coord, event) in &repo_ref.events {
242 if coord.coordinate.public_key == *trusted {
243 continue;
244 }
245 let lister = coord.coordinate.public_key;
246 let maintainers_listed: Vec<PublicKey> = event
247 .tags
248 .iter()
249 .filter_map(|t| {
250 if let Some(TagStandard::PublicKey { public_key, .. }) = t.as_standardized() {
251 Some(*public_key)
252 } else {
253 None
254 }
255 })
256 .collect();
257 if maintainers_listed.contains(target) {
258 let lister_npub = lister.to_bech32().unwrap_or_else(|_| lister.to_hex());
259 return Some(lister_npub);
260 }
261 }
262
263 None
264}
265
266/// Find which maintainer(s) contribute a given git server URL.
267fn attribute_server_to_maintainer(
268 repo_ref: &RepoRef,
269 server_url: &str,
270 coordinate: &Nip19Coordinate,
271) -> String {
272 // For grasp-format URLs, the npub in the path tells us the owner
273 if is_grasp_server_clone_url(server_url) {
274 if let Ok(npub) = extract_npub(server_url) {
275 return format!("[{}]", short_npub(npub));
276 }
277 }
278
279 // For non-grasp URLs, find which maintainer's event lists it
280 let owners = find_server_owners(repo_ref, server_url, coordinate);
281 if owners.is_empty() {
282 String::new()
283 } else {
284 format!("[{}]", owners.join(", "))
285 }
286}
287
288/// Find which maintainer(s) contribute a given relay URL.
289fn attribute_relay_to_maintainer(
290 repo_ref: &RepoRef,
291 relay_url: &str,
292 coordinate: &Nip19Coordinate,
293) -> String {
294 let owners = find_relay_owners(repo_ref, relay_url, coordinate);
295 if owners.is_empty() {
296 String::new()
297 } else {
298 format!("[{}]", owners.join(", "))
299 }
300}
301
302fn find_server_owners(
303 repo_ref: &RepoRef,
304 server_url: &str,
305 _coordinate: &Nip19Coordinate,
306) -> Vec<String> {
307 let mut owners = Vec::new();
308 for (coord, event) in &repo_ref.events {
309 if let Ok(event_ref) = RepoRef::try_from((event.clone(), None)) {
310 if event_ref
311 .git_server
312 .iter()
313 .any(|s| s.trim_end_matches('/') == server_url.trim_end_matches('/'))
314 {
315 let npub = coord
316 .coordinate
317 .public_key
318 .to_bech32()
319 .unwrap_or_else(|_| coord.coordinate.public_key.to_hex());
320 owners.push(short_npub(&npub));
321 }
322 }
323 }
324 owners
325}
326
327fn find_relay_owners(
328 repo_ref: &RepoRef,
329 relay_url: &str,
330 _coordinate: &Nip19Coordinate,
331) -> Vec<String> {
332 let mut owners = Vec::new();
333 for (coord, event) in &repo_ref.events {
334 if let Ok(event_ref) = RepoRef::try_from((event.clone(), None)) {
335 if event_ref
336 .relays
337 .iter()
338 .any(|r| r.as_str().trim_end_matches('/') == relay_url.trim_end_matches('/'))
339 {
340 let npub = coord
341 .coordinate
342 .public_key
343 .to_bech32()
344 .unwrap_or_else(|_| coord.coordinate.public_key.to_hex());
345 owners.push(short_npub(&npub));
346 }
347 }
348 }
349 owners
350}
351
352/// Shorten an npub for display: show first 8 + "..." + last 4 chars.
353fn short_npub(npub: &str) -> String {
354 if npub.len() <= 16 {
355 return npub.to_string();
356 }
357 format!("{}...{}", &npub[..12], &npub[npub.len() - 4..])
358}