upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin/ngit/sub_commands
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-08-07 17:52:22 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2025-08-07 17:52:22 +0100
commit92c2362a9bed1bc1f256e7948e087c4102b7c4f9 (patch)
tree700bf840ba52a8bd576afcfbe532a669f104dfcb /src/bin/ngit/sub_commands
parent8724af191f520a822214109f75a1851856c74fd2 (diff)
parentfa7adf840ac2d78defee398a61b60888f615622a (diff)
Merge branch 'add-prs-to-ngit-send'
Diffstat (limited to 'src/bin/ngit/sub_commands')
-rw-r--r--src/bin/ngit/sub_commands/init.rs137
-rw-r--r--src/bin/ngit/sub_commands/send.rs499
-rw-r--r--src/bin/ngit/sub_commands/sync.rs66
3 files changed, 481 insertions, 221 deletions
diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs
index eaaf83d..98daee4 100644
--- a/src/bin/ngit/sub_commands/init.rs
+++ b/src/bin/ngit/sub_commands/init.rs
@@ -9,15 +9,17 @@ use std::{
9 9
10use anyhow::{Context, Result, bail}; 10use anyhow::{Context, Result, bail};
11use console::{Style, Term}; 11use console::{Style, Term};
12use dialoguer::theme::{ColorfulTheme, Theme};
13use ngit::{ 12use ngit::{
14 UrlWithoutSlash, 13 UrlWithoutSlash,
15 cli_interactor::{PromptChoiceParms, PromptConfirmParms, PromptMultiChoiceParms}, 14 cli_interactor::{
15 PromptChoiceParms, PromptConfirmParms, multi_select_with_custom_value,
16 show_multi_input_prompt_success,
17 },
16 client::{Params, send_events}, 18 client::{Params, send_events},
17 git::nostr_url::{CloneUrl, NostrUrlDecoded}, 19 git::nostr_url::{CloneUrl, NostrUrlDecoded},
18 repo_ref::{ 20 repo_ref::{
19 detect_existing_grasp_servers, extract_npub, extract_pks, normalize_grasp_server_url, 21 detect_existing_grasp_servers, extract_npub, extract_pks,
20 save_repo_config_to_yaml, 22 format_grasp_server_url_as_relay_url, normalize_grasp_server_url, save_repo_config_to_yaml,
21 }, 23 },
22}; 24};
23use nostr::{ 25use nostr::{
@@ -266,11 +268,23 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
266 ); 268 );
267 let mut selections: Vec<bool> = vec![true; options.len()]; // Initialize selections based on existing options 269 let mut selections: Vec<bool> = vec![true; options.len()]; // Initialize selections based on existing options
268 let empty = options.is_empty(); 270 let empty = options.is_empty();
271 for user_grasp_option in user_ref.grasp_list.urls {
272 // Check if any option contains the user_grasp_option as a substring
273 if !options
274 .iter()
275 .any(|option| option.contains(user_grasp_option.as_str()))
276 {
277 options.push(user_grasp_option.to_string()); // Add if not found
278 selections.push(empty); // mark as selected if no existing grasp otherwise not
279 }
280 }
281
282 let empty = options.is_empty();
269 for fallback in fallback_grasp_servers { 283 for fallback in fallback_grasp_servers {
270 // Check if any option contains the fallback as a substring 284 // Check if any option contains the fallback as a substring
271 if !options.iter().any(|option| option.contains(fallback)) { 285 if !options.iter().any(|option| option.contains(fallback)) {
272 options.push(fallback.clone()); // Add fallback if not found 286 options.push(fallback.clone()); // Add fallback if not found
273 selections.push(empty); // mark as selected if no existing ngit relay otherwise not 287 selections.push(empty); // mark as selected if no existing selections otherwise not
274 } 288 }
275 } 289 }
276 let selected = multi_select_with_custom_value( 290 let selected = multi_select_with_custom_value(
@@ -727,6 +741,11 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
727 web, 741 web,
728 relays: relays.clone(), 742 relays: relays.clone(),
729 blossoms, 743 blossoms,
744 hashtags: if let Some(repo_ref) = repo_ref {
745 repo_ref.hashtags
746 } else {
747 vec![]
748 },
730 trusted_maintainer: user_ref.public_key, 749 trusted_maintainer: user_ref.public_key,
731 maintainers_without_annoucnement: None, 750 maintainers_without_annoucnement: None,
732 maintainers: maintainers.clone(), 751 maintainers: maintainers.clone(),
@@ -848,93 +867,6 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
848 Ok(()) 867 Ok(())
849} 868}
850 869
851fn multi_select_with_custom_value<F>(
852 prompt: &str,
853 custom_choice_prompt: &str,
854 mut choices: Vec<String>,
855 mut defaults: Vec<bool>,
856 validate_choice: F,
857) -> Result<Vec<String>>
858where
859 F: Fn(&str) -> Result<String>,
860{
861 let mut selected_choices = vec![];
862
863 // Loop to allow users to add more choices
864 loop {
865 // Add 'add another' option at the end of the choices
866 let mut current_choices = choices.clone();
867 current_choices.push(if current_choices.is_empty() {
868 "add".to_string()
869 } else {
870 "add another".to_string()
871 });
872
873 // Create default selections based on the provided defaults
874 let mut current_defaults = defaults.clone();
875 current_defaults.push(current_choices.len() == 1); // 'add another' should not be selected by default
876
877 // Prompt for selections
878 let selected_indices: Vec<usize> = Interactor::default().multi_choice(
879 PromptMultiChoiceParms::default()
880 .with_prompt(prompt)
881 .dont_report()
882 .with_choices(current_choices.clone())
883 .with_defaults(current_defaults),
884 )?;
885
886 // Collect selected choices
887 selected_choices.clear(); // Clear previous selections to update
888 for &index in &selected_indices {
889 if index < choices.len() {
890 // Exclude 'add another' option
891 selected_choices.push(choices[index].clone());
892 }
893 }
894
895 // Check if 'add another' was selected
896 if selected_indices.contains(&(choices.len())) {
897 // Last index is 'add another'
898 let mut new_choice: String;
899 loop {
900 new_choice = Interactor::default().input(
901 PromptInputParms::default()
902 .with_prompt(custom_choice_prompt)
903 .dont_report()
904 .optional(),
905 )?;
906
907 if new_choice.is_empty() {
908 break;
909 }
910 // Validate the new choice
911 match validate_choice(&new_choice) {
912 Ok(valid_choice) => {
913 new_choice = valid_choice; // Use the fixed version of the input
914 break; // Valid choice, exit the loop
915 }
916 Err(err) => {
917 // Inform the user about the validation error
918 println!("Error: {err}");
919 }
920 }
921 }
922
923 // Add the new choice to the choices vector
924 if !new_choice.is_empty() {
925 choices.push(new_choice.clone()); // Add new choice to the end of the list
926 selected_choices.push(new_choice); // Automatically select the new choice
927 defaults.push(true); // Set the new choice as selected by default
928 }
929 } else {
930 // Exit the loop if 'add another' was not selected
931 break;
932 }
933 }
934
935 Ok(selected_choices)
936}
937
938fn format_grasp_server_url_as_clone_url( 870fn format_grasp_server_url_as_clone_url(
939 url: &str, 871 url: &str,
940 public_key: &PublicKey, 872 public_key: &PublicKey,
@@ -953,14 +885,6 @@ fn format_grasp_server_url_as_clone_url(
953 )) 885 ))
954} 886}
955 887
956fn format_grasp_server_url_as_relay_url(url: &str) -> Result<String> {
957 let grasp_server_url = normalize_grasp_server_url(url)?;
958 if grasp_server_url.contains("http://") {
959 return Ok(grasp_server_url.replace("http://", "ws://"));
960 }
961 Ok(format!("wss://{grasp_server_url}"))
962}
963
964fn format_grasp_server_url_as_blossom_url(url: &str) -> Result<String> { 888fn format_grasp_server_url_as_blossom_url(url: &str) -> Result<String> {
965 let grasp_server_url = normalize_grasp_server_url(url)?; 889 let grasp_server_url = normalize_grasp_server_url(url)?;
966 if grasp_server_url.contains("http://") { 890 if grasp_server_url.contains("http://") {
@@ -982,19 +906,6 @@ fn parse_relay_url(s: &str) -> Result<RelayUrl> {
982 .context(format!("failed to parse relay url: {s}")) 906 .context(format!("failed to parse relay url: {s}"))
983} 907}
984 908
985pub fn show_multi_input_prompt_success(label: &str, values: &[String]) {
986 let values_str: Vec<&str> = values.iter().map(std::string::String::as_str).collect();
987 eprintln!("{}", {
988 let mut s = String::new();
989 let _ = ColorfulTheme::default().format_multi_select_prompt_selection(
990 &mut s,
991 label,
992 &values_str,
993 );
994 s
995 });
996}
997
998fn push_main_or_master_branch(git_repo: &Repo) -> Result<()> { 909fn push_main_or_master_branch(git_repo: &Repo) -> Result<()> {
999 let main_branch_name = { 910 let main_branch_name = {
1000 let local_branches = git_repo 911 let local_branches = git_repo
diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs
index 8b49e37..3ae941f 100644
--- a/src/bin/ngit/sub_commands/send.rs
+++ b/src/bin/ngit/sub_commands/send.rs
@@ -1,12 +1,32 @@
1use std::path::Path; 1use std::{path::Path, str::FromStr, thread, time::Duration};
2 2
3use anyhow::{Context, Result, bail}; 3use anyhow::{Context, Result, bail};
4use console::Style; 4use console::Style;
5use ngit::{ 5use ngit::{
6 cli_interactor::{
7 PromptChoiceParms, multi_select_with_custom_value, show_multi_input_prompt_success,
8 },
6 client::{Params, send_events}, 9 client::{Params, send_events},
7 git_events::{EventRefType, generate_cover_letter_and_patch_events}, 10 git::nostr_url::CloneUrl,
11 git_events::{
12 EventRefType, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE,
13 generate_cover_letter_and_patch_events,
14 },
15 push::push_refs_and_generate_pr_or_pr_update_event,
16 repo_ref::{
17 format_grasp_server_url_as_clone_url, format_grasp_server_url_as_relay_url,
18 is_grasp_server_in_list, normalize_grasp_server_url,
19 },
20 utils::proposal_tip_is_pr_or_pr_update,
21};
22use nostr::{
23 ToBech32,
24 event::{Event, Kind},
25 nips::{
26 nip01::Coordinate,
27 nip19::{Nip19Coordinate, Nip19Event},
28 },
8}; 29};
9use nostr::{ToBech32, nips::nip19::Nip19Event};
10use nostr_sdk::hashes::sha1::Hash as Sha1Hash; 30use nostr_sdk::hashes::sha1::Hash as Sha1Hash;
11 31
12use crate::{ 32use crate::{
@@ -60,12 +80,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
60 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; 80 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
61 } 81 }
62 82
63 let (root_proposal_id, mention_tags) = 83 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
64 get_root_proposal_id_and_mentions_from_in_reply_to(git_repo.get_path()?, &args.in_reply_to) 84
85 let (root_proposal, mention_tags) =
86 get_root_proposal_and_mentions_from_in_reply_to(git_repo.get_path()?, &args.in_reply_to)
65 .await?; 87 .await?;
66 88
67 if let Some(root_ref) = args.in_reply_to.first() { 89 if let Some(root_ref) = args.in_reply_to.first() {
68 if root_proposal_id.is_some() { 90 if root_proposal.is_some() {
69 println!("creating proposal revision for: {root_ref}"); 91 println!("creating proposal revision for: {root_ref}");
70 } 92 }
71 } 93 }
@@ -104,41 +126,38 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
104 let (first_commit_ahead, behind) = 126 let (first_commit_ahead, behind) =
105 git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?; 127 git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?;
106 128
107 // check proposal ahead of origin/main 129 check_commits_are_suitable_for_proposal(
108 if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm( 130 &first_commit_ahead,
109 PromptConfirmParms::default() 131 &commits,
110 .with_prompt( 132 &behind,
111 format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1) 133 main_branch_name,
112 ) 134 &main_tip,
113 .with_default(false) 135 )?;
114 ).context("failed to get confirmation response from interactor confirm")? { 136
115 bail!("aborting because selected commits were ahead of origin/master"); 137 let as_pr = {
116 } 138 if let Some(root_proposal) = &root_proposal {
117 139 proposal_tip_is_pr_or_pr_update(git_repo_path, &repo_ref, &root_proposal.id).await?
118 // check if a selected commit is already in origin 140 } else {
119 if commits.iter().any(|c| c.eq(&main_tip)) { 141 false
120 if !Interactor::default().confirm(
121 PromptConfirmParms::default()
122 .with_prompt(
123 format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?")
124 )
125 .with_default(false)
126 ).context("failed to get confirmation response from interactor confirm")? {
127 bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'");
128 } 142 }
129 } 143 } || git_repo.are_commits_too_big_for_patches(&commits);
130 // check proposal isn't behind origin/main
131 else if !behind.is_empty() && !Interactor::default().confirm(
132 PromptConfirmParms::default()
133 .with_prompt(
134 format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len())
135 )
136 .with_default(false)
137 ).context("failed to get confirmation response from interactor confirm")? {
138 bail!("aborting so commits can be rebased");
139 }
140 144
141 let title = if args.no_cover_letter { 145 let title = if as_pr {
146 match &args.title {
147 Some(t) => Some(t.clone()),
148 None => {
149 if root_proposal.is_none() {
150 Some(
151 Interactor::default()
152 .input(PromptInputParms::default().with_prompt("title"))?
153 .clone(),
154 )
155 } else {
156 None
157 }
158 }
159 }
160 } else if args.no_cover_letter {
142 None 161 None
143 } else { 162 } else {
144 match &args.title { 163 match &args.title {
@@ -168,7 +187,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
168 t.clone() 187 t.clone()
169 } else { 188 } else {
170 Interactor::default() 189 Interactor::default()
171 .input(PromptInputParms::default().with_prompt("cover letter description"))? 190 .input(PromptInputParms::default().with_prompt("description"))?
172 .clone() 191 .clone()
173 }, 192 },
174 )) 193 ))
@@ -176,7 +195,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
176 None 195 None
177 }; 196 };
178 197
179 let (signer, user_ref, _) = login::login_or_signup( 198 let (signer, mut user_ref, _) = login::login_or_signup(
180 &Some(&git_repo), 199 &Some(&git_repo),
181 &extract_signer_cli_arguments(cli_args).unwrap_or(None), 200 &extract_signer_cli_arguments(cli_args).unwrap_or(None),
182 &cli_args.password, 201 &cli_args.password,
@@ -187,42 +206,316 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
187 206
188 client.set_signer(signer.clone()).await; 207 client.set_signer(signer.clone()).await;
189 208
190 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
191
192 // oldest first 209 // oldest first
193 commits.reverse(); 210 commits.reverse();
194 211
195 let events = generate_cover_letter_and_patch_events( 212 let events = if as_pr {
196 cover_letter_title_description.clone(), 213 let mut to_try = vec![];
197 &git_repo, 214 let mut tried = vec![];
198 &commits, 215 let repo_grasps = repo_ref.grasp_servers();
199 &signer, 216 // if the user already has a fork, or is a maintainer, use those git servers
200 &repo_ref, 217 let mut user_repo_ref = get_repo_ref_from_cache(
201 &root_proposal_id, 218 Some(git_repo_path),
202 &mention_tags, 219 &Nip19Coordinate {
203 ) 220 coordinate: Coordinate {
204 .await?; 221 kind: nostr::event::Kind::GitRepoAnnouncement,
205 222 public_key: user_ref.public_key,
206 println!( 223 identifier: repo_ref.identifier.clone(),
207 "posting {} patch{} {} a covering letter...", 224 },
208 if cover_letter_title_description.is_none() { 225 relays: vec![],
209 events.len() 226 },
210 } else { 227 )
211 events.len() - 1 228 .await
212 }, 229 .ok();
213 if cover_letter_title_description.is_none() && events.len().eq(&1) 230 if let Some(user_repo_ref) = &user_repo_ref {
214 || cover_letter_title_description.is_some() && events.len().eq(&2) 231 for url in &user_repo_ref.git_server {
215 { 232 if CloneUrl::from_str(url).is_ok() {
216 "" 233 to_try.push(url.clone());
217 } else { 234 }
218 "es" 235 }
219 }, 236 }
220 if cover_letter_title_description.is_none() { 237 if !to_try.is_empty() || !repo_grasps.is_empty() {
221 "without" 238 println!(
239 "pushing proposal refs to {}",
240 if repo_ref.maintainers.contains(&user_ref.public_key) {
241 "repository git servers"
242 } else if to_try.is_empty() {
243 "repository grasp servers"
244 } else if repo_grasps.is_empty() {
245 "the git servers listed in your fork"
246 } else {
247 "the git servers listed in your fork and repository grasp servers"
248 }
249 );
222 } else { 250 } else {
223 "with" 251 println!(
252 "The repository doesn't list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request."
253 );
254 }
255 // also use repo grasp servers
256 for url in &repo_ref.git_server {
257 if is_grasp_server_in_list(url, &repo_grasps) && !to_try.contains(url) {
258 to_try.push(url.clone());
259 }
224 } 260 }
225 ); 261
262 let mut git_ref = None;
263 let events = loop {
264 let (events, _server_responses) = push_refs_and_generate_pr_or_pr_update_event(
265 &git_repo,
266 &repo_ref,
267 commits.last().context("no commits")?,
268 &user_ref,
269 root_proposal.as_ref(),
270 &cover_letter_title_description,
271 &to_try,
272 git_ref.clone(),
273 &signer,
274 &console::Term::stdout(),
275 )
276 .await?;
277 for url in to_try {
278 tried.push(url);
279 }
280 to_try = vec![];
281 if let Some(events) = events {
282 break events;
283 }
284 // fallback to creating user personal-fork on their grasp servers
285 let untried_user_grasp_servers: Vec<String> = user_ref
286 .grasp_list
287 .urls
288 .iter()
289 .map(std::string::ToString::to_string)
290 .filter(|g| {
291 // is a grasp server not in list of tried
292 !is_grasp_server_in_list(g, &tried)
293 })
294 .collect();
295
296 if untried_user_grasp_servers.is_empty()
297 && Interactor::default().choice(
298 PromptChoiceParms::default()
299 .with_prompt("choose alternative git server")
300 .dont_report()
301 .with_choices(vec![
302 "choose grasp server(s)".to_string(),
303 "enter a git repo url with write permission".to_string(),
304 ])
305 .with_default(0),
306 )? == 1
307 {
308 loop {
309 let clone_url = Interactor::default()
310 .input(
311 PromptInputParms::default()
312 .with_prompt("git repo url with write permission"),
313 )?
314 .clone();
315 if CloneUrl::from_str(&clone_url).is_ok() {
316 to_try.push(clone_url);
317 let mut git_ref_or_branch_name = Interactor::default()
318 .input(
319 PromptInputParms::default()
320 .with_prompt("ref / branch name")
321 .with_default(
322 git_ref.unwrap_or("refs/nostr/<event-id>".to_string()),
323 ),
324 )?
325 .clone();
326 if !git_ref_or_branch_name.starts_with("refs/") {
327 git_ref_or_branch_name = format!("refs/heads/{git_ref_or_branch_name}");
328 }
329 git_ref = Some(git_ref_or_branch_name);
330 break;
331 }
332 println!("invalid clone url");
333 }
334 continue;
335 }
336
337 let mut new_grasp_server_events: Vec<Event> = vec![];
338
339 let grasp_servers = if untried_user_grasp_servers.is_empty() {
340 let default_choices: Vec<String> = client
341 .get_grasp_default_set()
342 .iter()
343 .filter(|g| !is_grasp_server_in_list(g, &tried))
344 .cloned()
345 .collect();
346 let selections = vec![true; default_choices.len()]; // all selected by default
347 let grasp_servers = multi_select_with_custom_value(
348 "alternative grasp server(s)",
349 "grasp server",
350 default_choices,
351 selections,
352 normalize_grasp_server_url,
353 )?;
354 show_multi_input_prompt_success("alternative grasp server(s)", &grasp_servers);
355 if grasp_servers.is_empty() {
356 // ask again
357 continue;
358 }
359 let normalised_grasp_servers: Vec<String> = grasp_servers
360 .iter()
361 .filter_map(|g| normalize_grasp_server_url(g).ok())
362 .collect();
363 // if any grasp servers not listed in user grasp list prompt to update
364 let grasp_servers_not_in_user_prefs: Vec<String> = normalised_grasp_servers
365 .iter()
366 .filter(|g| {
367 !user_ref.grasp_list.urls.contains(
368 // unwrap is safe as we constructed g
369 &nostr::Url::parse(&format_grasp_server_url_as_relay_url(g).unwrap())
370 .unwrap(),
371 )
372 })
373 .cloned()
374 .collect();
375 if !grasp_servers_not_in_user_prefs.is_empty()
376 && Interactor::default().confirm(
377 PromptConfirmParms::default()
378 .with_prompt(
379 "add these to your list of prefered grasp servers?".to_string(),
380 )
381 .with_default(true),
382 )?
383 {
384 for g in &normalised_grasp_servers {
385 let as_url = nostr::Url::parse(&format_grasp_server_url_as_relay_url(g)?)?;
386 if !user_ref.grasp_list.urls.contains(&as_url) {
387 user_ref.grasp_list.urls.push(as_url);
388 }
389 }
390 new_grasp_server_events.push(user_ref.grasp_list.to_event(&signer).await?);
391 }
392 normalised_grasp_servers
393 } else {
394 untried_user_grasp_servers
395 };
396 println!(
397 "{} personal-fork so we can push commits to your prefered grasp servers",
398 if user_repo_ref.is_some() {
399 "Updating"
400 } else {
401 "Creating a"
402 },
403 );
404
405 let grasp_servers_as_personal_clone_url: Vec<String> = grasp_servers
406 .iter()
407 .filter_map(|g| {
408 format_grasp_server_url_as_clone_url(
409 g,
410 &user_ref.public_key,
411 &repo_ref.identifier,
412 )
413 .ok()
414 })
415 .collect();
416
417 // create personal-fork / update existing user repo and add these grasp servers
418 let updated_user_repo_ref = {
419 if let Some(mut user_repo_ref) = user_repo_ref {
420 for g in &grasp_servers_as_personal_clone_url {
421 user_repo_ref.add_grasp_server(g)?;
422 }
423 user_repo_ref
424 } else {
425 // clone repo_ref and reset as personal-fork
426 let mut user_repo_ref = repo_ref.clone();
427 user_repo_ref.trusted_maintainer = user_ref.public_key;
428 user_repo_ref.maintainers = vec![user_ref.public_key];
429 user_repo_ref.git_server = vec![];
430 user_repo_ref.relays = vec![];
431 if !user_repo_ref
432 .hashtags
433 .contains(&"personal-fork".to_string())
434 {
435 user_repo_ref.hashtags.push("personal-fork".to_string());
436 }
437 user_repo_ref
438 }
439 };
440 // pubish event to my-relays and my-fork-relays
441 new_grasp_server_events.push(updated_user_repo_ref.to_event(&signer).await?);
442 send_events(
443 &client,
444 Some(git_repo_path),
445 new_grasp_server_events,
446 user_ref.relays.write(),
447 updated_user_repo_ref.relays.clone(),
448 !cli_args.disable_cli_spinners,
449 false,
450 )
451 .await?;
452 user_repo_ref = Some(updated_user_repo_ref);
453 // wait a few seconds
454 let countdown_start = 5;
455 let term = console::Term::stdout();
456 for i in (1..=countdown_start).rev() {
457 term.write_line(
458 format!(
459 "waiting {i}s grasp servers to create your repo before we push your data"
460 )
461 .as_str(),
462 )?;
463 thread::sleep(Duration::new(1, 0)); // Sleep for 1 second
464 term.clear_last_lines(1)?;
465 }
466 term.flush().unwrap(); // Ensure the output is flushed to the terminal
467
468 // add grasp servers to to_try
469 for url in grasp_servers_as_personal_clone_url {
470 to_try.push(url);
471 }
472 // the loop with continue with the grasp servers
473 };
474 println!(
475 "posting {}",
476 if events.iter().any(|e| e.kind.eq(&Kind::GitStatusClosed)) {
477 "proposal revision as new PR event, and a close status for the old patch"
478 } else if events.iter().any(|e| e.kind.eq(&KIND_PULL_REQUEST_UPDATE)) {
479 "proposal revision as PR update event"
480 } else {
481 "proposal as PR event"
482 }
483 );
484 events
485 } else {
486 let events = generate_cover_letter_and_patch_events(
487 cover_letter_title_description.clone(),
488 &git_repo,
489 &commits,
490 &signer,
491 &repo_ref,
492 &root_proposal.as_ref().map(|e| e.id.to_string()),
493 &mention_tags,
494 )
495 .await?;
496
497 println!(
498 "posting {} patch{} {} a covering letter...",
499 if cover_letter_title_description.is_none() {
500 events.len()
501 } else {
502 events.len() - 1
503 },
504 if cover_letter_title_description.is_none() && events.len().eq(&1)
505 || cover_letter_title_description.is_some() && events.len().eq(&2)
506 {
507 ""
508 } else {
509 "es"
510 },
511 if cover_letter_title_description.is_none() {
512 "without"
513 } else {
514 "with"
515 }
516 );
517 events
518 };
226 519
227 send_events( 520 send_events(
228 &client, 521 &client,
@@ -235,7 +528,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
235 ) 528 )
236 .await?; 529 .await?;
237 530
238 if root_proposal_id.is_none() { 531 if root_proposal.is_none() {
239 if let Some(event) = events.first() { 532 if let Some(event) = events.first() {
240 let event_bech32 = if let Some(relay) = repo_ref.relays.first() { 533 let event_bech32 = if let Some(relay) = repo_ref.relays.first() {
241 Nip19Event { 534 Nip19Event {
@@ -251,8 +544,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
251 println!( 544 println!(
252 "{}", 545 "{}",
253 dim.apply_to(format!( 546 dim.apply_to(format!(
254 "view in gitworkshop.dev: https://gitworkshop.dev/repo/{}/proposal/{}", 547 "view in gitworkshop.dev: https://gitworkshop.dev/{}",
255 repo_ref.coordinate_with_hint().to_bech32()?,
256 &event_bech32, 548 &event_bech32,
257 )) 549 ))
258 ); 550 );
@@ -269,6 +561,49 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
269 Ok(()) 561 Ok(())
270} 562}
271 563
564fn check_commits_are_suitable_for_proposal(
565 first_commit_ahead: &[Sha1Hash],
566 commits: &[Sha1Hash],
567 behind: &[Sha1Hash],
568 main_branch_name: &str,
569 main_tip: &Sha1Hash,
570) -> Result<()> {
571 // check proposal ahead of origin/main
572 if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm(
573 PromptConfirmParms::default()
574 .with_prompt(
575 format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1)
576 )
577 .with_default(false)
578 ).context("failed to get confirmation response from interactor confirm")? {
579 bail!("aborting because selected commits were ahead of origin/master");
580 }
581
582 // check if a selected commit is already in origin
583 if commits.iter().any(|c| c.eq(main_tip)) {
584 if !Interactor::default().confirm(
585 PromptConfirmParms::default()
586 .with_prompt(
587 format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?")
588 )
589 .with_default(false)
590 ).context("failed to get confirmation response from interactor confirm")? {
591 bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'");
592 }
593 }
594 // check proposal isn't behind origin/main
595 else if !behind.is_empty() && !Interactor::default().confirm(
596 PromptConfirmParms::default()
597 .with_prompt(
598 format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len())
599 )
600 .with_default(false)
601 ).context("failed to get confirmation response from interactor confirm")? {
602 bail!("aborting so commits can be rebased");
603 }
604 Ok(())
605}
606
272fn choose_commits(git_repo: &Repo, proposed_commits: Vec<Sha1Hash>) -> Result<Vec<Sha1Hash>> { 607fn choose_commits(git_repo: &Repo, proposed_commits: Vec<Sha1Hash>) -> Result<Vec<Sha1Hash>> {
273 let mut proposed_commits = if proposed_commits.len().gt(&10) { 608 let mut proposed_commits = if proposed_commits.len().gt(&10) {
274 vec![] 609 vec![]
@@ -360,11 +695,11 @@ fn summarise_commit_for_selection(git_repo: &Repo, commit: &Sha1Hash) -> Result<
360 )) 695 ))
361} 696}
362 697
363async fn get_root_proposal_id_and_mentions_from_in_reply_to( 698async fn get_root_proposal_and_mentions_from_in_reply_to(
364 git_repo_path: &Path, 699 git_repo_path: &Path,
365 in_reply_to: &[String], 700 in_reply_to: &[String],
366) -> Result<(Option<String>, Vec<nostr::Tag>)> { 701) -> Result<(Option<Event>, Vec<nostr::Tag>)> {
367 let root_proposal_id = if let Some(first) = in_reply_to.first() { 702 let root_proposal = if let Some(first) = in_reply_to.first() {
368 match event_tag_from_nip19_or_hex(first, "in-reply-to", EventRefType::Root, true, false)? 703 match event_tag_from_nip19_or_hex(first, "in-reply-to", EventRefType::Root, true, false)?
369 .as_standardized() 704 .as_standardized()
370 { 705 {
@@ -382,8 +717,8 @@ async fn get_root_proposal_id_and_mentions_from_in_reply_to(
382 .await?; 717 .await?;
383 718
384 if let Some(first) = events.iter().find(|e| e.id.eq(event_id)) { 719 if let Some(first) = events.iter().find(|e| e.id.eq(event_id)) {
385 if event_is_patch_set_root(first) { 720 if event_is_patch_set_root(first) || first.kind.eq(&KIND_PULL_REQUEST) {
386 Some(event_id.to_string()) 721 Some(first.clone())
387 } else { 722 } else {
388 None 723 None
389 } 724 }
@@ -399,7 +734,7 @@ async fn get_root_proposal_id_and_mentions_from_in_reply_to(
399 734
400 let mut mention_tags = vec![]; 735 let mut mention_tags = vec![];
401 for (i, reply_to) in in_reply_to.iter().enumerate() { 736 for (i, reply_to) in in_reply_to.iter().enumerate() {
402 if i.ne(&0) || root_proposal_id.is_none() { 737 if i.ne(&0) || root_proposal.is_none() {
403 mention_tags.push( 738 mention_tags.push(
404 event_tag_from_nip19_or_hex( 739 event_tag_from_nip19_or_hex(
405 reply_to, 740 reply_to,
@@ -415,7 +750,7 @@ async fn get_root_proposal_id_and_mentions_from_in_reply_to(
415 } 750 }
416 } 751 }
417 752
418 Ok((root_proposal_id, mention_tags)) 753 Ok((root_proposal, mention_tags))
419} 754}
420 755
421// TODO 756// TODO
diff --git a/src/bin/ngit/sub_commands/sync.rs b/src/bin/ngit/sub_commands/sync.rs
index c1a3484..00dfe75 100644
--- a/src/bin/ngit/sub_commands/sync.rs
+++ b/src/bin/ngit/sub_commands/sync.rs
@@ -127,33 +127,47 @@ pub async fn launch(args: &SubCommandArgs) -> Result<()> {
127 term.write_line(&format!("{remote_name} already in sync"))?; 127 term.write_line(&format!("{remote_name} already in sync"))?;
128 } 128 }
129 // report already in sync 129 // report already in sync
130 } else if let Err(error) = push_to_remote(
131 &git_repo,
132 url,
133 &decoded_nostr_url,
134 &refspecs,
135 &term,
136 *is_grasp_server,
137 ) {
138 term.write_line(&format!(
139 "error pushing updates to {remote_name}: error: {error}"
140 ))?;
141 } else if *is_grasp_server || args.force {
142 term.write_line(&format!("{remote_name} sync completed"))?;
143 // TODO we only know if there was an error but not if it
144 // rejected any updates
145 } else { 130 } else {
146 // we should report on refs not force pushed 131 match push_to_remote(
147 term.write_line(&format!("{remote_name} sync completed"))?; 132 &git_repo,
148 } 133 url,
149 for name in &not_deleted { 134 &decoded_nostr_url,
150 term.write_line(&format!(" - {name} not deleted"))?; 135 &refspecs,
151 } 136 &term,
152 for name in &not_updated { 137 *is_grasp_server,
153 term.write_line(&format!(" - {name} not updated due to conflicts"))?; 138 ) {
154 } 139 Err(error) => {
155 if !not_updated.is_empty() || !not_deleted.is_empty() { 140 term.write_line(&format!(
156 term.write_line("run `ngit sync --force` to delete refs or overwrite conflicts and potentially lose work")?; 141 "error pushing updates to {remote_name}: error: {error}"
142 ))?;
143 }
144 Ok(updated_refs) => {
145 if updated_refs.values().all(std::option::Option::is_none) {
146 if *is_grasp_server || args.force {
147 term.write_line(&format!("{remote_name} sync completed"))?;
148 // TODO we only know if there was an error but not
149 // if it rejected any
150 // updates
151 } else {
152 // we should report on refs not force pushed
153 term.write_line(&format!("{remote_name} sync completed"))?;
154 }
155 } else {
156 term.write_line(&format!(
157 "{remote_name} sync completed but not all changes were accepted"
158 ))?;
159 }
160 for name in &not_deleted {
161 term.write_line(&format!(" - {name} not deleted"))?;
162 }
163 for name in &not_updated {
164 term.write_line(&format!(" - {name} not updated due to conflicts"))?;
165 }
166 if !not_updated.is_empty() || !not_deleted.is_empty() {
167 term.write_line("run `ngit sync --force` to delete refs or overwrite conflicts and potentially lose work")?;
168 }
169 }
170 }
157 } 171 }
158 } 172 }
159 173