diff options
Diffstat (limited to 'src/bin/ngit/sub_commands/list.rs')
| -rw-r--r-- | src/bin/ngit/sub_commands/list.rs | 906 |
1 files changed, 906 insertions, 0 deletions
diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs new file mode 100644 index 0000000..ac1f4ab --- /dev/null +++ b/src/bin/ngit/sub_commands/list.rs | |||
| @@ -0,0 +1,906 @@ | |||
| 1 | use std::{collections::HashSet, io::Write, ops::Add, path::Path}; | ||
| 2 | |||
| 3 | use anyhow::{bail, Context, Result}; | ||
| 4 | use nostr::nips::nip01::Coordinate; | ||
| 5 | use nostr_sdk::{Kind, PublicKey}; | ||
| 6 | |||
| 7 | use super::send::event_is_patch_set_root; | ||
| 8 | #[cfg(test)] | ||
| 9 | use crate::client::MockConnect; | ||
| 10 | #[cfg(not(test))] | ||
| 11 | use crate::client::{Client, Connect}; | ||
| 12 | use crate::{ | ||
| 13 | cli_interactor::{Interactor, InteractorPrompt, PromptChoiceParms, PromptConfirmParms}, | ||
| 14 | client::{fetching_with_report, get_events_from_cache, get_repo_ref_from_cache}, | ||
| 15 | git::{str_to_sha1, Repo, RepoActions}, | ||
| 16 | repo_ref::{get_repo_coordinates, RepoRef}, | ||
| 17 | sub_commands::send::{ | ||
| 18 | commit_msg_from_patch_oneliner, event_is_cover_letter, event_is_revision_root, | ||
| 19 | event_to_cover_letter, patch_supports_commit_ids, | ||
| 20 | }, | ||
| 21 | }; | ||
| 22 | |||
| 23 | #[allow(clippy::too_many_lines)] | ||
| 24 | pub async fn launch() -> Result<()> { | ||
| 25 | let git_repo = Repo::discover().context("cannot find a git repository")?; | ||
| 26 | let git_repo_path = git_repo.get_path()?; | ||
| 27 | |||
| 28 | // TODO: check for empty repo | ||
| 29 | // TODO: check for existing maintaiers file | ||
| 30 | // TODO: check for other claims | ||
| 31 | |||
| 32 | #[cfg(not(test))] | ||
| 33 | let client = Client::default(); | ||
| 34 | #[cfg(test)] | ||
| 35 | let client = <MockConnect as std::default::Default>::default(); | ||
| 36 | |||
| 37 | let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; | ||
| 38 | |||
| 39 | fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; | ||
| 40 | |||
| 41 | let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?; | ||
| 42 | |||
| 43 | let proposals_and_revisions: Vec<nostr::Event> = | ||
| 44 | get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?; | ||
| 45 | if proposals_and_revisions.is_empty() { | ||
| 46 | println!("no proposals found... create one? try `ngit send`"); | ||
| 47 | return Ok(()); | ||
| 48 | } | ||
| 49 | |||
| 50 | let statuses: Vec<nostr::Event> = { | ||
| 51 | let mut statuses = get_events_from_cache( | ||
| 52 | git_repo_path, | ||
| 53 | vec![ | ||
| 54 | nostr::Filter::default() | ||
| 55 | .kinds(status_kinds().clone()) | ||
| 56 | .events(proposals_and_revisions.iter().map(nostr::Event::id)), | ||
| 57 | ], | ||
| 58 | ) | ||
| 59 | .await?; | ||
| 60 | statuses.sort_by_key(|e| e.created_at); | ||
| 61 | statuses.reverse(); | ||
| 62 | statuses | ||
| 63 | }; | ||
| 64 | |||
| 65 | let mut open_proposals: Vec<&nostr::Event> = vec![]; | ||
| 66 | let mut draft_proposals: Vec<&nostr::Event> = vec![]; | ||
| 67 | let mut closed_proposals: Vec<&nostr::Event> = vec![]; | ||
| 68 | let mut applied_proposals: Vec<&nostr::Event> = vec![]; | ||
| 69 | |||
| 70 | let proposals: Vec<nostr::Event> = proposals_and_revisions | ||
| 71 | .iter() | ||
| 72 | .filter(|e| !event_is_revision_root(e)) | ||
| 73 | .cloned() | ||
| 74 | .collect(); | ||
| 75 | |||
| 76 | for proposal in &proposals { | ||
| 77 | let status = if let Some(e) = statuses | ||
| 78 | .iter() | ||
| 79 | .filter(|e| { | ||
| 80 | status_kinds().contains(&e.kind()) | ||
| 81 | && e.tags() | ||
| 82 | .iter() | ||
| 83 | .any(|t| t.as_vec()[1].eq(&proposal.id.to_string())) | ||
| 84 | }) | ||
| 85 | .collect::<Vec<&nostr::Event>>() | ||
| 86 | .first() | ||
| 87 | { | ||
| 88 | e.kind() | ||
| 89 | } else { | ||
| 90 | Kind::GitStatusOpen | ||
| 91 | }; | ||
| 92 | if status.eq(&Kind::GitStatusOpen) { | ||
| 93 | open_proposals.push(proposal); | ||
| 94 | } else if status.eq(&Kind::GitStatusClosed) { | ||
| 95 | closed_proposals.push(proposal); | ||
| 96 | } else if status.eq(&Kind::GitStatusDraft) { | ||
| 97 | draft_proposals.push(proposal); | ||
| 98 | } else if status.eq(&Kind::GitStatusApplied) { | ||
| 99 | applied_proposals.push(proposal); | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | let mut selected_status = Kind::GitStatusOpen; | ||
| 104 | |||
| 105 | loop { | ||
| 106 | let proposals_for_status = if selected_status == Kind::GitStatusOpen { | ||
| 107 | &open_proposals | ||
| 108 | } else if selected_status == Kind::GitStatusDraft { | ||
| 109 | &draft_proposals | ||
| 110 | } else if selected_status == Kind::GitStatusClosed { | ||
| 111 | &closed_proposals | ||
| 112 | } else if selected_status == Kind::GitStatusApplied { | ||
| 113 | &applied_proposals | ||
| 114 | } else { | ||
| 115 | &open_proposals | ||
| 116 | }; | ||
| 117 | |||
| 118 | let prompt = if proposals.len().eq(&open_proposals.len()) { | ||
| 119 | "all proposals" | ||
| 120 | } else if selected_status == Kind::GitStatusOpen { | ||
| 121 | if open_proposals.is_empty() { | ||
| 122 | "proposals menu" | ||
| 123 | } else { | ||
| 124 | "open proposals" | ||
| 125 | } | ||
| 126 | } else if selected_status == Kind::GitStatusDraft { | ||
| 127 | "draft proposals" | ||
| 128 | } else if selected_status == Kind::GitStatusClosed { | ||
| 129 | "closed proposals" | ||
| 130 | } else { | ||
| 131 | "applied proposals" | ||
| 132 | }; | ||
| 133 | |||
| 134 | let mut choices: Vec<String> = proposals_for_status | ||
| 135 | .iter() | ||
| 136 | .map(|e| { | ||
| 137 | if let Ok(cl) = event_to_cover_letter(e) { | ||
| 138 | cl.title | ||
| 139 | } else if let Ok(msg) = tag_value(e, "description") { | ||
| 140 | msg.split('\n').collect::<Vec<&str>>()[0].to_string() | ||
| 141 | } else { | ||
| 142 | e.id.to_string() | ||
| 143 | } | ||
| 144 | }) | ||
| 145 | .collect(); | ||
| 146 | |||
| 147 | if !selected_status.eq(&Kind::GitStatusOpen) && open_proposals.len().gt(&0) { | ||
| 148 | choices.push(format!("({}) Open proposals...", open_proposals.len())); | ||
| 149 | } | ||
| 150 | if !selected_status.eq(&Kind::GitStatusDraft) && draft_proposals.len().gt(&0) { | ||
| 151 | choices.push(format!("({}) Draft proposals...", draft_proposals.len())); | ||
| 152 | } | ||
| 153 | if !selected_status.eq(&Kind::GitStatusClosed) && closed_proposals.len().gt(&0) { | ||
| 154 | choices.push(format!("({}) Closed proposals...", closed_proposals.len())); | ||
| 155 | } | ||
| 156 | if !selected_status.eq(&Kind::GitStatusApplied) && applied_proposals.len().gt(&0) { | ||
| 157 | choices.push(format!( | ||
| 158 | "({}) Applied proposals...", | ||
| 159 | applied_proposals.len() | ||
| 160 | )); | ||
| 161 | } | ||
| 162 | |||
| 163 | let selected_index = Interactor::default().choice( | ||
| 164 | PromptChoiceParms::default() | ||
| 165 | .with_prompt(prompt) | ||
| 166 | .with_choices(choices.clone()), | ||
| 167 | )?; | ||
| 168 | |||
| 169 | if (selected_index + 1).gt(&proposals_for_status.len()) { | ||
| 170 | if choices[selected_index].contains("Open") { | ||
| 171 | selected_status = Kind::GitStatusOpen; | ||
| 172 | } else if choices[selected_index].contains("Draft") { | ||
| 173 | selected_status = Kind::GitStatusDraft; | ||
| 174 | } else if choices[selected_index].contains("Closed") { | ||
| 175 | selected_status = Kind::GitStatusClosed; | ||
| 176 | } else if choices[selected_index].contains("Applied") { | ||
| 177 | selected_status = Kind::GitStatusApplied; | ||
| 178 | } | ||
| 179 | continue; | ||
| 180 | } | ||
| 181 | |||
| 182 | let cover_letter = event_to_cover_letter(proposals_for_status[selected_index]) | ||
| 183 | .context("cannot extract proposal details from proposal root event")?; | ||
| 184 | |||
| 185 | let commits_events: Vec<nostr::Event> = get_all_proposal_patch_events_from_cache( | ||
| 186 | git_repo_path, | ||
| 187 | &repo_ref, | ||
| 188 | &proposals_for_status[selected_index].id(), | ||
| 189 | ) | ||
| 190 | .await?; | ||
| 191 | |||
| 192 | let Ok(most_recent_proposal_patch_chain) = | ||
| 193 | get_most_recent_patch_with_ancestors(commits_events.clone()) | ||
| 194 | else { | ||
| 195 | if Interactor::default().confirm( | ||
| 196 | PromptConfirmParms::default() | ||
| 197 | .with_default(true) | ||
| 198 | .with_prompt( | ||
| 199 | "cannot find any patches on this proposal. choose another proposal?", | ||
| 200 | ), | ||
| 201 | )? { | ||
| 202 | continue; | ||
| 203 | } | ||
| 204 | return Ok(()); | ||
| 205 | }; | ||
| 206 | // for commit in &most_recent_proposal_patch_chain { | ||
| 207 | // println!("recent_event: {:?}", commit.as_json()); | ||
| 208 | // } | ||
| 209 | |||
| 210 | let binding_patch_text_ref = format!("{} commits", most_recent_proposal_patch_chain.len()); | ||
| 211 | let patch_text_ref = if most_recent_proposal_patch_chain.len().gt(&1) { | ||
| 212 | binding_patch_text_ref.as_str() | ||
| 213 | } else { | ||
| 214 | "1 commit" | ||
| 215 | }; | ||
| 216 | |||
| 217 | let no_support_for_patches_as_branch = most_recent_proposal_patch_chain | ||
| 218 | .iter() | ||
| 219 | .any(|event| !patch_supports_commit_ids(event)); | ||
| 220 | |||
| 221 | if no_support_for_patches_as_branch { | ||
| 222 | println!("{patch_text_ref}"); | ||
| 223 | return match Interactor::default().choice( | ||
| 224 | PromptChoiceParms::default() | ||
| 225 | .with_default(0) | ||
| 226 | .with_choices(vec![ | ||
| 227 | "learn why 'patch only' proposals can't be checked out".to_string(), | ||
| 228 | format!("apply to current branch with `git am`"), | ||
| 229 | format!("download to ./patches"), | ||
| 230 | "back".to_string(), | ||
| 231 | ]), | ||
| 232 | )? { | ||
| 233 | 0 => { | ||
| 234 | println!("Some proposals are posted as 'patch only'\n"); | ||
| 235 | println!( | ||
| 236 | "they are not anchored against a particular state of the code base like a standard proposal or a GitHub Pull Request can be\n" | ||
| 237 | ); | ||
| 238 | println!( | ||
| 239 | "they are designed to reviewed by studying the diff (in a tool like gitworkshop.dev) and if acceptable by a maintainer, applied to the latest version of master with any conflicts resolved as the do so\n" | ||
| 240 | ); | ||
| 241 | println!( | ||
| 242 | "this has proven to be a smoother workflow for large scale projects with a high frequency of changes, even when patches are exchanged via email\n" | ||
| 243 | ); | ||
| 244 | println!( | ||
| 245 | "by default ngit posts proposals that support both the branch and patch model so either workflow can be used" | ||
| 246 | ); | ||
| 247 | Interactor::default().choice( | ||
| 248 | PromptChoiceParms::default() | ||
| 249 | .with_default(0) | ||
| 250 | .with_choices(vec!["back".to_string()]), | ||
| 251 | )?; | ||
| 252 | continue; | ||
| 253 | } | ||
| 254 | 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), | ||
| 255 | 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), | ||
| 256 | 3 => continue, | ||
| 257 | _ => { | ||
| 258 | bail!("unexpected choice") | ||
| 259 | } | ||
| 260 | }; | ||
| 261 | } | ||
| 262 | |||
| 263 | let branch_exists = git_repo | ||
| 264 | .get_local_branch_names() | ||
| 265 | .context("gitlib2 will not show a list of local branch names")? | ||
| 266 | .iter() | ||
| 267 | .any(|n| n.eq(&cover_letter.get_branch_name().unwrap())); | ||
| 268 | |||
| 269 | let checked_out_proposal_branch = git_repo | ||
| 270 | .get_checked_out_branch_name()? | ||
| 271 | .eq(&cover_letter.get_branch_name()?); | ||
| 272 | |||
| 273 | let proposal_base_commit = str_to_sha1(&tag_value( | ||
| 274 | most_recent_proposal_patch_chain.last().context( | ||
| 275 | "there should be at least one patch as we have already checked for this", | ||
| 276 | )?, | ||
| 277 | "parent-commit", | ||
| 278 | )?) | ||
| 279 | .context("cannot get valid parent commit id from patch")?; | ||
| 280 | |||
| 281 | let (main_branch_name, master_tip) = git_repo.get_main_or_master_branch()?; | ||
| 282 | |||
| 283 | if !git_repo.does_commit_exist(&proposal_base_commit.to_string())? { | ||
| 284 | println!("your '{main_branch_name}' branch may not be up-to-date."); | ||
| 285 | println!("the proposal parent commit doesnt exist in your local repository."); | ||
| 286 | return match Interactor::default().choice(PromptChoiceParms::default().with_default(0).with_choices( | ||
| 287 | vec![ | ||
| 288 | format!( | ||
| 289 | "manually run `git pull` on '{main_branch_name}' and select proposal again" | ||
| 290 | ), | ||
| 291 | format!("apply to current branch with `git am`"), | ||
| 292 | format!("download to ./patches"), | ||
| 293 | "back".to_string(), | ||
| 294 | ], | ||
| 295 | ))? { | ||
| 296 | 0 | 3 => continue, | ||
| 297 | 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), | ||
| 298 | 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), | ||
| 299 | _ => { | ||
| 300 | bail!("unexpected choice") | ||
| 301 | } | ||
| 302 | }; | ||
| 303 | } | ||
| 304 | |||
| 305 | let proposal_tip = str_to_sha1( | ||
| 306 | &get_commit_id_from_patch(most_recent_proposal_patch_chain.first().context( | ||
| 307 | "there should be at least one patch as we have already checked for this", | ||
| 308 | )?) | ||
| 309 | .context("cannot get valid commit_id from patch")?, | ||
| 310 | ) | ||
| 311 | .context("cannot get valid commit_id from patch")?; | ||
| 312 | |||
| 313 | let (_, proposal_behind_main) = | ||
| 314 | git_repo.get_commits_ahead_behind(&master_tip, &proposal_base_commit)?; | ||
| 315 | |||
| 316 | // branch doesnt exist | ||
| 317 | if !branch_exists { | ||
| 318 | return match Interactor::default() | ||
| 319 | .choice(PromptChoiceParms::default().with_default(0).with_choices(vec![ | ||
| 320 | format!( | ||
| 321 | "create and checkout proposal branch ({} ahead {} behind '{main_branch_name}')", | ||
| 322 | most_recent_proposal_patch_chain.len(), | ||
| 323 | proposal_behind_main.len(), | ||
| 324 | ), | ||
| 325 | format!("apply to current branch with `git am`"), | ||
| 326 | format!("download to ./patches"), | ||
| 327 | "back".to_string(), | ||
| 328 | ]))? { | ||
| 329 | 0 => { | ||
| 330 | check_clean(&git_repo)?; | ||
| 331 | let _ = git_repo | ||
| 332 | .apply_patch_chain( | ||
| 333 | &cover_letter.get_branch_name()?, | ||
| 334 | most_recent_proposal_patch_chain, | ||
| 335 | ) | ||
| 336 | .context("cannot apply patch chain")?; | ||
| 337 | |||
| 338 | println!( | ||
| 339 | "checked out proposal as '{}' branch", | ||
| 340 | cover_letter.get_branch_name()? | ||
| 341 | ); | ||
| 342 | Ok(()) | ||
| 343 | } | ||
| 344 | 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), | ||
| 345 | 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), | ||
| 346 | 3 => continue, | ||
| 347 | _ => { | ||
| 348 | bail!("unexpected choice") | ||
| 349 | } | ||
| 350 | }; | ||
| 351 | } | ||
| 352 | |||
| 353 | let local_branch_tip = git_repo.get_tip_of_branch(&cover_letter.get_branch_name()?)?; | ||
| 354 | |||
| 355 | // up-to-date | ||
| 356 | if proposal_tip.eq(&local_branch_tip) { | ||
| 357 | if checked_out_proposal_branch { | ||
| 358 | println!("branch checked out and up-to-date"); | ||
| 359 | return match Interactor::default().choice( | ||
| 360 | PromptChoiceParms::default() | ||
| 361 | .with_default(0) | ||
| 362 | .with_choices(vec!["exit".to_string(), "back".to_string()]), | ||
| 363 | )? { | ||
| 364 | 0 => Ok(()), | ||
| 365 | 1 => continue, | ||
| 366 | _ => { | ||
| 367 | bail!("unexpected choice") | ||
| 368 | } | ||
| 369 | }; | ||
| 370 | } | ||
| 371 | |||
| 372 | return match Interactor::default().choice( | ||
| 373 | PromptChoiceParms::default() | ||
| 374 | .with_default(0) | ||
| 375 | .with_choices(vec![ | ||
| 376 | format!( | ||
| 377 | "checkout proposal branch ({} ahead {} behind '{main_branch_name}')", | ||
| 378 | most_recent_proposal_patch_chain.len(), | ||
| 379 | proposal_behind_main.len(), | ||
| 380 | ), | ||
| 381 | format!("apply to current branch with `git am`"), | ||
| 382 | format!("download to ./patches"), | ||
| 383 | "back".to_string(), | ||
| 384 | ]), | ||
| 385 | )? { | ||
| 386 | 0 => { | ||
| 387 | check_clean(&git_repo)?; | ||
| 388 | git_repo.checkout(&cover_letter.get_branch_name()?)?; | ||
| 389 | println!( | ||
| 390 | "checked out proposal as '{}' branch", | ||
| 391 | cover_letter.get_branch_name()? | ||
| 392 | ); | ||
| 393 | Ok(()) | ||
| 394 | } | ||
| 395 | 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), | ||
| 396 | 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), | ||
| 397 | 3 => continue, | ||
| 398 | _ => { | ||
| 399 | bail!("unexpected choice") | ||
| 400 | } | ||
| 401 | }; | ||
| 402 | } | ||
| 403 | |||
| 404 | let (local_ahead_of_main, local_beind_main) = | ||
| 405 | git_repo.get_commits_ahead_behind(&master_tip, &local_branch_tip)?; | ||
| 406 | |||
| 407 | // new appendments to proposal | ||
| 408 | if let Some(index) = most_recent_proposal_patch_chain.iter().position(|patch| { | ||
| 409 | get_commit_id_from_patch(patch) | ||
| 410 | .unwrap_or_default() | ||
| 411 | .eq(&local_branch_tip.to_string()) | ||
| 412 | }) { | ||
| 413 | return match Interactor::default().choice( | ||
| 414 | PromptChoiceParms::default() | ||
| 415 | .with_default(0) | ||
| 416 | .with_choices(vec![ | ||
| 417 | format!("checkout proposal branch and apply {} appendments", &index,), | ||
| 418 | format!("apply to current branch with `git am`"), | ||
| 419 | format!("download to ./patches"), | ||
| 420 | "back".to_string(), | ||
| 421 | ]), | ||
| 422 | )? { | ||
| 423 | 0 => { | ||
| 424 | check_clean(&git_repo)?; | ||
| 425 | git_repo.checkout(&cover_letter.get_branch_name()?)?; | ||
| 426 | let _ = git_repo | ||
| 427 | .apply_patch_chain( | ||
| 428 | &cover_letter.get_branch_name()?, | ||
| 429 | most_recent_proposal_patch_chain, | ||
| 430 | ) | ||
| 431 | .context("cannot apply patch chain")?; | ||
| 432 | println!( | ||
| 433 | "checked out proposal branch and applied {} appendments ({} ahead {} behind '{main_branch_name}')", | ||
| 434 | &index, | ||
| 435 | local_ahead_of_main.len().add(&index), | ||
| 436 | local_beind_main.len(), | ||
| 437 | ); | ||
| 438 | Ok(()) | ||
| 439 | } | ||
| 440 | 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), | ||
| 441 | 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), | ||
| 442 | 3 => continue, | ||
| 443 | _ => { | ||
| 444 | bail!("unexpected choice") | ||
| 445 | } | ||
| 446 | }; | ||
| 447 | } | ||
| 448 | |||
| 449 | // new proposal revision / rebase | ||
| 450 | // tip of local in proposal history (new, amended or rebased version but no | ||
| 451 | // local changes) | ||
| 452 | if commits_events.iter().any(|patch| { | ||
| 453 | get_commit_id_from_patch(patch) | ||
| 454 | .unwrap_or_default() | ||
| 455 | .eq(&local_branch_tip.to_string()) | ||
| 456 | }) { | ||
| 457 | println!( | ||
| 458 | "updated proposal available ({} ahead {} behind '{main_branch_name}'). existing version is {} ahead {} behind '{main_branch_name}'", | ||
| 459 | most_recent_proposal_patch_chain.len(), | ||
| 460 | proposal_behind_main.len(), | ||
| 461 | local_ahead_of_main.len(), | ||
| 462 | local_beind_main.len(), | ||
| 463 | ); | ||
| 464 | return match Interactor::default().choice( | ||
| 465 | PromptChoiceParms::default() | ||
| 466 | .with_default(0) | ||
| 467 | .with_choices(vec![ | ||
| 468 | format!("checkout and overwrite existing proposal branch"), | ||
| 469 | format!("checkout existing outdated proposal branch"), | ||
| 470 | format!("apply to current branch with `git am`"), | ||
| 471 | format!("download to ./patches"), | ||
| 472 | "back".to_string(), | ||
| 473 | ]), | ||
| 474 | )? { | ||
| 475 | 0 => { | ||
| 476 | check_clean(&git_repo)?; | ||
| 477 | git_repo.create_branch_at_commit( | ||
| 478 | &cover_letter.get_branch_name()?, | ||
| 479 | &proposal_base_commit.to_string(), | ||
| 480 | )?; | ||
| 481 | git_repo.checkout(&cover_letter.get_branch_name()?)?; | ||
| 482 | let chain_length = most_recent_proposal_patch_chain.len(); | ||
| 483 | let _ = git_repo | ||
| 484 | .apply_patch_chain( | ||
| 485 | &cover_letter.get_branch_name()?, | ||
| 486 | most_recent_proposal_patch_chain, | ||
| 487 | ) | ||
| 488 | .context("cannot apply patch chain")?; | ||
| 489 | println!( | ||
| 490 | "checked out new version of proposal ({} ahead {} behind '{main_branch_name}'), replacing old version ({} ahead {} behind '{main_branch_name}')", | ||
| 491 | chain_length, | ||
| 492 | proposal_behind_main.len(), | ||
| 493 | local_ahead_of_main.len(), | ||
| 494 | local_beind_main.len(), | ||
| 495 | ); | ||
| 496 | Ok(()) | ||
| 497 | } | ||
| 498 | 1 => { | ||
| 499 | check_clean(&git_repo)?; | ||
| 500 | git_repo.checkout(&cover_letter.get_branch_name()?)?; | ||
| 501 | println!( | ||
| 502 | "checked out old proposal in existing branch ({} ahead {} behind '{main_branch_name}')", | ||
| 503 | local_ahead_of_main.len(), | ||
| 504 | local_beind_main.len(), | ||
| 505 | ); | ||
| 506 | Ok(()) | ||
| 507 | } | ||
| 508 | 2 => launch_git_am_with_patches(most_recent_proposal_patch_chain), | ||
| 509 | 3 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), | ||
| 510 | 4 => continue, | ||
| 511 | _ => { | ||
| 512 | bail!("unexpected choice") | ||
| 513 | } | ||
| 514 | }; | ||
| 515 | } | ||
| 516 | // tip of proposal in branch in history (local appendments made to up-to-date | ||
| 517 | // proposal) | ||
| 518 | else if git_repo.ancestor_of(&local_branch_tip, &proposal_tip)? { | ||
| 519 | let (local_ahead_of_proposal, _) = git_repo | ||
| 520 | .get_commits_ahead_behind(&proposal_tip, &local_branch_tip) | ||
| 521 | .context("cannot get commits ahead behind for propsal_top and local_branch_tip")?; | ||
| 522 | |||
| 523 | println!( | ||
| 524 | "local proposal branch exists with {} unpublished commits on top of the most up-to-date version of the proposal ({} ahead {} behind '{main_branch_name}')", | ||
| 525 | local_ahead_of_proposal.len(), | ||
| 526 | local_ahead_of_main.len(), | ||
| 527 | proposal_behind_main.len(), | ||
| 528 | ); | ||
| 529 | return match Interactor::default().choice( | ||
| 530 | PromptChoiceParms::default() | ||
| 531 | .with_default(0) | ||
| 532 | .with_choices(vec![ | ||
| 533 | format!( | ||
| 534 | "checkout proposal branch with {} unpublished commits", | ||
| 535 | local_ahead_of_proposal.len(), | ||
| 536 | ), | ||
| 537 | "back".to_string(), | ||
| 538 | ]), | ||
| 539 | )? { | ||
| 540 | 0 => { | ||
| 541 | git_repo.checkout(&cover_letter.get_branch_name()?)?; | ||
| 542 | println!( | ||
| 543 | "checked out proposal branch with {} unpublished commits ({} ahead {} behind '{main_branch_name}')", | ||
| 544 | local_ahead_of_proposal.len(), | ||
| 545 | local_ahead_of_main.len(), | ||
| 546 | proposal_behind_main.len(), | ||
| 547 | ); | ||
| 548 | Ok(()) | ||
| 549 | } | ||
| 550 | 1 => continue, | ||
| 551 | _ => { | ||
| 552 | bail!("unexpected choice") | ||
| 553 | } | ||
| 554 | }; | ||
| 555 | } | ||
| 556 | |||
| 557 | println!("you have an amended/rebase version the proposal that is unpublished"); | ||
| 558 | // user probably has a unpublished amended or rebase version of the latest | ||
| 559 | // proposal version | ||
| 560 | // if tip of proposal commits exist (were once part of branch but have been | ||
| 561 | // amended and git clean up job hasn't removed them) | ||
| 562 | if git_repo.does_commit_exist(&proposal_tip.to_string())? { | ||
| 563 | println!( | ||
| 564 | "you have previously applied the latest version of the proposal ({} ahead {} behind '{main_branch_name}') but your local proposal branch has amended or rebased it ({} ahead {} behind '{main_branch_name}')", | ||
| 565 | most_recent_proposal_patch_chain.len(), | ||
| 566 | proposal_behind_main.len(), | ||
| 567 | local_ahead_of_main.len(), | ||
| 568 | local_beind_main.len(), | ||
| 569 | ); | ||
| 570 | } | ||
| 571 | // user probably has a unpublished amended or rebase version of an older | ||
| 572 | // proposal version | ||
| 573 | else { | ||
| 574 | println!( | ||
| 575 | "your local proposal branch ({} ahead {} behind '{main_branch_name}') has conflicting changes with the latest published proposal ({} ahead {} behind '{main_branch_name}')", | ||
| 576 | local_ahead_of_main.len(), | ||
| 577 | local_beind_main.len(), | ||
| 578 | most_recent_proposal_patch_chain.len(), | ||
| 579 | proposal_behind_main.len(), | ||
| 580 | ); | ||
| 581 | |||
| 582 | println!( | ||
| 583 | "its likely that you have rebased / amended an old proposal version because git has no record of the latest proposal commit." | ||
| 584 | ); | ||
| 585 | println!( | ||
| 586 | "it is possible that you have been working off the latest version and git has delete this commit as part of a clean up" | ||
| 587 | ); | ||
| 588 | } | ||
| 589 | println!("to view the latest proposal but retain your changes:"); | ||
| 590 | println!(" 1) create a new branch off the tip commit of this one to store your changes"); | ||
| 591 | println!(" 2) run `ngit list` and checkout the latest published version of this proposal"); | ||
| 592 | |||
| 593 | println!("if you are confident in your changes consider running `ngit push --force`"); | ||
| 594 | |||
| 595 | return match Interactor::default().choice( | ||
| 596 | PromptChoiceParms::default() | ||
| 597 | .with_default(0) | ||
| 598 | .with_choices(vec![ | ||
| 599 | format!("checkout local branch with unpublished changes"), | ||
| 600 | format!("discard unpublished changes and checkout new revision",), | ||
| 601 | format!("apply to current branch with `git am`"), | ||
| 602 | format!("download to ./patches"), | ||
| 603 | "back".to_string(), | ||
| 604 | ]), | ||
| 605 | )? { | ||
| 606 | 0 => { | ||
| 607 | check_clean(&git_repo)?; | ||
| 608 | git_repo.checkout(&cover_letter.get_branch_name()?)?; | ||
| 609 | println!( | ||
| 610 | "checked out old proposal in existing branch ({} ahead {} behind '{main_branch_name}')", | ||
| 611 | local_ahead_of_main.len(), | ||
| 612 | local_beind_main.len(), | ||
| 613 | ); | ||
| 614 | Ok(()) | ||
| 615 | } | ||
| 616 | 1 => { | ||
| 617 | check_clean(&git_repo)?; | ||
| 618 | git_repo.create_branch_at_commit( | ||
| 619 | &cover_letter.get_branch_name()?, | ||
| 620 | &proposal_base_commit.to_string(), | ||
| 621 | )?; | ||
| 622 | let chain_length = most_recent_proposal_patch_chain.len(); | ||
| 623 | let _ = git_repo | ||
| 624 | .apply_patch_chain( | ||
| 625 | &cover_letter.get_branch_name()?, | ||
| 626 | most_recent_proposal_patch_chain, | ||
| 627 | ) | ||
| 628 | .context("cannot apply patch chain")?; | ||
| 629 | |||
| 630 | git_repo.checkout(&cover_letter.get_branch_name()?)?; | ||
| 631 | println!( | ||
| 632 | "checked out latest version of proposal ({} ahead {} behind '{main_branch_name}'), replacing unpublished version ({} ahead {} behind '{main_branch_name}')", | ||
| 633 | chain_length, | ||
| 634 | proposal_behind_main.len(), | ||
| 635 | local_ahead_of_main.len(), | ||
| 636 | local_beind_main.len(), | ||
| 637 | ); | ||
| 638 | Ok(()) | ||
| 639 | } | ||
| 640 | 2 => launch_git_am_with_patches(most_recent_proposal_patch_chain), | ||
| 641 | 3 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), | ||
| 642 | 4 => continue, | ||
| 643 | _ => { | ||
| 644 | bail!("unexpected choice") | ||
| 645 | } | ||
| 646 | }; | ||
| 647 | } | ||
| 648 | } | ||
| 649 | |||
| 650 | fn launch_git_am_with_patches(mut patches: Vec<nostr::Event>) -> Result<()> { | ||
| 651 | println!("applying to current branch with `git am`"); | ||
| 652 | // TODO: add PATCH x/n to appended patches | ||
| 653 | patches.reverse(); | ||
| 654 | |||
| 655 | let mut am = std::process::Command::new("git") | ||
| 656 | .arg("am") | ||
| 657 | .stdin(std::process::Stdio::piped()) | ||
| 658 | .stdout(std::process::Stdio::inherit()) | ||
| 659 | .stderr(std::process::Stdio::inherit()) | ||
| 660 | .spawn() | ||
| 661 | .context("failed to spawn git am")?; | ||
| 662 | |||
| 663 | let stdin = am | ||
| 664 | .stdin | ||
| 665 | .as_mut() | ||
| 666 | .context("git am process failed to take stdin")?; | ||
| 667 | |||
| 668 | for patch in patches { | ||
| 669 | stdin | ||
| 670 | .write(format!("{}\n\n", patch.content).as_bytes()) | ||
| 671 | .context("failed to write patch content into git am stdin buffer")?; | ||
| 672 | } | ||
| 673 | stdin.flush()?; | ||
| 674 | let output = am | ||
| 675 | .wait_with_output() | ||
| 676 | .context("failed to read git am stdout")?; | ||
| 677 | print!("{:?}", output.stdout); | ||
| 678 | Ok(()) | ||
| 679 | } | ||
| 680 | |||
| 681 | fn event_id_extra_shorthand(event: &nostr::Event) -> String { | ||
| 682 | event.id.to_string()[..5].to_string() | ||
| 683 | } | ||
| 684 | |||
| 685 | fn save_patches_to_dir(mut patches: Vec<nostr::Event>, git_repo: &Repo) -> Result<()> { | ||
| 686 | // TODO: add PATCH x/n to appended patches | ||
| 687 | patches.reverse(); | ||
| 688 | let path = git_repo.get_path()?.join("patches"); | ||
| 689 | std::fs::create_dir_all(&path)?; | ||
| 690 | let id = event_id_extra_shorthand( | ||
| 691 | patches | ||
| 692 | .first() | ||
| 693 | .context("there must be at least one patch to save")?, | ||
| 694 | ); | ||
| 695 | for (i, patch) in patches.iter().enumerate() { | ||
| 696 | let path = path.join(format!( | ||
| 697 | "{}-{:0>4}-{}.patch", | ||
| 698 | &id, | ||
| 699 | i.add(&1), | ||
| 700 | commit_msg_from_patch_oneliner(patch)? | ||
| 701 | )); | ||
| 702 | let mut file = std::fs::OpenOptions::new() | ||
| 703 | .create(true) | ||
| 704 | .write(true) | ||
| 705 | .truncate(true) | ||
| 706 | .open(path) | ||
| 707 | .context("open new patch file with write and truncate options")?; | ||
| 708 | file.write_all(patch.content().as_bytes())?; | ||
| 709 | file.write_all("\n\n".as_bytes())?; | ||
| 710 | file.flush()?; | ||
| 711 | } | ||
| 712 | println!("created {} patch files in ./patches/{id}-*", patches.len()); | ||
| 713 | Ok(()) | ||
| 714 | } | ||
| 715 | |||
| 716 | fn check_clean(git_repo: &Repo) -> Result<()> { | ||
| 717 | if git_repo.has_outstanding_changes()? { | ||
| 718 | bail!( | ||
| 719 | "cannot pull proposal branch when repository is not clean. discard or stash (un)staged changes and try again." | ||
| 720 | ); | ||
| 721 | } | ||
| 722 | Ok(()) | ||
| 723 | } | ||
| 724 | |||
| 725 | pub fn tag_value(event: &nostr::Event, tag_name: &str) -> Result<String> { | ||
| 726 | Ok(event | ||
| 727 | .tags | ||
| 728 | .iter() | ||
| 729 | .find(|t| t.as_vec()[0].eq(tag_name)) | ||
| 730 | .context(format!("tag '{tag_name}'not present"))? | ||
| 731 | .as_vec()[1] | ||
| 732 | .clone()) | ||
| 733 | } | ||
| 734 | |||
| 735 | pub fn get_commit_id_from_patch(event: &nostr::Event) -> Result<String> { | ||
| 736 | let value = tag_value(event, "commit"); | ||
| 737 | |||
| 738 | if value.is_ok() { | ||
| 739 | value | ||
| 740 | } else if event.content.starts_with("From ") && event.content.len().gt(&45) { | ||
| 741 | Ok(event.content[5..45].to_string()) | ||
| 742 | } else { | ||
| 743 | bail!("event is not a patch") | ||
| 744 | } | ||
| 745 | } | ||
| 746 | |||
| 747 | fn get_event_parent_id(event: &nostr::Event) -> Result<String> { | ||
| 748 | Ok(if let Some(reply_tag) = event | ||
| 749 | .tags | ||
| 750 | .iter() | ||
| 751 | .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("reply")) | ||
| 752 | { | ||
| 753 | reply_tag | ||
| 754 | } else { | ||
| 755 | event | ||
| 756 | .tags | ||
| 757 | .iter() | ||
| 758 | .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("root")) | ||
| 759 | .context("no reply or root e tag present".to_string())? | ||
| 760 | } | ||
| 761 | .as_vec()[1] | ||
| 762 | .clone()) | ||
| 763 | } | ||
| 764 | |||
| 765 | pub fn get_most_recent_patch_with_ancestors( | ||
| 766 | mut patches: Vec<nostr::Event>, | ||
| 767 | ) -> Result<Vec<nostr::Event>> { | ||
| 768 | patches.sort_by_key(|e| e.created_at); | ||
| 769 | |||
| 770 | let youngest_patch = patches.last().context("no patches found")?; | ||
| 771 | |||
| 772 | let patches_with_youngest_created_at: Vec<&nostr::Event> = patches | ||
| 773 | .iter() | ||
| 774 | .filter(|p| p.created_at.eq(&youngest_patch.created_at)) | ||
| 775 | .collect(); | ||
| 776 | |||
| 777 | let mut res = vec![]; | ||
| 778 | |||
| 779 | let mut event_id_to_search = patches_with_youngest_created_at | ||
| 780 | .clone() | ||
| 781 | .iter() | ||
| 782 | .find(|p| { | ||
| 783 | !patches_with_youngest_created_at.iter().any(|p2| { | ||
| 784 | if let Ok(reply_to) = get_event_parent_id(p2) { | ||
| 785 | reply_to.eq(&p.id.to_string()) | ||
| 786 | } else { | ||
| 787 | false | ||
| 788 | } | ||
| 789 | }) | ||
| 790 | }) | ||
| 791 | .context("cannot find patches_with_youngest_created_at")? | ||
| 792 | .id | ||
| 793 | .to_string(); | ||
| 794 | |||
| 795 | while let Some(event) = patches | ||
| 796 | .iter() | ||
| 797 | .find(|e| e.id.to_string().eq(&event_id_to_search)) | ||
| 798 | { | ||
| 799 | res.push(event.clone()); | ||
| 800 | if event_is_patch_set_root(event) { | ||
| 801 | break; | ||
| 802 | } | ||
| 803 | event_id_to_search = get_event_parent_id(event).unwrap_or_default(); | ||
| 804 | } | ||
| 805 | Ok(res) | ||
| 806 | } | ||
| 807 | |||
| 808 | pub fn status_kinds() -> Vec<nostr::Kind> { | ||
| 809 | vec![ | ||
| 810 | nostr::Kind::GitStatusOpen, | ||
| 811 | nostr::Kind::GitStatusApplied, | ||
| 812 | nostr::Kind::GitStatusClosed, | ||
| 813 | nostr::Kind::GitStatusDraft, | ||
| 814 | ] | ||
| 815 | } | ||
| 816 | |||
| 817 | pub async fn get_proposals_and_revisions_from_cache( | ||
| 818 | git_repo_path: &Path, | ||
| 819 | repo_coordinates: HashSet<Coordinate>, | ||
| 820 | ) -> Result<Vec<nostr::Event>> { | ||
| 821 | let mut proposals = get_events_from_cache( | ||
| 822 | git_repo_path, | ||
| 823 | vec![ | ||
| 824 | nostr::Filter::default() | ||
| 825 | .kind(nostr::Kind::GitPatch) | ||
| 826 | .custom_tag( | ||
| 827 | nostr::SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), | ||
| 828 | repo_coordinates | ||
| 829 | .iter() | ||
| 830 | .map(std::string::ToString::to_string) | ||
| 831 | .collect::<Vec<String>>(), | ||
| 832 | ), | ||
| 833 | ], | ||
| 834 | ) | ||
| 835 | .await? | ||
| 836 | .iter() | ||
| 837 | .filter(|e| event_is_patch_set_root(e)) | ||
| 838 | .cloned() | ||
| 839 | .collect::<Vec<nostr::Event>>(); | ||
| 840 | proposals.sort_by_key(|e| e.created_at); | ||
| 841 | proposals.reverse(); | ||
| 842 | Ok(proposals) | ||
| 843 | } | ||
| 844 | |||
| 845 | pub async fn get_all_proposal_patch_events_from_cache( | ||
| 846 | git_repo_path: &Path, | ||
| 847 | repo_ref: &RepoRef, | ||
| 848 | proposal_id: &nostr::EventId, | ||
| 849 | ) -> Result<Vec<nostr::Event>> { | ||
| 850 | let mut commit_events = get_events_from_cache( | ||
| 851 | git_repo_path, | ||
| 852 | vec![ | ||
| 853 | nostr::Filter::default() | ||
| 854 | .kind(nostr::Kind::GitPatch) | ||
| 855 | .event(*proposal_id), | ||
| 856 | nostr::Filter::default() | ||
| 857 | .kind(nostr::Kind::GitPatch) | ||
| 858 | .id(*proposal_id), | ||
| 859 | ], | ||
| 860 | ) | ||
| 861 | .await?; | ||
| 862 | |||
| 863 | let permissioned_users: HashSet<PublicKey> = [ | ||
| 864 | repo_ref.maintainers.clone(), | ||
| 865 | vec![ | ||
| 866 | commit_events | ||
| 867 | .iter() | ||
| 868 | .find(|e| e.id().eq(proposal_id)) | ||
| 869 | .context("proposal not in cache")? | ||
| 870 | .author(), | ||
| 871 | ], | ||
| 872 | ] | ||
| 873 | .concat() | ||
| 874 | .iter() | ||
| 875 | .copied() | ||
| 876 | .collect(); | ||
| 877 | commit_events.retain(|e| permissioned_users.contains(&e.author())); | ||
| 878 | |||
| 879 | let revision_roots: HashSet<nostr::EventId> = commit_events | ||
| 880 | .iter() | ||
| 881 | .filter(|e| event_is_revision_root(e)) | ||
| 882 | .map(nostr::Event::id) | ||
| 883 | .collect(); | ||
| 884 | |||
| 885 | if !revision_roots.is_empty() { | ||
| 886 | for event in get_events_from_cache( | ||
| 887 | git_repo_path, | ||
| 888 | vec![ | ||
| 889 | nostr::Filter::default() | ||
| 890 | .kind(nostr::Kind::GitPatch) | ||
| 891 | .events(revision_roots) | ||
| 892 | .authors(permissioned_users.clone()), | ||
| 893 | ], | ||
| 894 | ) | ||
| 895 | .await? | ||
| 896 | { | ||
| 897 | commit_events.push(event); | ||
| 898 | } | ||
| 899 | } | ||
| 900 | |||
| 901 | Ok(commit_events | ||
| 902 | .iter() | ||
| 903 | .filter(|e| !event_is_cover_letter(e) && permissioned_users.contains(&e.author())) | ||
| 904 | .cloned() | ||
| 905 | .collect()) | ||
| 906 | } | ||