upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin/ngit/sub_commands/init.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-10 13:10:18 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-10 13:10:18 +0000
commit1e7aeb4d7972d29c6586df18128a8a4f7667845a (patch)
tree0f7e5fcaa5a005aeec7ae2d9f35b2c473ef8f785 /src/bin/ngit/sub_commands/init.rs
parentd2412565334f48bd31e57d29d7959c24258ccd98 (diff)
parentaae452697d152694a8f163219f707356e84b420b (diff)
Make ngit non-interactive by default
Implements non-interactive mode as the default behavior for ngit. Users must now use -i flag for interactive prompts, or provide all required arguments explicitly. Adds -d flag for sensible defaults and -f flag for force operations. Changes: - CLI interactor infrastructure supports non-interactive mode - Global flags: -i (interactive), --defaults (use defaults), -f (force) - ngit init: requires --name or --identifier, supports --defaults - ngit account: new signup command, login supports non-interactive - ngit send: validates required fields, supports --defaults - git-remote-nostr: fixed to prevent interactive prompts during push - Comprehensive test coverage: 234 unit tests + integration tests
Diffstat (limited to 'src/bin/ngit/sub_commands/init.rs')
-rw-r--r--src/bin/ngit/sub_commands/init.rs1759
1 files changed, 1171 insertions, 588 deletions
diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs
index 39fe670..827acf8 100644
--- a/src/bin/ngit/sub_commands/init.rs
+++ b/src/bin/ngit/sub_commands/init.rs
@@ -3,6 +3,7 @@ use std::{
3 env, 3 env,
4 process::{Command, Stdio}, 4 process::{Command, Stdio},
5 str::FromStr, 5 str::FromStr,
6 sync::Arc,
6 thread, 7 thread,
7 time::Duration, 8 time::Duration,
8}; 9};
@@ -13,7 +14,7 @@ use git2::Oid;
13use ngit::{ 14use ngit::{
14 UrlWithoutSlash, 15 UrlWithoutSlash,
15 cli_interactor::{ 16 cli_interactor::{
16 PromptChoiceParms, PromptConfirmParms, multi_select_with_custom_value, 17 PromptChoiceParms, PromptConfirmParms, cli_error, multi_select_with_custom_value,
17 show_multi_input_prompt_success, 18 show_multi_input_prompt_success,
18 }, 19 },
19 client::{Params, get_state_from_cache, send_events}, 20 client::{Params, get_state_from_cache, send_events},
@@ -44,112 +45,638 @@ use crate::{
44 }, 45 },
45}; 46};
46 47
48// ---------------------------------------------------------------------------
49// InitState: determines what scenario we're in
50// ---------------------------------------------------------------------------
51
52enum InitState {
53 /// No coordinate found anywhere (State A)
54 Fresh,
55 /// Coordinate found but no announcement event on relays (State B)
56 CoordinateOnly { coordinate: Nip19Coordinate },
57 /// Announcement exists, I am the trusted maintainer (State C)
58 MyAnnouncement {
59 coordinate: Nip19Coordinate,
60 repo_ref: RepoRef,
61 },
62 /// Announcement exists, I'm in the maintainer set (State D)
63 CoMaintainer {
64 coordinate: Nip19Coordinate,
65 repo_ref: RepoRef,
66 },
67 /// Announcement exists, I'm not in the maintainer set (State E)
68 NotListed {
69 coordinate: Nip19Coordinate,
70 repo_ref: RepoRef,
71 },
72}
73
74impl InitState {
75 fn coordinate(&self) -> Option<&Nip19Coordinate> {
76 match self {
77 Self::Fresh => None,
78 Self::CoordinateOnly { coordinate }
79 | Self::MyAnnouncement { coordinate, .. }
80 | Self::CoMaintainer { coordinate, .. }
81 | Self::NotListed { coordinate, .. } => Some(coordinate),
82 }
83 }
84
85 fn repo_ref(&self) -> Option<&RepoRef> {
86 match self {
87 Self::Fresh | Self::CoordinateOnly { .. } => None,
88 Self::MyAnnouncement { repo_ref, .. }
89 | Self::CoMaintainer { repo_ref, .. }
90 | Self::NotListed { repo_ref, .. } => Some(repo_ref),
91 }
92 }
93
94 /// Extract my own announcement's `RepoRef` from the events map.
95 /// Returns `None` if no coordinate, no announcement, or I have no event.
96 fn my_repo_ref(&self, my_pubkey: &PublicKey) -> Option<RepoRef> {
97 self.repo_ref()
98 .and_then(|rr| my_event_repo_ref(rr, my_pubkey))
99 }
100
101 fn has_coordinate(&self) -> bool {
102 !matches!(self, Self::Fresh)
103 }
104}
105
106struct ResolvedFields {
107 identifier: String,
108 name: String,
109 description: String,
110 git_servers: Vec<String>,
111 relays: Vec<RelayUrl>,
112 blossoms: Vec<Url>,
113 web: Vec<String>,
114 maintainers: Vec<PublicKey>,
115 earliest_unique_commit: String,
116 hashtags: Vec<String>,
117 selected_grasp_servers: Vec<String>,
118}
119
120/// Extract my own announcement's `RepoRef` from the events map.
121fn my_event_repo_ref(repo_ref: &RepoRef, my_pubkey: &PublicKey) -> Option<RepoRef> {
122 repo_ref
123 .events
124 .values()
125 .find(|e| e.pubkey == *my_pubkey)
126 .and_then(|e| RepoRef::try_from((e.clone(), None)).ok())
127}
128
129/// Find the latest event (by `created_at`) across all maintainer events and
130/// parse it into a `RepoRef` for shared metadata (name, description, web).
131fn latest_event_repo_ref(repo_ref: &RepoRef) -> Option<RepoRef> {
132 repo_ref
133 .events
134 .values()
135 .max_by_key(|e| e.created_at)
136 .and_then(|e| RepoRef::try_from((e.clone(), None)).ok())
137}
138
139/// Check if a grasp-format clone URL belongs to the given public key.
140fn is_my_grasp_clone_url(url: &str, my_pubkey: &PublicKey) -> bool {
141 if !is_grasp_server_clone_url(url) {
142 return false;
143 }
144 if let Ok(npub) = extract_npub(url) {
145 if let Ok(url_pk) = PublicKey::from_bech32(npub) {
146 return url_pk == *my_pubkey;
147 }
148 }
149 false
150}
151
152/// Check if a relay URL corresponds to one of the given grasp servers.
153fn is_grasp_derived_relay(relay: &str, grasp_servers: &[String]) -> bool {
154 let Ok(relay_normalized) = normalize_grasp_server_url(relay) else {
155 return false;
156 };
157 grasp_servers.iter().any(|gs| {
158 normalize_grasp_server_url(gs).is_ok_and(|gs_normalized| gs_normalized == relay_normalized)
159 })
160}
161
162/// Check if a blossom URL corresponds to one of the given grasp servers.
163fn is_grasp_derived_blossom(blossom: &str, grasp_servers: &[String]) -> bool {
164 // Blossom URLs are https://{grasp_server} — same normalization as relays
165 is_grasp_derived_relay(blossom, grasp_servers)
166}
167
168fn dir_name_fallback() -> String {
169 env::current_dir()
170 .ok()
171 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
172 .unwrap_or_default()
173}
174
175fn identifier_from_name(name: &str) -> String {
176 name.replace(' ', "-")
177 .chars()
178 .map(|c| {
179 if c.is_ascii_alphanumeric() || c.eq(&'/') {
180 c
181 } else {
182 '-'
183 }
184 })
185 .collect()
186}
187
188fn build_gitworkshop_url(
189 public_key: &PublicKey,
190 identifier: &str,
191 first_relay: Option<&RelayUrl>,
192) -> String {
193 NostrUrlDecoded {
194 original_string: String::new(),
195 coordinate: Nip19Coordinate {
196 coordinate: Coordinate {
197 public_key: *public_key,
198 kind: Kind::GitRepoAnnouncement,
199 identifier: identifier.to_string(),
200 },
201 relays: first_relay.into_iter().cloned().collect(),
202 },
203 protocol: None,
204 ssh_key_file: None,
205 nip05: None,
206 }
207 .to_string()
208 .replace("nostr://", "https://gitworkshop.dev/")
209}
210
211/// Resolve the `web` field from args, existing announcement, or gitworkshop
212/// default.
213fn resolve_web(
214 args_web: &[String],
215 state: &InitState,
216 identifier: &str,
217 gitworkshop_url: &str,
218) -> Vec<String> {
219 if !args_web.is_empty() {
220 return args_web.to_vec();
221 }
222 if let Some(rr) = state.repo_ref() {
223 let latest_web = latest_event_repo_ref(rr).map_or_else(|| rr.web.clone(), |lr| lr.web);
224 let joined = latest_web.join(" ");
225 // replace legacy gitworkshop.dev url format
226 if joined.contains(&format!("https://gitworkshop.dev/repo/{identifier}")) {
227 return vec![gitworkshop_url.to_string()];
228 }
229 return latest_web;
230 }
231 vec![gitworkshop_url.to_string()]
232}
233
234/// Derive clone-urls, relays, and blossoms from selected grasp servers.
235///
236/// For each grasp server, adds/replaces the corresponding clone URL in
237/// `git_servers`, adds a relay URL to `relays`, and adds a blossom URL to
238/// `blossoms`. Grasp-derived infrastructure is always added — the other
239/// lists (`git_servers`, `relays`, `blossoms`) contain *additional*
240/// infrastructure beyond what grasp servers provide.
241fn apply_grasp_infrastructure(
242 grasp_servers: &[String],
243 git_servers: &mut Vec<String>,
244 relays: &mut Vec<String>,
245 blossoms: &mut Vec<String>,
246 public_key: &PublicKey,
247 identifier: &str,
248) -> Result<()> {
249 for grasp_server in grasp_servers {
250 // Always add grasp-derived clone URL
251 let clone_url = format_grasp_server_url_as_clone_url(grasp_server, public_key, identifier)?;
252
253 let grasp_server_clone_root = if clone_url.contains("https://") {
254 format!("https://{grasp_server}")
255 } else {
256 grasp_server.to_string()
257 };
258
259 let matching_positions: Vec<usize> = git_servers
260 .iter()
261 .enumerate()
262 .filter_map(|(idx, url)| {
263 if url.contains(&grasp_server_clone_root) {
264 Some(idx)
265 } else {
266 None
267 }
268 })
269 .collect();
270
271 if matching_positions.is_empty() {
272 git_servers.push(clone_url);
273 } else {
274 git_servers[matching_positions[0]] = clone_url;
275 for &position in matching_positions.iter().skip(1).rev() {
276 git_servers.remove(position);
277 }
278 }
279
280 // Always add grasp-derived relay
281 let relay_url = format_grasp_server_url_as_relay_url(grasp_server)?;
282 if !relays.contains(&relay_url) {
283 relays.push(relay_url);
284 }
285
286 // Always add grasp-derived blossom
287 let blossom = format_grasp_server_url_as_blossom_url(grasp_server)?;
288 if !blossoms.contains(&blossom) {
289 blossoms.push(blossom);
290 }
291 }
292 Ok(())
293}
294
295/// Resolve which grasp servers to use. Handles flag overrides, detection from
296/// existing URLs, user grasp list / system fallbacks, and interactive
297/// prompting.
298fn resolve_grasp_servers(
299 args: &SubCommandArgs,
300 cli: &Cli,
301 state: &InitState,
302 user_ref: &ngit::login::user::UserRef,
303 client: &Client,
304 identifier: &str,
305 interactive: bool,
306) -> Result<Vec<String>> {
307 if !args.grasp_servers.is_empty() {
308 return Ok(args.grasp_servers.clone());
309 }
310
311 let has_both_relays_and_clone_url = !args.relays.is_empty() && !args.clone.is_empty();
312 if has_both_relays_and_clone_url {
313 return Ok(vec![]);
314 }
315
316 // Use my own announcement (not the consolidated union) for grasp detection.
317 // Infrastructure is personal — each maintainer has their own servers.
318 let my_ref = state.my_repo_ref(&user_ref.public_key);
319
320 if !args.clone.is_empty() {
321 return Ok(detect_existing_grasp_servers(
322 my_ref.as_ref(),
323 &args.relays,
324 &args.clone,
325 identifier,
326 ));
327 }
328
329 if !interactive || cli.defaults || state.has_coordinate() || cli.force {
330 // Prefer grasp servers from my existing announcement, then user's grasp
331 // list, then system fallbacks
332 let existing =
333 detect_existing_grasp_servers(my_ref.as_ref(), &args.relays, &[], identifier);
334 if !existing.is_empty() {
335 return Ok(existing);
336 }
337 return Ok(grasp_servers_from_user_or_fallback(user_ref, client));
338 }
339
340 // Interactive prompt
341 let mut options: Vec<String> =
342 detect_existing_grasp_servers(my_ref.as_ref(), &args.relays, &args.clone, identifier);
343 let mut selections: Vec<bool> = vec![true; options.len()];
344 let empty = options.is_empty();
345 for user_grasp_option in &user_ref.grasp_list.urls {
346 if !options
347 .iter()
348 .any(|option| option.contains(user_grasp_option.as_str()))
349 {
350 options.push(user_grasp_option.to_string());
351 selections.push(empty);
352 }
353 }
354 let empty = options.is_empty();
355 let fallback_grasp_servers = client.get_grasp_default_set();
356 for fallback in fallback_grasp_servers {
357 if !options.iter().any(|option| option.contains(fallback)) {
358 options.push(fallback.clone());
359 selections.push(empty);
360 }
361 }
362 let selected = multi_select_with_custom_value(
363 "grasp servers (ideally use between 2-4)",
364 "grasp server",
365 options,
366 selections,
367 normalize_grasp_server_url,
368 )?;
369 show_multi_input_prompt_success("grasp servers", &selected);
370 Ok(selected)
371}
372
373fn grasp_servers_from_user_or_fallback(
374 user_ref: &ngit::login::user::UserRef,
375 client: &Client,
376) -> Vec<String> {
377 if user_ref.grasp_list.urls.is_empty() {
378 client
379 .get_grasp_default_set()
380 .iter()
381 .map(std::string::ToString::to_string)
382 .collect()
383 } else {
384 user_ref
385 .grasp_list
386 .urls
387 .iter()
388 .map(std::string::ToString::to_string)
389 .collect()
390 }
391}
392
393// ---------------------------------------------------------------------------
394// Validation
395// ---------------------------------------------------------------------------
396
397/// Validation for State A (Fresh): no existing coordinate.
398fn validate_fresh(cli: &Cli, args: &SubCommandArgs, user_has_grasp_list: bool) -> Result<()> {
399 // -d or -f with no substantive flags: proceed with all defaults
400 if !args.has_substantive_flags() && (cli.defaults || cli.force) {
401 return Ok(());
402 }
403
404 // Substantive flags provided: -d fills any gaps
405 if cli.defaults {
406 return Ok(());
407 }
408
409 // Validate essential fields
410 let mut missing: Vec<(&str, &str)> = Vec::new();
411
412 let missing_name = args.identifier.is_none() && args.name.is_none();
413 if missing_name {
414 missing.push(("--name <NAME>", "repository name or identifier"));
415 }
416
417 let has_grasp_servers = !args.grasp_servers.is_empty();
418 let has_both_relays_and_clone_url = !args.relays.is_empty() && !args.clone.is_empty();
419 let missing_servers =
420 !has_grasp_servers && !user_has_grasp_list && !has_both_relays_and_clone_url;
421 if missing_servers {
422 missing.push((
423 "--grasp-servers <URL>...",
424 "where your git+nostr data is hosted",
425 ));
426 }
427
428 if missing.is_empty() {
429 return Ok(());
430 }
431
432 let message = if missing.len() == 1 {
433 let (flag, desc) = missing[0];
434 format!("missing {flag} ({desc})")
435 } else {
436 "missing required fields".to_string()
437 };
438
439 let mut details: Vec<(&str, &str)> = if missing.len() > 1 {
440 missing.clone()
441 } else {
442 vec![]
443 };
444
445 details.push(("-d, --defaults", "or just use sensible defaults"));
446 let name_part = if missing_name {
447 " --name \"My Project\""
448 } else {
449 ""
450 };
451 let suggestion =
452 format!("ngit init{name_part} --description \"my project description\" --defaults");
453
454 Err(cli_error(&message, &details, &[&suggestion]))
455}
456
47#[derive(Debug, clap::Args)] 457#[derive(Debug, clap::Args)]
48pub struct SubCommandArgs { 458pub struct SubCommandArgs {
49 #[clap(short, long)] 459 #[clap(long)]
50 /// name of repository 460 /// name of repository (preferred over --identifier)
51 title: Option<String>, 461 name: Option<String>,
52 #[clap(short, long)] 462 #[clap(long)]
463 /// shortname with no spaces or special characters
464 identifier: Option<String>,
465 #[clap(long)]
53 /// optional description 466 /// optional description
54 description: Option<String>, 467 description: Option<String>,
55 #[clap(long)]
56 /// git server url users can clone from
57 clone_url: Vec<String>,
58 #[clap(short, long, value_parser, num_args = 1..)] 468 #[clap(short, long, value_parser, num_args = 1..)]
59 /// homepage 469 /// where your git+nostr data is hosted
60 web: Vec<String>, 470 grasp_servers: Vec<String>,
61 #[clap(short, long, value_parser, num_args = 1..)] 471 #[clap(long, value_parser, num_args = 1..)]
62 /// relays contributors push patches and comments to 472 /// additional relays beyond grasp servers
63 relays: Vec<String>, 473 relays: Vec<String>,
64 #[clap(short, long, value_parser, num_args = 1..)] 474 #[clap(long)]
65 /// blossom servers 475 /// additional git server URLs beyond grasp servers
476 clone: Vec<String>,
477 #[clap(long, value_parser, num_args = 1..)]
478 /// additional blossom servers beyond grasp servers
66 blossoms: Vec<String>, 479 blossoms: Vec<String>,
67 #[clap(short, long, value_parser, num_args = 1..)] 480 #[clap(long, value_parser, num_args = 1..)]
481 /// homepage
482 web: Vec<String>,
483 #[clap(long, value_parser, num_args = 1..)]
68 /// npubs of other maintainers 484 /// npubs of other maintainers
69 other_maintainers: Vec<String>, 485 other_maintainers: Vec<String>,
70 #[clap(long)] 486 #[clap(long)]
71 /// usually root commit but will be more recent commit for forks 487 /// usually root commit but will be more recent commit for forks
72 earliest_unique_commit: Option<String>, 488 earliest_unique_commit: Option<String>,
73 #[clap(short, long)]
74 /// shortname with no spaces or special characters
75 identifier: Option<String>,
76} 489}
77 490
78#[allow(clippy::too_many_lines)] 491impl SubCommandArgs {
79pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { 492 fn has_substantive_flags(&self) -> bool {
80 let git_repo = Repo::discover().context("failed to find a git repository")?; 493 self.name.is_some()
81 let git_repo_path = git_repo.get_path()?; 494 || self.identifier.is_some()
495 || self.description.is_some()
496 || !self.clone.is_empty()
497 || !self.relays.is_empty()
498 || !self.grasp_servers.is_empty()
499 || !self.web.is_empty()
500 || !self.blossoms.is_empty()
501 || !self.other_maintainers.is_empty()
502 || self.earliest_unique_commit.is_some()
503 }
504}
82 505
83 let root_commit = git_repo 506// ---------------------------------------------------------------------------
84 .get_root_commit() 507// Pre/post-fetch validation
85 .context("failed to get root commit of the repository")?; 508// ---------------------------------------------------------------------------
509
510fn validate_pre_fetch(
511 cli: &Cli,
512 args: &SubCommandArgs,
513 repo_coordinate: Option<&Nip19Coordinate>,
514 user_has_grasp_list: bool,
515) -> Result<()> {
516 // Interactive mode bypasses pre-fetch validation
517 if cli.interactive {
518 return Ok(());
519 }
86 520
87 // TODO: check for empty repo 521 // If no coordinate exists, we're in State A (Fresh) - validate now
88 // TODO: check for existing maintaiers file 522 if repo_coordinate.is_none() {
523 return validate_fresh(cli, args, user_has_grasp_list);
524 }
89 525
90 let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); 526 // Coordinate exists - we need to fetch before we can validate further
527 Ok(())
528}
91 529
92 let repo_coordinate = (try_and_get_repo_coordinates_when_remote_unknown(&git_repo).await).ok(); 530fn validate_post_fetch(cli: &Cli, args: &SubCommandArgs, state: &InitState) -> Result<()> {
531 // Interactive mode bypasses all validation
532 if cli.interactive {
533 return Ok(());
534 }
93 535
94 let repo_ref = if let Some(repo_coordinate) = &repo_coordinate { 536 match state {
95 fetching_with_report(git_repo_path, &client, repo_coordinate).await?; 537 InitState::Fresh => {
96 (get_repo_ref_from_cache(Some(git_repo_path), repo_coordinate).await).ok() 538 // Already validated in pre-fetch
539 Ok(())
540 }
541 InitState::CoordinateOnly { coordinate } => {
542 if cli.force {
543 Ok(())
544 } else {
545 let id = &coordinate.identifier;
546 Err(cli_error(
547 &format!(
548 "no announcement found for coordinate '{id}'\n\n\
549 \x20 This could be a relay or network issue. Only proceed with --force\n\
550 \x20 if you are sure there isn't an existing announcement event."
551 ),
552 &[],
553 &["ngit init --force"],
554 ))
555 }
556 }
557 InitState::MyAnnouncement { repo_ref, .. } => {
558 if let Some(new_id) = &args.identifier {
559 if *new_id != repo_ref.identifier && !cli.force {
560 let suggestion = format!("ngit init --identifier {new_id} --force");
561 return Err(cli_error(
562 "changing identifier creates a new repository",
563 &[],
564 &[&suggestion],
565 ));
566 }
567 }
568 if !args.has_substantive_flags() && !cli.force {
569 return Err(cli_error(
570 "no arguments specified, use --force to publish with new timestamp",
571 &[],
572 &["ngit init --force"],
573 ));
574 }
575 Ok(())
576 }
577 InitState::CoMaintainer { repo_ref, .. } => {
578 if let Some(new_id) = &args.identifier {
579 if *new_id != repo_ref.identifier && !cli.force {
580 let suggestion = format!("ngit init --identifier {new_id} --force");
581 return Err(cli_error(
582 "changing identifier creates a new repository",
583 &[],
584 &[&suggestion],
585 ));
586 }
587 }
588 Ok(())
589 }
590 InitState::NotListed { .. } => {
591 if cli.force {
592 Ok(())
593 } else {
594 Err(cli_error(
595 "you are not listed as a maintainer",
596 &[],
597 &["ngit init --force"],
598 ))
599 }
600 }
601 }
602}
603
604#[allow(clippy::too_many_lines)]
605#[allow(clippy::too_many_arguments)]
606fn resolve_fields(
607 state: &InitState,
608 user_ref: &ngit::login::user::UserRef,
609 args: &SubCommandArgs,
610 cli: &Cli,
611 git_repo: &Repo,
612 root_commit: &str,
613 client: &Client,
614 repo_config_result: &Result<ngit::repo_ref::RepoConfigYaml>,
615 interactive: bool,
616) -> Result<ResolvedFields> {
617 let my_pubkey = &user_ref.public_key;
618
619 // Shared lookups used by multiple fields below
620 let latest = state.repo_ref().and_then(latest_event_repo_ref);
621 let my_ref = state.my_repo_ref(my_pubkey);
622
623 // --- Identifier default ---
624 let identifier_default = if let Some(coord) = state.coordinate() {
625 coord.identifier.clone()
626 } else if let Ok(config) = repo_config_result {
627 if let Some(id) = &config.identifier {
628 id.clone()
629 } else {
630 dir_name_fallback()
631 }
97 } else { 632 } else {
98 None 633 dir_name_fallback()
99 }; 634 };
100 635
101 let (signer, user_ref, _) = login::login_or_signup( 636 // --- Name ---
102 &Some(&git_repo), 637 let name_default = if let Some(ref lr) = latest {
103 &extract_signer_cli_arguments(cli_args).unwrap_or(None), 638 lr.name.clone()
104 &cli_args.password, 639 } else if let Some(coord) = state.coordinate() {
105 Some(&client), 640 coord.identifier.clone()
106 true, 641 } else {
107 ) 642 dir_name_fallback()
108 .await?; 643 };
109
110 let repo_config_result = get_repo_config_from_yaml(&git_repo);
111 // TODO: check for other claims
112 644
113 let name = match &args.title { 645 let name = if let Some(v) = &args.name {
114 Some(t) => t.clone(), 646 v.clone()
115 None => Interactor::default().input( 647 } else if interactive {
648 Interactor::default().input(
116 PromptInputParms::default() 649 PromptInputParms::default()
117 .with_prompt("repo name") 650 .with_prompt("repo name")
118 .with_default(if let Some(repo_ref) = &repo_ref { 651 .with_default(name_default.clone())
119 repo_ref.name.clone() 652 .with_flag_name("--name"),
120 } else if let Some(coordinate) = &repo_coordinate { 653 )?
121 coordinate.identifier.clone() 654 } else {
122 } else if let Ok(path) = env::current_dir() { 655 name_default.clone()
123 if let Some(current_dir_name) = path.file_name() {
124 current_dir_name.to_string_lossy().to_string()
125 } else {
126 String::new()
127 }
128 } else {
129 String::new()
130 }),
131 )?,
132 }; 656 };
133 657
134 let description = match &args.description { 658 // --- Description ---
135 Some(t) => t.clone(), 659 let description_default = latest
136 None => Interactor::default().input( 660 .as_ref()
661 .map_or_else(String::new, |lr| lr.description.clone());
662
663 let description = if let Some(v) = &args.description {
664 v.clone()
665 } else if interactive {
666 Interactor::default().input(
137 PromptInputParms::default() 667 PromptInputParms::default()
138 .with_prompt("repo description (one sentance)") 668 .with_prompt("repo description (one sentence)")
139 .optional() 669 .optional()
140 .with_default(if let Some(repo_ref) = &repo_ref { 670 .with_default(description_default.clone())
141 repo_ref.description.clone() 671 .with_flag_name("--description"),
142 } else { 672 )?
143 String::new() 673 } else {
144 }), 674 description_default
145 )?,
146 }; 675 };
147 676
148 // this is important so init can be completed done without prompts 677 // --- Simple mode (interactive only) ---
149 let has_server_and_relay_flags = !args.clone_url.is_empty() && !args.relays.is_empty(); 678 let simple_mode = if !interactive || (!args.clone.is_empty() && !args.relays.is_empty()) {
150 679 false // not used in non-interactive, but avoids Option
151 let simple_mode = if has_server_and_relay_flags {
152 false
153 } else { 680 } else {
154 Interactor::default().choice( 681 Interactor::default().choice(
155 PromptChoiceParms::default() 682 PromptChoiceParms::default()
@@ -162,216 +689,142 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
162 )? == 0 689 )? == 0
163 }; 690 };
164 691
165 let identifier_default = if let Some(repo_ref) = &repo_ref { 692 // --- Identifier ---
166 repo_ref.identifier.clone() 693 let identifier = if let Some(id) = &args.identifier {
167 } else if let Some(repo_coordinate) = &repo_coordinate { 694 id.clone()
168 repo_coordinate.identifier.clone() 695 } else if state.has_coordinate() {
169 } else { 696 identifier_default.clone()
170 let fallback = name 697 } else if !interactive || cli.defaults {
171 .clone() 698 if args.name.is_some() && !state.has_coordinate() {
172 .replace(' ', "-") 699 identifier_from_name(&name)
173 .chars()
174 .map(|c| {
175 if c.is_ascii_alphanumeric() || c.eq(&'/') {
176 c
177 } else {
178 '-'
179 }
180 })
181 .collect();
182 if let Ok(config) = &repo_config_result {
183 if let Some(identifier) = &config.identifier {
184 identifier.to_string()
185 } else {
186 fallback
187 }
188 } else { 700 } else {
189 fallback 701 identifier_default.clone()
190 } 702 }
703 } else {
704 let id_default = if args.name.is_some() || name != name_default {
705 identifier_from_name(&name)
706 } else {
707 identifier_default.clone()
708 };
709 Interactor::default().input(
710 PromptInputParms::default()
711 .with_prompt("repo identifier")
712 .with_default(id_default)
713 .with_flag_name("--identifier"),
714 )?
191 }; 715 };
192 716
193 let identifier = match &args.identifier { 717 // --- Grasp servers ---
194 Some(t) => t.clone(), 718 let selected_grasp_servers =
195 None => { 719 resolve_grasp_servers(args, cli, state, user_ref, client, &identifier, interactive)?;
196 if simple_mode { 720
197 identifier_default 721 // --- Base infrastructure (flag > my event > fallback) ---
722 // Grasp-derived infrastructure (my clone URLs, relays, blossoms) is handled
723 // by apply_grasp_infrastructure below. Defaults here are *additional*
724 // infrastructure only. My own grasp-format clone URLs are filtered out so
725 // they get re-derived from the resolved grasp servers. Grasp-format clone
726 // URLs belonging to other maintainers are kept as additional git servers.
727 let no_state = git_repo
728 .get_git_config_item("nostr.nostate", None)
729 .ok()
730 .flatten()
731 .is_some_and(|s| s == "true");
732
733 // Detect my grasp servers from my existing announcement (for filtering)
734 let my_existing_grasp_servers: Vec<String> = my_ref
735 .as_ref()
736 .map(|mr| detect_existing_grasp_servers(Some(mr), &[], &[], &identifier))
737 .unwrap_or_default();
738
739 let git_servers_default = if let Some(ref mr) = my_ref {
740 // Keep non-grasp URLs and grasp URLs from other maintainers;
741 // filter out my own grasp-derived clone URLs (re-derived from grasp servers)
742 mr.git_server
743 .iter()
744 .filter(|url| !is_my_grasp_clone_url(url, my_pubkey))
745 .cloned()
746 .collect()
747 } else if no_state {
748 // Only fall back to origin URL when nostate is set (user pushes directly
749 // to a traditional git server rather than through grasp servers)
750 if let Ok(url) = git_repo.get_origin_url() {
751 if let Ok(fetch_url) = convert_clone_url_to_https(&url) {
752 vec![fetch_url]
753 } else if url.starts_with("nostr://") {
754 vec![]
198 } else { 755 } else {
199 Interactor::default().input( 756 vec![url]
200 PromptInputParms::default()
201 .with_prompt(
202 "repo identifier (typically the short name with hypens instead of spaces)",
203 )
204 .with_default(identifier_default),
205 )?
206 } 757 }
207 }
208 };
209
210 let mut git_server_defaults: Vec<String> = if !args.clone_url.is_empty() {
211 args.clone_url.clone()
212 } else if let Some(repo_ref) = &repo_ref {
213 // TODO dont default to git servers of other maintainers (?)
214 repo_ref.git_server.clone()
215 } else if let Ok(url) = git_repo.get_origin_url() {
216 if let Ok(fetch_url) = convert_clone_url_to_https(&url) {
217 vec![fetch_url]
218 } else if url.starts_with("nostr://") {
219 // nostr added as origin remote before repo announcement sent
220 vec![]
221 } else { 758 } else {
222 // local repo or custom protocol 759 vec![]
223 vec![url]
224 } 760 }
225 } else { 761 } else {
226 vec![] 762 vec![]
227 }; 763 };
228 764
229 let mut relay_defaults = if args.relays.is_empty() { 765 let relays_default = if let Some(ref mr) = my_ref {
230 if let Ok(config) = &repo_config_result { 766 // Keep relays that don't correspond to my grasp servers
231 config.relays.clone() 767 // (grasp-derived relays are re-added by apply_grasp_infrastructure)
232 } else if let Some(repo_ref) = &repo_ref { 768 mr.relays
233 repo_ref 769 .iter()
234 .relays 770 .map(std::string::ToString::to_string)
235 .iter() 771 .filter(|r| !is_grasp_derived_relay(r, &my_existing_grasp_servers))
236 .map(std::string::ToString::to_string) 772 .collect()
237 .collect::<Vec<String>>() 773 } else if let Ok(config) = repo_config_result {
238 } else { 774 if config.relays.is_empty() {
239 client.get_relay_default_set().clone() 775 client.get_relay_default_set().clone()
776 } else {
777 config.relays.clone()
240 } 778 }
241 } else { 779 } else {
242 args.relays.clone() 780 client.get_relay_default_set().clone()
243 }; 781 };
244 782
245 let mut blossoms_defaults = if args.blossoms.is_empty() { 783 let blossoms_default: Vec<String> = if let Some(ref mr) = my_ref {
246 if let Some(repo_ref) = &repo_ref { 784 // Keep blossoms that don't correspond to my grasp servers
247 repo_ref 785 mr.blossoms
248 .blossoms 786 .iter()
249 .iter() 787 .map(UrlWithoutSlash::to_string_without_trailing_slash)
250 .map(UrlWithoutSlash::to_string_without_trailing_slash) 788 .filter(|b| !is_grasp_derived_blossom(b, &my_existing_grasp_servers))
251 .collect::<Vec<String>>() 789 .collect()
252 // } else if user_ref.blossoms.read().is_empty() {
253 // client.get_fallback_relays().clone()
254 } else {
255 vec![]
256 // user_ref.relays.read().clone()
257 }
258 } else { 790 } else {
259 args.blossoms.clone() 791 vec![]
260 }; 792 };
261 793
262 let fallback_grasp_servers = client.get_grasp_default_set(); 794 let mut git_servers = if args.clone.is_empty() {
263 795 git_servers_default
264 let selected_grasp_servers = if has_server_and_relay_flags {
265 // ignore so a script running `ngit init` can contiue without prompts
266 vec![]
267 } else { 796 } else {
268 let mut options: Vec<String> = detect_existing_grasp_servers( 797 args.clone.clone()
269 repo_ref.as_ref(),
270 &args.relays,
271 &args.clone_url,
272 &identifier,
273 );
274 let mut selections: Vec<bool> = vec![true; options.len()]; // Initialize selections based on existing options
275 let empty = options.is_empty();
276 for user_grasp_option in user_ref.grasp_list.urls {
277 // Check if any option contains the user_grasp_option as a substring
278 if !options
279 .iter()
280 .any(|option| option.contains(user_grasp_option.as_str()))
281 {
282 options.push(user_grasp_option.to_string()); // Add if not found
283 selections.push(empty); // mark as selected if no existing grasp otherwise not
284 }
285 }
286
287 let empty = options.is_empty();
288 for fallback in fallback_grasp_servers {
289 // Check if any option contains the fallback as a substring
290 if !options.iter().any(|option| option.contains(fallback)) {
291 options.push(fallback.clone()); // Add fallback if not found
292 selections.push(empty); // mark as selected if no existing selections otherwise not
293 }
294 }
295 let selected = multi_select_with_custom_value(
296 "grasp servers (ideally use between 2-4)",
297 "grasp server",
298 options,
299 selections,
300 normalize_grasp_server_url,
301 )?;
302 show_multi_input_prompt_success("grasp servers", &selected);
303 selected
304 }; 798 };
305 799 let mut relay_strings = if args.relays.is_empty() {
306 // ensure ngit relays are added as git server, relay and blossom entries 800 relays_default
307 for grasp_server in &selected_grasp_servers {
308 if args.clone_url.is_empty() {
309 let clone_url = format_grasp_server_url_as_clone_url(
310 grasp_server,
311 &user_ref.public_key,
312 &identifier,
313 )?;
314
315 let grasp_server_clone_root = if clone_url.contains("https://") {
316 format!("https://{grasp_server}")
317 } else {
318 grasp_server.to_string()
319 };
320
321 // Find all positions of entries containing the relay root
322 let matching_positions: Vec<usize> = git_server_defaults
323 .iter()
324 .enumerate()
325 .filter_map(|(idx, url)| {
326 if url.contains(&grasp_server_clone_root) {
327 Some(idx)
328 } else {
329 None
330 }
331 })
332 .collect();
333
334 // If we found any matches
335 if matching_positions.is_empty() {
336 // No existing entries found, so add a new one
337 git_server_defaults.push(clone_url);
338 } else {
339 // Replace the first occurrence
340 git_server_defaults[matching_positions[0]] = clone_url;
341
342 // Remove any subsequent occurrences (in reverse order to avoid index issues)
343 for &position in matching_positions.iter().skip(1).rev() {
344 git_server_defaults.remove(position);
345 }
346 }
347 }
348 if args.relays.is_empty() {
349 let relay_url = format_grasp_server_url_as_relay_url(grasp_server)?;
350 if !relay_defaults.contains(&relay_url) {
351 relay_defaults.push(relay_url);
352 }
353 }
354 if args.blossoms.is_empty() {
355 let blossom = format_grasp_server_url_as_blossom_url(grasp_server)?;
356 if !blossoms_defaults.contains(&blossom) {
357 blossoms_defaults.push(blossom);
358 }
359 }
360 }
361
362 let no_state = if let Ok(Some(s)) = git_repo.get_git_config_item("nostr.nostate", None) {
363 s == "true"
364 } else { 801 } else {
365 false 802 args.relays.clone()
803 };
804 let mut blossom_strings = if args.blossoms.is_empty() {
805 blossoms_default
806 } else {
807 args.blossoms.clone()
366 }; 808 };
367 if no_state 809
810 apply_grasp_infrastructure(
811 &selected_grasp_servers,
812 &mut git_servers,
813 &mut relay_strings,
814 &mut blossom_strings,
815 &user_ref.public_key,
816 &identifier,
817 )?;
818
819 // --- Interactive: nostr.nostate prompt ---
820 if interactive
821 && no_state
368 && Interactor::default().confirm( 822 && Interactor::default().confirm(
369 PromptConfirmParms::default() 823 PromptConfirmParms::default()
370 .with_prompt("store state on nostr? required for nostr-permissioned git servers") 824 .with_prompt("store state on nostr? required for nostr-permissioned git servers")
371 .with_default(true), 825 .with_default(true),
372 )? 826 )?
373 { 827 {
374 // TODO check if grasp servers in use and if so turn this off:
375 if git_repo 828 if git_repo
376 .get_git_config_item("nostr.nostate", Some(true)) 829 .get_git_config_item("nostr.nostate", Some(true))
377 .unwrap_or(None) 830 .unwrap_or(None)
@@ -383,214 +836,146 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
383 } 836 }
384 } 837 }
385 838
386 let git_server = if args.clone_url.is_empty() { 839 // --- Git servers (interactive prompting) ---
387 let grasp_server_git_servers: Vec<String> = git_server_defaults 840 let git_servers = if !args.clone.is_empty() || !interactive {
841 git_servers
842 } else {
843 prompt_git_servers(git_servers, &selected_grasp_servers, simple_mode)?
844 };
845
846 // --- Relays ---
847 let relays: Vec<RelayUrl> = if !args.relays.is_empty() || !interactive {
848 relay_strings
388 .iter() 849 .iter()
389 .filter(|s| is_grasp_server_clone_url(s)) 850 .filter_map(|r| parse_relay_url(r).ok())
390 .cloned() 851 .collect()
852 } else if simple_mode {
853 let grasp_relay_urls: Vec<String> = selected_grasp_servers
854 .iter()
855 .filter_map(|r| format_grasp_server_url_as_relay_url(r).ok())
391 .collect(); 856 .collect();
392 let mut additional_server_options: Vec<String> = git_server_defaults 857 let mut options: Vec<String> = relay_strings
393 .iter() 858 .iter()
394 .filter(|s| !is_grasp_server_clone_url(s)) 859 .filter(|s| !grasp_relay_urls.iter().any(|r| s.as_str() == r))
395 .cloned() 860 .cloned()
396 .collect(); 861 .collect();
397 862 let mut selections: Vec<bool> = vec![true; options.len()];
398 if simple_mode && !selected_grasp_servers.is_empty() { 863 for relay in client.get_relay_default_set().clone() {
399 if additional_server_options.is_empty() { 864 if !options.iter().any(|r| r.contains(&relay))
400 git_server_defaults 865 && !grasp_relay_urls.iter().any(|r| relay.contains(r))
401 } else { 866 {
402 // additional git servers were listed 867 options.push(relay);
403 let selected = loop { 868 selections.push(selections.is_empty());
404 let selections: Vec<bool> = vec![true; additional_server_options.len()];
405 let selected = multi_select_with_custom_value(
406 "additional git server(s) on top of grasp servers",
407 "git server remote url",
408 additional_server_options,
409 selections,
410 |s| {
411 CloneUrl::from_str(s)
412 .map(|_| s.to_string())
413 .context(format!("Invalid git server URL format: {s}"))
414 },
415 )?;
416
417 if selected.is_empty() || Interactor::default().choice(
418 PromptChoiceParms::default()
419 .with_prompt("if you or another maintainer start pushing directly to these, nostr will be out of date")
420 .dont_report()
421 .with_choices(vec![
422 "I'll always push to the nostr remote".to_string(),
423 "change setup".to_string(),
424 ])
425 .with_default(0),
426 )? == 1 {
427 additional_server_options = selected;
428 continue
429 }
430 break selected;
431 };
432 show_multi_input_prompt_success("additional git servers", &selected);
433 let mut combined = grasp_server_git_servers;
434 combined.extend(selected);
435 combined
436 } 869 }
437 } else {
438 // show all git servers
439 let selections: Vec<bool> = vec![true; git_server_defaults.len()];
440
441 let selected = multi_select_with_custom_value(
442 "git server remote url(s)",
443 "git server remote url",
444 git_server_defaults,
445 selections,
446 |s| {
447 CloneUrl::from_str(s)
448 .map(|_| s.to_string())
449 .context(format!("Invalid git server URL format: {s}"))
450 },
451 )?;
452 show_multi_input_prompt_success("git servers", &selected);
453 selected
454 } 870 }
871 let selected = multi_select_with_custom_value(
872 "additional nostr relays on top of nostr-relays - 1 or 2 public relays are reccomended",
873 "nostr relay",
874 options,
875 selections,
876 |s| {
877 parse_relay_url(s)
878 .map(|_| s.to_string())
879 .context(format!("Invalid relay URL format: {s}"))
880 },
881 )?;
882 show_multi_input_prompt_success("additional nostr relays", &selected);
883 [
884 grasp_relay_urls
885 .iter()
886 .filter_map(|r| parse_relay_url(r).ok())
887 .collect::<Vec<RelayUrl>>(),
888 selected
889 .iter()
890 .filter_map(|r| parse_relay_url(r).ok())
891 .collect::<Vec<RelayUrl>>(),
892 ]
893 .concat()
455 } else { 894 } else {
456 git_server_defaults 895 // advanced interactive
896 let selections: Vec<bool> = vec![true; relay_strings.len()];
897 let selected = multi_select_with_custom_value(
898 "nostr relays",
899 "nostr relay",
900 relay_strings,
901 selections,
902 |s| {
903 parse_relay_url(s)
904 .map(|_| s.to_string())
905 .context(format!("Invalid relay URL format: {s}"))
906 },
907 )?;
908 show_multi_input_prompt_success("nostr relays", &selected);
909 selected
910 .iter()
911 .filter_map(|r| parse_relay_url(r).ok())
912 .collect()
457 }; 913 };
458 914
459 let relays: Vec<RelayUrl> = { 915 // --- Blossoms ---
460 if simple_mode { 916 let blossoms: Vec<Url> = if !args.blossoms.is_empty() || !interactive {
461 let formatted_selected_grasp_servers: Vec<String> = selected_grasp_servers 917 blossom_strings
462 .iter() 918 .iter()
463 .filter_map(|r| format_grasp_server_url_as_relay_url(r).ok()) 919 .filter_map(|b| Url::parse(b).ok())
464 .collect(); 920 .collect()
465 let mut options: Vec<String> = relay_defaults 921 } else if !simple_mode {
466 .iter() 922 let selections: Vec<bool> = vec![true; blossom_strings.len()];
467 .filter(|s| { 923 let selected = multi_select_with_custom_value(
468 !formatted_selected_grasp_servers 924 "blossom servers",
469 .iter() 925 "blossom server",
470 .any(|r| s.as_str() == r) 926 blossom_strings,
471 }) 927 selections,
472 .cloned() 928 |s| {
473 .collect(); 929 format_grasp_server_url_as_blossom_url(s)
474 930 .context(format!("Invalid blossom URL format: {s}"))
475 let mut selections: Vec<bool> = vec![true; options.len()]; 931 },
476 932 )?;
477 // add fallback relays as options 933 show_multi_input_prompt_success("blossom servers", &selected);
478 for relay in client.get_relay_default_set().clone() { 934 selected.iter().filter_map(|b| Url::parse(b).ok()).collect()
479 if !options.iter().any(|r| r.contains(&relay)) 935 } else {
480 && !formatted_selected_grasp_servers 936 blossom_strings
481 .iter() 937 .iter()
482 .any(|r| relay.contains(r)) 938 .filter_map(|b| Url::parse(b).ok())
483 { 939 .collect()
484 options.push(relay); 940 };
485 selections.push(selections.is_empty());
486 }
487 }
488 941
489 let selected = multi_select_with_custom_value( 942 // --- Maintainers ---
490 "additional nostr relays on top of nostr-relays - 1 or 2 public relays are reccomended", 943 let maintainers_default = if let Some(ref mr) = my_ref {
491 "nostr relay", 944 let mut m = vec![*my_pubkey];
492 options, 945 for pk in &mr.maintainers {
493 selections, 946 if !m.contains(pk) {
494 |s| { 947 m.push(*pk);
495 parse_relay_url(s)
496 .map(|_| s.to_string())
497 .context(format!("Invalid relay URL format: {s}"))
498 },
499 )?;
500 show_multi_input_prompt_success("additional nostr relays", &selected);
501 [
502 formatted_selected_grasp_servers
503 .iter()
504 .filter_map(|r| parse_relay_url(r).ok())
505 .collect::<Vec<RelayUrl>>(),
506 selected
507 .iter()
508 .filter_map(|r| parse_relay_url(r).ok())
509 .collect::<Vec<RelayUrl>>(),
510 ]
511 .concat()
512 } else {
513 let selections: Vec<bool> = vec![true; relay_defaults.len()];
514 if args.relays.is_empty() {
515 let selected = multi_select_with_custom_value(
516 "nostr relays",
517 "nostr relay",
518 relay_defaults,
519 selections,
520 |s| {
521 parse_relay_url(s)
522 .map(|_| s.to_string())
523 .context(format!("Invalid relay URL format: {s}"))
524 },
525 )?;
526 show_multi_input_prompt_success("nostr relays", &selected);
527 selected
528 .iter()
529 .filter_map(|r| parse_relay_url(r).ok())
530 .collect()
531 } else {
532 relay_defaults
533 .iter()
534 .filter_map(|r| parse_relay_url(r).ok())
535 .collect()
536 } 948 }
537 } 949 }
538 }; 950 m
539 951 } else if let Some(coord) = state.coordinate() {
540 let blossoms: Vec<Url> = { 952 let trusted = coord.coordinate.public_key;
541 if simple_mode || has_server_and_relay_flags { 953 if trusted == *my_pubkey {
542 blossoms_defaults 954 vec![*my_pubkey]
543 .iter()
544 .filter_map(|b| Url::parse(b).ok())
545 .collect()
546 } else { 955 } else {
547 let selections: Vec<bool> = vec![true; blossoms_defaults.len()]; 956 vec![*my_pubkey, trusted]
548 if args.blossoms.is_empty() {
549 let selected = multi_select_with_custom_value(
550 "blossom servers",
551 "blossom server",
552 blossoms_defaults,
553 selections,
554 |s| {
555 format_grasp_server_url_as_blossom_url(s)
556 .context(format!("Invalid blossom URL format: {s}"))
557 },
558 )?;
559 show_multi_input_prompt_success("nostr relays", &selected);
560 selected.iter().filter_map(|b| Url::parse(b).ok()).collect()
561 } else {
562 blossoms_defaults
563 .iter()
564 .filter_map(|b| Url::parse(b).ok())
565 .collect()
566 }
567 } 957 }
958 } else {
959 vec![*my_pubkey]
568 }; 960 };
569 961
570 let default_maintainers = { 962 let base_maintainers = if args.other_maintainers.is_empty() {
571 let mut maintainers = vec![user_ref.public_key]; 963 maintainers_default
572 if args.other_maintainers.is_empty() { 964 } else {
573 if let Some(repo_ref) = &repo_ref { 965 let mut m = vec![user_ref.public_key];
574 for m in &repo_ref.maintainers { 966 for npub in &args.other_maintainers {
575 if !maintainers.contains(m) { 967 if let Ok(pk) = PublicKey::from_bech32(npub) {
576 maintainers.push(*m); 968 if !m.contains(&pk) {
577 } 969 m.push(pk);
578 }
579 }
580 } else {
581 for m in &args.other_maintainers {
582 if let Ok(pubkey) = PublicKey::from_bech32(m).context("invalid npub") {
583 if !maintainers.contains(&pubkey) {
584 maintainers.push(pubkey);
585 }
586 } 970 }
587 } 971 }
588 } 972 }
589 maintainers 973 m
590 }; 974 };
591 975
592 let maintainers: Vec<PublicKey> = if args.other_maintainers.is_empty() { 976 let maintainers = if !args.other_maintainers.is_empty()
593 if default_maintainers.len() == 1 977 || !interactive
978 || (base_maintainers.len() == 1
594 && Interactor::default().choice( 979 && Interactor::default().choice(
595 PromptChoiceParms::default() 980 PromptChoiceParms::default()
596 .with_prompt("add other maintainers now?") 981 .with_prompt("add other maintainers now?")
@@ -600,41 +985,44 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
600 "add maintainers".to_string(), 985 "add maintainers".to_string(),
601 ]) 986 ])
602 .with_default(0), 987 .with_default(0),
603 )? == 0 988 )? == 0)
604 { 989 {
605 default_maintainers 990 base_maintainers
606 } else {
607 let selections: Vec<bool> = vec![true; default_maintainers.len()];
608
609 let selected = multi_select_with_custom_value(
610 "maintainers",
611 "maintainer npub",
612 default_maintainers
613 .iter()
614 .filter_map(|m| m.to_bech32().ok())
615 .collect(),
616 selections,
617 |s| {
618 extract_npub(s)
619 .map(|_| s.to_string())
620 .context(format!("Invalid npub: {s}"))
621 },
622 )?;
623 show_multi_input_prompt_success("maintainers", &selected);
624 selected
625 .iter()
626 .filter_map(|npub| PublicKey::parse(npub).ok())
627 .collect()
628 }
629 } else { 991 } else {
630 default_maintainers 992 let selections: Vec<bool> = vec![true; base_maintainers.len()];
993 let selected = multi_select_with_custom_value(
994 "maintainers",
995 "maintainer npub",
996 base_maintainers
997 .iter()
998 .filter_map(|m| m.to_bech32().ok())
999 .collect(),
1000 selections,
1001 |s| {
1002 extract_npub(s)
1003 .map(|_| s.to_string())
1004 .context(format!("Invalid npub: {s}"))
1005 },
1006 )?;
1007 show_multi_input_prompt_success("maintainers", &selected);
1008 selected
1009 .iter()
1010 .filter_map(|npub| PublicKey::parse(npub).ok())
1011 .collect()
631 }; 1012 };
632 1013
633 if selected_grasp_servers.is_empty() && git_server.iter().any(|s| s.contains("github.com") || s.contains("codeberg.org")) && Interactor::default().confirm( 1014 // --- Interactive: github/codeberg warning ---
1015 if interactive
1016 && selected_grasp_servers.is_empty()
1017 && git_servers
1018 .iter()
1019 .any(|s| s.contains("github.com") || s.contains("codeberg.org"))
1020 && Interactor::default().confirm(
634 PromptConfirmParms::default() 1021 PromptConfirmParms::default()
635 .with_prompt("you have listed github / codeberg. Are you or other maintainers planning on pushing directly to github / codeberg rather than using your shiny new nostr clone url which will do this for you?") 1022 .with_prompt("you have listed github / codeberg. Are you or other maintainers planning on pushing directly to github / codeberg rather than using your shiny new nostr clone url which will do this for you?")
636 .with_default(false), 1023 .with_default(false),
637 )? { 1024 )?
1025 {
638 println!("This means people using the nostr URL won't get your latest branch updates."); 1026 println!("This means people using the nostr URL won't get your latest branch updates.");
639 if Interactor::default().confirm( 1027 if Interactor::default().confirm(
640 PromptConfirmParms::default() 1028 PromptConfirmParms::default()
@@ -645,124 +1033,228 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
645 } 1033 }
646 } 1034 }
647 1035
648 let gitworkshop_url = NostrUrlDecoded { 1036 // --- Web ---
649 original_string: String::new(), 1037 let gitworkshop_url = build_gitworkshop_url(&user_ref.public_key, &identifier, relays.first());
650 coordinate: Nip19Coordinate { 1038 let web_default = resolve_web(&args.web, state, &identifier, &gitworkshop_url);
651 coordinate: Coordinate {
652 public_key: user_ref.public_key,
653 kind: Kind::GitRepoAnnouncement,
654 identifier: identifier.clone(),
655 },
656 relays: if let Some(relay) = relays.first() {
657 vec![relay.clone()]
658 } else {
659 vec![]
660 },
661 },
662 protocol: None,
663 ssh_key_file: None,
664 nip05: None,
665 }
666 .to_string()
667 .replace("nostr://", "https://gitworkshop.dev/");
668
669 let web: Vec<String> = if args.web.is_empty() {
670 let web_default = if let Some(repo_ref) = &repo_ref {
671 if repo_ref
672 .web
673 .clone()
674 .join(" ")
675 // replace legacy gitworkshop.dev url format with new one
676 .contains(format!("https://gitworkshop.dev/repo/{}", &identifier).as_str())
677 {
678 gitworkshop_url.clone()
679 } else {
680 repo_ref.web.clone().join(" ")
681 }
682 } else {
683 gitworkshop_url.clone()
684 };
685 1039
686 if simple_mode { 1040 let web = if !args.web.is_empty() || !interactive || simple_mode {
687 web_default 1041 web_default
688 } else { 1042 } else {
689 Interactor::default().input( 1043 // advanced interactive
1044 let web_default_str = web_default.join(" ");
1045 Interactor::default()
1046 .input(
690 PromptInputParms::default() 1047 PromptInputParms::default()
691 .with_prompt("repo website") 1048 .with_prompt("repo website")
692 .optional() 1049 .optional()
693 .with_default(web_default), 1050 .with_default(web_default_str)
1051 .with_flag_name("--web"),
694 )? 1052 )?
695 } 1053 .split(' ')
696 .split(' ') 1054 .map(std::string::ToString::to_string)
697 .map(std::string::ToString::to_string) 1055 .collect()
698 .collect()
699 } else {
700 args.web.clone()
701 }; 1056 };
702 1057
703 let earliest_unique_commit = if let Some(t) = &args.earliest_unique_commit { 1058 // --- Earliest unique commit ---
704 t.clone() 1059 // Cascade: my event -> consolidated RepoRef (trusted maintainer's) -> local
705 } else { 1060 // root commit
706 let mut earliest_unique_commit = if let Some(repo_ref) = &repo_ref { 1061 let my_euc = my_ref
707 repo_ref.root_commit.clone() 1062 .as_ref()
708 } else { 1063 .map(|mr| &mr.root_commit)
709 root_commit.to_string() 1064 .filter(|c| !c.is_empty());
710 }; 1065 let repo_euc = state
711 if simple_mode { 1066 .repo_ref()
712 earliest_unique_commit 1067 .map(|rr| &rr.root_commit)
1068 .filter(|c| !c.is_empty());
1069 let euc_default = my_euc
1070 .or(repo_euc)
1071 .cloned()
1072 .unwrap_or_else(|| root_commit.to_string());
1073
1074 let earliest_unique_commit = if let Some(commit) = &args.earliest_unique_commit {
1075 if let Ok(exists) = git_repo.does_commit_exist(commit) {
1076 if !exists {
1077 bail!("earliest unique commit does not exist on current repository");
1078 }
713 } else { 1079 } else {
714 println!( 1080 bail!("earliest unique commit id not formatted correctly");
715 "the earliest unique commit helps with discoverability. It defaults to the root commit. Only change this if your repo has completely forked off an has formed its own identity." 1081 }
716 ); 1082 if commit.len() != 40 {
717 loop { 1083 bail!("earliest unique commit id must be 40 characters long");
718 earliest_unique_commit = Interactor::default().input( 1084 }
719 PromptInputParms::default() 1085 commit.clone()
720 .with_prompt("earliest unique commit (to help with discoverability)") 1086 } else if interactive && !simple_mode {
721 .with_default(earliest_unique_commit.clone()), 1087 println!(
722 )?; 1088 "the earliest unique commit helps with discoverability. It defaults to the root commit. Only change this if your repo has completely forked off an has formed its own identity."
723 if let Ok(exists) = git_repo.does_commit_exist(&earliest_unique_commit) { 1089 );
724 if exists { 1090 let mut result = euc_default.clone();
725 break earliest_unique_commit; 1091 loop {
726 } 1092 result = Interactor::default().input(
727 println!("commit does not exist on current repository"); 1093 PromptInputParms::default()
728 } else { 1094 .with_prompt("earliest unique commit (to help with discoverability)")
729 println!("commit id not formatted correctly"); 1095 .with_default(result.clone())
1096 .with_flag_name("--earliest-unique-commit"),
1097 )?;
1098 if let Ok(exists) = git_repo.does_commit_exist(&result) {
1099 if exists && result.len() == 40 {
1100 break;
730 } 1101 }
731 if earliest_unique_commit.len().ne(&40) { 1102 if !exists {
732 println!("commit id must be 40 characters long"); 1103 println!("commit does not exist on current repository");
733 } 1104 }
1105 } else {
1106 println!("commit id not formatted correctly");
1107 }
1108 if result.len() != 40 {
1109 println!("commit id must be 40 characters long");
734 } 1110 }
735 } 1111 }
1112 result
1113 } else {
1114 euc_default
736 }; 1115 };
737 1116
738 println!("publishing repostory announcement to nostr..."); 1117 // --- Hashtags (shared metadata — from latest event, like name/description/web)
1118 // ---
1119 let hashtags = latest
1120 .as_ref()
1121 .map_or_else(Vec::new, |lr| lr.hashtags.clone());
739 1122
740 let repo_ref = RepoRef { 1123 Ok(ResolvedFields {
741 identifier: identifier.clone(), 1124 identifier,
742 name, 1125 name,
743 description, 1126 description,
744 root_commit: earliest_unique_commit, 1127 git_servers,
745 git_server, 1128 relays,
746 web,
747 relays: relays.clone(),
748 blossoms, 1129 blossoms,
749 hashtags: if let Some(repo_ref) = repo_ref { 1130 web,
750 repo_ref.hashtags 1131 maintainers,
751 } else { 1132 earliest_unique_commit,
752 vec![] 1133 hashtags,
753 }, 1134 selected_grasp_servers,
1135 })
1136}
1137
1138/// Interactive prompt for git server selection with simple/advanced modes.
1139fn prompt_git_servers(
1140 git_servers: Vec<String>,
1141 selected_grasp_servers: &[String],
1142 simple_mode: bool,
1143) -> Result<Vec<String>> {
1144 let grasp_server_git_servers: Vec<String> = git_servers
1145 .iter()
1146 .filter(|s| is_grasp_server_clone_url(s))
1147 .cloned()
1148 .collect();
1149 let mut additional_server_options: Vec<String> = git_servers
1150 .iter()
1151 .filter(|s| !is_grasp_server_clone_url(s))
1152 .cloned()
1153 .collect();
1154
1155 if simple_mode && !selected_grasp_servers.is_empty() {
1156 if additional_server_options.is_empty() {
1157 return Ok(git_servers);
1158 }
1159 let selected = loop {
1160 let selections: Vec<bool> = vec![true; additional_server_options.len()];
1161 let selected = multi_select_with_custom_value(
1162 "additional git server(s) on top of grasp servers",
1163 "git server remote url",
1164 additional_server_options,
1165 selections,
1166 |s| {
1167 CloneUrl::from_str(s)
1168 .map(|_| s.to_string())
1169 .context(format!("Invalid git server URL format: {s}"))
1170 },
1171 )?;
1172
1173 if selected.is_empty()
1174 || Interactor::default().choice(
1175 PromptChoiceParms::default()
1176 .with_prompt("if you or another maintainer start pushing directly to these, nostr will be out of date")
1177 .dont_report()
1178 .with_choices(vec![
1179 "I'll always push to the nostr remote".to_string(),
1180 "change setup".to_string(),
1181 ])
1182 .with_default(0),
1183 )? == 1
1184 {
1185 additional_server_options = selected;
1186 continue;
1187 }
1188 break selected;
1189 };
1190 show_multi_input_prompt_success("additional git servers", &selected);
1191 let mut combined = grasp_server_git_servers;
1192 combined.extend(selected);
1193 Ok(combined)
1194 } else {
1195 let selections: Vec<bool> = vec![true; git_servers.len()];
1196 let selected = multi_select_with_custom_value(
1197 "git server remote url(s)",
1198 "git server remote url",
1199 git_servers,
1200 selections,
1201 |s| {
1202 CloneUrl::from_str(s)
1203 .map(|_| s.to_string())
1204 .context(format!("Invalid git server URL format: {s}"))
1205 },
1206 )?;
1207 show_multi_input_prompt_success("git servers", &selected);
1208 Ok(selected)
1209 }
1210}
1211
1212#[allow(clippy::too_many_lines)]
1213async fn publish_and_finalize(
1214 fields: ResolvedFields,
1215 signer: Arc<dyn nostr::prelude::NostrSigner>,
1216 user_ref: &ngit::login::user::UserRef,
1217 client: &mut Client,
1218 cli: &Cli,
1219 git_repo: &Repo,
1220 repo_config_result: &Result<ngit::repo_ref::RepoConfigYaml>,
1221) -> Result<()> {
1222 let git_repo_path = git_repo.get_path()?;
1223
1224 // Step 1: Build RepoRef
1225 let repo_ref = RepoRef {
1226 identifier: fields.identifier.clone(),
1227 name: fields.name,
1228 description: fields.description,
1229 root_commit: fields.earliest_unique_commit,
1230 git_server: fields.git_servers,
1231 web: fields.web,
1232 relays: fields.relays.clone(),
1233 blossoms: fields.blossoms,
1234 hashtags: fields.hashtags,
754 trusted_maintainer: user_ref.public_key, 1235 trusted_maintainer: user_ref.public_key,
755 maintainers_without_annoucnement: None, 1236 maintainers_without_annoucnement: None,
756 maintainers: maintainers.clone(), 1237 maintainers: fields.maintainers.clone(),
757 events: HashMap::new(), 1238 events: HashMap::new(),
758 nostr_git_url: None, 1239 nostr_git_url: None,
759 }; 1240 };
1241
1242 // Step 2: Create event
1243 println!("publishing repostory announcement to nostr...");
760 let repo_event = repo_ref.to_event(&signer).await?; 1244 let repo_event = repo_ref.to_event(&signer).await?;
761 1245
762 let nostr_url_decoded = repo_ref.to_nostr_git_url(&Some(&git_repo)); 1246 // Step 3: Build nostr URL
1247 let nostr_url_decoded = repo_ref.to_nostr_git_url(&Some(git_repo));
763 1248
764 let mut events = vec![repo_event]; 1249 let mut events = vec![repo_event];
765 1250
1251 // Step 4: Handle state events and push/sync logic
1252 let no_state = if let Ok(Some(s)) = git_repo.get_git_config_item("nostr.nostate", None) {
1253 s == "true"
1254 } else {
1255 false
1256 };
1257
766 let (need_push, need_sync) = if std::env::var("NGITTEST").is_ok() || no_state { 1258 let (need_push, need_sync) = if std::env::var("NGITTEST").is_ok() || no_state {
767 // dont push or sync during tests as git-remote-nostr isn't installed during 1259 // dont push or sync during tests as git-remote-nostr isn't installed during
768 // ngit binary tests 1260 // ngit binary tests
@@ -785,7 +1277,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
785 if let Some(url) = remote.url() { 1277 if let Some(url) = remote.url() {
786 // issue a state event with origin state, to all (inc. new) repo relays 1278 // issue a state event with origin state, to all (inc. new) repo relays
787 if let Ok(mut origin_state) = 1279 if let Ok(mut origin_state) =
788 list_from_remote(&Term::stdout(), &git_repo, url, &nostr_url_decoded, false) 1280 list_from_remote(&Term::stdout(), git_repo, url, &nostr_url_decoded, false)
789 { 1281 {
790 origin_state.retain(|key, _| { 1282 origin_state.retain(|key, _| {
791 key.starts_with("refs/heads/") 1283 key.starts_with("refs/heads/")
@@ -809,7 +1301,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
809 if required_oids.is_empty() { 1301 if required_oids.is_empty() {
810 println!("fetching refs missing locally from existing origin..."); 1302 println!("fetching refs missing locally from existing origin...");
811 if let Err(error) = fetch_from_git_server( 1303 if let Err(error) = fetch_from_git_server(
812 &git_repo, 1304 git_repo,
813 &required_oids, 1305 &required_oids,
814 url, 1306 url,
815 &nostr_url_decoded, 1307 &nostr_url_decoded,
@@ -839,27 +1331,28 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
839 (true, false) 1331 (true, false)
840 }; 1332 };
841 1333
1334 // Step 5: Publish events
842 client.set_signer(signer).await; 1335 client.set_signer(signer).await;
843 1336
844 send_events( 1337 send_events(
845 &client, 1338 client,
846 Some(git_repo_path), 1339 Some(git_repo_path),
847 events, 1340 events,
848 user_ref.relays.write(), 1341 user_ref.relays.write(),
849 relays.clone(), 1342 fields.relays.clone(),
850 !cli_args.disable_cli_spinners, 1343 !cli.disable_cli_spinners,
851 false, 1344 false,
852 ) 1345 )
853 .await?; 1346 .await?;
854 1347
855 // TODO - does this git config item do more harm than good? 1348 // Step 6: Set git config
856 git_repo.save_git_config_item( 1349 git_repo.save_git_config_item(
857 "nostr.repo", 1350 "nostr.repo",
858 &Nip19Coordinate { 1351 &Nip19Coordinate {
859 coordinate: Coordinate { 1352 coordinate: Coordinate {
860 kind: Kind::GitRepoAnnouncement, 1353 kind: Kind::GitRepoAnnouncement,
861 public_key: user_ref.public_key, 1354 public_key: user_ref.public_key,
862 identifier: identifier.clone(), 1355 identifier: fields.identifier.clone(),
863 }, 1356 },
864 relays: vec![], 1357 relays: vec![],
865 } 1358 }
@@ -867,9 +1360,8 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
867 false, 1360 false,
868 )?; 1361 )?;
869 1362
870 // set origin remote 1363 // Step 7: Set origin remote
871 let nostr_url = nostr_url_decoded.to_string(); 1364 let nostr_url = nostr_url_decoded.to_string();
872
873 if git_repo.git_repo.find_remote("origin").is_ok() { 1365 if git_repo.git_repo.find_remote("origin").is_ok() {
874 git_repo.git_repo.remote_set_url("origin", &nostr_url)?; 1366 git_repo.git_repo.remote_set_url("origin", &nostr_url)?;
875 } else { 1367 } else {
@@ -877,8 +1369,9 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
877 } 1369 }
878 println!("set remote origin to nostr url"); 1370 println!("set remote origin to nostr url");
879 1371
1372 // Step 8: Push/sync
880 if need_push { 1373 if need_push {
881 if selected_grasp_servers.is_empty() { 1374 if fields.selected_grasp_servers.is_empty() {
882 println!("running `ngit push` to publish your repository data"); 1375 println!("running `ngit push` to publish your repository data");
883 } else { 1376 } else {
884 let countdown_start = 5; 1377 let countdown_start = 5;
@@ -894,14 +1387,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
894 term.flush().unwrap(); // Ensure the output is flushed to the terminal 1387 term.flush().unwrap(); // Ensure the output is flushed to the terminal
895 } 1388 }
896 1389
897 if let Err(err) = push_main_or_master_branch(&git_repo) { 1390 if let Err(err) = push_main_or_master_branch(git_repo) {
898 println!( 1391 println!(
899 "your repository announcement was published to nostr but git push exited with an error: {err}" 1392 "your repository announcement was published to nostr but git push exited with an error: {err}"
900 ); 1393 );
901 } 1394 }
902 } 1395 }
903 if need_sync { 1396 if need_sync {
904 if selected_grasp_servers.is_empty() { 1397 if fields.selected_grasp_servers.is_empty() {
905 println!( 1398 println!(
906 "running `ngit sync` to ensure your repository data is available on repository git servers" 1399 "running `ngit sync` to ensure your repository data is available on repository git servers"
907 ); 1400 );
@@ -926,27 +1419,25 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
926 } 1419 }
927 } 1420 }
928 1421
929 // println!( 1422 // Step 9: Print share URLs
930 // "any remote branches beginning with `pr/` are open PRs from contributors. 1423 let gitworkshop_url = nostr_url_decoded
931 // they can submit these by simply pushing a branch with this `pr/` prefix." 1424 .to_string()
932 // ); 1425 .replace("nostr://", "https://gitworkshop.dev/");
933 println!("share your repository: {gitworkshop_url}"); 1426 println!("share your repository: {gitworkshop_url}");
934 println!("clone url: {nostr_url}"); 1427 println!("clone url: {nostr_url}");
935 1428
936 // no longer create a new maintainers.yaml file - its too confusing for users 1429 // Step 10: Update maintainers.yaml if needed
937 // as it falls out of sync with data in nostr event . update if it already 1430 let relays = fields
938 // exists 1431 .relays
939
940 let relays = relays
941 .iter() 1432 .iter()
942 .map(std::string::ToString::to_string) 1433 .map(std::string::ToString::to_string)
943 .collect::<Vec<String>>(); 1434 .collect::<Vec<String>>();
944 if match &repo_config_result { 1435 if match repo_config_result {
945 Ok(config) => { 1436 Ok(config) => {
946 !<std::option::Option<std::string::String> as Clone>::clone(&config.identifier) 1437 !<std::option::Option<std::string::String> as Clone>::clone(&config.identifier)
947 .unwrap_or_default() 1438 .unwrap_or_default()
948 .eq(&identifier) 1439 .eq(&fields.identifier)
949 || !extract_pks(config.maintainers.clone())?.eq(&maintainers) 1440 || !extract_pks(config.maintainers.clone())?.eq(&fields.maintainers)
950 || !config.relays.eq(&relays) 1441 || !config.relays.eq(&relays)
951 } 1442 }
952 Err(_) => false, 1443 Err(_) => false,
@@ -954,9 +1445,9 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
954 let title_style = Style::new().bold().fg(console::Color::Yellow); 1445 let title_style = Style::new().bold().fg(console::Color::Yellow);
955 println!("{}", title_style.apply_to("maintainers.yaml")); 1446 println!("{}", title_style.apply_to("maintainers.yaml"));
956 save_repo_config_to_yaml( 1447 save_repo_config_to_yaml(
957 &git_repo, 1448 git_repo,
958 identifier.clone(), 1449 fields.identifier.clone(),
959 maintainers.clone(), 1450 fields.maintainers.clone(),
960 relays.clone(), 1451 relays.clone(),
961 )?; 1452 )?;
962 println!( 1453 println!(
@@ -974,6 +1465,98 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
974 Ok(()) 1465 Ok(())
975} 1466}
976 1467
1468#[allow(clippy::too_many_lines)]
1469pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
1470 // Phase 1: Local-only setup
1471 let git_repo = Repo::discover().context("failed to find a git repository")?;
1472 let git_repo_path = git_repo.get_path()?;
1473 let root_commit = git_repo
1474 .get_root_commit()
1475 .context("failed to get root commit of the repository")?;
1476 let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
1477 let (signer, user_ref, _) = login::login_or_signup(
1478 &Some(&git_repo),
1479 &extract_signer_cli_arguments(cli_args).unwrap_or(None),
1480 &cli_args.password,
1481 Some(&client),
1482 false,
1483 )
1484 .await?;
1485
1486 let repo_coordinate = (try_and_get_repo_coordinates_when_remote_unknown(&git_repo).await).ok();
1487
1488 // Phase 2: Pre-fetch validation (fail fast)
1489 let user_has_grasp_list = !user_ref.grasp_list.urls.is_empty();
1490 validate_pre_fetch(
1491 cli_args,
1492 args,
1493 repo_coordinate.as_ref(),
1494 user_has_grasp_list,
1495 )?;
1496
1497 // Phase 3: Network fetch (only if coordinate exists)
1498 let repo_ref = if let Some(repo_coordinate) = &repo_coordinate {
1499 fetching_with_report(git_repo_path, &client, repo_coordinate).await?;
1500 (get_repo_ref_from_cache(Some(git_repo_path), repo_coordinate).await).ok()
1501 } else {
1502 None
1503 };
1504
1505 // Phase 4: Determine state + post-fetch validation
1506 let state = match (&repo_coordinate, &repo_ref) {
1507 (None, _) => InitState::Fresh,
1508 (Some(coord), None) => InitState::CoordinateOnly {
1509 coordinate: coord.clone(),
1510 },
1511 (Some(coord), Some(rr)) => {
1512 if coord.coordinate.public_key == user_ref.public_key {
1513 InitState::MyAnnouncement {
1514 coordinate: coord.clone(),
1515 repo_ref: rr.clone(),
1516 }
1517 } else if rr.maintainers.contains(&user_ref.public_key) {
1518 InitState::CoMaintainer {
1519 coordinate: coord.clone(),
1520 repo_ref: rr.clone(),
1521 }
1522 } else {
1523 InitState::NotListed {
1524 coordinate: coord.clone(),
1525 repo_ref: rr.clone(),
1526 }
1527 }
1528 }
1529 };
1530
1531 validate_post_fetch(cli_args, args, &state)?;
1532
1533 // Phase 5: Resolve all fields
1534 let repo_config_result = get_repo_config_from_yaml(&git_repo);
1535 let fields = resolve_fields(
1536 &state,
1537 &user_ref,
1538 args,
1539 cli_args,
1540 &git_repo,
1541 &root_commit.to_string(),
1542 &client,
1543 &repo_config_result,
1544 cli_args.interactive,
1545 )?;
1546
1547 // Phase 6: Build and publish
1548 publish_and_finalize(
1549 fields,
1550 signer,
1551 &user_ref,
1552 &mut client,
1553 cli_args,
1554 &git_repo,
1555 &repo_config_result,
1556 )
1557 .await
1558}
1559
977fn format_grasp_server_url_as_clone_url( 1560fn format_grasp_server_url_as_clone_url(
978 url: &str, 1561 url: &str,
979 public_key: &PublicKey, 1562 public_key: &PublicKey,