upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-05-22 16:20:25 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2025-05-22 17:09:47 +0100
commit4dc5d0c9fb170981cf4fade5558d7cc8da404aa3 (patch)
tree9f87365d5c479831a0fe7bb2db60a4440c166639
parentb6a161e1107d836d410d225d6700eeab38f12023 (diff)
feat(init): overhaul & simplify with ngit-relays
introduce ngit-relays as a way of setting git servers and relays at the same time using a standard for specific repo locations: https://<domain-port-path>/<npub>/<identifer>.git add simple and advanced modes. prompt less. eg always set remote origin to nostr url. automatically push main or master branch.
-rw-r--r--src/bin/ngit/sub_commands/init.rs1113
-rw-r--r--src/lib/login/fresh.rs2
-rw-r--r--tests/ngit_init.rs12
3 files changed, 729 insertions, 398 deletions
diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs
index bdecbe3..83e434f 100644
--- a/src/bin/ngit/sub_commands/init.rs
+++ b/src/bin/ngit/sub_commands/init.rs
@@ -1,30 +1,31 @@
1use std::collections::{HashMap, HashSet}; 1use std::{
2 collections::HashMap, process::{Command, Stdio}, str::FromStr, thread, time::Duration
3};
2 4
3use anyhow::{Context, Result}; 5use anyhow::{Context, Result, bail};
4use console::{Style, Term}; 6use console::Style;
7use dialoguer::theme::{ColorfulTheme, Theme};
5use ngit::{ 8use ngit::{
6 cli_interactor::PromptConfirmParms, 9 cli_interactor::{PromptChoiceParms, PromptConfirmParms, PromptMultiChoiceParms},
7 client::Params, 10 client::{send_events, Params},
8 git::nostr_url::{NostrUrlDecoded, save_nip05_to_git_config_cache}, 11 git::nostr_url::{CloneUrl, NostrUrlDecoded}, repo_ref::{extract_pks, save_repo_config_to_yaml},
9}; 12};
10use nostr::{ 13use nostr::{
11 FromBech32, PublicKey, ToBech32,
12 nips::{ 14 nips::{
13 nip01::Coordinate, 15 nip01::Coordinate,
14 nip05::{self},
15 nip19::Nip19Coordinate, 16 nip19::Nip19Coordinate,
16 }, 17 }, FromBech32, PublicKey, ToBech32
17}; 18};
18use nostr_sdk::{Kind, RelayUrl}; 19use nostr_sdk::{Kind, RelayUrl, Url};
19 20
20use crate::{ 21use crate::{
21 cli::{Cli, extract_signer_cli_arguments}, 22 cli::{Cli, extract_signer_cli_arguments},
22 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, 23 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms},
23 client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache, send_events}, 24 client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache},
24 git::{Repo, RepoActions, nostr_url::convert_clone_url_to_https}, 25 git::{Repo, RepoActions, nostr_url::convert_clone_url_to_https},
25 login, 26 login,
26 repo_ref::{ 27 repo_ref::{
27 RepoRef, extract_pks, get_repo_config_from_yaml, save_repo_config_to_yaml, 28 RepoRef, get_repo_config_from_yaml,
28 try_and_get_repo_coordinates_when_remote_unknown, 29 try_and_get_repo_coordinates_when_remote_unknown,
29 }, 30 },
30}; 31};
@@ -107,43 +108,6 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
107 )?, 108 )?,
108 }; 109 };
109 110
110 let identifier = match &args.identifier {
111 Some(t) => t.clone(),
112 None => Interactor::default().input(
113 PromptInputParms::default()
114 .with_prompt(
115 "repo identifier (typically the short name with hypens instead of spaces)",
116 )
117 .with_default(if let Some(repo_ref) = &repo_ref {
118 repo_ref.identifier.clone()
119 } else if let Some(repo_coordinate) = &repo_coordinate {
120 repo_coordinate.identifier.clone()
121 } else {
122 let fallback = name
123 .clone()
124 .replace(' ', "-")
125 .chars()
126 .map(|c| {
127 if c.is_ascii_alphanumeric() || c.eq(&'/') {
128 c
129 } else {
130 '-'
131 }
132 })
133 .collect();
134 if let Ok(config) = &repo_config_result {
135 if let Some(identifier) = &config.identifier {
136 identifier.to_string()
137 } else {
138 fallback
139 }
140 } else {
141 fallback
142 }
143 }),
144 )?,
145 };
146
147 let description = match &args.description { 111 let description = match &args.description {
148 Some(t) => t.clone(), 112 Some(t) => t.clone(),
149 None => Interactor::default().input( 113 None => Interactor::default().input(
@@ -158,163 +122,89 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
158 )?, 122 )?,
159 }; 123 };
160 124
161 let maintainers: Vec<PublicKey> = { 125 // this is important so init can be completed done without prompts
162 let mut dont_ask_for_maintainers = !args.other_maintainers.is_empty(); 126 let has_server_and_relay_flags = !args.clone_url.is_empty() && !args.relays.is_empty();
163 let mut maintainers_string = if !args.other_maintainers.is_empty() { 127
164 [args.other_maintainers.clone()].concat().join(" ") 128 let simple_mode = if has_server_and_relay_flags {
165 } else if repo_ref.is_none() && repo_config_result.is_err() { 129 false
166 user_ref.public_key.to_bech32()? 130 } else {
167 } else { 131 Interactor::default().choice(
168 let maintainers = if let Ok(config) = &repo_config_result { 132 PromptChoiceParms::default()
169 config.maintainers.clone() 133 .with_prompt("config mode")
170 } else if let Some(repo_ref) = &repo_ref { 134 .with_choices(vec![
171 repo_ref 135 "simple - all you need".to_string(),
172 .maintainers 136 "advanced - all the dials and switches".to_string(),
173 .clone() 137 ])
174 .iter() 138 .with_default(0),
175 .map(|k| k.to_bech32().unwrap()) 139 )? == 0
176 .collect() 140 };
177 } else { 141
178 //unreachable 142 let identifier_default = if let Some(repo_ref) = &repo_ref {
179 vec![user_ref.public_key.to_bech32()?] 143 repo_ref.identifier.clone()
180 }; 144 } else if let Some(repo_coordinate) = &repo_coordinate {
181 // add current user if not present 145 repo_coordinate.identifier.clone()
182 if maintainers.iter().any(|m| { 146 } else {
183 if let Ok(m_pubkey) = PublicKey::from_bech32(m) { 147 let fallback = name
184 user_ref.public_key.eq(&m_pubkey) 148 .clone()
149 .replace(' ', "-")
150 .chars()
151 .map(|c| {
152 if c.is_ascii_alphanumeric() || c.eq(&'/') {
153 c
185 } else { 154 } else {
186 false 155 '-'
187 } 156 }
188 }) { 157 })
189 maintainers.join(" ") 158 .collect();
159 if let Ok(config) = &repo_config_result {
160 if let Some(identifier) = &config.identifier {
161 identifier.to_string()
190 } else { 162 } else {
191 [maintainers, vec![user_ref.public_key.to_bech32()?]] 163 fallback
192 .concat()
193 .join(" ")
194 } 164 }
195 }; 165 } else {
196 'outer: loop { 166 fallback
197 if !dont_ask_for_maintainers && user_ref.public_key.to_bech32()?.eq(&maintainers_string) 167 }
198 { 168 };
199 if Interactor::default().confirm(
200 PromptConfirmParms::default()
201 .with_prompt("are you the only maintainer?")
202 .with_default(true),
203 )? {
204 dont_ask_for_maintainers = true;
205 } else {
206 let mut ask_about_state = false;
207 if !Interactor::default().confirm(
208 PromptConfirmParms::default()
209 .with_prompt("are the other maintainers on nostr?")
210 .with_default(true),
211 )? {
212 dont_ask_for_maintainers = true;
213 ask_about_state = true;
214 } else if !Interactor::default().confirm(
215 PromptConfirmParms::default()
216 .with_prompt(
217 "are you going to ask them to use ngit with this repository?",
218 )
219 .with_default(true),
220 )? {
221 ask_about_state = true;
222 }
223 169
224 if ask_about_state { 170 let identifier = match &args.identifier {
225 println!( 171 Some(t) => t.clone(),
226 "nostr can reduce the trust placed in git servers by storing the state of git branches and tags. You can also use nostr-permissioned git servers. If you have other maintainers not using git via nostr, the verifiable state can fall behind the git server." 172 None => {
227 ); 173 if simple_mode {
228 if Interactor::default().confirm( 174 identifier_default
229 PromptConfirmParms::default() 175 } else {
230 .with_prompt("opt-out of storing git state on nostr and relay on git server for now? you will still receive PRs and issues via nostr") 176 Interactor::default().input(
231 .with_default(true), 177 PromptInputParms::default()
232 )? { 178 .with_prompt(
233 git_repo.save_git_config_item("nostr.nostate", "true", false)?; 179 "repo identifier (typically the short name with hypens instead of spaces)",
234 } 180 )
235 } 181 .with_default(identifier_default),
236 } 182 )?
237 }
238 if !dont_ask_for_maintainers {
239 maintainers_string = Interactor::default().input(
240 PromptInputParms::default()
241 .with_prompt("maintainers - space seperated list of npubs")
242 .with_default(maintainers_string),
243 )?;
244 }
245 let mut maintainers: Vec<PublicKey> = vec![];
246 for m in maintainers_string.split(' ') {
247 if let Ok(m_pubkey) = PublicKey::from_bech32(m) {
248 maintainers.push(m_pubkey);
249 } else {
250 println!("not a valid set of space seperated npubs");
251 dont_ask_for_maintainers = false;
252 continue 'outer;
253 }
254 }
255 // add current user incase removed
256 if !maintainers.iter().any(|m| user_ref.public_key.eq(m)) {
257 maintainers.push(user_ref.public_key);
258 } 183 }
259 break maintainers;
260 } 184 }
261 }; 185 };
262 186
263 let git_server = if args.clone_url.is_empty() { 187 let mut git_server_defaults: Vec<String> = if !args.clone_url.is_empty() {
264 let no_state = if let Ok(Some(s)) = git_repo.get_git_config_item("nostr.nostate", None) { 188 args.clone_url.clone()
265 s == "true" 189 } else if let Some(repo_ref) = &repo_ref {
266 } else { 190 // TODO dont default to git servers of other maintainers (?)
267 false 191 repo_ref.git_server.clone()
268 }; 192 } else if let Ok(url) = git_repo.get_origin_url() {
269 if no_state { 193 if let Ok(fetch_url) = convert_clone_url_to_https(&url) {
270 println!( 194 vec![fetch_url]
271 "you have opted out of storing git state on nostr, so a git server must be used for the state of authoritative branches, tags and related git objects. you can run `ngit init` again to change this later." 195 } else if url.starts_with("nostr://") {
272 ); 196 // nostr added as origin remote before repo announcement sent
197 vec![]
273 } else { 198 } else {
274 println!( 199 // local repo or custom protocol
275 "your repository state will be stored on nostr, but a git server is still required to store the git objects associated with this state." 200 vec![url]
276 );
277 } 201 }
278 println!(
279 "you can change this git server at any time and even configure multiple servers for redundancy. In this case, the git plugin will push to all of them when using the nostr remote."
280 );
281 println!("only maintainers need write access as PRs are sent over nostr.");
282 println!(
283 "a lightweight git server implementation for use with nostr, requiring no signup, is in development. several providers have shown interest in hosting it. for now use github, codeberg, or self-hosted song, forge, etc."
284 );
285 Interactor::default()
286 .input(
287 PromptInputParms::default()
288 .with_prompt("git server remote url(s) (space seperated)")
289 .with_default(if let Some(repo_ref) = &repo_ref {
290 repo_ref.git_server.clone().join(" ")
291 } else if let Ok(url) = git_repo.get_origin_url() {
292 if let Ok(fetch_url) = convert_clone_url_to_https(&url) {
293 fetch_url
294 } else if url.starts_with("nostr://") {
295 // nostr added as origin remote before repo announcement sent
296 String::new()
297 } else {
298 // local repo or custom protocol
299 url
300 }
301 } else {
302 String::new()
303 }),
304 )?
305 .split(' ')
306 .map(std::string::ToString::to_string)
307 .collect()
308 } else { 202 } else {
309 args.clone_url.clone() 203 vec![]
310 }; 204 };
311 205
312 // TODO: when NIP-66 is functional, use this to reccommend relays and filter out 206 let mut relay_defaults = if args.relays.is_empty() {
313 // relays that won't accept contributors events. NIP-11 'limitations' 207 if let Ok(config) = &repo_config_result {
314 // isn't widely used enough to be usedful.
315
316 let relays: Vec<RelayUrl> = {
317 let mut default = if let Ok(config) = &repo_config_result {
318 config.relays.clone() 208 config.relays.clone()
319 } else if let Some(repo_ref) = &repo_ref { 209 } else if let Some(repo_ref) = &repo_ref {
320 repo_ref 210 repo_ref
@@ -327,82 +217,333 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
327 } else { 217 } else {
328 user_ref.relays.read().clone() 218 user_ref.relays.read().clone()
329 } 219 }
330 .join(" "); 220 } else {
331 'outer: loop { 221 args.relays.clone()
332 let relays: Vec<String> = if args.relays.is_empty() { 222 };
333 Interactor::default() 223
334 .input( 224
335 PromptInputParms::default() 225 let selected_ngit_relays = if has_server_and_relay_flags {
336 .with_prompt("relays") 226 // ignore so a script running `ngit init` can contiue without prompts
337 .with_default(default), 227 vec![]
338 )? 228 } else {
339 .split(' ') 229 let mut options: Vec<String> = guess_at_existing_ngit_relays(
340 .map(std::string::ToString::to_string) 230 repo_ref.as_ref(),
231 &args.relays,
232 &args.clone_url,
233 &identifier,
234 );
235 let mut selections: Vec<bool> = vec![true; options.len()]; // Initialize selections based on existing options
236 let empty = options.is_empty();
237 let fallbacks = vec!["relay.ngit.dev".to_string(), "gitnostr.com".to_string()];
238 for fallback in fallbacks {
239 // Check if any option contains the fallback as a substring
240 if !options.iter().any(|option| option.contains(&fallback)) {
241 options.push(fallback.clone()); // Add fallback if not found
242 selections.push(empty); // mark as selected if no existing ngit relay otherwise not
243 }
244 }
245 let selected = multi_select_with_custom_value(
246 "ngit-relays (ideally use between 2-4)",
247 "ngit-relay",
248 options,
249 selections,
250 normalize_ngit_relay_url,
251 )?;
252 show_multi_input_prompt_success("ngit-relays", &selected);
253 selected
254 };
255
256 // ensure ngit relays are added as git server, relay and blossom entries
257 for ngit_relay in &selected_ngit_relays {
258 if args.clone_url.is_empty() {
259 let clone_url = format_ngit_relay_url_as_clone_url(ngit_relay, &user_ref.public_key, &identifier)?;
260 if !git_server_defaults.contains(&clone_url) {
261 git_server_defaults.push(clone_url);
262 }
263 }
264 if args.clone_url.is_empty() {
265 let relay_url = format_ngit_relay_url_as_relay_url(ngit_relay)?;
266 if !relay_defaults.contains(&relay_url) {
267 relay_defaults.push(relay_url);
268 }
269 }
270 // TODO blossom
271 }
272
273 let no_state = if let Ok(Some(s)) = git_repo.get_git_config_item("nostr.nostate", None) {
274 s == "true"
275 } else {
276 false
277 };
278 if no_state && Interactor::default().confirm(
279 PromptConfirmParms::default()
280 .with_prompt("store state on nostr? required for nostr-permissioned git servers")
281 .with_default(true),
282 )?{
283 // TODO check if ngit-relays in use and if so turn this off:
284 if git_repo.get_git_config_item("nostr.nostate",Some(true)).unwrap_or(None).is_some() {
285 git_repo.remove_git_config_item("nostr.nostate", true)?;
286 } else {
287 git_repo.remove_git_config_item("nostr.nostate", false)?;
288 }
289 }
290
291 let git_server = if args.clone_url.is_empty() {
292 let ngit_relay_git_servers: Vec<String> = git_server_defaults.iter().filter(|s| selected_ngit_relays.iter().any(|r|s.contains(r))).cloned().collect();
293 let mut additional_server_options: Vec<String> = git_server_defaults.iter().filter(|s| ngit_relay_git_servers.iter().any(|r|s.eq(&r))).cloned().collect();
294
295 if simple_mode && !selected_ngit_relays.is_empty() {
296 if additional_server_options.is_empty() {
297 // additional git servers were listed
298 let selected = loop {
299 let selections: Vec<bool> = vec![true; additional_server_options.len()];
300 let selected = multi_select_with_custom_value(
301 "additional git server(s) on top of ngit-relays",
302 "git server remote url",
303 additional_server_options,
304 selections,
305 |s| {
306 CloneUrl::from_str(s)
307 .map(|_| s.to_string())
308 .context(format!("Invalid git server URL format: {s}"))
309 },
310 )?;
311
312 if !selected.is_empty() || Interactor::default().choice(
313 PromptChoiceParms::default()
314 .with_prompt("if you or another maintainer start pushing directly to these, nostr will be out of date")
315 .dont_report()
316 .with_choices(vec![
317 "I'll always push to the nostr remote".to_string(),
318 "change setup".to_string(),
319 ])
320 .with_default(0),
321 )? == 1 {
322 additional_server_options = selected;
323 continue
324 }
325 break selected
326 };
327 show_multi_input_prompt_success("git servers", &selected);
328 let mut combined = ngit_relay_git_servers;
329 combined.extend(selected);
330 combined
331 } else {
332 git_server_defaults
333 }
334 } else {
335 // show all git servers
336 let selections: Vec<bool> = vec![true; git_server_defaults.len()];
337
338 let selected = multi_select_with_custom_value(
339 "git server remote url(s)",
340 "git server remote url",
341 git_server_defaults,
342 selections,
343 |s| {
344 CloneUrl::from_str(s)
345 .map(|_| s.to_string())
346 .context(format!("Invalid git server URL format: {s}"))
347 },
348 )?;
349 show_multi_input_prompt_success("git servers", &selected);
350 selected
351 }
352 } else {
353 git_server_defaults
354 };
355
356 let relays: Vec<RelayUrl> = {
357 if simple_mode {
358 let formatted_selected_ngit_relays: Vec<String> = selected_ngit_relays.iter()
359 .filter_map(|r| format_ngit_relay_url_as_relay_url(r).ok())
360 .collect();
361 let mut options: Vec<String> = relay_defaults.iter()
362 .filter(|s| !formatted_selected_ngit_relays.iter().any(|r| s.as_str() == r))
363 .cloned()
364 .collect();
365
366 let mut selections: Vec<bool> = vec![true; options.len()];
367
368 // add fallback relays as options
369 for relay in client.get_fallback_relays().clone() {
370 if !options.iter().any(|r|r.contains(&relay)) && !formatted_selected_ngit_relays.iter().any(|r|relay.contains(r)) {
371 options.push(relay);
372 selections.push(selections.is_empty());
373 }
374 }
375
376 let selected = multi_select_with_custom_value(
377 "additional nostr relays on top of nostr-relays - 1 or 2 public relays are reccomended",
378 "nostr relay",
379 options,
380 selections,
381 |s| {
382 parse_relay_url(s)
383 .map(|_| s.to_string())
384 .context(format!("Invalid relay URL format: {s}"))
385 },
386 )?;
387 show_multi_input_prompt_success("additional nostr relays", &selected);
388 selected.iter()
389 .filter_map(|r| parse_relay_url(r).ok())
390 .collect()
391 } else {
392
393 let selections: Vec<bool> = vec![true; relay_defaults.len()];
394 if args.relays.is_empty() {
395 let selected = multi_select_with_custom_value(
396 "nostr relays",
397 "nostr relay",
398 relay_defaults,
399 selections,
400 |s| {
401 parse_relay_url(s)
402 .map(|_| s.to_string())
403 .context(format!("Invalid relay URL format: {s}"))
404 },
405 )?;
406 show_multi_input_prompt_success("nostr relays", &selected);
407 selected.iter()
408 .filter_map(|r| parse_relay_url(r).ok())
341 .collect() 409 .collect()
342 } else { 410 } else {
343 args.relays.clone() 411 relay_defaults
344 }; 412 .iter()
345 let mut relay_urls = vec![]; 413 .filter_map(|r| parse_relay_url(r).ok())
346 for r in &relays { 414 .collect()
347 if let Ok(r) = RelayUrl::parse(r) { 415 }
348 relay_urls.push(r); 416 }
349 } else { 417 };
350 eprintln!("{r} is not a valid relay url"); 418
351 default = relays.join(" "); 419 let default_maintainers = {
352 continue 'outer; 420 let mut maintainers = vec![user_ref.public_key];
421 if args.other_maintainers.is_empty() {
422 if let Some(repo_ref) = &repo_ref {
423 for m in &repo_ref.maintainers {
424 if !maintainers.contains(m) {
425 maintainers.push(*m);
426 }
427 }
428 }
429 } else {
430 for m in &args.other_maintainers {
431 if let Ok(pubkey) = PublicKey::from_bech32(m).context("invalid npub") {
432 if !maintainers.contains(&pubkey) {
433 maintainers.push(pubkey);
434 }
353 } 435 }
354 } 436 }
355 break relay_urls;
356 } 437 }
438 maintainers
357 }; 439 };
358 440
359 let web: Vec<String> = if args.web.is_empty() { 441 let maintainers: Vec<PublicKey> = if args.other_maintainers.is_empty() {
360 let gitworkshop_url = NostrUrlDecoded { 442 if default_maintainers.len() == 1
361 original_string: String::new(), 443 && Interactor::default().choice(
362 coordinate: Nip19Coordinate { 444 PromptChoiceParms::default()
363 coordinate: Coordinate { 445 .with_prompt("add other maintainers now?")
364 public_key: user_ref.public_key, 446 .dont_report()
365 kind: Kind::GitRepoAnnouncement, 447 .with_choices(vec![
366 identifier: identifier.clone(), 448 "maybe later".to_string(),
367 }, 449 "add maintainers".to_string(),
368 relays: if let Some(relay) = relays.first() { 450 ])
369 vec![relay.clone()] 451 .with_default(0),
370 } else { 452 )? == 0
371 vec![] 453 {
454 default_maintainers
455 } else {
456 let selections: Vec<bool> = vec![true; default_maintainers.len()];
457
458 let selected = multi_select_with_custom_value(
459 "maintainers",
460 "maintainer npub",
461 default_maintainers
462 .iter()
463 .filter_map(|m| m.to_bech32().ok())
464 .collect(),
465 selections,
466 |s| {
467 extract_npub(s)
468 .map(|_| s.to_string())
469 .context(format!("Invalid npub: {s}"))
372 }, 470 },
373 }, 471 )?;
374 protocol: None, 472 show_multi_input_prompt_success("maintainers", &selected);
375 user: None, 473 selected.iter()
376 nip05: None, 474 .filter_map(|npub| PublicKey::parse(npub).ok())
475 .collect()
377 } 476 }
477 } else {
478 default_maintainers
479 };
480
481 if selected_ngit_relays.is_empty() && git_server.iter().any(|s| s.contains("github.com") || s.contains("codeberg.org")) && Interactor::default().confirm(
482 PromptConfirmParms::default()
483 .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?")
484 .with_default(false),
485 )? {
486 println!("This means people using the nostr URL won't get your latest branch updates.");
487 if Interactor::default().confirm(
488 PromptConfirmParms::default()
489 .with_prompt("opt-out of storing git state on nostr and relay on github for now? you will still receive PRs and issues via nostr")
490 .with_default(true),
491 )? {
492 git_repo.save_git_config_item("nostr.nostate", "true", false)?;
493 }
494 }
495
496 let gitworkshop_url = NostrUrlDecoded {
497 original_string: String::new(),
498 coordinate: Nip19Coordinate {
499 coordinate: Coordinate {
500 public_key: user_ref.public_key,
501 kind: Kind::GitRepoAnnouncement,
502 identifier: identifier.clone(),
503 },
504 relays: if let Some(relay) = relays.first() {
505 vec![relay.clone()]
506 } else {
507 vec![]
508 },
509 },
510 protocol: None,
511 user: None,
512 nip05: None,
513 }
378 .to_string() 514 .to_string()
379 .replace("nostr://", "https://gitworkshop.dev/"); 515 .replace("nostr://", "https://gitworkshop.dev/");
380 Interactor::default() 516
381 .input( 517 let web: Vec<String> = if args.web.is_empty() {
518 let web_default = if let Some(repo_ref) = &repo_ref {
519 if repo_ref
520 .web
521 .clone()
522 .join(" ")
523 // replace legacy gitworkshop.dev url format with new one
524 .contains(format!("https://gitworkshop.dev/repo/{}", &identifier).as_str())
525 {
526 gitworkshop_url.clone()
527 } else {
528 repo_ref.web.clone().join(" ")
529 }
530 } else {
531 gitworkshop_url.clone()
532 };
533
534 if simple_mode {
535 web_default
536 } else {
537 Interactor::default().input(
382 PromptInputParms::default() 538 PromptInputParms::default()
383 .with_prompt("repo website") 539 .with_prompt("repo website")
384 .optional() 540 .optional()
385 .with_default(if let Some(repo_ref) = &repo_ref { 541 .with_default(web_default),
386 if repo_ref
387 .web
388 .clone()
389 .join(" ")
390 // replace legacy gitworkshop.dev url format with new one
391 .contains(
392 format!("https://gitworkshop.dev/repo/{}", &identifier).as_str(),
393 )
394 {
395 gitworkshop_url
396 } else {
397 repo_ref.web.clone().join(" ")
398 }
399 } else {
400 gitworkshop_url
401 }),
402 )? 542 )?
403 .split(' ') 543 }
404 .map(std::string::ToString::to_string) 544 .split(' ')
405 .collect() 545 .map(std::string::ToString::to_string)
546 .collect()
406 } else { 547 } else {
407 args.web.clone() 548 args.web.clone()
408 }; 549 };
@@ -415,32 +556,36 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
415 } else { 556 } else {
416 root_commit.to_string() 557 root_commit.to_string()
417 }; 558 };
418 println!( 559 if simple_mode {
419 "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." 560 earliest_unique_commit
420 ); 561 } else {
421 loop { 562 println!(
422 earliest_unique_commit = Interactor::default().input( 563 "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."
423 PromptInputParms::default() 564 );
424 .with_prompt("earliest unique commit (to help with discoverability)") 565 loop {
425 .with_default(earliest_unique_commit.clone()), 566 earliest_unique_commit = Interactor::default().input(
426 )?; 567 PromptInputParms::default()
427 if let Ok(exists) = git_repo.does_commit_exist(&earliest_unique_commit) { 568 .with_prompt("earliest unique commit (to help with discoverability)")
428 if exists { 569 .with_default(earliest_unique_commit.clone()),
429 break earliest_unique_commit; 570 )?;
571 if let Ok(exists) = git_repo.does_commit_exist(&earliest_unique_commit) {
572 if exists {
573 break earliest_unique_commit;
574 }
575 println!("commit does not exist on current repository");
576 } else {
577 println!("commit id not formatted correctly");
578 }
579 if earliest_unique_commit.len().ne(&40) {
580 println!("commit id must be 40 characters long");
430 } 581 }
431 println!("commit does not exist on current repository");
432 } else {
433 println!("commit id not formatted correctly");
434 }
435 if earliest_unique_commit.len().ne(&40) {
436 println!("commit id must be 40 characters long");
437 } 582 }
438 } 583 }
439 }; 584 };
440 585
441 println!("publishing repostory reference..."); 586 println!("publishing repostory reference...");
442 587
443 let mut repo_ref = RepoRef { 588 let repo_ref = RepoRef {
444 identifier: identifier.clone(), 589 identifier: identifier.clone(),
445 name, 590 name,
446 description, 591 description,
@@ -483,70 +628,34 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
483 false, 628 false,
484 )?; 629 )?;
485 630
486 // if nip05 valid, set nostr git url to use that format 631 // set origin remote
487 let hint_for_nip05_address = { 632 let nostr_url = repo_ref.to_nostr_git_url(&Some(&git_repo)).to_string();
488 if let Some(nip05) = user_ref.metadata.nip05 {
489 let term = Term::stdout();
490 term.write_line(&format!("fetching nip05 details for {nip05}..."))?;
491 if let Ok(nprofile) = nip05::profile(nip05.clone(), None).await {
492 let _ = term.clear_last_lines(1);
493 let _ =
494 save_nip05_to_git_config_cache(&nip05, &nprofile.public_key, &Some(&git_repo));
495 // Normalize URLs before doing the intersection.
496 let repo_relays: HashSet<RelayUrl> = relays
497 .iter()
498 .map(|r| RelayUrl::parse(r.as_str_without_trailing_slash()).unwrap())
499 .collect();
500 let nip05_relays: HashSet<RelayUrl> = nprofile
501 .relays
502 .iter()
503 .map(|r| RelayUrl::parse(r.as_str_without_trailing_slash()).unwrap())
504 .collect();
505 let mut inter = repo_relays.intersection(&nip05_relays);
506
507 repo_ref.set_nostr_git_url(NostrUrlDecoded {
508 original_string: String::new(),
509 nip05: Some(nip05.clone()),
510 coordinate: Nip19Coordinate {
511 coordinate: Coordinate {
512 kind: Kind::GitRepoAnnouncement,
513 public_key: user_ref.public_key,
514 identifier: repo_ref.identifier.clone(),
515 },
516 relays: if inter.next().is_some() || relays.is_empty() {
517 vec![]
518 } else {
519 vec![relays.first().unwrap().clone()]
520 },
521 },
522 protocol: None,
523 user: None,
524 });
525 if inter.next().is_some() {
526 "note: point your NIP-05 relays to one of the repo relays for a cleaner nostr:// remote URL.".to_string()
527 } else {
528 String::new()
529 }
530 } else {
531 "note: could not validate your nip05 address {nip05} which could be used for a shorter nostr:// remote URL.".to_string()
532 }
533 } else {
534 String::new()
535 }
536 };
537 633
538 prompt_to_set_nostr_url_as_origin(&repo_ref, &git_repo).await?; 634 if git_repo.git_repo.find_remote("origin").is_ok() {
635 git_repo.git_repo.remote_set_url("origin", &nostr_url)?;
636 } else {
637 git_repo.git_repo.remote("origin",&nostr_url)?;
638 }
639 thread::sleep(Duration::new(1, 0)); // wait for annoucment event to be receieved and processed by ngit-relays
539 640
540 if !hint_for_nip05_address.is_empty() { 641 if std::env::var("NGITTEST").is_err() { // ignore during tests as git-remote-nostr isn't installed during ngit binary tests
541 println!("{hint_for_nip05_address}"); 642 if let Err(err) = push_main_or_master_branch(&git_repo) {
643 println!("your repository announcement was published to nostr but git push exited with an error: {err}");
644 }
542 } 645 }
543 646
544 // TODO: if no state event exists and there is currently a remote called 647 // println!(
545 // "origin", automtically push rather than waiting for the next commit 648 // "any remote branches beginning with `pr/` are open PRs from contributors. they can submit these by simply pushing a branch with this `pr/` prefix."
649 // );
650 println!("share your repository: {gitworkshop_url}" );
651 println!("clone url: {nostr_url}");
652
546 653
547 // no longer create a new maintainers.yaml file - its too confusing for users 654 // no longer create a new maintainers.yaml file - its too confusing for users
548 // as it falls out of sync with data in nostr event . update if it already 655 // as it falls out of sync with data in nostr event . update if it already
549 // exists 656 // exists
657
658
550 let relays = relays 659 let relays = relays
551 .iter() 660 .iter()
552 .map(std::string::ToString::to_string) 661 .map(std::string::ToString::to_string)
@@ -584,74 +693,308 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
584 Ok(()) 693 Ok(())
585} 694}
586 695
587async fn prompt_to_set_nostr_url_as_origin(repo_ref: &RepoRef, git_repo: &Repo) -> Result<()> { 696fn multi_select_with_custom_value<F>(
588 println!( 697 prompt: &str,
589 "starting from your next commit, when you `git push` to a remote that uses your nostr url, it will store your repository state on nostr and update the state of the git server(s) you just listed." 698 custom_choice_prompt: &str,
590 ); 699 mut choices: Vec<String>,
591 println!( 700 mut defaults: Vec<bool>,
592 "in addition, any remote branches beginning with `pr/` are open PRs from contributors. they can submit these by simply pushing a branch with this `pr/` prefix." 701 validate_choice: F,
593 ); 702) -> Result<Vec<String>>
594 703where
595 if let Ok(origin_remote) = git_repo.git_repo.find_remote("origin") { 704 F: Fn(&str) -> Result<String>,
596 if let Some(origin_url) = origin_remote.url() { 705{
597 if let Ok(nostr_url) = 706 let mut selected_choices = vec![];
598 NostrUrlDecoded::parse_and_resolve(origin_url, &Some(git_repo)).await 707
599 { 708 // Loop to allow users to add more choices
600 if nostr_url.coordinate.identifier == repo_ref.identifier { 709 loop {
601 if nostr_url.coordinate.public_key == repo_ref.trusted_maintainer { 710 // Add 'add another' option at the end of the choices
602 return Ok(()); 711 let mut current_choices = choices.clone();
712 current_choices.push(if current_choices.is_empty() {
713 "add".to_string()
714 } else {
715 "add another".to_string()
716 });
717
718 // Create default selections based on the provided defaults
719 let mut current_defaults = defaults.clone();
720 current_defaults.push(current_choices.len() == 1); // 'add another' should not be selected by default
721
722 // Prompt for selections
723 let selected_indices: Vec<usize> = Interactor::default().multi_choice(
724 PromptMultiChoiceParms::default()
725 .with_prompt(prompt)
726 .dont_report()
727 .with_choices(current_choices.clone())
728 .with_defaults(current_defaults),
729 )?;
730
731 // Collect selected choices
732 selected_choices.clear(); // Clear previous selections to update
733 for &index in &selected_indices {
734 if index < choices.len() {
735 // Exclude 'add another' option
736 selected_choices.push(choices[index].clone());
737 }
738 }
739
740 // Check if 'add another' was selected
741 if selected_indices.contains(&(choices.len())) {
742 // Last index is 'add another'
743 let mut new_choice: String;
744 loop {
745 new_choice = Interactor::default().input(
746 PromptInputParms::default()
747 .with_prompt(custom_choice_prompt)
748 .dont_report()
749 .optional(),
750 )?;
751
752 if new_choice.is_empty() {
753 break;
754 }
755 // Validate the new choice
756 match validate_choice(&new_choice) {
757 Ok(valid_choice) => {
758 new_choice = valid_choice; // Use the fixed version of the input
759 break; // Valid choice, exit the loop
760 }
761 Err(err) => {
762 // Inform the user about the validation error
763 println!("Error: {err}");
603 } 764 }
604 // origin is set to a different trusted maintainer
605 println!(
606 "warning: currently git remote 'origin' is set to a different trusted maintainer with the same identifier"
607 );
608 ask_to_set_origin_remote(repo_ref, git_repo)?;
609 } else {
610 // origin is linked to a different identifier
611 println!(
612 "warning: currently git remote 'origin' is set to a different repository identifier"
613 );
614 ask_to_set_origin_remote(repo_ref, git_repo)?;
615 } 765 }
616 } else { 766 }
617 // remote is non-nostr url 767
618 ask_to_set_origin_remote(repo_ref, git_repo)?; 768 // Add the new choice to the choices vector
769 if !new_choice.is_empty() {
770 choices.push(new_choice.clone()); // Add new choice to the end of the list
771 selected_choices.push(new_choice); // Automatically select the new choice
772 defaults.push(true); // Set the new choice as selected by default
619 } 773 }
620 } else { 774 } else {
621 // no origin remote 775 // Exit the loop if 'add another' was not selected
622 ask_to_create_new_origin_remote(repo_ref, git_repo)?; 776 break;
623 } 777 }
624 } 778 }
625 println!("contributors can clone your repository by installing ngit and using this clone url:");
626 println!("{}", repo_ref.to_nostr_git_url(&Some(git_repo)));
627 779
628 Ok(()) 780 Ok(selected_choices)
629} 781}
630 782
631fn ask_to_set_origin_remote(repo_ref: &RepoRef, git_repo: &Repo) -> Result<()> { 783fn guess_at_existing_ngit_relays(
632 if Interactor::default().confirm( 784 repo_ref: Option<&RepoRef>,
633 PromptConfirmParms::default() 785 args_relays: &[String],
634 .with_default(true) 786 args_clone_url: &[String],
635 .with_prompt("set remote \"origin\" to the nostr url of your repository?"), 787 identifier: &str,
636 )? { 788) -> Vec<String> {
637 git_repo.git_repo.remote_set_url( 789 // Collect clone URLs from arguments or repo_ref
638 "origin", 790 let clone_urls: Vec<String> = if !args_clone_url.is_empty() {
639 &repo_ref.to_nostr_git_url(&Some(git_repo)).to_string(), 791 args_clone_url.to_vec()
640 )?; 792 } else if let Some(repo) = repo_ref {
793 repo.git_server.clone()
794 } else {
795 Vec::new()
796 };
797
798 // Collect relays from arguments or repo_ref
799 let relays: Vec<RelayUrl> = if !args_relays.is_empty() {
800 args_relays
801 .iter()
802 .filter_map(|r| RelayUrl::parse(r).ok())
803 .collect()
804 } else if let Some(repo) = repo_ref {
805 repo.relays.clone()
806 } else {
807 Vec::new()
808 };
809
810 let mut existing_ngit_relays = Vec::new();
811 for url in &clone_urls {
812 if let Ok(npub) = extract_npub(url) {
813 let postfix = format!("/{npub}/{identifier}.git");
814 if url.contains(&postfix) {
815 if let Ok(ngit_relay_url) = normalize_ngit_relay_url(url) {
816 let is_also_relay = relays.iter()
817 .any(|r| normalize_ngit_relay_url(&r.to_string()).is_ok_and(|r| r.eq(&ngit_relay_url)));
818 if !existing_ngit_relays.contains(&ngit_relay_url) && is_also_relay {
819 existing_ngit_relays.push(ngit_relay_url);
820
821 }
822 }
823 }
824 }
641 } 825 }
642 Ok(()) 826 existing_ngit_relays
643} 827}
644 828
645fn ask_to_create_new_origin_remote(repo_ref: &RepoRef, git_repo: &Repo) -> Result<()> { 829fn normalize_ngit_relay_url(url: &str) -> Result<String> {
646 if Interactor::default().confirm( 830 // Parse the URL and handle errors
647 PromptConfirmParms::default() 831 let mut parsed = Url::parse(url)
648 .with_default(true) 832 .or_else(|_| Url::parse(&format!("https://{url}")))
649 .with_prompt("set remote \"origin\" to the nostr url of your repository?"), 833 .context(format!("{url} not a valid ngit relay URL"))?;
650 )? { 834 if parsed.host_str().is_none() {
651 git_repo.git_repo.remote( 835 // so sub.domain.org gets identifier as host in "sub.domain.org"
652 "origin", 836 parsed = Url::parse(&format!("https://{url}"))?;
653 &repo_ref.to_nostr_git_url(&Some(git_repo)).to_string(), 837 }
654 )?; 838
839 // Extract the scheme, host, port, and path
840 let scheme = parsed.scheme();
841 let host = parsed.host_str().context(format!(
842 "{url} not a ngit relay url reference: missing host in URL {parsed}"
843 ))?;
844 let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
845 let path = parsed.path();
846
847 // Normalize the URL based on the scheme and path
848 let mut normalized_url = match scheme {
849 "ws" | "http" => format!("http://{host}{port}{path}"),
850 _ => format!("{host}{port}{path}"),
851 };
852
853 // If the normalized URL contains "npub1", remove "npub1" and everything after
854 // it
855 if let Some(pos) = normalized_url.find("npub1") {
856 normalized_url.truncate(pos); // Keep everything before "npub1"
857 }
858 // Return the normalized URL
859 Ok(normalized_url.trim_end_matches('/').to_string())
860}
861
862fn format_ngit_relay_url_as_clone_url(url:&str, public_key:&PublicKey, identifier: &str) -> Result<String> {
863 let ngit_relay_url = normalize_ngit_relay_url(url)?;
864 if ngit_relay_url.contains("http://") {
865 return Ok(format!("{ngit_relay_url}/{}/{identifier}.git", public_key.to_bech32()?))
866 }
867 Ok(format!("https://{ngit_relay_url}/{}/{identifier}.git", public_key.to_bech32()?))
868}
869
870fn format_ngit_relay_url_as_relay_url(url:&str) -> Result<String> {
871 let ngit_relay_url = normalize_ngit_relay_url(url)?;
872 if ngit_relay_url.contains("http://") {
873 return Ok(ngit_relay_url.replace("http://", "ws://"))
874 }
875 Ok(format!("wss://{ngit_relay_url}"))
876}
877
878fn extract_npub(s: &str) -> Result<&str> {
879 // Find the starting index of "npub1"
880 if let Some(start) = s.find("npub1") {
881 let mut end = start + 5; // Start after "npub1"
882
883 // Move the end index to include valid characters (0-9, a-z)
884 while end < s.len() && s[end..=end].chars().all(|c| c.is_ascii_alphanumeric()) {
885 end += 1;
886 }
887 // Extract the npub substring
888 let npub = &s[start..end];
889 // Attempt to create a PublicKey from the extracted npub
890 PublicKey::from_bech32(npub).context("invalid npub")?;
891 Ok(npub)
892 } else {
893 bail!("No npub found")
894 }
895}
896
897fn parse_relay_url(s: &str) -> Result<RelayUrl> {
898 // Attempt to parse the original string
899 match RelayUrl::parse(s) {
900 Ok(url) => Ok(url),
901 Err(original_err) => {
902 // If parsing fails, prefix with "wss://" and try again
903 let prefixed = format!("wss://{s}");
904 RelayUrl::parse(&prefixed).map_err(|_| original_err)
905 }
906 }
907 .context(format!("failed to parse relay url: {s}"))
908}
909
910pub fn show_multi_input_prompt_success(label: &str, values: &[String]) {
911 let values_str: Vec<&str> = values.iter().map(std::string::String::as_str).collect();
912 eprintln!("{}", {
913 let mut s = String::new();
914 let _ = ColorfulTheme::default().format_multi_select_prompt_selection(&mut s, label, &values_str);
915 s
916 });
917}
918
919fn push_main_or_master_branch(git_repo: &Repo) -> Result<()> {
920 let main_branch_name = {
921 let local_branches = git_repo
922 .get_local_branch_names()
923 .context("failed to find any local branches")?;
924 if local_branches.contains(&"main".to_string()) {
925 "main"
926 } else if local_branches.contains(&"master".to_string()) {
927 "master"
928 } else {
929 bail!("set remote origin to nostr url and tried to push main or master branch but they dont exist yet")
930 }
931 };
932
933 println!("set remote origin to nostr url and pushing {main_branch_name} branch.");
934
935 let command = "git";
936 let args = ["push", "origin", "-u", main_branch_name];
937
938 // Spawn the process
939 let mut child = Command::new(command)
940 .args(args)
941 .stdout(Stdio::inherit()) // Redirect stdout to the console
942 .stderr(Stdio::inherit()) // Redirect stderr to the console
943 .spawn()
944 .context("Failed to start git push process")?;
945
946 // Wait for the process to finish
947 let exit_status = child.wait().context("Failed to start git push process")?;
948
949 // Check the exit status
950 if exit_status.success() {
951 Ok(())
952 } else {
953 bail!("git push process exited with an error: {}", exit_status);
954 }
955}
956
957
958#[cfg(test)]
959mod tests {
960 use anyhow::Result;
961
962 use super::*;
963
964 #[test]
965 fn normalize_ngit_relay_url_all_checks() -> Result<()> {
966 let test_cases = vec![
967 ("https://sub.domain.org", "sub.domain.org"),
968 ("wss://sub.domain.org", "sub.domain.org"),
969 ("sub.domain.org", "sub.domain.org"),
970 ("http://sub.domain.org", "http://sub.domain.org"),
971 ("ws://sub.domain.org", "http://sub.domain.org"),
972 ("http://localhost", "http://localhost"),
973 ("localhost", "localhost"),
974 ("https://sub.domain.org:8080", "sub.domain.org:8080"),
975 ("http://sub.domain.org:8080", "http://sub.domain.org:8080"),
976 ("sub.domain.org:8080", "sub.domain.org:8080"),
977 ("https://sub.domain.org/path/to", "sub.domain.org/path/to"),
978 (
979 "https://sub.domain.org:8080/path/to",
980 "sub.domain.org:8080/path/to",
981 ),
982 (
983 "https://sub.domain.org/npub143675782648/to.git",
984 "sub.domain.org",
985 ),
986 (
987 "https://sub.domain.org/path/npub143675782648/to.git",
988 "sub.domain.org/path",
989 ),
990 ("https://sub.domain.org/", "sub.domain.org"),
991 ("http://sub.domain.org/", "http://sub.domain.org"),
992 ];
993
994 for (input, expected) in test_cases {
995 let normalized = normalize_ngit_relay_url(input)?;
996 assert_eq!(normalized, expected);
997 }
998 Ok(())
655 } 999 }
656 Ok(())
657} 1000}
diff --git a/src/lib/login/fresh.rs b/src/lib/login/fresh.rs
index 76998ff..683d4af 100644
--- a/src/lib/login/fresh.rs
+++ b/src/lib/login/fresh.rs
@@ -210,7 +210,7 @@ pub async fn get_fresh_nsec_signer() -> Result<
210 } 210 }
211} 211}
212 212
213fn show_prompt_success(label: &str, value: &str) { 213pub fn show_prompt_success(label: &str, value: &str) {
214 eprintln!("{}", { 214 eprintln!("{}", {
215 let mut s = String::new(); 215 let mut s = String::new();
216 let _ = ColorfulTheme::default().format_input_prompt_selection(&mut s, label, value); 216 let _ = ColorfulTheme::default().format_input_prompt_selection(&mut s, label, value);
diff --git a/tests/ngit_init.rs b/tests/ngit_init.rs
index 409bd51..70a3f57 100644
--- a/tests/ngit_init.rs
+++ b/tests/ngit_init.rs
@@ -11,15 +11,6 @@ fn expect_msgs_first(p: &mut CliTester) -> Result<()> {
11 Ok(()) 11 Ok(())
12} 12}
13 13
14fn expect_prompt_to_set_origin(p: &mut CliTester) -> Result<()> {
15 p.expect_confirm_eventually(
16 "set remote \"origin\" to the nostr url of your repository?",
17 Some(true),
18 )?
19 .succeeds_with(Some(false))?;
20 Ok(())
21}
22
23fn get_cli_args() -> Vec<&'static str> { 14fn get_cli_args() -> Vec<&'static str> {
24 vec![ 15 vec![
25 "--nsec", 16 "--nsec",
@@ -101,7 +92,6 @@ mod when_repo_not_previously_claimed {
101 // // check relay had the right number of events 92 // // check relay had the right number of events
102 let cli_tester_handle = std::thread::spawn(move || -> Result<()> { 93 let cli_tester_handle = std::thread::spawn(move || -> Result<()> {
103 let mut p = cli_tester_init(&git_repo); 94 let mut p = cli_tester_init(&git_repo);
104 expect_prompt_to_set_origin(&mut p)?;
105 p.expect_end_eventually()?; 95 p.expect_end_eventually()?;
106 for p in [51, 52, 53, 55, 56, 57] { 96 for p in [51, 52, 53, 55, 56, 57] {
107 relay::shutdown_relay(8000 + p)?; 97 relay::shutdown_relay(8000 + p)?;
@@ -224,7 +214,6 @@ mod when_repo_not_previously_claimed {
224 // // check relay had the right number of events 214 // // check relay had the right number of events
225 let cli_tester_handle = std::thread::spawn(move || -> Result<()> { 215 let cli_tester_handle = std::thread::spawn(move || -> Result<()> {
226 let mut p = cli_tester_init(&git_repo); 216 let mut p = cli_tester_init(&git_repo);
227 expect_prompt_to_set_origin(&mut p)?;
228 p.expect_end_eventually()?; 217 p.expect_end_eventually()?;
229 for p in [51, 52, 53, 55, 56, 57] { 218 for p in [51, 52, 53, 55, 56, 57] {
230 relay::shutdown_relay(8000 + p)?; 219 relay::shutdown_relay(8000 + p)?;
@@ -495,7 +484,6 @@ mod when_repo_not_previously_claimed {
495 ], 484 ],
496 1, 485 1,
497 )?; 486 )?;
498 expect_prompt_to_set_origin(&mut p)?;
499 p.expect_end_eventually()?; 487 p.expect_end_eventually()?;
500 for p in [51, 52, 53, 55, 56, 57] { 488 for p in [51, 52, 53, 55, 56, 57] {
501 relay::shutdown_relay(8000 + p)?; 489 relay::shutdown_relay(8000 + p)?;