diff options
Diffstat (limited to 'src/bin')
| -rw-r--r-- | src/bin/git_remote_nostr/main.rs | 1897 | ||||
| -rw-r--r-- | src/bin/ngit/cli.rs | 44 | ||||
| -rw-r--r-- | src/bin/ngit/main.rs | 26 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/fetch.rs | 44 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/init.rs | 385 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/list.rs | 906 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/login.rs | 52 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/mod.rs | 7 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/pull.rs | 209 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/push.rs | 223 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/send.rs | 1363 |
11 files changed, 5156 insertions, 0 deletions
diff --git a/src/bin/git_remote_nostr/main.rs b/src/bin/git_remote_nostr/main.rs new file mode 100644 index 0000000..a5244bf --- /dev/null +++ b/src/bin/git_remote_nostr/main.rs | |||
| @@ -0,0 +1,1897 @@ | |||
| 1 | #![cfg_attr(not(test), warn(clippy::pedantic))] | ||
| 2 | #![allow(clippy::large_futures)] | ||
| 3 | // better solution to dead_code error on multiple binaries than https://stackoverflow.com/a/66196291 | ||
| 4 | #![allow(dead_code)] | ||
| 5 | #![cfg_attr(not(test), warn(clippy::expect_used))] | ||
| 6 | |||
| 7 | use core::str; | ||
| 8 | use std::{ | ||
| 9 | collections::{HashMap, HashSet}, | ||
| 10 | env, | ||
| 11 | io::{self, Stdin}, | ||
| 12 | path::{Path, PathBuf}, | ||
| 13 | }; | ||
| 14 | |||
| 15 | use anyhow::{anyhow, bail, Context, Result}; | ||
| 16 | use auth_git2::GitAuthenticator; | ||
| 17 | use client::{ | ||
| 18 | consolidate_fetch_reports, get_events_from_cache, get_repo_ref_from_cache, | ||
| 19 | get_state_from_cache, sign_event, Connect, STATE_KIND, | ||
| 20 | }; | ||
| 21 | use console::Term; | ||
| 22 | use git::{sha1_to_oid, NostrUrlDecoded, RepoActions}; | ||
| 23 | use git2::{Oid, Repository}; | ||
| 24 | use nostr::nips::{nip01::Coordinate, nip10::Marker}; | ||
| 25 | use nostr_sdk::{ | ||
| 26 | hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, EventId, Kind, PublicKey, Tag, Url, | ||
| 27 | }; | ||
| 28 | use nostr_signer::NostrSigner; | ||
| 29 | use repo_ref::RepoRef; | ||
| 30 | use repo_state::RepoState; | ||
| 31 | use sub_commands::{ | ||
| 32 | list::{ | ||
| 33 | get_all_proposal_patch_events_from_cache, get_commit_id_from_patch, | ||
| 34 | get_most_recent_patch_with_ancestors, get_proposals_and_revisions_from_cache, status_kinds, | ||
| 35 | tag_value, | ||
| 36 | }, | ||
| 37 | send::{ | ||
| 38 | event_is_revision_root, event_to_cover_letter, generate_cover_letter_and_patch_events, | ||
| 39 | generate_patch_event, send_events, | ||
| 40 | }, | ||
| 41 | }; | ||
| 42 | |||
| 43 | #[cfg(not(test))] | ||
| 44 | use crate::client::Client; | ||
| 45 | #[cfg(test)] | ||
| 46 | use crate::client::MockConnect; | ||
| 47 | use crate::git::Repo; | ||
| 48 | |||
| 49 | mod cli; | ||
| 50 | mod cli_interactor; | ||
| 51 | mod client; | ||
| 52 | mod config; | ||
| 53 | mod git; | ||
| 54 | mod key_handling; | ||
| 55 | mod login; | ||
| 56 | mod repo_ref; | ||
| 57 | mod repo_state; | ||
| 58 | mod sub_commands; | ||
| 59 | |||
| 60 | #[tokio::main] | ||
| 61 | async fn main() -> Result<()> { | ||
| 62 | let args = env::args(); | ||
| 63 | let args = args.skip(1).take(2).collect::<Vec<_>>(); | ||
| 64 | |||
| 65 | let ([_, nostr_remote_url] | [nostr_remote_url]) = args.as_slice() else { | ||
| 66 | bail!("invalid arguments - no url"); | ||
| 67 | }; | ||
| 68 | if env::args().nth(1).as_deref() == Some("--version") { | ||
| 69 | const VERSION: &str = env!("CARGO_PKG_VERSION"); | ||
| 70 | println!("v{VERSION}"); | ||
| 71 | return Ok(()); | ||
| 72 | } | ||
| 73 | |||
| 74 | let git_repo = Repo::from_path(&PathBuf::from( | ||
| 75 | std::env::var("GIT_DIR").context("git should set GIT_DIR when remote helper is called")?, | ||
| 76 | ))?; | ||
| 77 | let git_repo_path = git_repo.get_path()?; | ||
| 78 | |||
| 79 | #[cfg(not(test))] | ||
| 80 | let client = Client::default(); | ||
| 81 | #[cfg(test)] | ||
| 82 | let client = <MockConnect as std::default::Default>::default(); | ||
| 83 | |||
| 84 | let decoded_nostr_url = | ||
| 85 | NostrUrlDecoded::from_str(nostr_remote_url).context("invalid nostr url")?; | ||
| 86 | |||
| 87 | fetching_with_report_for_helper(git_repo_path, &client, &decoded_nostr_url.coordinates).await?; | ||
| 88 | |||
| 89 | let repo_ref = get_repo_ref_from_cache(git_repo_path, &decoded_nostr_url.coordinates).await?; | ||
| 90 | |||
| 91 | let stdin = io::stdin(); | ||
| 92 | let mut line = String::new(); | ||
| 93 | |||
| 94 | let mut list_outputs = None; | ||
| 95 | loop { | ||
| 96 | let tokens = read_line(&stdin, &mut line)?; | ||
| 97 | |||
| 98 | match tokens.as_slice() { | ||
| 99 | ["capabilities"] => { | ||
| 100 | println!("option"); | ||
| 101 | println!("push"); | ||
| 102 | println!("fetch"); | ||
| 103 | println!(); | ||
| 104 | } | ||
| 105 | ["option", "verbosity"] => { | ||
| 106 | println!("ok"); | ||
| 107 | } | ||
| 108 | ["option", ..] => { | ||
| 109 | println!("unsupported"); | ||
| 110 | } | ||
| 111 | ["fetch", oid, refstr] => { | ||
| 112 | fetch(&git_repo, &repo_ref, &stdin, oid, refstr).await?; | ||
| 113 | } | ||
| 114 | ["push", refspec] => { | ||
| 115 | push( | ||
| 116 | &git_repo, | ||
| 117 | &repo_ref, | ||
| 118 | nostr_remote_url, | ||
| 119 | &stdin, | ||
| 120 | refspec, | ||
| 121 | &client, | ||
| 122 | list_outputs.clone(), | ||
| 123 | ) | ||
| 124 | .await?; | ||
| 125 | } | ||
| 126 | ["list"] => { | ||
| 127 | list_outputs = Some(list(&git_repo, &repo_ref, false).await?); | ||
| 128 | } | ||
| 129 | ["list", "for-push"] => { | ||
| 130 | list_outputs = Some(list(&git_repo, &repo_ref, true).await?); | ||
| 131 | } | ||
| 132 | [] => { | ||
| 133 | return Ok(()); | ||
| 134 | } | ||
| 135 | _ => { | ||
| 136 | bail!(format!("unknown command: {}", line.trim().to_owned())); | ||
| 137 | } | ||
| 138 | } | ||
| 139 | } | ||
| 140 | } | ||
| 141 | |||
| 142 | /// Read one line from stdin, and split it into tokens. | ||
| 143 | pub(crate) fn read_line<'a>(stdin: &io::Stdin, line: &'a mut String) -> io::Result<Vec<&'a str>> { | ||
| 144 | line.clear(); | ||
| 145 | |||
| 146 | let read = stdin.read_line(line)?; | ||
| 147 | if read == 0 { | ||
| 148 | return Ok(vec![]); | ||
| 149 | } | ||
| 150 | let line = line.trim(); | ||
| 151 | let tokens = line.split(' ').filter(|t| !t.is_empty()).collect(); | ||
| 152 | |||
| 153 | Ok(tokens) | ||
| 154 | } | ||
| 155 | |||
| 156 | async fn fetching_with_report_for_helper( | ||
| 157 | git_repo_path: &Path, | ||
| 158 | #[cfg(test)] client: &crate::client::MockConnect, | ||
| 159 | #[cfg(not(test))] client: &Client, | ||
| 160 | repo_coordinates: &HashSet<Coordinate>, | ||
| 161 | ) -> Result<()> { | ||
| 162 | let term = console::Term::stderr(); | ||
| 163 | term.write_line("nostr: fetching...")?; | ||
| 164 | let (relay_reports, progress_reporter) = client | ||
| 165 | .fetch_all(git_repo_path, repo_coordinates, &HashSet::new()) | ||
| 166 | .await?; | ||
| 167 | if !relay_reports.iter().any(std::result::Result::is_err) { | ||
| 168 | let _ = progress_reporter.clear(); | ||
| 169 | term.clear_last_lines(1)?; | ||
| 170 | } | ||
| 171 | let report = consolidate_fetch_reports(relay_reports); | ||
| 172 | if report.to_string().is_empty() { | ||
| 173 | term.write_line("nostr: no updates")?; | ||
| 174 | } else { | ||
| 175 | term.write_line(&format!("nostr updates: {report}"))?; | ||
| 176 | } | ||
| 177 | Ok(()) | ||
| 178 | } | ||
| 179 | |||
| 180 | async fn list( | ||
| 181 | git_repo: &Repo, | ||
| 182 | repo_ref: &RepoRef, | ||
| 183 | for_push: bool, | ||
| 184 | ) -> Result<HashMap<String, HashMap<String, String>>> { | ||
| 185 | let nostr_state = | ||
| 186 | if let Ok(nostr_state) = get_state_from_cache(git_repo.get_path()?, repo_ref).await { | ||
| 187 | Some(nostr_state) | ||
| 188 | } else { | ||
| 189 | None | ||
| 190 | }; | ||
| 191 | |||
| 192 | let term = console::Term::stderr(); | ||
| 193 | |||
| 194 | let remote_states = list_from_remotes(&term, git_repo, &repo_ref.git_server)?; | ||
| 195 | |||
| 196 | let mut state = if let Some(nostr_state) = nostr_state { | ||
| 197 | for (name, value) in &nostr_state.state { | ||
| 198 | for (url, remote_state) in &remote_states { | ||
| 199 | let remote_name = get_short_git_server_name(git_repo, url); | ||
| 200 | if let Some(remote_value) = remote_state.get(name) { | ||
| 201 | if value.ne(remote_value) { | ||
| 202 | term.write_line( | ||
| 203 | format!( | ||
| 204 | "WARNING: {remote_name} {name} is {} nostr ", | ||
| 205 | if let Ok((ahead, behind)) = | ||
| 206 | get_ahead_behind(git_repo, value, remote_value) | ||
| 207 | { | ||
| 208 | format!("{} ahead {} behind", ahead.len(), behind.len()) | ||
| 209 | } else { | ||
| 210 | "out of sync with".to_string() | ||
| 211 | } | ||
| 212 | ) | ||
| 213 | .as_str(), | ||
| 214 | )?; | ||
| 215 | } | ||
| 216 | } else { | ||
| 217 | term.write_line( | ||
| 218 | format!("WARNING: {remote_name} {name} is missing but tracked on nostr") | ||
| 219 | .as_str(), | ||
| 220 | )?; | ||
| 221 | } | ||
| 222 | } | ||
| 223 | } | ||
| 224 | nostr_state.state | ||
| 225 | } else { | ||
| 226 | repo_ref | ||
| 227 | .git_server | ||
| 228 | .iter() | ||
| 229 | .filter_map(|server| remote_states.get(server)) | ||
| 230 | .cloned() | ||
| 231 | .collect::<Vec<HashMap<String, String>>>() | ||
| 232 | .first() | ||
| 233 | .context("failed to get refs from git server")? | ||
| 234 | .clone() | ||
| 235 | }; | ||
| 236 | |||
| 237 | state.retain(|k, _| !k.starts_with("refs/heads/pr/")); | ||
| 238 | |||
| 239 | let open_proposals = get_open_proposals(git_repo, repo_ref).await?; | ||
| 240 | let current_user = get_curent_user(git_repo)?; | ||
| 241 | for (_, (proposal, patches)) in open_proposals { | ||
| 242 | if let Ok(cl) = event_to_cover_letter(&proposal) { | ||
| 243 | if let Ok(mut branch_name) = cl.get_branch_name() { | ||
| 244 | branch_name = if let Some(public_key) = current_user { | ||
| 245 | if proposal.author().eq(&public_key) { | ||
| 246 | cl.branch_name.to_string() | ||
| 247 | } else { | ||
| 248 | branch_name | ||
| 249 | } | ||
| 250 | } else { | ||
| 251 | branch_name | ||
| 252 | }; | ||
| 253 | if let Some(patch) = patches.first() { | ||
| 254 | // TODO this isn't resilient because the commit id stated may not be correct | ||
| 255 | // we will need to check whether the commit id exists in the repo or apply the | ||
| 256 | // proposal and each patch to check | ||
| 257 | if let Ok(commit_id) = get_commit_id_from_patch(patch) { | ||
| 258 | state.insert(format!("refs/heads/{branch_name}"), commit_id); | ||
| 259 | } | ||
| 260 | } | ||
| 261 | } | ||
| 262 | } | ||
| 263 | } | ||
| 264 | |||
| 265 | // TODO 'for push' should we check with the git servers to see if any of them | ||
| 266 | // allow push from the user? | ||
| 267 | for (name, value) in state { | ||
| 268 | if value.starts_with("ref: ") { | ||
| 269 | if !for_push { | ||
| 270 | println!("{} {name}", value.replace("ref: ", "@")); | ||
| 271 | } | ||
| 272 | } else { | ||
| 273 | println!("{value} {name}"); | ||
| 274 | } | ||
| 275 | } | ||
| 276 | |||
| 277 | println!(); | ||
| 278 | Ok(remote_states) | ||
| 279 | } | ||
| 280 | |||
| 281 | fn list_from_remotes( | ||
| 282 | term: &console::Term, | ||
| 283 | git_repo: &Repo, | ||
| 284 | git_servers: &Vec<String>, | ||
| 285 | ) -> Result<HashMap<String, HashMap<String, String>>> { | ||
| 286 | let mut remote_states = HashMap::new(); | ||
| 287 | for url in git_servers { | ||
| 288 | let short_name = get_short_git_server_name(git_repo, url); | ||
| 289 | term.write_line(format!("fetching refs list: {short_name}...").as_str())?; | ||
| 290 | match list_from_remote(git_repo, url) { | ||
| 291 | Ok(remote_state) => { | ||
| 292 | remote_states.insert(url.clone(), remote_state); | ||
| 293 | } | ||
| 294 | Err(error1) => { | ||
| 295 | if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(url) { | ||
| 296 | match list_from_remote(git_repo, &alternative_url) { | ||
| 297 | Ok(remote_state) => { | ||
| 298 | remote_states.insert(url.clone(), remote_state); | ||
| 299 | } | ||
| 300 | Err(error2) => { | ||
| 301 | term.write_line( | ||
| 302 | format!("WARNING: {short_name} failed to list refs error: {error1}\r\nand alternative protocol {alternative_url}: {error2}").as_str(), | ||
| 303 | )?; | ||
| 304 | } | ||
| 305 | } | ||
| 306 | } else { | ||
| 307 | term.write_line( | ||
| 308 | format!("WARNING: {short_name} failed to list refs error: {error1}",) | ||
| 309 | .as_str(), | ||
| 310 | )?; | ||
| 311 | } | ||
| 312 | } | ||
| 313 | } | ||
| 314 | term.clear_last_lines(1)?; | ||
| 315 | } | ||
| 316 | Ok(remote_states) | ||
| 317 | } | ||
| 318 | |||
| 319 | fn switch_clone_url_between_ssh_and_https(url: &str) -> Result<String> { | ||
| 320 | if url.starts_with("https://") { | ||
| 321 | // Convert HTTPS to git@ syntax | ||
| 322 | let parts: Vec<&str> = url.trim_start_matches("https://").split('/').collect(); | ||
| 323 | if parts.len() >= 2 { | ||
| 324 | // Construct the git@ URL | ||
| 325 | Ok(format!("git@{}:{}", parts[0], parts[1..].join("/"))) | ||
| 326 | } else { | ||
| 327 | // If the format is unexpected, return an error | ||
| 328 | bail!("Invalid HTTPS URL format: {}", url); | ||
| 329 | } | ||
| 330 | } else if url.starts_with("ssh://") { | ||
| 331 | // Convert SSH to git@ syntax | ||
| 332 | let parts: Vec<&str> = url.trim_start_matches("ssh://").split('/').collect(); | ||
| 333 | if parts.len() >= 2 { | ||
| 334 | // Construct the git@ URL | ||
| 335 | Ok(format!("git@{}:{}", parts[0], parts[1..].join("/"))) | ||
| 336 | } else { | ||
| 337 | // If the format is unexpected, return an error | ||
| 338 | bail!("Invalid SSH URL format: {}", url); | ||
| 339 | } | ||
| 340 | } else if url.starts_with("git@") { | ||
| 341 | // Convert git@ syntax to HTTPS | ||
| 342 | let parts: Vec<&str> = url.split(':').collect(); | ||
| 343 | if parts.len() == 2 { | ||
| 344 | // Construct the HTTPS URL | ||
| 345 | Ok(format!( | ||
| 346 | "https://{}/{}", | ||
| 347 | parts[0].trim_end_matches('@'), | ||
| 348 | parts[1] | ||
| 349 | )) | ||
| 350 | } else { | ||
| 351 | // If the format is unexpected, return an error | ||
| 352 | bail!("Invalid git@ URL format: {}", url); | ||
| 353 | } | ||
| 354 | } else { | ||
| 355 | // If the URL is neither HTTPS, SSH, nor git@, return an error | ||
| 356 | bail!("Unsupported URL protocol: {}", url); | ||
| 357 | } | ||
| 358 | } | ||
| 359 | |||
| 360 | fn list_from_remote( | ||
| 361 | git_repo: &Repo, | ||
| 362 | git_server_remote_url: &str, | ||
| 363 | ) -> Result<HashMap<String, String>> { | ||
| 364 | let git_config = git_repo.git_repo.config()?; | ||
| 365 | |||
| 366 | let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_remote_url)?; | ||
| 367 | // authentication may be required | ||
| 368 | let auth = GitAuthenticator::default(); | ||
| 369 | let mut remote_callbacks = git2::RemoteCallbacks::new(); | ||
| 370 | remote_callbacks.credentials(auth.credentials(&git_config)); | ||
| 371 | git_server_remote.connect_auth(git2::Direction::Fetch, Some(remote_callbacks), None)?; | ||
| 372 | let mut state = HashMap::new(); | ||
| 373 | for head in git_server_remote.list()? { | ||
| 374 | if let Some(symbolic_reference) = head.symref_target() { | ||
| 375 | state.insert( | ||
| 376 | head.name().to_string(), | ||
| 377 | format!("ref: {symbolic_reference}"), | ||
| 378 | ); | ||
| 379 | } else { | ||
| 380 | state.insert(head.name().to_string(), head.oid().to_string()); | ||
| 381 | } | ||
| 382 | } | ||
| 383 | git_server_remote.disconnect()?; | ||
| 384 | Ok(state) | ||
| 385 | } | ||
| 386 | |||
| 387 | fn get_ahead_behind( | ||
| 388 | git_repo: &Repo, | ||
| 389 | base_ref_or_oid: &str, | ||
| 390 | latest_ref_or_oid: &str, | ||
| 391 | ) -> Result<(Vec<Sha1Hash>, Vec<Sha1Hash>)> { | ||
| 392 | let base = git_repo.get_commit_or_tip_of_reference(base_ref_or_oid)?; | ||
| 393 | let latest = git_repo.get_commit_or_tip_of_reference(latest_ref_or_oid)?; | ||
| 394 | git_repo.get_commits_ahead_behind(&base, &latest) | ||
| 395 | } | ||
| 396 | |||
| 397 | async fn get_open_proposals( | ||
| 398 | git_repo: &Repo, | ||
| 399 | repo_ref: &RepoRef, | ||
| 400 | ) -> Result<HashMap<EventId, (Event, Vec<Event>)>> { | ||
| 401 | let git_repo_path = git_repo.get_path()?; | ||
| 402 | let proposals: Vec<nostr::Event> = | ||
| 403 | get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()) | ||
| 404 | .await? | ||
| 405 | .iter() | ||
| 406 | .filter(|e| !event_is_revision_root(e)) | ||
| 407 | .cloned() | ||
| 408 | .collect(); | ||
| 409 | |||
| 410 | let statuses: Vec<nostr::Event> = { | ||
| 411 | let mut statuses = get_events_from_cache( | ||
| 412 | git_repo_path, | ||
| 413 | vec![ | ||
| 414 | nostr::Filter::default() | ||
| 415 | .kinds(status_kinds().clone()) | ||
| 416 | .events(proposals.iter().map(nostr::Event::id)), | ||
| 417 | ], | ||
| 418 | ) | ||
| 419 | .await?; | ||
| 420 | statuses.sort_by_key(|e| e.created_at); | ||
| 421 | statuses.reverse(); | ||
| 422 | statuses | ||
| 423 | }; | ||
| 424 | let mut open_proposals = HashMap::new(); | ||
| 425 | |||
| 426 | for proposal in proposals { | ||
| 427 | let status = if let Some(e) = statuses | ||
| 428 | .iter() | ||
| 429 | .filter(|e| { | ||
| 430 | status_kinds().contains(&e.kind()) | ||
| 431 | && e.tags() | ||
| 432 | .iter() | ||
| 433 | .any(|t| t.as_vec()[1].eq(&proposal.id.to_string())) | ||
| 434 | }) | ||
| 435 | .collect::<Vec<&nostr::Event>>() | ||
| 436 | .first() | ||
| 437 | { | ||
| 438 | e.kind() | ||
| 439 | } else { | ||
| 440 | Kind::GitStatusOpen | ||
| 441 | }; | ||
| 442 | if status.eq(&Kind::GitStatusOpen) { | ||
| 443 | if let Ok(commits_events) = | ||
| 444 | get_all_proposal_patch_events_from_cache(git_repo_path, repo_ref, &proposal.id) | ||
| 445 | .await | ||
| 446 | { | ||
| 447 | if let Ok(most_recent_proposal_patch_chain) = | ||
| 448 | get_most_recent_patch_with_ancestors(commits_events.clone()) | ||
| 449 | { | ||
| 450 | open_proposals | ||
| 451 | .insert(proposal.id(), (proposal, most_recent_proposal_patch_chain)); | ||
| 452 | } | ||
| 453 | } | ||
| 454 | } | ||
| 455 | } | ||
| 456 | Ok(open_proposals) | ||
| 457 | } | ||
| 458 | |||
| 459 | fn get_curent_user(git_repo: &Repo) -> Result<Option<PublicKey>> { | ||
| 460 | Ok( | ||
| 461 | if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? { | ||
| 462 | if let Ok(public_key) = PublicKey::parse(npub) { | ||
| 463 | Some(public_key) | ||
| 464 | } else { | ||
| 465 | None | ||
| 466 | } | ||
| 467 | } else { | ||
| 468 | None | ||
| 469 | }, | ||
| 470 | ) | ||
| 471 | } | ||
| 472 | |||
| 473 | async fn get_all_proposals( | ||
| 474 | git_repo: &Repo, | ||
| 475 | repo_ref: &RepoRef, | ||
| 476 | ) -> Result<HashMap<EventId, (Event, Vec<Event>)>> { | ||
| 477 | let git_repo_path = git_repo.get_path()?; | ||
| 478 | let proposals: Vec<nostr::Event> = | ||
| 479 | get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()) | ||
| 480 | .await? | ||
| 481 | .iter() | ||
| 482 | .filter(|e| !event_is_revision_root(e)) | ||
| 483 | .cloned() | ||
| 484 | .collect(); | ||
| 485 | |||
| 486 | let mut all_proposals = HashMap::new(); | ||
| 487 | |||
| 488 | for proposal in proposals { | ||
| 489 | if let Ok(commits_events) = | ||
| 490 | get_all_proposal_patch_events_from_cache(git_repo_path, repo_ref, &proposal.id).await | ||
| 491 | { | ||
| 492 | if let Ok(most_recent_proposal_patch_chain) = | ||
| 493 | get_most_recent_patch_with_ancestors(commits_events.clone()) | ||
| 494 | { | ||
| 495 | all_proposals.insert(proposal.id(), (proposal, most_recent_proposal_patch_chain)); | ||
| 496 | } | ||
| 497 | } | ||
| 498 | } | ||
| 499 | Ok(all_proposals) | ||
| 500 | } | ||
| 501 | |||
| 502 | async fn fetch( | ||
| 503 | git_repo: &Repo, | ||
| 504 | repo_ref: &RepoRef, | ||
| 505 | stdin: &Stdin, | ||
| 506 | oid: &str, | ||
| 507 | refstr: &str, | ||
| 508 | ) -> Result<()> { | ||
| 509 | let mut fetch_batch = get_oids_from_fetch_batch(stdin, oid, refstr)?; | ||
| 510 | |||
| 511 | let oids_from_git_servers = fetch_batch | ||
| 512 | .iter() | ||
| 513 | .filter(|(refstr, _)| !refstr.contains("refs/heads/pr/")) | ||
| 514 | .map(|(_, oid)| oid.clone()) | ||
| 515 | .collect::<Vec<String>>(); | ||
| 516 | |||
| 517 | let mut errors = HashMap::new(); | ||
| 518 | let term = console::Term::stderr(); | ||
| 519 | |||
| 520 | for git_server_url in &repo_ref.git_server { | ||
| 521 | let term = console::Term::stderr(); | ||
| 522 | let short_name = get_short_git_server_name(git_repo, git_server_url); | ||
| 523 | term.write_line(format!("fetching from {short_name}...").as_str())?; | ||
| 524 | let res = fetch_from_git_server(&git_repo.git_repo, &oids_from_git_servers, git_server_url); | ||
| 525 | term.clear_last_lines(1)?; | ||
| 526 | if let Err(error1) = res { | ||
| 527 | if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(git_server_url) { | ||
| 528 | let res2 = fetch_from_git_server( | ||
| 529 | &git_repo.git_repo, | ||
| 530 | &oids_from_git_servers, | ||
| 531 | &alternative_url, | ||
| 532 | ); | ||
| 533 | if let Err(error2) = res2 { | ||
| 534 | term.write_line( | ||
| 535 | format!( | ||
| 536 | "WARNING: failed to fetch from {short_name} error:{error1}\r\nand using alternative protocol {alternative_url}: {error2}" | ||
| 537 | ).as_str() | ||
| 538 | )?; | ||
| 539 | errors.insert( | ||
| 540 | short_name.to_string(), | ||
| 541 | anyhow!( | ||
| 542 | "{error1} and using alternative protocol {alternative_url}: {error2}" | ||
| 543 | ), | ||
| 544 | ); | ||
| 545 | } else { | ||
| 546 | break; | ||
| 547 | } | ||
| 548 | } else { | ||
| 549 | term.write_line( | ||
| 550 | format!("WARNING: failed to fetch from {short_name} error:{error1}").as_str(), | ||
| 551 | )?; | ||
| 552 | errors.insert(short_name.to_string(), error1); | ||
| 553 | } | ||
| 554 | } else { | ||
| 555 | break; | ||
| 556 | } | ||
| 557 | } | ||
| 558 | |||
| 559 | if oids_from_git_servers | ||
| 560 | .iter() | ||
| 561 | .any(|oid| !git_repo.does_commit_exist(oid).unwrap()) | ||
| 562 | && !errors.is_empty() | ||
| 563 | { | ||
| 564 | bail!( | ||
| 565 | "failed to fetch objects in nostr state event from:\r\n{}", | ||
| 566 | errors | ||
| 567 | .iter() | ||
| 568 | .map(|(url, error)| format!("{url}: {error}")) | ||
| 569 | .collect::<Vec<String>>() | ||
| 570 | .join("\r\n") | ||
| 571 | ); | ||
| 572 | } | ||
| 573 | |||
| 574 | fetch_batch.retain(|refstr, _| refstr.contains("refs/heads/pr/")); | ||
| 575 | |||
| 576 | if !fetch_batch.is_empty() { | ||
| 577 | let open_proposals = get_open_proposals(git_repo, repo_ref).await?; | ||
| 578 | |||
| 579 | let current_user = get_curent_user(git_repo)?; | ||
| 580 | |||
| 581 | for (refstr, oid) in fetch_batch { | ||
| 582 | if let Some((_, (_, patches))) = | ||
| 583 | find_proposal_and_patches_by_branch_name(&refstr, &open_proposals, ¤t_user) | ||
| 584 | { | ||
| 585 | if !git_repo.does_commit_exist(&oid)? { | ||
| 586 | let mut patches_ancestor_first = patches.clone(); | ||
| 587 | patches_ancestor_first.reverse(); | ||
| 588 | if git_repo.does_commit_exist(&tag_value( | ||
| 589 | patches_ancestor_first.first().unwrap(), | ||
| 590 | "parent-commit", | ||
| 591 | )?)? { | ||
| 592 | for patch in &patches_ancestor_first { | ||
| 593 | git_repo.create_commit_from_patch(patch)?; | ||
| 594 | } | ||
| 595 | } else { | ||
| 596 | term.write_line( | ||
| 597 | format!("WARNING: cannot find parent commit for {refstr}").as_str(), | ||
| 598 | )?; | ||
| 599 | } | ||
| 600 | } | ||
| 601 | } else { | ||
| 602 | term.write_line(format!("WARNING: cannot find proposal for {refstr}").as_str())?; | ||
| 603 | } | ||
| 604 | } | ||
| 605 | } | ||
| 606 | |||
| 607 | term.flush()?; | ||
| 608 | println!(); | ||
| 609 | Ok(()) | ||
| 610 | } | ||
| 611 | |||
| 612 | fn find_proposal_and_patches_by_branch_name<'a>( | ||
| 613 | refstr: &'a str, | ||
| 614 | open_proposals: &'a HashMap<EventId, (Event, Vec<Event>)>, | ||
| 615 | current_user: &Option<PublicKey>, | ||
| 616 | ) -> Option<(&'a EventId, &'a (Event, Vec<Event>))> { | ||
| 617 | open_proposals.iter().find(|(_, (proposal, _))| { | ||
| 618 | if let Ok(cl) = event_to_cover_letter(proposal) { | ||
| 619 | if let Ok(mut branch_name) = cl.get_branch_name() { | ||
| 620 | branch_name = if let Some(public_key) = current_user { | ||
| 621 | if proposal.author().eq(public_key) { | ||
| 622 | cl.branch_name.to_string() | ||
| 623 | } else { | ||
| 624 | branch_name | ||
| 625 | } | ||
| 626 | } else { | ||
| 627 | branch_name | ||
| 628 | }; | ||
| 629 | branch_name.eq(&refstr.replace("refs/heads/", "")) | ||
| 630 | } else { | ||
| 631 | false | ||
| 632 | } | ||
| 633 | } else { | ||
| 634 | false | ||
| 635 | } | ||
| 636 | }) | ||
| 637 | } | ||
| 638 | |||
| 639 | fn fetch_from_git_server( | ||
| 640 | git_repo: &Repository, | ||
| 641 | oids: &[String], | ||
| 642 | git_server_url: &str, | ||
| 643 | ) -> Result<()> { | ||
| 644 | let git_config = git_repo.config()?; | ||
| 645 | |||
| 646 | let mut git_server_remote = git_repo.remote_anonymous(git_server_url)?; | ||
| 647 | // authentication may be required (and will be requird if clone url is ssh) | ||
| 648 | let auth = GitAuthenticator::default(); | ||
| 649 | let mut fetch_options = git2::FetchOptions::new(); | ||
| 650 | let mut remote_callbacks = git2::RemoteCallbacks::new(); | ||
| 651 | remote_callbacks.credentials(auth.credentials(&git_config)); | ||
| 652 | fetch_options.remote_callbacks(remote_callbacks); | ||
| 653 | git_server_remote.download(oids, Some(&mut fetch_options))?; | ||
| 654 | git_server_remote.disconnect()?; | ||
| 655 | Ok(()) | ||
| 656 | } | ||
| 657 | |||
| 658 | #[allow(clippy::too_many_lines)] | ||
| 659 | async fn push( | ||
| 660 | git_repo: &Repo, | ||
| 661 | repo_ref: &RepoRef, | ||
| 662 | nostr_remote_url: &str, | ||
| 663 | stdin: &Stdin, | ||
| 664 | initial_refspec: &str, | ||
| 665 | #[cfg(test)] client: &crate::client::MockConnect, | ||
| 666 | #[cfg(not(test))] client: &Client, | ||
| 667 | list_outputs: Option<HashMap<String, HashMap<String, String>>>, | ||
| 668 | ) -> Result<()> { | ||
| 669 | let refspecs = get_refspecs_from_push_batch(stdin, initial_refspec)?; | ||
| 670 | |||
| 671 | let proposal_refspecs = refspecs | ||
| 672 | .iter() | ||
| 673 | .filter(|r| r.contains("refs/heads/pr/")) | ||
| 674 | .cloned() | ||
| 675 | .collect::<Vec<String>>(); | ||
| 676 | |||
| 677 | let mut git_server_refspecs = refspecs | ||
| 678 | .iter() | ||
| 679 | .filter(|r| !r.contains("refs/heads/pr/")) | ||
| 680 | .cloned() | ||
| 681 | .collect::<Vec<String>>(); | ||
| 682 | |||
| 683 | let term = console::Term::stderr(); | ||
| 684 | |||
| 685 | let list_outputs = match list_outputs { | ||
| 686 | Some(outputs) => outputs, | ||
| 687 | _ => list_from_remotes(&term, git_repo, &repo_ref.git_server)?, | ||
| 688 | }; | ||
| 689 | |||
| 690 | let nostr_state = get_state_from_cache(git_repo.get_path()?, repo_ref).await; | ||
| 691 | |||
| 692 | let existing_state = { | ||
| 693 | // if no state events - create from first git server listed | ||
| 694 | if let Ok(nostr_state) = &nostr_state { | ||
| 695 | nostr_state.state.clone() | ||
| 696 | } else if let Some(url) = repo_ref | ||
| 697 | .git_server | ||
| 698 | .iter() | ||
| 699 | .find(|&url| list_outputs.contains_key(url)) | ||
| 700 | { | ||
| 701 | list_outputs.get(url).unwrap().to_owned() | ||
| 702 | } else { | ||
| 703 | bail!( | ||
| 704 | "cannot connect to git servers: {}", | ||
| 705 | repo_ref.git_server.join(" ") | ||
| 706 | ); | ||
| 707 | } | ||
| 708 | }; | ||
| 709 | |||
| 710 | let (rejected_refspecs, remote_refspecs) = create_rejected_refspecs_and_remotes_refspecs( | ||
| 711 | &term, | ||
| 712 | git_repo, | ||
| 713 | &git_server_refspecs, | ||
| 714 | &existing_state, | ||
| 715 | &list_outputs, | ||
| 716 | )?; | ||
| 717 | |||
| 718 | git_server_refspecs.retain(|refspec| { | ||
| 719 | if let Some(rejected) = rejected_refspecs.get(&refspec.to_string()) { | ||
| 720 | let (_, to) = refspec_to_from_to(refspec).unwrap(); | ||
| 721 | println!("error {to} {} out of sync with nostr", rejected.join(" ")); | ||
| 722 | false | ||
| 723 | } else { | ||
| 724 | true | ||
| 725 | } | ||
| 726 | }); | ||
| 727 | |||
| 728 | let mut events = vec![]; | ||
| 729 | |||
| 730 | if git_server_refspecs.is_empty() && proposal_refspecs.is_empty() { | ||
| 731 | // all refspecs rejected | ||
| 732 | println!(); | ||
| 733 | return Ok(()); | ||
| 734 | } | ||
| 735 | |||
| 736 | let (signer, user_ref) = login::launch( | ||
| 737 | git_repo, | ||
| 738 | &None, | ||
| 739 | &None, | ||
| 740 | &None, | ||
| 741 | &None, | ||
| 742 | Some(client), | ||
| 743 | false, | ||
| 744 | true, | ||
| 745 | ) | ||
| 746 | .await?; | ||
| 747 | |||
| 748 | if !repo_ref.maintainers.contains(&user_ref.public_key) { | ||
| 749 | for refspec in &git_server_refspecs { | ||
| 750 | let (_, to) = refspec_to_from_to(refspec).unwrap(); | ||
| 751 | println!( | ||
| 752 | "error {to} your nostr account {} isn't listed as a maintainer of the repo", | ||
| 753 | user_ref.metadata.name | ||
| 754 | ); | ||
| 755 | } | ||
| 756 | git_server_refspecs.clear(); | ||
| 757 | if proposal_refspecs.is_empty() { | ||
| 758 | println!(); | ||
| 759 | return Ok(()); | ||
| 760 | } | ||
| 761 | } | ||
| 762 | |||
| 763 | if !git_server_refspecs.is_empty() { | ||
| 764 | let new_state = generate_updated_state(git_repo, &existing_state, &git_server_refspecs)?; | ||
| 765 | |||
| 766 | let new_repo_state = | ||
| 767 | RepoState::build(repo_ref.identifier.clone(), new_state, &signer).await?; | ||
| 768 | |||
| 769 | events.push(new_repo_state.event); | ||
| 770 | |||
| 771 | for event in get_merged_status_events( | ||
| 772 | &term, | ||
| 773 | repo_ref, | ||
| 774 | git_repo, | ||
| 775 | nostr_remote_url, | ||
| 776 | &signer, | ||
| 777 | &git_server_refspecs, | ||
| 778 | ) | ||
| 779 | .await? | ||
| 780 | { | ||
| 781 | events.push(event); | ||
| 782 | } | ||
| 783 | } | ||
| 784 | |||
| 785 | let mut rejected_proposal_refspecs = vec![]; | ||
| 786 | if !proposal_refspecs.is_empty() { | ||
| 787 | let all_proposals = get_all_proposals(git_repo, repo_ref).await?; | ||
| 788 | let current_user = get_curent_user(git_repo)?; | ||
| 789 | |||
| 790 | for refspec in &proposal_refspecs { | ||
| 791 | let (from, to) = refspec_to_from_to(refspec).unwrap(); | ||
| 792 | let tip_of_pushed_branch = git_repo.get_commit_or_tip_of_reference(from)?; | ||
| 793 | |||
| 794 | if let Some((_, (proposal, patches))) = | ||
| 795 | find_proposal_and_patches_by_branch_name(to, &all_proposals, ¤t_user) | ||
| 796 | { | ||
| 797 | if [repo_ref.maintainers.clone(), vec![proposal.author()]] | ||
| 798 | .concat() | ||
| 799 | .contains(&user_ref.public_key) | ||
| 800 | { | ||
| 801 | if refspec.starts_with('+') { | ||
| 802 | // force push | ||
| 803 | let (_, main_tip) = git_repo.get_main_or_master_branch()?; | ||
| 804 | let (mut ahead, _) = | ||
| 805 | git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?; | ||
| 806 | ahead.reverse(); | ||
| 807 | for patch in generate_cover_letter_and_patch_events( | ||
| 808 | None, | ||
| 809 | git_repo, | ||
| 810 | &ahead, | ||
| 811 | &signer, | ||
| 812 | repo_ref, | ||
| 813 | &Some(proposal.id().to_string()), | ||
| 814 | &[], | ||
| 815 | ) | ||
| 816 | .await? | ||
| 817 | { | ||
| 818 | events.push(patch); | ||
| 819 | } | ||
| 820 | } else { | ||
| 821 | // fast forward push | ||
| 822 | let tip_patch = patches.first().unwrap(); | ||
| 823 | let tip_of_proposal = get_commit_id_from_patch(tip_patch)?; | ||
| 824 | let tip_of_proposal_commit = | ||
| 825 | git_repo.get_commit_or_tip_of_reference(&tip_of_proposal)?; | ||
| 826 | |||
| 827 | let (mut ahead, behind) = git_repo.get_commits_ahead_behind( | ||
| 828 | &tip_of_proposal_commit, | ||
| 829 | &tip_of_pushed_branch, | ||
| 830 | )?; | ||
| 831 | if behind.is_empty() { | ||
| 832 | let thread_id = if let Ok(root_event_id) = get_event_root(tip_patch) { | ||
| 833 | root_event_id | ||
| 834 | } else { | ||
| 835 | // tip patch is the root proposal | ||
| 836 | tip_patch.id() | ||
| 837 | }; | ||
| 838 | let mut parent_patch = tip_patch.clone(); | ||
| 839 | ahead.reverse(); | ||
| 840 | for (i, commit) in ahead.iter().enumerate() { | ||
| 841 | let new_patch = generate_patch_event( | ||
| 842 | git_repo, | ||
| 843 | &git_repo.get_root_commit()?, | ||
| 844 | commit, | ||
| 845 | Some(thread_id), | ||
| 846 | &signer, | ||
| 847 | repo_ref, | ||
| 848 | Some(parent_patch.id()), | ||
| 849 | Some(( | ||
| 850 | (patches.len() + i + 1).try_into().unwrap(), | ||
| 851 | (patches.len() + ahead.len()).try_into().unwrap(), | ||
| 852 | )), | ||
| 853 | None, | ||
| 854 | &None, | ||
| 855 | &[], | ||
| 856 | ) | ||
| 857 | .await | ||
| 858 | .context("cannot make patch event from commit")?; | ||
| 859 | events.push(new_patch.clone()); | ||
| 860 | parent_patch = new_patch; | ||
| 861 | } | ||
| 862 | } else { | ||
| 863 | // we shouldn't get here | ||
| 864 | term.write_line( | ||
| 865 | format!( | ||
| 866 | "WARNING: failed to push {from} as nostr proposal. Try and force push ", | ||
| 867 | ) | ||
| 868 | .as_str(), | ||
| 869 | ) | ||
| 870 | .unwrap(); | ||
| 871 | println!( | ||
| 872 | "error {to} cannot fastforward as newer patches found on proposal" | ||
| 873 | ); | ||
| 874 | rejected_proposal_refspecs.push(refspec.to_string()); | ||
| 875 | } | ||
| 876 | } | ||
| 877 | } else { | ||
| 878 | println!( | ||
| 879 | "error {to} permission denied. you are not the proposal author or a repo maintainer" | ||
| 880 | ); | ||
| 881 | rejected_proposal_refspecs.push(refspec.to_string()); | ||
| 882 | } | ||
| 883 | } else { | ||
| 884 | // TODO new proposal / couldn't find exisiting proposal | ||
| 885 | let (_, main_tip) = git_repo.get_main_or_master_branch()?; | ||
| 886 | let (mut ahead, _) = | ||
| 887 | git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?; | ||
| 888 | ahead.reverse(); | ||
| 889 | for patch in generate_cover_letter_and_patch_events( | ||
| 890 | None, | ||
| 891 | git_repo, | ||
| 892 | &ahead, | ||
| 893 | &signer, | ||
| 894 | repo_ref, | ||
| 895 | &None, | ||
| 896 | &[], | ||
| 897 | ) | ||
| 898 | .await? | ||
| 899 | { | ||
| 900 | events.push(patch); | ||
| 901 | } | ||
| 902 | } | ||
| 903 | } | ||
| 904 | } | ||
| 905 | |||
| 906 | // TODO check whether tip of each branch pushed is on at least one git server | ||
| 907 | // before broadcasting the nostr state | ||
| 908 | if !events.is_empty() { | ||
| 909 | send_events( | ||
| 910 | client, | ||
| 911 | git_repo.get_path()?, | ||
| 912 | events, | ||
| 913 | user_ref.relays.write(), | ||
| 914 | repo_ref.relays.clone(), | ||
| 915 | false, | ||
| 916 | true, | ||
| 917 | ) | ||
| 918 | .await?; | ||
| 919 | } | ||
| 920 | |||
| 921 | for refspec in &[git_server_refspecs.clone(), proposal_refspecs.clone()].concat() { | ||
| 922 | if rejected_proposal_refspecs.contains(refspec) { | ||
| 923 | continue; | ||
| 924 | } | ||
| 925 | let (_, to) = refspec_to_from_to(refspec)?; | ||
| 926 | println!("ok {to}"); | ||
| 927 | update_remote_refs_pushed(&git_repo.git_repo, refspec, nostr_remote_url) | ||
| 928 | .context("could not update remote_ref locally")?; | ||
| 929 | } | ||
| 930 | |||
| 931 | // TODO make async - check gitlib2 callbacks work async | ||
| 932 | for (git_server_url, remote_refspecs) in remote_refspecs { | ||
| 933 | let remote_refspecs = remote_refspecs | ||
| 934 | .iter() | ||
| 935 | .filter(|refspec| git_server_refspecs.contains(refspec)) | ||
| 936 | .cloned() | ||
| 937 | .collect::<Vec<String>>(); | ||
| 938 | if !refspecs.is_empty() | ||
| 939 | && push_to_remote(git_repo, &git_server_url, &remote_refspecs, &term).is_err() | ||
| 940 | { | ||
| 941 | if let Ok(alternative_url) = switch_clone_url_between_ssh_and_https(&git_server_url) { | ||
| 942 | if push_to_remote(git_repo, &alternative_url, &remote_refspecs, &term).is_err() { | ||
| 943 | // errors get printed as part of callback | ||
| 944 | // TODO prevent 2 warning messages and instead use one | ||
| 945 | // to say it didnt work over either https or ssh | ||
| 946 | } else { | ||
| 947 | term.write_line( | ||
| 948 | format!("but succeed over alterantive protocol {alternative_url}",) | ||
| 949 | .as_str(), | ||
| 950 | )?; | ||
| 951 | } | ||
| 952 | } | ||
| 953 | } | ||
| 954 | } | ||
| 955 | println!(); | ||
| 956 | Ok(()) | ||
| 957 | } | ||
| 958 | |||
| 959 | fn push_to_remote( | ||
| 960 | git_repo: &Repo, | ||
| 961 | git_server_url: &str, | ||
| 962 | remote_refspecs: &[String], | ||
| 963 | term: &Term, | ||
| 964 | ) -> Result<()> { | ||
| 965 | let git_config = git_repo.git_repo.config()?; | ||
| 966 | let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_url)?; | ||
| 967 | let auth = GitAuthenticator::default(); | ||
| 968 | let mut push_options = git2::PushOptions::new(); | ||
| 969 | let mut remote_callbacks = git2::RemoteCallbacks::new(); | ||
| 970 | remote_callbacks.credentials(auth.credentials(&git_config)); | ||
| 971 | remote_callbacks.push_update_reference(|name, error| { | ||
| 972 | if let Some(error) = error { | ||
| 973 | term.write_line( | ||
| 974 | format!( | ||
| 975 | "WARNING: {} failed to push {name} error: {error}", | ||
| 976 | get_short_git_server_name(git_repo, git_server_url), | ||
| 977 | ) | ||
| 978 | .as_str(), | ||
| 979 | ) | ||
| 980 | .unwrap(); | ||
| 981 | } | ||
| 982 | Ok(()) | ||
| 983 | }); | ||
| 984 | push_options.remote_callbacks(remote_callbacks); | ||
| 985 | git_server_remote.push(remote_refspecs, Some(&mut push_options))?; | ||
| 986 | let _ = git_server_remote.disconnect(); | ||
| 987 | Ok(()) | ||
| 988 | } | ||
| 989 | |||
| 990 | fn get_event_root(event: &nostr::Event) -> Result<EventId> { | ||
| 991 | Ok(EventId::parse( | ||
| 992 | event | ||
| 993 | .tags() | ||
| 994 | .iter() | ||
| 995 | .find(|t| t.is_root()) | ||
| 996 | .context("no thread root in event")? | ||
| 997 | .as_vec() | ||
| 998 | .get(1) | ||
| 999 | .unwrap(), | ||
| 1000 | )?) | ||
| 1001 | } | ||
| 1002 | |||
| 1003 | type HashMapUrlRefspecs = HashMap<String, Vec<String>>; | ||
| 1004 | |||
| 1005 | #[allow(clippy::too_many_lines)] | ||
| 1006 | fn create_rejected_refspecs_and_remotes_refspecs( | ||
| 1007 | term: &console::Term, | ||
| 1008 | git_repo: &Repo, | ||
| 1009 | refspecs: &Vec<String>, | ||
| 1010 | nostr_state: &HashMap<String, String>, | ||
| 1011 | list_outputs: &HashMap<String, HashMap<String, String>>, | ||
| 1012 | ) -> Result<(HashMapUrlRefspecs, HashMapUrlRefspecs)> { | ||
| 1013 | let mut refspecs_for_remotes = HashMap::new(); | ||
| 1014 | |||
| 1015 | let mut rejected_refspecs: HashMapUrlRefspecs = HashMap::new(); | ||
| 1016 | |||
| 1017 | for (url, remote_state) in list_outputs { | ||
| 1018 | let short_name = get_short_git_server_name(git_repo, url); | ||
| 1019 | let mut refspecs_for_remote = vec![]; | ||
| 1020 | for refspec in refspecs { | ||
| 1021 | let (from, to) = refspec_to_from_to(refspec)?; | ||
| 1022 | let nostr_value = nostr_state.get(to); | ||
| 1023 | let remote_value = remote_state.get(to); | ||
| 1024 | if from.is_empty() { | ||
| 1025 | if remote_value.is_some() { | ||
| 1026 | // delete remote branch | ||
| 1027 | refspecs_for_remote.push(refspec.clone()); | ||
| 1028 | } | ||
| 1029 | continue; | ||
| 1030 | } | ||
| 1031 | let from_tip = git_repo.get_commit_or_tip_of_reference(from)?; | ||
| 1032 | if let Some(nostr_value) = nostr_value { | ||
| 1033 | if let Some(remote_value) = remote_value { | ||
| 1034 | if nostr_value.eq(remote_value) { | ||
| 1035 | // in sync - existing branch at same state | ||
| 1036 | let is_remote_tip_ancestor_of_commit = if let Ok(remote_value_tip) = | ||
| 1037 | git_repo.get_commit_or_tip_of_reference(remote_value) | ||
| 1038 | { | ||
| 1039 | if let Ok((_, behind)) = | ||
| 1040 | git_repo.get_commits_ahead_behind(&remote_value_tip, &from_tip) | ||
| 1041 | { | ||
| 1042 | behind.is_empty() | ||
| 1043 | } else { | ||
| 1044 | false | ||
| 1045 | } | ||
| 1046 | } else { | ||
| 1047 | false | ||
| 1048 | }; | ||
| 1049 | if is_remote_tip_ancestor_of_commit { | ||
| 1050 | refspecs_for_remote.push(refspec.clone()); | ||
| 1051 | } else { | ||
| 1052 | // this is a force push so we need to force push to git server too | ||
| 1053 | if refspec.starts_with('+') { | ||
| 1054 | refspecs_for_remote.push(refspec.clone()); | ||
| 1055 | } else { | ||
| 1056 | refspecs_for_remote.push(format!("+{refspec}")); | ||
| 1057 | } | ||
| 1058 | } | ||
| 1059 | } else if let Ok(remote_value_tip) = | ||
| 1060 | git_repo.get_commit_or_tip_of_reference(remote_value) | ||
| 1061 | { | ||
| 1062 | if from_tip.eq(&remote_value_tip) { | ||
| 1063 | // remote already at correct state | ||
| 1064 | term.write_line( | ||
| 1065 | format!("{short_name} {to} already up-to-date").as_str(), | ||
| 1066 | )?; | ||
| 1067 | } | ||
| 1068 | let (ahead_of_local, behind_local) = | ||
| 1069 | git_repo.get_commits_ahead_behind(&from_tip, &remote_value_tip)?; | ||
| 1070 | if ahead_of_local.is_empty() { | ||
| 1071 | // can soft push | ||
| 1072 | refspecs_for_remote.push(refspec.clone()); | ||
| 1073 | } else { | ||
| 1074 | // cant soft push | ||
| 1075 | let (ahead_of_nostr, behind_nostr) = git_repo | ||
| 1076 | .get_commits_ahead_behind( | ||
| 1077 | &git_repo.get_commit_or_tip_of_reference(nostr_value)?, | ||
| 1078 | &remote_value_tip, | ||
| 1079 | )?; | ||
| 1080 | if ahead_of_nostr.is_empty() { | ||
| 1081 | // ancestor of nostr and we are force pushing anyway... | ||
| 1082 | refspecs_for_remote.push(refspec.clone()); | ||
| 1083 | } else { | ||
| 1084 | rejected_refspecs | ||
| 1085 | .entry(refspec.to_string()) | ||
| 1086 | .and_modify(|a| a.push(url.to_string())) | ||
| 1087 | .or_insert(vec![url.to_string()]); | ||
| 1088 | term.write_line( | ||
| 1089 | format!( | ||
| 1090 | "ERROR: {short_name} {to} conflicts with nostr ({} ahead {} behind) and local ({} ahead {} behind). either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote", | ||
| 1091 | ahead_of_nostr.len(), | ||
| 1092 | behind_nostr.len(), | ||
| 1093 | ahead_of_local.len(), | ||
| 1094 | behind_local.len(), | ||
| 1095 | ).as_str(), | ||
| 1096 | )?; | ||
| 1097 | } | ||
| 1098 | }; | ||
| 1099 | } else { | ||
| 1100 | // remote_value oid is not present locally | ||
| 1101 | // TODO can we download the remote reference? | ||
| 1102 | |||
| 1103 | // cant soft push | ||
| 1104 | rejected_refspecs | ||
| 1105 | .entry(refspec.to_string()) | ||
| 1106 | .and_modify(|a| a.push(url.to_string())) | ||
| 1107 | .or_insert(vec![url.to_string()]); | ||
| 1108 | term.write_line( | ||
| 1109 | format!("ERROR: {short_name} {to} conflicts with nostr and is not an ancestor of local branch. either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote").as_str(), | ||
| 1110 | )?; | ||
| 1111 | } | ||
| 1112 | } else { | ||
| 1113 | // existing nostr branch not on remote | ||
| 1114 | // report - creating new branch | ||
| 1115 | term.write_line( | ||
| 1116 | format!( | ||
| 1117 | "{short_name} {to} doesn't exist and will be added as a new branch" | ||
| 1118 | ) | ||
| 1119 | .as_str(), | ||
| 1120 | )?; | ||
| 1121 | refspecs_for_remote.push(refspec.clone()); | ||
| 1122 | } | ||
| 1123 | } else if let Some(remote_value) = remote_value { | ||
| 1124 | // new to nostr but on remote | ||
| 1125 | if let Ok(remote_value_tip) = git_repo.get_commit_or_tip_of_reference(remote_value) | ||
| 1126 | { | ||
| 1127 | let (ahead, behind) = | ||
| 1128 | git_repo.get_commits_ahead_behind(&from_tip, &remote_value_tip)?; | ||
| 1129 | if behind.is_empty() { | ||
| 1130 | // can soft push | ||
| 1131 | refspecs_for_remote.push(refspec.clone()); | ||
| 1132 | } else { | ||
| 1133 | // cant soft push | ||
| 1134 | rejected_refspecs | ||
| 1135 | .entry(refspec.to_string()) | ||
| 1136 | .and_modify(|a| a.push(url.to_string())) | ||
| 1137 | .or_insert(vec![url.to_string()]); | ||
| 1138 | term.write_line( | ||
| 1139 | format!( | ||
| 1140 | "ERROR: {short_name} already contains {to} {} ahead and {} behind local branch. either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote", | ||
| 1141 | ahead.len(), | ||
| 1142 | behind.len(), | ||
| 1143 | ).as_str(), | ||
| 1144 | )?; | ||
| 1145 | } | ||
| 1146 | } else { | ||
| 1147 | // havn't fetched oid from remote | ||
| 1148 | // TODO fetch oid from remote | ||
| 1149 | // cant soft push | ||
| 1150 | rejected_refspecs | ||
| 1151 | .entry(refspec.to_string()) | ||
| 1152 | .and_modify(|a| a.push(url.to_string())) | ||
| 1153 | .or_insert(vec![url.to_string()]); | ||
| 1154 | term.write_line( | ||
| 1155 | format!("ERROR: {short_name} already contains {to} at {remote_value} which is not an ancestor of local branch. either:\r\n 1. pull from that git server and resolve\r\n 2. force push your branch to the git server before pushing to nostr remote").as_str(), | ||
| 1156 | )?; | ||
| 1157 | } | ||
| 1158 | } else { | ||
| 1159 | // in sync - new branch | ||
| 1160 | refspecs_for_remote.push(refspec.clone()); | ||
| 1161 | } | ||
| 1162 | } | ||
| 1163 | if !refspecs_for_remote.is_empty() { | ||
| 1164 | refspecs_for_remotes.insert(url.to_string(), refspecs_for_remote); | ||
| 1165 | } | ||
| 1166 | } | ||
| 1167 | |||
| 1168 | // remove rejected refspecs so they dont get pushed to some remotes | ||
| 1169 | let mut remotes_refspecs_without_rejected = HashMap::new(); | ||
| 1170 | for (url, value) in &refspecs_for_remotes { | ||
| 1171 | remotes_refspecs_without_rejected.insert( | ||
| 1172 | url.to_string(), | ||
| 1173 | value | ||
| 1174 | .iter() | ||
| 1175 | .filter(|refspec| !rejected_refspecs.contains_key(*refspec)) | ||
| 1176 | .cloned() | ||
| 1177 | .collect(), | ||
| 1178 | ); | ||
| 1179 | } | ||
| 1180 | Ok((rejected_refspecs, remotes_refspecs_without_rejected)) | ||
| 1181 | } | ||
| 1182 | |||
| 1183 | fn generate_updated_state( | ||
| 1184 | git_repo: &Repo, | ||
| 1185 | existing_state: &HashMap<String, String>, | ||
| 1186 | refspecs: &Vec<String>, | ||
| 1187 | ) -> Result<HashMap<String, String>> { | ||
| 1188 | let mut new_state = existing_state.clone(); | ||
| 1189 | |||
| 1190 | for refspec in refspecs { | ||
| 1191 | let (from, to) = refspec_to_from_to(refspec)?; | ||
| 1192 | if from.is_empty() { | ||
| 1193 | // delete | ||
| 1194 | new_state.remove(to); | ||
| 1195 | if to.contains("refs/tags") { | ||
| 1196 | new_state.remove(&format!("{to}{}", "^{}")); | ||
| 1197 | } | ||
| 1198 | } else if to.contains("refs/tags") { | ||
| 1199 | new_state.insert( | ||
| 1200 | format!("{to}{}", "^{}"), | ||
| 1201 | git_repo | ||
| 1202 | .get_commit_or_tip_of_reference(from) | ||
| 1203 | .unwrap() | ||
| 1204 | .to_string(), | ||
| 1205 | ); | ||
| 1206 | new_state.insert( | ||
| 1207 | to.to_string(), | ||
| 1208 | git_repo | ||
| 1209 | .git_repo | ||
| 1210 | .find_reference(to) | ||
| 1211 | .unwrap() | ||
| 1212 | .peel(git2::ObjectType::Tag) | ||
| 1213 | .unwrap() | ||
| 1214 | .id() | ||
| 1215 | .to_string(), | ||
| 1216 | ); | ||
| 1217 | } else { | ||
| 1218 | // add or update | ||
| 1219 | new_state.insert( | ||
| 1220 | to.to_string(), | ||
| 1221 | git_repo | ||
| 1222 | .get_commit_or_tip_of_reference(from) | ||
| 1223 | .unwrap() | ||
| 1224 | .to_string(), | ||
| 1225 | ); | ||
| 1226 | } | ||
| 1227 | } | ||
| 1228 | Ok(new_state) | ||
| 1229 | } | ||
| 1230 | |||
| 1231 | async fn get_merged_status_events( | ||
| 1232 | term: &console::Term, | ||
| 1233 | repo_ref: &RepoRef, | ||
| 1234 | git_repo: &Repo, | ||
| 1235 | remote_nostr_url: &str, | ||
| 1236 | signer: &NostrSigner, | ||
| 1237 | refspecs_to_git_server: &Vec<String>, | ||
| 1238 | ) -> Result<Vec<Event>> { | ||
| 1239 | let mut events = vec![]; | ||
| 1240 | for refspec in refspecs_to_git_server { | ||
| 1241 | let (from, to) = refspec_to_from_to(refspec)?; | ||
| 1242 | if to.eq("refs/heads/main") || to.eq("refs/heads/master") { | ||
| 1243 | let tip_of_pushed_branch = git_repo.get_commit_or_tip_of_reference(from)?; | ||
| 1244 | let Ok(tip_of_remote_branch) = git_repo.get_commit_or_tip_of_reference( | ||
| 1245 | &refspec_remote_ref_name(&git_repo.git_repo, refspec, remote_nostr_url)?, | ||
| 1246 | ) else { | ||
| 1247 | // branch not on remote | ||
| 1248 | continue; | ||
| 1249 | }; | ||
| 1250 | let (ahead, _) = | ||
| 1251 | git_repo.get_commits_ahead_behind(&tip_of_remote_branch, &tip_of_pushed_branch)?; | ||
| 1252 | for commit_hash in ahead { | ||
| 1253 | let commit = git_repo.git_repo.find_commit(sha1_to_oid(&commit_hash)?)?; | ||
| 1254 | if commit.parent_count() > 1 { | ||
| 1255 | // merge commit | ||
| 1256 | for parent in commit.parents() { | ||
| 1257 | // lookup parent id | ||
| 1258 | let commit_events = get_events_from_cache( | ||
| 1259 | git_repo.get_path()?, | ||
| 1260 | vec![ | ||
| 1261 | nostr::Filter::default() | ||
| 1262 | .kind(nostr::Kind::GitPatch) | ||
| 1263 | .reference(parent.id().to_string()), | ||
| 1264 | ], | ||
| 1265 | ) | ||
| 1266 | .await?; | ||
| 1267 | if let Some(commit_event) = commit_events.iter().find(|e| { | ||
| 1268 | e.tags.iter().any(|t| { | ||
| 1269 | t.as_vec()[0].eq("commit") | ||
| 1270 | && t.as_vec()[1].eq(&parent.id().to_string()) | ||
| 1271 | }) | ||
| 1272 | }) { | ||
| 1273 | let (proposal_id, revision_id) = | ||
| 1274 | get_proposal_and_revision_root_from_patch(git_repo, commit_event) | ||
| 1275 | .await?; | ||
| 1276 | term.write_line( | ||
| 1277 | format!( | ||
| 1278 | "merge commit {}: create nostr proposal status event", | ||
| 1279 | &commit.id().to_string()[..7], | ||
| 1280 | ) | ||
| 1281 | .as_str(), | ||
| 1282 | )?; | ||
| 1283 | |||
| 1284 | events.push( | ||
| 1285 | create_merge_status( | ||
| 1286 | signer, | ||
| 1287 | repo_ref, | ||
| 1288 | &get_event_from_cache_by_id(git_repo, &proposal_id).await?, | ||
| 1289 | &if let Some(revision_id) = revision_id { | ||
| 1290 | Some( | ||
| 1291 | get_event_from_cache_by_id(git_repo, &revision_id) | ||
| 1292 | .await?, | ||
| 1293 | ) | ||
| 1294 | } else { | ||
| 1295 | None | ||
| 1296 | }, | ||
| 1297 | &commit_hash, | ||
| 1298 | commit_event.id(), | ||
| 1299 | ) | ||
| 1300 | .await?, | ||
| 1301 | ); | ||
| 1302 | } | ||
| 1303 | } | ||
| 1304 | } | ||
| 1305 | } | ||
| 1306 | } | ||
| 1307 | } | ||
| 1308 | Ok(events) | ||
| 1309 | } | ||
| 1310 | |||
| 1311 | async fn get_event_from_cache_by_id(git_repo: &Repo, event_id: &EventId) -> Result<Event> { | ||
| 1312 | Ok(get_events_from_cache( | ||
| 1313 | git_repo.get_path()?, | ||
| 1314 | vec![nostr::Filter::default().id(*event_id)], | ||
| 1315 | ) | ||
| 1316 | .await? | ||
| 1317 | .first() | ||
| 1318 | .context("cannot find event in cache")? | ||
| 1319 | .clone()) | ||
| 1320 | } | ||
| 1321 | |||
| 1322 | async fn create_merge_status( | ||
| 1323 | signer: &NostrSigner, | ||
| 1324 | repo_ref: &RepoRef, | ||
| 1325 | proposal: &Event, | ||
| 1326 | revision: &Option<Event>, | ||
| 1327 | merge_commit: &Sha1Hash, | ||
| 1328 | merged_patch: EventId, | ||
| 1329 | ) -> Result<Event> { | ||
| 1330 | let mut public_keys = repo_ref | ||
| 1331 | .maintainers | ||
| 1332 | .iter() | ||
| 1333 | .copied() | ||
| 1334 | .collect::<HashSet<PublicKey>>(); | ||
| 1335 | public_keys.insert(proposal.author()); | ||
| 1336 | if let Some(revision) = revision { | ||
| 1337 | public_keys.insert(revision.author()); | ||
| 1338 | } | ||
| 1339 | sign_event( | ||
| 1340 | EventBuilder::new( | ||
| 1341 | nostr::event::Kind::GitStatusApplied, | ||
| 1342 | String::new(), | ||
| 1343 | [ | ||
| 1344 | vec![ | ||
| 1345 | Tag::custom( | ||
| 1346 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), | ||
| 1347 | vec!["git proposal merged / applied".to_string()], | ||
| 1348 | ), | ||
| 1349 | Tag::from_standardized(nostr::TagStandard::Event { | ||
| 1350 | event_id: proposal.id(), | ||
| 1351 | relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new), | ||
| 1352 | marker: Some(Marker::Root), | ||
| 1353 | public_key: None, | ||
| 1354 | }), | ||
| 1355 | Tag::from_standardized(nostr::TagStandard::Event { | ||
| 1356 | event_id: merged_patch, | ||
| 1357 | relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new), | ||
| 1358 | marker: Some(Marker::Mention), | ||
| 1359 | public_key: None, | ||
| 1360 | }), | ||
| 1361 | ], | ||
| 1362 | if let Some(revision) = revision { | ||
| 1363 | vec![Tag::from_standardized(nostr::TagStandard::Event { | ||
| 1364 | event_id: revision.id(), | ||
| 1365 | relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new), | ||
| 1366 | marker: Some(Marker::Root), | ||
| 1367 | public_key: None, | ||
| 1368 | })] | ||
| 1369 | } else { | ||
| 1370 | vec![] | ||
| 1371 | }, | ||
| 1372 | public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(), | ||
| 1373 | repo_ref | ||
| 1374 | .coordinates() | ||
| 1375 | .iter() | ||
| 1376 | .map(|c| Tag::coordinate(c.clone())) | ||
| 1377 | .collect::<Vec<Tag>>(), | ||
| 1378 | vec![ | ||
| 1379 | Tag::from_standardized(nostr::TagStandard::Reference( | ||
| 1380 | repo_ref.root_commit.to_string(), | ||
| 1381 | )), | ||
| 1382 | Tag::from_standardized(nostr::TagStandard::Reference(format!( | ||
| 1383 | "{merge_commit}" | ||
| 1384 | ))), | ||
| 1385 | Tag::custom( | ||
| 1386 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("merge-commit-id")), | ||
| 1387 | vec![format!("{merge_commit}")], | ||
| 1388 | ), | ||
| 1389 | ], | ||
| 1390 | ] | ||
| 1391 | .concat(), | ||
| 1392 | ), | ||
| 1393 | signer, | ||
| 1394 | ) | ||
| 1395 | .await | ||
| 1396 | } | ||
| 1397 | |||
| 1398 | async fn get_proposal_and_revision_root_from_patch( | ||
| 1399 | git_repo: &Repo, | ||
| 1400 | patch: &Event, | ||
| 1401 | ) -> Result<(EventId, Option<EventId>)> { | ||
| 1402 | let proposal_or_revision = if patch.tags.iter().any(|t| t.as_vec()[1].eq("root")) { | ||
| 1403 | patch.clone() | ||
| 1404 | } else { | ||
| 1405 | let proposal_or_revision_id = EventId::parse( | ||
| 1406 | if let Some(t) = patch.tags.iter().find(|t| t.is_root()) { | ||
| 1407 | t.clone() | ||
| 1408 | } else if let Some(t) = patch.tags.iter().find(|t| t.is_reply()) { | ||
| 1409 | t.clone() | ||
| 1410 | } else { | ||
| 1411 | Tag::event(patch.id()) | ||
| 1412 | } | ||
| 1413 | .as_vec()[1] | ||
| 1414 | .clone(), | ||
| 1415 | )?; | ||
| 1416 | |||
| 1417 | get_events_from_cache( | ||
| 1418 | git_repo.get_path()?, | ||
| 1419 | vec![nostr::Filter::default().id(proposal_or_revision_id)], | ||
| 1420 | ) | ||
| 1421 | .await? | ||
| 1422 | .first() | ||
| 1423 | .unwrap() | ||
| 1424 | .clone() | ||
| 1425 | }; | ||
| 1426 | |||
| 1427 | if !proposal_or_revision.kind().eq(&Kind::GitPatch) { | ||
| 1428 | bail!("thread root is not a git patch"); | ||
| 1429 | } | ||
| 1430 | |||
| 1431 | if proposal_or_revision | ||
| 1432 | .tags | ||
| 1433 | .iter() | ||
| 1434 | .any(|t| t.as_vec()[1].eq("revision-root")) | ||
| 1435 | { | ||
| 1436 | Ok(( | ||
| 1437 | EventId::parse( | ||
| 1438 | proposal_or_revision | ||
| 1439 | .tags | ||
| 1440 | .iter() | ||
| 1441 | .find(|t| t.is_reply()) | ||
| 1442 | .unwrap() | ||
| 1443 | .as_vec()[1] | ||
| 1444 | .clone(), | ||
| 1445 | )?, | ||
| 1446 | Some(proposal_or_revision.id()), | ||
| 1447 | )) | ||
| 1448 | } else { | ||
| 1449 | Ok((proposal_or_revision.id(), None)) | ||
| 1450 | } | ||
| 1451 | } | ||
| 1452 | |||
| 1453 | fn update_remote_refs_pushed( | ||
| 1454 | git_repo: &Repository, | ||
| 1455 | refspec: &str, | ||
| 1456 | nostr_remote_url: &str, | ||
| 1457 | ) -> Result<()> { | ||
| 1458 | let (from, _) = refspec_to_from_to(refspec)?; | ||
| 1459 | |||
| 1460 | let target_ref_name = refspec_remote_ref_name(git_repo, refspec, nostr_remote_url)?; | ||
| 1461 | |||
| 1462 | if from.is_empty() { | ||
| 1463 | if let Ok(mut remote_ref) = git_repo.find_reference(&target_ref_name) { | ||
| 1464 | remote_ref.delete()?; | ||
| 1465 | } | ||
| 1466 | } else { | ||
| 1467 | let commit = reference_to_commit(git_repo, from) | ||
| 1468 | .context(format!("cannot get commit of reference {from}"))?; | ||
| 1469 | if let Ok(mut remote_ref) = git_repo.find_reference(&target_ref_name) { | ||
| 1470 | remote_ref.set_target(commit, "updated by nostr remote helper")?; | ||
| 1471 | } else { | ||
| 1472 | git_repo.reference( | ||
| 1473 | &target_ref_name, | ||
| 1474 | commit, | ||
| 1475 | false, | ||
| 1476 | "created by nostr remote helper", | ||
| 1477 | )?; | ||
| 1478 | } | ||
| 1479 | } | ||
| 1480 | Ok(()) | ||
| 1481 | } | ||
| 1482 | |||
| 1483 | fn refspec_to_from_to(refspec: &str) -> Result<(&str, &str)> { | ||
| 1484 | if !refspec.contains(':') { | ||
| 1485 | bail!( | ||
| 1486 | "refspec should contain a colon (:) but consists of: {}", | ||
| 1487 | refspec | ||
| 1488 | ); | ||
| 1489 | } | ||
| 1490 | let parts = refspec.split(':').collect::<Vec<&str>>(); | ||
| 1491 | Ok(( | ||
| 1492 | if parts.first().unwrap().starts_with('+') { | ||
| 1493 | &parts.first().unwrap()[1..] | ||
| 1494 | } else { | ||
| 1495 | parts.first().unwrap() | ||
| 1496 | }, | ||
| 1497 | parts.get(1).unwrap(), | ||
| 1498 | )) | ||
| 1499 | } | ||
| 1500 | |||
| 1501 | fn refspec_remote_ref_name( | ||
| 1502 | git_repo: &Repository, | ||
| 1503 | refspec: &str, | ||
| 1504 | nostr_remote_url: &str, | ||
| 1505 | ) -> Result<String> { | ||
| 1506 | let (_, to) = refspec_to_from_to(refspec)?; | ||
| 1507 | let nostr_remote = git_repo | ||
| 1508 | .find_remote(&get_remote_name_by_url(git_repo, nostr_remote_url)?) | ||
| 1509 | .context("we should have just located this remote")?; | ||
| 1510 | Ok(format!( | ||
| 1511 | "refs/remotes/{}/{}", | ||
| 1512 | nostr_remote.name().context("remote should have a name")?, | ||
| 1513 | to.replace("refs/heads/", ""), /* TODO only replace if it begins with this | ||
| 1514 | * TODO what about tags? */ | ||
| 1515 | )) | ||
| 1516 | } | ||
| 1517 | |||
| 1518 | fn reference_to_commit(git_repo: &Repository, reference: &str) -> Result<Oid> { | ||
| 1519 | Ok(git_repo | ||
| 1520 | .find_reference(reference) | ||
| 1521 | .context(format!("cannot find reference: {reference}"))? | ||
| 1522 | .peel_to_commit() | ||
| 1523 | .context(format!("cannot get commit from reference: {reference}"))? | ||
| 1524 | .id()) | ||
| 1525 | } | ||
| 1526 | |||
| 1527 | // this maybe a commit id or a ref: pointer | ||
| 1528 | fn reference_to_ref_value(git_repo: &Repository, reference: &str) -> Result<String> { | ||
| 1529 | let reference_obj = git_repo | ||
| 1530 | .find_reference(reference) | ||
| 1531 | .context(format!("cannot find reference: {reference}"))?; | ||
| 1532 | if let Some(symref) = reference_obj.symbolic_target() { | ||
| 1533 | Ok(symref.to_string()) | ||
| 1534 | } else { | ||
| 1535 | Ok(reference_obj | ||
| 1536 | .peel_to_commit() | ||
| 1537 | .context(format!("cannot get commit from reference: {reference}"))? | ||
| 1538 | .id() | ||
| 1539 | .to_string()) | ||
| 1540 | } | ||
| 1541 | } | ||
| 1542 | |||
| 1543 | fn get_remote_name_by_url(git_repo: &Repository, url: &str) -> Result<String> { | ||
| 1544 | let remotes = git_repo.remotes()?; | ||
| 1545 | Ok(remotes | ||
| 1546 | .iter() | ||
| 1547 | .find(|r| { | ||
| 1548 | if let Some(name) = r { | ||
| 1549 | if let Some(remote_url) = git_repo.find_remote(name).unwrap().url() { | ||
| 1550 | url == remote_url | ||
| 1551 | } else { | ||
| 1552 | false | ||
| 1553 | } | ||
| 1554 | } else { | ||
| 1555 | false | ||
| 1556 | } | ||
| 1557 | }) | ||
| 1558 | .context("could not find remote with matching url")? | ||
| 1559 | .context("remote with matching url must be named")? | ||
| 1560 | .to_string()) | ||
| 1561 | } | ||
| 1562 | |||
| 1563 | fn get_short_git_server_name(git_repo: &Repo, url: &str) -> std::string::String { | ||
| 1564 | if let Ok(name) = get_remote_name_by_url(&git_repo.git_repo, url) { | ||
| 1565 | return name; | ||
| 1566 | } | ||
| 1567 | if let Ok(url) = Url::parse(url) { | ||
| 1568 | if let Some(domain) = url.domain() { | ||
| 1569 | return domain.to_string(); | ||
| 1570 | } | ||
| 1571 | } | ||
| 1572 | url.to_string() | ||
| 1573 | } | ||
| 1574 | |||
| 1575 | fn get_oids_from_fetch_batch( | ||
| 1576 | stdin: &Stdin, | ||
| 1577 | initial_oid: &str, | ||
| 1578 | initial_refstr: &str, | ||
| 1579 | ) -> Result<HashMap<String, String>> { | ||
| 1580 | let mut line = String::new(); | ||
| 1581 | let mut batch = HashMap::new(); | ||
| 1582 | batch.insert(initial_refstr.to_string(), initial_oid.to_string()); | ||
| 1583 | loop { | ||
| 1584 | let tokens = read_line(stdin, &mut line)?; | ||
| 1585 | match tokens.as_slice() { | ||
| 1586 | ["fetch", oid, refstr] => { | ||
| 1587 | batch.insert((*refstr).to_string(), (*oid).to_string()); | ||
| 1588 | } | ||
| 1589 | [] => break, | ||
| 1590 | _ => bail!( | ||
| 1591 | "after a `fetch` command we are only expecting another fetch or an empty line" | ||
| 1592 | ), | ||
| 1593 | } | ||
| 1594 | } | ||
| 1595 | Ok(batch) | ||
| 1596 | } | ||
| 1597 | |||
| 1598 | fn get_refspecs_from_push_batch(stdin: &Stdin, initial_refspec: &str) -> Result<Vec<String>> { | ||
| 1599 | let mut line = String::new(); | ||
| 1600 | let mut refspecs = vec![initial_refspec.to_string()]; | ||
| 1601 | loop { | ||
| 1602 | let tokens = read_line(stdin, &mut line)?; | ||
| 1603 | match tokens.as_slice() { | ||
| 1604 | ["push", spec] => { | ||
| 1605 | refspecs.push((*spec).to_string()); | ||
| 1606 | } | ||
| 1607 | [] => break, | ||
| 1608 | _ => { | ||
| 1609 | bail!("after a `push` command we are only expecting another push or an empty line") | ||
| 1610 | } | ||
| 1611 | } | ||
| 1612 | } | ||
| 1613 | Ok(refspecs) | ||
| 1614 | } | ||
| 1615 | |||
| 1616 | impl RepoState { | ||
| 1617 | pub async fn build( | ||
| 1618 | identifier: String, | ||
| 1619 | state: HashMap<String, String>, | ||
| 1620 | signer: &NostrSigner, | ||
| 1621 | ) -> Result<RepoState> { | ||
| 1622 | let mut tags = vec![Tag::identifier(identifier.clone())]; | ||
| 1623 | for (name, value) in &state { | ||
| 1624 | tags.push(Tag::custom( | ||
| 1625 | nostr_sdk::TagKind::Custom(name.into()), | ||
| 1626 | vec![value.clone()], | ||
| 1627 | )); | ||
| 1628 | } | ||
| 1629 | let event = sign_event(EventBuilder::new(STATE_KIND, "", tags), signer).await?; | ||
| 1630 | Ok(RepoState { | ||
| 1631 | identifier, | ||
| 1632 | state, | ||
| 1633 | event, | ||
| 1634 | }) | ||
| 1635 | } | ||
| 1636 | } | ||
| 1637 | |||
| 1638 | #[cfg(test)] | ||
| 1639 | mod tests { | ||
| 1640 | use super::*; | ||
| 1641 | |||
| 1642 | mod nostr_git_url_paramemters_from_str { | ||
| 1643 | use git::ServerProtocol; | ||
| 1644 | use nostr_sdk::PublicKey; | ||
| 1645 | |||
| 1646 | use super::*; | ||
| 1647 | |||
| 1648 | fn get_model_coordinate(relays: bool) -> Coordinate { | ||
| 1649 | Coordinate { | ||
| 1650 | identifier: "ngit".to_string(), | ||
| 1651 | public_key: PublicKey::parse( | ||
| 1652 | "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", | ||
| 1653 | ) | ||
| 1654 | .unwrap(), | ||
| 1655 | kind: nostr_sdk::Kind::GitRepoAnnouncement, | ||
| 1656 | relays: if relays { | ||
| 1657 | vec!["wss://nos.lol/".to_string()] | ||
| 1658 | } else { | ||
| 1659 | vec![] | ||
| 1660 | }, | ||
| 1661 | } | ||
| 1662 | } | ||
| 1663 | |||
| 1664 | #[test] | ||
| 1665 | fn from_naddr() -> Result<()> { | ||
| 1666 | assert_eq!( | ||
| 1667 | NostrUrlDecoded::from_str( | ||
| 1668 | "nostr://naddr1qqzxuemfwsqs6amnwvaz7tmwdaejumr0dspzpgqgmmc409hm4xsdd74sf68a2uyf9pwel4g9mfdg8l5244t6x4jdqvzqqqrhnym0k2qj" | ||
| 1669 | )?, | ||
| 1670 | NostrUrlDecoded { | ||
| 1671 | coordinates: HashSet::from([Coordinate { | ||
| 1672 | identifier: "ngit".to_string(), | ||
| 1673 | public_key: PublicKey::parse( | ||
| 1674 | "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", | ||
| 1675 | ) | ||
| 1676 | .unwrap(), | ||
| 1677 | kind: nostr_sdk::Kind::GitRepoAnnouncement, | ||
| 1678 | relays: vec!["wss://nos.lol".to_string()], // wont add the slash | ||
| 1679 | }]), | ||
| 1680 | protocol: None, | ||
| 1681 | user: None, | ||
| 1682 | }, | ||
| 1683 | ); | ||
| 1684 | Ok(()) | ||
| 1685 | } | ||
| 1686 | mod from_npub_slash_identifier { | ||
| 1687 | use super::*; | ||
| 1688 | |||
| 1689 | #[test] | ||
| 1690 | fn without_relay() -> Result<()> { | ||
| 1691 | assert_eq!( | ||
| 1692 | NostrUrlDecoded::from_str( | ||
| 1693 | "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit" | ||
| 1694 | )?, | ||
| 1695 | NostrUrlDecoded { | ||
| 1696 | coordinates: HashSet::from([get_model_coordinate(false)]), | ||
| 1697 | protocol: None, | ||
| 1698 | user: None, | ||
| 1699 | }, | ||
| 1700 | ); | ||
| 1701 | Ok(()) | ||
| 1702 | } | ||
| 1703 | |||
| 1704 | mod with_url_parameters { | ||
| 1705 | |||
| 1706 | use super::*; | ||
| 1707 | |||
| 1708 | #[test] | ||
| 1709 | fn with_relay_without_scheme_defaults_to_wss() -> Result<()> { | ||
| 1710 | assert_eq!( | ||
| 1711 | NostrUrlDecoded::from_str( | ||
| 1712 | "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay=nos.lol" | ||
| 1713 | )?, | ||
| 1714 | NostrUrlDecoded { | ||
| 1715 | coordinates: HashSet::from([get_model_coordinate(true)]), | ||
| 1716 | protocol: None, | ||
| 1717 | user: None, | ||
| 1718 | }, | ||
| 1719 | ); | ||
| 1720 | Ok(()) | ||
| 1721 | } | ||
| 1722 | |||
| 1723 | #[test] | ||
| 1724 | fn with_encoded_relay() -> Result<()> { | ||
| 1725 | assert_eq!( | ||
| 1726 | NostrUrlDecoded::from_str(&format!( | ||
| 1727 | "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay={}", | ||
| 1728 | urlencoding::encode("wss://nos.lol") | ||
| 1729 | ))?, | ||
| 1730 | NostrUrlDecoded { | ||
| 1731 | coordinates: HashSet::from([get_model_coordinate(true)]), | ||
| 1732 | protocol: None, | ||
| 1733 | user: None, | ||
| 1734 | }, | ||
| 1735 | ); | ||
| 1736 | Ok(()) | ||
| 1737 | } | ||
| 1738 | #[test] | ||
| 1739 | fn with_multiple_encoded_relays() -> Result<()> { | ||
| 1740 | assert_eq!( | ||
| 1741 | NostrUrlDecoded::from_str(&format!( | ||
| 1742 | "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay={}&relay1={}", | ||
| 1743 | urlencoding::encode("wss://nos.lol"), | ||
| 1744 | urlencoding::encode("wss://relay.damus.io"), | ||
| 1745 | ))?, | ||
| 1746 | NostrUrlDecoded { | ||
| 1747 | coordinates: HashSet::from([Coordinate { | ||
| 1748 | identifier: "ngit".to_string(), | ||
| 1749 | public_key: PublicKey::parse( | ||
| 1750 | "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", | ||
| 1751 | ) | ||
| 1752 | .unwrap(), | ||
| 1753 | kind: nostr_sdk::Kind::GitRepoAnnouncement, | ||
| 1754 | relays: vec![ | ||
| 1755 | "wss://nos.lol/".to_string(), | ||
| 1756 | "wss://relay.damus.io/".to_string(), | ||
| 1757 | ], | ||
| 1758 | }]), | ||
| 1759 | protocol: None, | ||
| 1760 | user: None, | ||
| 1761 | }, | ||
| 1762 | ); | ||
| 1763 | Ok(()) | ||
| 1764 | } | ||
| 1765 | |||
| 1766 | #[test] | ||
| 1767 | fn with_server_protocol() -> Result<()> { | ||
| 1768 | assert_eq!( | ||
| 1769 | NostrUrlDecoded::from_str( | ||
| 1770 | "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?protocol=ssh" | ||
| 1771 | )?, | ||
| 1772 | NostrUrlDecoded { | ||
| 1773 | coordinates: HashSet::from([get_model_coordinate(false)]), | ||
| 1774 | protocol: Some(ServerProtocol::Ssh), | ||
| 1775 | user: None, | ||
| 1776 | }, | ||
| 1777 | ); | ||
| 1778 | Ok(()) | ||
| 1779 | } | ||
| 1780 | #[test] | ||
| 1781 | fn with_server_protocol_and_user() -> Result<()> { | ||
| 1782 | assert_eq!( | ||
| 1783 | NostrUrlDecoded::from_str( | ||
| 1784 | "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?protocol=ssh&user=fred" | ||
| 1785 | )?, | ||
| 1786 | NostrUrlDecoded { | ||
| 1787 | coordinates: HashSet::from([get_model_coordinate(false)]), | ||
| 1788 | protocol: Some(ServerProtocol::Ssh), | ||
| 1789 | user: Some("fred".to_string()), | ||
| 1790 | }, | ||
| 1791 | ); | ||
| 1792 | Ok(()) | ||
| 1793 | } | ||
| 1794 | } | ||
| 1795 | mod with_parameters_embedded_with_slashes { | ||
| 1796 | use super::*; | ||
| 1797 | |||
| 1798 | #[test] | ||
| 1799 | fn with_relay_without_scheme_defaults_to_wss() -> Result<()> { | ||
| 1800 | assert_eq!( | ||
| 1801 | NostrUrlDecoded::from_str( | ||
| 1802 | "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/nos.lol/ngit" | ||
| 1803 | )?, | ||
| 1804 | NostrUrlDecoded { | ||
| 1805 | coordinates: HashSet::from([get_model_coordinate(true)]), | ||
| 1806 | protocol: None, | ||
| 1807 | user: None, | ||
| 1808 | }, | ||
| 1809 | ); | ||
| 1810 | Ok(()) | ||
| 1811 | } | ||
| 1812 | |||
| 1813 | #[test] | ||
| 1814 | fn with_encoded_relay() -> Result<()> { | ||
| 1815 | assert_eq!( | ||
| 1816 | NostrUrlDecoded::from_str(&format!( | ||
| 1817 | "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/{}/ngit", | ||
| 1818 | urlencoding::encode("wss://nos.lol") | ||
| 1819 | ))?, | ||
| 1820 | NostrUrlDecoded { | ||
| 1821 | coordinates: HashSet::from([get_model_coordinate(true)]), | ||
| 1822 | protocol: None, | ||
| 1823 | user: None, | ||
| 1824 | }, | ||
| 1825 | ); | ||
| 1826 | Ok(()) | ||
| 1827 | } | ||
| 1828 | #[test] | ||
| 1829 | fn with_multiple_encoded_relays() -> Result<()> { | ||
| 1830 | assert_eq!( | ||
| 1831 | NostrUrlDecoded::from_str(&format!( | ||
| 1832 | "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/{}/{}/ngit", | ||
| 1833 | urlencoding::encode("wss://nos.lol"), | ||
| 1834 | urlencoding::encode("wss://relay.damus.io"), | ||
| 1835 | ))?, | ||
| 1836 | NostrUrlDecoded { | ||
| 1837 | coordinates: HashSet::from([Coordinate { | ||
| 1838 | identifier: "ngit".to_string(), | ||
| 1839 | public_key: PublicKey::parse( | ||
| 1840 | "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", | ||
| 1841 | ) | ||
| 1842 | .unwrap(), | ||
| 1843 | kind: nostr_sdk::Kind::GitRepoAnnouncement, | ||
| 1844 | relays: vec![ | ||
| 1845 | "wss://nos.lol/".to_string(), | ||
| 1846 | "wss://relay.damus.io/".to_string(), | ||
| 1847 | ], | ||
| 1848 | }]), | ||
| 1849 | protocol: None, | ||
| 1850 | user: None, | ||
| 1851 | }, | ||
| 1852 | ); | ||
| 1853 | Ok(()) | ||
| 1854 | } | ||
| 1855 | |||
| 1856 | #[test] | ||
| 1857 | fn with_server_protocol() -> Result<()> { | ||
| 1858 | assert_eq!( | ||
| 1859 | NostrUrlDecoded::from_str( | ||
| 1860 | "nostr://ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit" | ||
| 1861 | )?, | ||
| 1862 | NostrUrlDecoded { | ||
| 1863 | coordinates: HashSet::from([get_model_coordinate(false)]), | ||
| 1864 | protocol: Some(ServerProtocol::Ssh), | ||
| 1865 | user: None, | ||
| 1866 | }, | ||
| 1867 | ); | ||
| 1868 | Ok(()) | ||
| 1869 | } | ||
| 1870 | #[test] | ||
| 1871 | fn with_server_protocol_and_user() -> Result<()> { | ||
| 1872 | assert_eq!( | ||
| 1873 | NostrUrlDecoded::from_str( | ||
| 1874 | "nostr://fred@ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit" | ||
| 1875 | )?, | ||
| 1876 | NostrUrlDecoded { | ||
| 1877 | coordinates: HashSet::from([get_model_coordinate(false)]), | ||
| 1878 | protocol: Some(ServerProtocol::Ssh), | ||
| 1879 | user: Some("fred".to_string()), | ||
| 1880 | }, | ||
| 1881 | ); | ||
| 1882 | Ok(()) | ||
| 1883 | } | ||
| 1884 | } | ||
| 1885 | } | ||
| 1886 | } | ||
| 1887 | |||
| 1888 | mod refspec_to_from_to { | ||
| 1889 | use super::*; | ||
| 1890 | |||
| 1891 | #[test] | ||
| 1892 | fn trailing_plus_stripped() { | ||
| 1893 | let (from, _) = refspec_to_from_to("+testing:testingb").unwrap(); | ||
| 1894 | assert_eq!(from, "testing"); | ||
| 1895 | } | ||
| 1896 | } | ||
| 1897 | } | ||
diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs new file mode 100644 index 0000000..d0f934e --- /dev/null +++ b/src/bin/ngit/cli.rs | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | use clap::{Parser, Subcommand}; | ||
| 2 | |||
| 3 | use crate::sub_commands; | ||
| 4 | |||
| 5 | #[derive(Parser)] | ||
| 6 | #[command(author, version, about, long_about = None)] | ||
| 7 | #[command(propagate_version = true)] | ||
| 8 | pub struct Cli { | ||
| 9 | #[command(subcommand)] | ||
| 10 | pub command: Commands, | ||
| 11 | /// remote signer address | ||
| 12 | #[arg(long, global = true)] | ||
| 13 | pub bunker_uri: Option<String>, | ||
| 14 | /// remote signer app secret key | ||
| 15 | #[arg(long, global = true)] | ||
| 16 | pub bunker_app_key: Option<String>, | ||
| 17 | /// nsec or hex private key | ||
| 18 | #[arg(short, long, global = true)] | ||
| 19 | pub nsec: Option<String>, | ||
| 20 | /// password to decrypt nsec | ||
| 21 | #[arg(short, long, global = true)] | ||
| 22 | pub password: Option<String>, | ||
| 23 | /// disable spinner animations | ||
| 24 | #[arg(long, action)] | ||
| 25 | pub disable_cli_spinners: bool, | ||
| 26 | } | ||
| 27 | |||
| 28 | #[derive(Subcommand)] | ||
| 29 | pub enum Commands { | ||
| 30 | /// update cache with latest updates from nostr | ||
| 31 | Fetch(sub_commands::fetch::SubCommandArgs), | ||
| 32 | /// signal you are this repo's maintainer accepting proposals via nostr | ||
| 33 | Init(sub_commands::init::SubCommandArgs), | ||
| 34 | /// issue commits as a proposal | ||
| 35 | Send(sub_commands::send::SubCommandArgs), | ||
| 36 | /// list proposals; checkout, apply or download selected | ||
| 37 | List, | ||
| 38 | /// send proposal revision | ||
| 39 | Push(sub_commands::push::SubCommandArgs), | ||
| 40 | /// fetch and apply new proposal commits / revisions linked to branch | ||
| 41 | Pull, | ||
| 42 | /// run with --nsec flag to change npub | ||
| 43 | Login(sub_commands::login::SubCommandArgs), | ||
| 44 | } | ||
diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs new file mode 100644 index 0000000..97e5981 --- /dev/null +++ b/src/bin/ngit/main.rs | |||
| @@ -0,0 +1,26 @@ | |||
| 1 | #![cfg_attr(not(test), warn(clippy::pedantic))] | ||
| 2 | #![allow(clippy::large_futures)] | ||
| 3 | #![cfg_attr(not(test), warn(clippy::expect_used))] | ||
| 4 | |||
| 5 | use anyhow::Result; | ||
| 6 | use clap::Parser; | ||
| 7 | use cli::{Cli, Commands}; | ||
| 8 | |||
| 9 | mod cli; | ||
| 10 | use ngit::*; | ||
| 11 | |||
| 12 | mod sub_commands; | ||
| 13 | |||
| 14 | #[tokio::main] | ||
| 15 | async fn main() -> Result<()> { | ||
| 16 | let cli = Cli::parse(); | ||
| 17 | match &cli.command { | ||
| 18 | Commands::Fetch(args) => sub_commands::fetch::launch(&cli, args).await, | ||
| 19 | Commands::Login(args) => sub_commands::login::launch(&cli, args).await, | ||
| 20 | Commands::Init(args) => sub_commands::init::launch(&cli, args).await, | ||
| 21 | Commands::Send(args) => sub_commands::send::launch(&cli, args, false).await, | ||
| 22 | Commands::List => sub_commands::list::launch().await, | ||
| 23 | Commands::Pull => sub_commands::pull::launch().await, | ||
| 24 | Commands::Push(args) => sub_commands::push::launch(&cli, args).await, | ||
| 25 | } | ||
| 26 | } | ||
diff --git a/src/bin/ngit/sub_commands/fetch.rs b/src/bin/ngit/sub_commands/fetch.rs new file mode 100644 index 0000000..b1e83c5 --- /dev/null +++ b/src/bin/ngit/sub_commands/fetch.rs | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | use std::collections::HashSet; | ||
| 2 | |||
| 3 | use anyhow::{Context, Result}; | ||
| 4 | use clap; | ||
| 5 | use nostr::nips::nip01::Coordinate; | ||
| 6 | |||
| 7 | #[cfg(not(test))] | ||
| 8 | use crate::client::Client; | ||
| 9 | #[cfg(test)] | ||
| 10 | use crate::client::MockConnect; | ||
| 11 | use crate::{ | ||
| 12 | cli::Cli, | ||
| 13 | client::{fetching_with_report, Connect}, | ||
| 14 | git::{Repo, RepoActions}, | ||
| 15 | repo_ref::get_repo_coordinates, | ||
| 16 | }; | ||
| 17 | |||
| 18 | #[derive(clap::Args)] | ||
| 19 | pub struct SubCommandArgs { | ||
| 20 | /// address pointer to repo announcement | ||
| 21 | #[arg(long, action)] | ||
| 22 | repo: Vec<String>, | ||
| 23 | } | ||
| 24 | |||
| 25 | pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { | ||
| 26 | let _ = args; | ||
| 27 | let git_repo = Repo::discover().context("cannot find a git repository")?; | ||
| 28 | #[cfg(not(test))] | ||
| 29 | let client = Client::default(); | ||
| 30 | #[cfg(test)] | ||
| 31 | let client = <MockConnect as std::default::Default>::default(); | ||
| 32 | let repo_coordinates = if command_args.repo.is_empty() { | ||
| 33 | get_repo_coordinates(&git_repo, &client).await? | ||
| 34 | } else { | ||
| 35 | let mut repo_coordinates = HashSet::new(); | ||
| 36 | for repo in &command_args.repo { | ||
| 37 | repo_coordinates.insert(Coordinate::parse(repo.clone())?); | ||
| 38 | } | ||
| 39 | repo_coordinates | ||
| 40 | }; | ||
| 41 | fetching_with_report(git_repo.get_path()?, &client, &repo_coordinates).await?; | ||
| 42 | client.disconnect().await?; | ||
| 43 | Ok(()) | ||
| 44 | } | ||
diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs new file mode 100644 index 0000000..5b7e03d --- /dev/null +++ b/src/bin/ngit/sub_commands/init.rs | |||
| @@ -0,0 +1,385 @@ | |||
| 1 | use std::collections::HashMap; | ||
| 2 | |||
| 3 | use anyhow::{Context, Result}; | ||
| 4 | use nostr::{nips::nip01::Coordinate, FromBech32, PublicKey, ToBech32}; | ||
| 5 | use nostr_sdk::Kind; | ||
| 6 | |||
| 7 | use super::send::send_events; | ||
| 8 | #[cfg(not(test))] | ||
| 9 | use crate::client::Client; | ||
| 10 | #[cfg(test)] | ||
| 11 | use crate::client::MockConnect; | ||
| 12 | use crate::{ | ||
| 13 | cli::Cli, | ||
| 14 | cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, | ||
| 15 | client::{fetching_with_report, get_repo_ref_from_cache, Connect}, | ||
| 16 | git::{convert_clone_url_to_https, Repo, RepoActions}, | ||
| 17 | login, | ||
| 18 | repo_ref::{ | ||
| 19 | extract_pks, get_repo_config_from_yaml, save_repo_config_to_yaml, | ||
| 20 | try_and_get_repo_coordinates, RepoRef, | ||
| 21 | }, | ||
| 22 | }; | ||
| 23 | |||
| 24 | #[derive(Debug, clap::Args)] | ||
| 25 | pub struct SubCommandArgs { | ||
| 26 | #[clap(short, long)] | ||
| 27 | /// name of repository | ||
| 28 | title: Option<String>, | ||
| 29 | #[clap(short, long)] | ||
| 30 | /// optional description | ||
| 31 | description: Option<String>, | ||
| 32 | #[clap(long)] | ||
| 33 | /// git server url users can clone from | ||
| 34 | clone_url: Vec<String>, | ||
| 35 | #[clap(short, long, value_parser, num_args = 1..)] | ||
| 36 | /// homepage | ||
| 37 | web: Vec<String>, | ||
| 38 | #[clap(short, long, value_parser, num_args = 1..)] | ||
| 39 | /// relays contributors push patches and comments to | ||
| 40 | relays: Vec<String>, | ||
| 41 | #[clap(short, long, value_parser, num_args = 1..)] | ||
| 42 | /// npubs of other maintainers | ||
| 43 | other_maintainers: Vec<String>, | ||
| 44 | #[clap(long)] | ||
| 45 | /// usually root commit but will be more recent commit for forks | ||
| 46 | earliest_unique_commit: Option<String>, | ||
| 47 | #[clap(short, long)] | ||
| 48 | /// shortname with no spaces or special characters | ||
| 49 | identifier: Option<String>, | ||
| 50 | } | ||
| 51 | |||
| 52 | #[allow(clippy::too_many_lines)] | ||
| 53 | pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | ||
| 54 | let git_repo = Repo::discover().context("cannot find a git repository")?; | ||
| 55 | let git_repo_path = git_repo.get_path()?; | ||
| 56 | |||
| 57 | let root_commit = git_repo | ||
| 58 | .get_root_commit() | ||
| 59 | .context("failed to get root commit of the repository")?; | ||
| 60 | |||
| 61 | // TODO: check for empty repo | ||
| 62 | // TODO: check for existing maintaiers file | ||
| 63 | |||
| 64 | #[cfg(not(test))] | ||
| 65 | let mut client = Client::default(); | ||
| 66 | #[cfg(test)] | ||
| 67 | let mut client = <MockConnect as std::default::Default>::default(); | ||
| 68 | |||
| 69 | let repo_coordinates = if let Ok(repo_coordinates) = | ||
| 70 | try_and_get_repo_coordinates(&git_repo, &client, false).await | ||
| 71 | { | ||
| 72 | Some(repo_coordinates) | ||
| 73 | } else { | ||
| 74 | None | ||
| 75 | }; | ||
| 76 | |||
| 77 | let repo_ref = if let Some(repo_coordinates) = repo_coordinates { | ||
| 78 | fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; | ||
| 79 | Some(get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?) | ||
| 80 | } else { | ||
| 81 | None | ||
| 82 | }; | ||
| 83 | |||
| 84 | let (signer, user_ref) = login::launch( | ||
| 85 | &git_repo, | ||
| 86 | &cli_args.bunker_uri, | ||
| 87 | &cli_args.bunker_app_key, | ||
| 88 | &cli_args.nsec, | ||
| 89 | &cli_args.password, | ||
| 90 | Some(&client), | ||
| 91 | false, | ||
| 92 | false, | ||
| 93 | ) | ||
| 94 | .await?; | ||
| 95 | |||
| 96 | let repo_config_result = get_repo_config_from_yaml(&git_repo); | ||
| 97 | // TODO: check for other claims | ||
| 98 | |||
| 99 | let name = match &args.title { | ||
| 100 | Some(t) => t.clone(), | ||
| 101 | None => Interactor::default().input( | ||
| 102 | PromptInputParms::default() | ||
| 103 | .with_prompt("name") | ||
| 104 | .with_default(if let Some(repo_ref) = &repo_ref { | ||
| 105 | repo_ref.name.clone() | ||
| 106 | } else { | ||
| 107 | String::new() | ||
| 108 | }), | ||
| 109 | )?, | ||
| 110 | }; | ||
| 111 | |||
| 112 | let identifier = match &args.identifier { | ||
| 113 | Some(t) => t.clone(), | ||
| 114 | None => Interactor::default().input( | ||
| 115 | PromptInputParms::default() | ||
| 116 | .with_prompt("identifier") | ||
| 117 | .with_default(if let Some(repo_ref) = &repo_ref { | ||
| 118 | repo_ref.identifier.clone() | ||
| 119 | } else { | ||
| 120 | let fallback = name | ||
| 121 | .clone() | ||
| 122 | .replace(' ', "-") | ||
| 123 | .chars() | ||
| 124 | .map(|c| { | ||
| 125 | if c.is_ascii_alphanumeric() || c.eq(&'/') { | ||
| 126 | c | ||
| 127 | } else { | ||
| 128 | '-' | ||
| 129 | } | ||
| 130 | }) | ||
| 131 | .collect(); | ||
| 132 | if let Ok(config) = &repo_config_result { | ||
| 133 | if let Some(identifier) = &config.identifier { | ||
| 134 | identifier.to_string() | ||
| 135 | } else { | ||
| 136 | fallback | ||
| 137 | } | ||
| 138 | } else { | ||
| 139 | fallback | ||
| 140 | } | ||
| 141 | }), | ||
| 142 | )?, | ||
| 143 | }; | ||
| 144 | |||
| 145 | let description = match &args.description { | ||
| 146 | Some(t) => t.clone(), | ||
| 147 | None => Interactor::default().input( | ||
| 148 | PromptInputParms::default() | ||
| 149 | .with_prompt("description") | ||
| 150 | .with_default(if let Some(repo_ref) = &repo_ref { | ||
| 151 | repo_ref.description.clone() | ||
| 152 | } else { | ||
| 153 | String::new() | ||
| 154 | }), | ||
| 155 | )?, | ||
| 156 | }; | ||
| 157 | |||
| 158 | let git_server = if args.clone_url.is_empty() { | ||
| 159 | Interactor::default() | ||
| 160 | .input( | ||
| 161 | PromptInputParms::default() | ||
| 162 | .with_prompt("clone url (for fetch)") | ||
| 163 | .with_default(if let Some(repo_ref) = &repo_ref { | ||
| 164 | repo_ref.git_server.clone().join(" ") | ||
| 165 | } else if let Ok(url) = git_repo.get_origin_url() { | ||
| 166 | if let Ok(fetch_url) = convert_clone_url_to_https(&url) { | ||
| 167 | fetch_url | ||
| 168 | } else { | ||
| 169 | // local repo or custom protocol | ||
| 170 | url | ||
| 171 | } | ||
| 172 | } else { | ||
| 173 | String::new() | ||
| 174 | }), | ||
| 175 | )? | ||
| 176 | .split(' ') | ||
| 177 | .map(std::string::ToString::to_string) | ||
| 178 | .collect() | ||
| 179 | } else { | ||
| 180 | args.clone_url.clone() | ||
| 181 | }; | ||
| 182 | |||
| 183 | let web: Vec<String> = if args.web.is_empty() { | ||
| 184 | Interactor::default() | ||
| 185 | .input( | ||
| 186 | PromptInputParms::default() | ||
| 187 | .with_prompt("web") | ||
| 188 | .optional() | ||
| 189 | .with_default(if let Some(repo_ref) = &repo_ref { | ||
| 190 | repo_ref.web.clone().join(" ") | ||
| 191 | } else { | ||
| 192 | format!("https://gitworkshop.dev/repo/{}", &identifier) | ||
| 193 | }), | ||
| 194 | )? | ||
| 195 | .split(' ') | ||
| 196 | .map(std::string::ToString::to_string) | ||
| 197 | .collect() | ||
| 198 | } else { | ||
| 199 | args.web.clone() | ||
| 200 | }; | ||
| 201 | |||
| 202 | let maintainers: Vec<PublicKey> = { | ||
| 203 | let mut dont_ask = !args.other_maintainers.is_empty(); | ||
| 204 | let mut maintainers_string = if !args.other_maintainers.is_empty() { | ||
| 205 | [args.other_maintainers.clone()].concat().join(" ") | ||
| 206 | } else if repo_ref.is_none() && repo_config_result.is_err() { | ||
| 207 | signer.public_key().await?.to_bech32()? | ||
| 208 | } else { | ||
| 209 | let maintainers = if let Ok(config) = &repo_config_result { | ||
| 210 | config.maintainers.clone() | ||
| 211 | } else if let Some(repo_ref) = &repo_ref { | ||
| 212 | repo_ref | ||
| 213 | .maintainers | ||
| 214 | .clone() | ||
| 215 | .iter() | ||
| 216 | .map(|k| k.to_bech32().unwrap()) | ||
| 217 | .collect() | ||
| 218 | } else { | ||
| 219 | //unreachable | ||
| 220 | vec![signer.public_key().await?.to_bech32()?] | ||
| 221 | }; | ||
| 222 | // add current user if not present | ||
| 223 | if maintainers.iter().any(|m| { | ||
| 224 | if let Ok(m_pubkey) = PublicKey::from_bech32(m) { | ||
| 225 | user_ref.public_key.eq(&m_pubkey) | ||
| 226 | } else { | ||
| 227 | false | ||
| 228 | } | ||
| 229 | }) { | ||
| 230 | maintainers.join(" ") | ||
| 231 | } else { | ||
| 232 | [maintainers, vec![signer.public_key().await?.to_bech32()?]] | ||
| 233 | .concat() | ||
| 234 | .join(" ") | ||
| 235 | } | ||
| 236 | }; | ||
| 237 | 'outer: loop { | ||
| 238 | if !dont_ask { | ||
| 239 | println!("{}", &maintainers_string); | ||
| 240 | maintainers_string = Interactor::default().input( | ||
| 241 | PromptInputParms::default() | ||
| 242 | .with_prompt("maintainers") | ||
| 243 | .with_default(maintainers_string), | ||
| 244 | )?; | ||
| 245 | } | ||
| 246 | let mut maintainers: Vec<PublicKey> = vec![]; | ||
| 247 | for m in maintainers_string.split(' ') { | ||
| 248 | if let Ok(m_pubkey) = PublicKey::from_bech32(m) { | ||
| 249 | maintainers.push(m_pubkey); | ||
| 250 | } else { | ||
| 251 | println!("not a valid set of npubs seperated by a space"); | ||
| 252 | dont_ask = false; | ||
| 253 | continue 'outer; | ||
| 254 | } | ||
| 255 | } | ||
| 256 | // add current user incase removed | ||
| 257 | if !maintainers.iter().any(|m| user_ref.public_key.eq(m)) { | ||
| 258 | maintainers.push(signer.public_key().await?); | ||
| 259 | } | ||
| 260 | break maintainers; | ||
| 261 | } | ||
| 262 | }; | ||
| 263 | |||
| 264 | // TODO: check if relays are free to post to so contributors can submit patches | ||
| 265 | // TODO: recommend some reliable free ones | ||
| 266 | let relays: Vec<String> = if args.relays.is_empty() { | ||
| 267 | Interactor::default() | ||
| 268 | .input( | ||
| 269 | PromptInputParms::default() | ||
| 270 | .with_prompt("relays") | ||
| 271 | .with_default(if let Ok(config) = &repo_config_result { | ||
| 272 | config.relays.clone().join(" ") | ||
| 273 | } else if let Some(repo_ref) = &repo_ref { | ||
| 274 | repo_ref.relays.clone().join(" ") | ||
| 275 | } else { | ||
| 276 | user_ref.relays.write().join(" ") | ||
| 277 | }), | ||
| 278 | )? | ||
| 279 | .split(' ') | ||
| 280 | .map(std::string::ToString::to_string) | ||
| 281 | .collect() | ||
| 282 | } else { | ||
| 283 | args.relays.clone() | ||
| 284 | }; | ||
| 285 | |||
| 286 | let earliest_unique_commit = match &args.earliest_unique_commit { | ||
| 287 | Some(t) => t.clone(), | ||
| 288 | None => { | ||
| 289 | let mut earliest_unique_commit = if let Some(repo_ref) = &repo_ref { | ||
| 290 | repo_ref.root_commit.clone() | ||
| 291 | } else { | ||
| 292 | root_commit.to_string() | ||
| 293 | }; | ||
| 294 | loop { | ||
| 295 | earliest_unique_commit = Interactor::default().input( | ||
| 296 | PromptInputParms::default() | ||
| 297 | .with_prompt("earliest unique commit") | ||
| 298 | .with_default(earliest_unique_commit.clone()), | ||
| 299 | )?; | ||
| 300 | if let Ok(exists) = git_repo.does_commit_exist(&earliest_unique_commit) { | ||
| 301 | if exists { | ||
| 302 | break earliest_unique_commit; | ||
| 303 | } | ||
| 304 | println!("commit does not exist on current repository"); | ||
| 305 | } else { | ||
| 306 | println!("commit id not formatted correctly"); | ||
| 307 | } | ||
| 308 | if earliest_unique_commit.len().ne(&40) { | ||
| 309 | println!("commit id must be 40 characters long"); | ||
| 310 | } | ||
| 311 | } | ||
| 312 | } | ||
| 313 | }; | ||
| 314 | |||
| 315 | println!("publishing repostory reference..."); | ||
| 316 | |||
| 317 | let repo_ref = RepoRef { | ||
| 318 | identifier: identifier.clone(), | ||
| 319 | name, | ||
| 320 | description, | ||
| 321 | root_commit: earliest_unique_commit, | ||
| 322 | git_server, | ||
| 323 | web, | ||
| 324 | relays: relays.clone(), | ||
| 325 | maintainers: maintainers.clone(), | ||
| 326 | events: HashMap::new(), | ||
| 327 | }; | ||
| 328 | let repo_event = repo_ref.to_event(&signer).await?; | ||
| 329 | |||
| 330 | client.set_signer(signer).await; | ||
| 331 | |||
| 332 | send_events( | ||
| 333 | &client, | ||
| 334 | git_repo_path, | ||
| 335 | vec![repo_event], | ||
| 336 | user_ref.relays.write(), | ||
| 337 | relays.clone(), | ||
| 338 | !cli_args.disable_cli_spinners, | ||
| 339 | false, | ||
| 340 | ) | ||
| 341 | .await?; | ||
| 342 | |||
| 343 | git_repo.save_git_config_item( | ||
| 344 | "nostr.repo", | ||
| 345 | &Coordinate { | ||
| 346 | kind: Kind::GitRepoAnnouncement, | ||
| 347 | public_key: user_ref.public_key, | ||
| 348 | identifier: identifier.clone(), | ||
| 349 | relays: vec![], | ||
| 350 | } | ||
| 351 | .to_bech32()?, | ||
| 352 | false, | ||
| 353 | )?; | ||
| 354 | |||
| 355 | // if yaml file doesnt exist or needs updating | ||
| 356 | if match &repo_config_result { | ||
| 357 | Ok(config) => { | ||
| 358 | !<std::option::Option<std::string::String> as Clone>::clone(&config.identifier) | ||
| 359 | .unwrap_or_default() | ||
| 360 | .eq(&identifier) | ||
| 361 | || !extract_pks(config.maintainers.clone())?.eq(&maintainers) | ||
| 362 | || !config.relays.eq(&relays) | ||
| 363 | } | ||
| 364 | Err(_) => true, | ||
| 365 | } { | ||
| 366 | save_repo_config_to_yaml( | ||
| 367 | &git_repo, | ||
| 368 | identifier.clone(), | ||
| 369 | maintainers.clone(), | ||
| 370 | relays.clone(), | ||
| 371 | )?; | ||
| 372 | println!( | ||
| 373 | "maintainers.yaml {}. commit and push.", | ||
| 374 | if repo_config_result.is_err() { | ||
| 375 | "created" | ||
| 376 | } else { | ||
| 377 | "updated" | ||
| 378 | } | ||
| 379 | ); | ||
| 380 | println!( | ||
| 381 | "this optional file helps in identifying who the maintainers are over time through the commit history" | ||
| 382 | ); | ||
| 383 | } | ||
| 384 | Ok(()) | ||
| 385 | } | ||
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 | } | ||
diff --git a/src/bin/ngit/sub_commands/login.rs b/src/bin/ngit/sub_commands/login.rs new file mode 100644 index 0000000..8a3788f --- /dev/null +++ b/src/bin/ngit/sub_commands/login.rs | |||
| @@ -0,0 +1,52 @@ | |||
| 1 | use anyhow::{Context, Result}; | ||
| 2 | use clap; | ||
| 3 | |||
| 4 | #[cfg(not(test))] | ||
| 5 | use crate::client::Client; | ||
| 6 | #[cfg(test)] | ||
| 7 | use crate::client::MockConnect; | ||
| 8 | use crate::{cli::Cli, client::Connect, git::Repo, login}; | ||
| 9 | |||
| 10 | #[derive(clap::Args)] | ||
| 11 | pub struct SubCommandArgs { | ||
| 12 | /// don't fetch user metadata and relay list from relays | ||
| 13 | #[arg(long, action)] | ||
| 14 | offline: bool, | ||
| 15 | } | ||
| 16 | |||
| 17 | pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { | ||
| 18 | let git_repo = Repo::discover().context("cannot find a git repository")?; | ||
| 19 | if command_args.offline { | ||
| 20 | login::launch( | ||
| 21 | &git_repo, | ||
| 22 | &args.bunker_uri, | ||
| 23 | &args.bunker_app_key, | ||
| 24 | &args.nsec, | ||
| 25 | &args.password, | ||
| 26 | None, | ||
| 27 | true, | ||
| 28 | false, | ||
| 29 | ) | ||
| 30 | .await?; | ||
| 31 | Ok(()) | ||
| 32 | } else { | ||
| 33 | #[cfg(not(test))] | ||
| 34 | let client = Client::default(); | ||
| 35 | #[cfg(test)] | ||
| 36 | let client = <MockConnect as std::default::Default>::default(); | ||
| 37 | |||
| 38 | login::launch( | ||
| 39 | &git_repo, | ||
| 40 | &args.bunker_uri, | ||
| 41 | &args.bunker_app_key, | ||
| 42 | &args.nsec, | ||
| 43 | &args.password, | ||
| 44 | Some(&client), | ||
| 45 | true, | ||
| 46 | false, | ||
| 47 | ) | ||
| 48 | .await?; | ||
| 49 | client.disconnect().await?; | ||
| 50 | Ok(()) | ||
| 51 | } | ||
| 52 | } | ||
diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs new file mode 100644 index 0000000..29a60f9 --- /dev/null +++ b/src/bin/ngit/sub_commands/mod.rs | |||
| @@ -0,0 +1,7 @@ | |||
| 1 | pub mod fetch; | ||
| 2 | pub mod init; | ||
| 3 | pub mod list; | ||
| 4 | pub mod login; | ||
| 5 | pub mod pull; | ||
| 6 | pub mod push; | ||
| 7 | pub mod send; | ||
diff --git a/src/bin/ngit/sub_commands/pull.rs b/src/bin/ngit/sub_commands/pull.rs new file mode 100644 index 0000000..e33a744 --- /dev/null +++ b/src/bin/ngit/sub_commands/pull.rs | |||
| @@ -0,0 +1,209 @@ | |||
| 1 | use anyhow::{bail, Context, Result}; | ||
| 2 | |||
| 3 | use super::{ | ||
| 4 | list::{ | ||
| 5 | get_all_proposal_patch_events_from_cache, get_commit_id_from_patch, | ||
| 6 | get_proposals_and_revisions_from_cache, tag_value, | ||
| 7 | }, | ||
| 8 | send::event_to_cover_letter, | ||
| 9 | }; | ||
| 10 | #[cfg(test)] | ||
| 11 | use crate::client::MockConnect; | ||
| 12 | #[cfg(not(test))] | ||
| 13 | use crate::client::{Client, Connect}; | ||
| 14 | use crate::{ | ||
| 15 | client::{fetching_with_report, get_repo_ref_from_cache}, | ||
| 16 | git::{str_to_sha1, Repo, RepoActions}, | ||
| 17 | repo_ref::get_repo_coordinates, | ||
| 18 | sub_commands::{list::get_most_recent_patch_with_ancestors, send::event_is_revision_root}, | ||
| 19 | }; | ||
| 20 | |||
| 21 | #[allow(clippy::too_many_lines)] | ||
| 22 | pub async fn launch() -> Result<()> { | ||
| 23 | let git_repo = Repo::discover().context("cannot find a git repository")?; | ||
| 24 | let git_repo_path = git_repo.get_path()?; | ||
| 25 | |||
| 26 | let (main_or_master_branch_name, _) = git_repo | ||
| 27 | .get_main_or_master_branch() | ||
| 28 | .context("no main or master branch")?; | ||
| 29 | |||
| 30 | let branch_name = git_repo | ||
| 31 | .get_checked_out_branch_name() | ||
| 32 | .context("cannot get checked out branch name")?; | ||
| 33 | |||
| 34 | if branch_name == main_or_master_branch_name { | ||
| 35 | bail!("checkout a branch associated with a proposal first") | ||
| 36 | } | ||
| 37 | #[cfg(not(test))] | ||
| 38 | let client = Client::default(); | ||
| 39 | #[cfg(test)] | ||
| 40 | let client = <MockConnect as std::default::Default>::default(); | ||
| 41 | |||
| 42 | let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; | ||
| 43 | |||
| 44 | fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; | ||
| 45 | |||
| 46 | let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?; | ||
| 47 | |||
| 48 | let proposal_root_event = | ||
| 49 | get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()) | ||
| 50 | .await? | ||
| 51 | .iter() | ||
| 52 | .find(|e| { | ||
| 53 | event_to_cover_letter(e) | ||
| 54 | .is_ok_and(|cl| cl.get_branch_name().is_ok_and(|s| s.eq(&branch_name))) | ||
| 55 | && !event_is_revision_root(e) | ||
| 56 | }) | ||
| 57 | .context("cannot find proposal that matches the current branch name")? | ||
| 58 | .clone(); | ||
| 59 | let commit_events = get_all_proposal_patch_events_from_cache( | ||
| 60 | git_repo_path, | ||
| 61 | &repo_ref, | ||
| 62 | &proposal_root_event.id(), | ||
| 63 | ) | ||
| 64 | .await?; | ||
| 65 | |||
| 66 | let most_recent_proposal_patch_chain = | ||
| 67 | get_most_recent_patch_with_ancestors(commit_events.clone()) | ||
| 68 | .context("cannot get most recent patch for proposal")?; | ||
| 69 | |||
| 70 | let local_branch_tip = git_repo.get_tip_of_branch(&branch_name)?; | ||
| 71 | |||
| 72 | let (main_branch_name, master_tip) = git_repo.get_main_or_master_branch()?; | ||
| 73 | |||
| 74 | let (local_ahead_of_main, local_beind_main) = | ||
| 75 | git_repo.get_commits_ahead_behind(&master_tip, &local_branch_tip)?; | ||
| 76 | |||
| 77 | let proposal_base_commit = str_to_sha1(&tag_value( | ||
| 78 | most_recent_proposal_patch_chain | ||
| 79 | .last() | ||
| 80 | .context("there should be at least one patch as we have already checked for this")?, | ||
| 81 | "parent-commit", | ||
| 82 | )?) | ||
| 83 | .context("cannot get valid parent commit id from patch")?; | ||
| 84 | |||
| 85 | let (_, proposal_behind_main) = | ||
| 86 | git_repo.get_commits_ahead_behind(&master_tip, &proposal_base_commit)?; | ||
| 87 | |||
| 88 | let proposal_tip = | ||
| 89 | str_to_sha1( | ||
| 90 | &get_commit_id_from_patch(most_recent_proposal_patch_chain.first().context( | ||
| 91 | "there should be at least one patch as we have already checked for this", | ||
| 92 | )?) | ||
| 93 | .context("cannot get valid commit_id from patch")?, | ||
| 94 | ) | ||
| 95 | .context("cannot get valid commit_id from patch")?; | ||
| 96 | |||
| 97 | // if uptodate | ||
| 98 | if proposal_tip.eq(&local_branch_tip) { | ||
| 99 | println!("branch already up-to-date"); | ||
| 100 | } | ||
| 101 | // if new appendments | ||
| 102 | else if most_recent_proposal_patch_chain.iter().any(|patch| { | ||
| 103 | get_commit_id_from_patch(patch) | ||
| 104 | .unwrap_or_default() | ||
| 105 | .eq(&local_branch_tip.to_string()) | ||
| 106 | }) { | ||
| 107 | check_clean(&git_repo)?; | ||
| 108 | let applied = git_repo | ||
| 109 | .apply_patch_chain(&branch_name, most_recent_proposal_patch_chain) | ||
| 110 | .context("cannot apply patch chain")?; | ||
| 111 | println!("applied {} new commits", applied.len(),); | ||
| 112 | } | ||
| 113 | // if parent commit doesnt exist | ||
| 114 | else if !git_repo.does_commit_exist(&proposal_base_commit.to_string())? { | ||
| 115 | println!( | ||
| 116 | "a new version of the proposal has a prant commit that doesnt exist in your local repository." | ||
| 117 | ); | ||
| 118 | println!("your '{main_branch_name}' branch may not be up-to-date."); | ||
| 119 | println!("manually run `git pull` on '{main_branch_name}' and try again"); | ||
| 120 | } | ||
| 121 | // if new revision and no local changes (tip of local in proposal history) | ||
| 122 | else if commit_events.iter().any(|patch| { | ||
| 123 | get_commit_id_from_patch(patch) | ||
| 124 | .unwrap_or_default() | ||
| 125 | .eq(&local_branch_tip.to_string()) | ||
| 126 | }) { | ||
| 127 | check_clean(&git_repo)?; | ||
| 128 | |||
| 129 | git_repo.create_branch_at_commit(&branch_name, &proposal_base_commit.to_string())?; | ||
| 130 | let applied = git_repo | ||
| 131 | .apply_patch_chain(&branch_name, most_recent_proposal_patch_chain) | ||
| 132 | .context("cannot apply patch chain")?; | ||
| 133 | |||
| 134 | println!( | ||
| 135 | "pulled new version of proposal ({} ahead {} behind '{main_branch_name}'), replacing old version ({} ahead {} behind '{main_branch_name}')", | ||
| 136 | applied.len(), | ||
| 137 | proposal_behind_main.len(), | ||
| 138 | local_ahead_of_main.len(), | ||
| 139 | local_beind_main.len(), | ||
| 140 | ); | ||
| 141 | } | ||
| 142 | // if tip of proposal in branch in history (local appendments made to up-to-date | ||
| 143 | // proposal) | ||
| 144 | else if git_repo.ancestor_of(&local_branch_tip, &proposal_tip)? { | ||
| 145 | let (local_ahead_of_proposal, _) = git_repo | ||
| 146 | .get_commits_ahead_behind(&proposal_tip, &local_branch_tip) | ||
| 147 | .context("cannot get commits ahead behind for propsal_top and local_branch_tip")?; | ||
| 148 | println!( | ||
| 149 | "local proposal branch exists with {} unpublished commits on top of the most up-to-date version of the proposal", | ||
| 150 | local_ahead_of_proposal.len() | ||
| 151 | ); | ||
| 152 | } else { | ||
| 153 | println!("you have an amended/rebase version the proposal that is unpublished"); | ||
| 154 | // user probably has a unpublished amended or rebase version of the latest | ||
| 155 | // proposal version | ||
| 156 | // if tip of proposal commits exist (were once part of branch but have been | ||
| 157 | // amended and git clean up job hasn't removed them) | ||
| 158 | if git_repo.does_commit_exist(&proposal_tip.to_string())? { | ||
| 159 | println!( | ||
| 160 | "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}')", | ||
| 161 | most_recent_proposal_patch_chain.len(), | ||
| 162 | proposal_behind_main.len(), | ||
| 163 | local_ahead_of_main.len(), | ||
| 164 | local_beind_main.len(), | ||
| 165 | ); | ||
| 166 | } | ||
| 167 | // user probably has a unpublished amended or rebase version of an older | ||
| 168 | // proposal version | ||
| 169 | else { | ||
| 170 | println!( | ||
| 171 | "your local proposal branch ({} ahead {} behind '{main_branch_name}') has conflicting changes with the latest published proposal ({} ahead {} behind '{main_branch_name}')", | ||
| 172 | local_ahead_of_main.len(), | ||
| 173 | local_beind_main.len(), | ||
| 174 | most_recent_proposal_patch_chain.len(), | ||
| 175 | proposal_behind_main.len(), | ||
| 176 | ); | ||
| 177 | |||
| 178 | println!( | ||
| 179 | "its likely that you have rebased / amended an old proposal version because git has no record of the latest proposal commit." | ||
| 180 | ); | ||
| 181 | println!( | ||
| 182 | "it is possible that you have been working off the latest version and git has delete this commit as part of a clean up" | ||
| 183 | ); | ||
| 184 | } | ||
| 185 | println!("to view the latest proposal but retain your changes:"); | ||
| 186 | println!(" 1) create a new branch off the tip commit of this one to store your changes"); | ||
| 187 | println!(" 2) run `ngit list` and checkout the latest published version of this proposal"); | ||
| 188 | |||
| 189 | println!("if you are confident in your changes consider running `ngit push --force`"); | ||
| 190 | |||
| 191 | // TODO: this copy could be refined further based on this: | ||
| 192 | // - amended commits in the proposal | ||
| 193 | // - if local_base eq proposal base | ||
| 194 | // - amended an older version of proposal | ||
| 195 | // - if local_base is behind proposal_base | ||
| 196 | // - rebased the proposal | ||
| 197 | // - if local_base is ahead of proposal_base | ||
| 198 | } | ||
| 199 | Ok(()) | ||
| 200 | } | ||
| 201 | |||
| 202 | fn check_clean(git_repo: &Repo) -> Result<()> { | ||
| 203 | if git_repo.has_outstanding_changes()? { | ||
| 204 | bail!( | ||
| 205 | "cannot pull proposal branch when repository is not clean. discard or stash (un)staged changes and try again." | ||
| 206 | ); | ||
| 207 | } | ||
| 208 | Ok(()) | ||
| 209 | } | ||
diff --git a/src/bin/ngit/sub_commands/push.rs b/src/bin/ngit/sub_commands/push.rs new file mode 100644 index 0000000..7a82c7a --- /dev/null +++ b/src/bin/ngit/sub_commands/push.rs | |||
| @@ -0,0 +1,223 @@ | |||
| 1 | use anyhow::{bail, Context, Result}; | ||
| 2 | |||
| 3 | #[cfg(not(test))] | ||
| 4 | use crate::client::Client; | ||
| 5 | #[cfg(test)] | ||
| 6 | use crate::client::MockConnect; | ||
| 7 | use crate::{ | ||
| 8 | cli::Cli, | ||
| 9 | client::{fetching_with_report, get_repo_ref_from_cache, Connect}, | ||
| 10 | git::{str_to_sha1, Repo, RepoActions}, | ||
| 11 | login, | ||
| 12 | repo_ref::get_repo_coordinates, | ||
| 13 | sub_commands::{ | ||
| 14 | self, | ||
| 15 | list::{ | ||
| 16 | get_all_proposal_patch_events_from_cache, get_commit_id_from_patch, | ||
| 17 | get_most_recent_patch_with_ancestors, get_proposals_and_revisions_from_cache, | ||
| 18 | tag_value, | ||
| 19 | }, | ||
| 20 | send::{ | ||
| 21 | event_is_revision_root, event_to_cover_letter, generate_patch_event, | ||
| 22 | identify_ahead_behind, send_events, | ||
| 23 | }, | ||
| 24 | }, | ||
| 25 | }; | ||
| 26 | |||
| 27 | #[derive(Debug, clap::Args)] | ||
| 28 | pub struct SubCommandArgs { | ||
| 29 | #[arg(long, action)] | ||
| 30 | /// send proposal revision from checked out proposal branch | ||
| 31 | force: bool, | ||
| 32 | } | ||
| 33 | |||
| 34 | #[allow(clippy::too_many_lines)] | ||
| 35 | pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { | ||
| 36 | let git_repo = Repo::discover().context("cannot find a git repository")?; | ||
| 37 | let git_repo_path = git_repo.get_path()?; | ||
| 38 | |||
| 39 | let (main_or_master_branch_name, _) = git_repo | ||
| 40 | .get_main_or_master_branch() | ||
| 41 | .context("no main or master branch")?; | ||
| 42 | |||
| 43 | let root_commit = git_repo | ||
| 44 | .get_root_commit() | ||
| 45 | .context("failed to get root commit of the repository")?; | ||
| 46 | |||
| 47 | let branch_name = git_repo | ||
| 48 | .get_checked_out_branch_name() | ||
| 49 | .context("cannot get checked out branch name")?; | ||
| 50 | |||
| 51 | if branch_name == main_or_master_branch_name { | ||
| 52 | bail!("checkout a branch associated with a proposal first") | ||
| 53 | } | ||
| 54 | #[cfg(not(test))] | ||
| 55 | let mut client = Client::default(); | ||
| 56 | #[cfg(test)] | ||
| 57 | let mut client = <MockConnect as std::default::Default>::default(); | ||
| 58 | |||
| 59 | let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; | ||
| 60 | |||
| 61 | fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; | ||
| 62 | |||
| 63 | let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?; | ||
| 64 | |||
| 65 | let proposal_root_event = | ||
| 66 | get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()) | ||
| 67 | .await? | ||
| 68 | .iter() | ||
| 69 | .find(|e| { | ||
| 70 | event_to_cover_letter(e) | ||
| 71 | .is_ok_and(|cl| cl.get_branch_name().is_ok_and(|s| s.eq(&branch_name))) | ||
| 72 | && !event_is_revision_root(e) | ||
| 73 | }) | ||
| 74 | .context("cannot find proposal that matches the current branch name")? | ||
| 75 | .clone(); | ||
| 76 | |||
| 77 | let commit_events = get_all_proposal_patch_events_from_cache( | ||
| 78 | git_repo_path, | ||
| 79 | &repo_ref, | ||
| 80 | &proposal_root_event.id(), | ||
| 81 | ) | ||
| 82 | .await?; | ||
| 83 | |||
| 84 | let most_recent_proposal_patch_chain = get_most_recent_patch_with_ancestors(commit_events) | ||
| 85 | .context("cannot get most recent patch for proposal")?; | ||
| 86 | |||
| 87 | let branch_tip = git_repo.get_tip_of_branch(&branch_name)?; | ||
| 88 | |||
| 89 | let most_recent_patch_commit_id = str_to_sha1( | ||
| 90 | &get_commit_id_from_patch( | ||
| 91 | most_recent_proposal_patch_chain | ||
| 92 | .first() | ||
| 93 | .context("no patches found")?, | ||
| 94 | ) | ||
| 95 | .context("latest patch event doesnt have a commit tag")?, | ||
| 96 | ) | ||
| 97 | .context("latest patch event commit tag isn't a valid SHA1 hash")?; | ||
| 98 | |||
| 99 | let proposal_base_commit_id = str_to_sha1( | ||
| 100 | &tag_value( | ||
| 101 | most_recent_proposal_patch_chain | ||
| 102 | .last() | ||
| 103 | .context("no patches found")?, | ||
| 104 | "parent-commit", | ||
| 105 | ) | ||
| 106 | .context("patch is incorrectly formatted")?, | ||
| 107 | ) | ||
| 108 | .context("latest patch event parent-commit tag isn't a valid SHA1 hash")?; | ||
| 109 | |||
| 110 | if most_recent_patch_commit_id.eq(&branch_tip) { | ||
| 111 | bail!("proposal already up-to-date with local branch"); | ||
| 112 | } | ||
| 113 | |||
| 114 | if args.force { | ||
| 115 | println!("preparing to force push proposal revision..."); | ||
| 116 | sub_commands::send::launch( | ||
| 117 | cli_args, | ||
| 118 | &sub_commands::send::SubCommandArgs { | ||
| 119 | // if not ahead of master prompt, otherwise assume proposal revision is all commits | ||
| 120 | // ahead | ||
| 121 | since_or_range: if let Ok((_, _, ahead, _)) = | ||
| 122 | identify_ahead_behind(&git_repo, &None, &None) | ||
| 123 | { | ||
| 124 | if ahead.is_empty() { | ||
| 125 | String::new() | ||
| 126 | } else { | ||
| 127 | format!("HEAD~{}", ahead.len()) | ||
| 128 | } | ||
| 129 | } else { | ||
| 130 | String::new() | ||
| 131 | }, | ||
| 132 | in_reply_to: vec![proposal_root_event.id.to_string()], | ||
| 133 | title: None, | ||
| 134 | description: None, | ||
| 135 | no_cover_letter: true, | ||
| 136 | }, | ||
| 137 | true, | ||
| 138 | ) | ||
| 139 | .await?; | ||
| 140 | println!("force pushed proposal revision"); | ||
| 141 | return Ok(()); | ||
| 142 | } | ||
| 143 | |||
| 144 | if most_recent_proposal_patch_chain.iter().any(|e| { | ||
| 145 | let c = tag_value(e, "parent-commit").unwrap_or_default(); | ||
| 146 | c.eq(&branch_tip.to_string()) | ||
| 147 | }) { | ||
| 148 | bail!("proposal is ahead of local branch"); | ||
| 149 | } | ||
| 150 | |||
| 151 | let Ok((ahead, behind)) = git_repo | ||
| 152 | .get_commits_ahead_behind(&most_recent_patch_commit_id, &branch_tip) | ||
| 153 | .context("the latest patch in proposal doesnt share an ancestor with your branch.") | ||
| 154 | else { | ||
| 155 | if git_repo.ancestor_of(&proposal_base_commit_id, &branch_tip)? { | ||
| 156 | bail!("local unpublished proposal ammendments. consider force pushing."); | ||
| 157 | } | ||
| 158 | bail!("local unpublished proposal has been rebased. consider force pushing"); | ||
| 159 | }; | ||
| 160 | |||
| 161 | if !behind.is_empty() { | ||
| 162 | bail!( | ||
| 163 | "your local proposal branch is {} behind patches on nostr. consider rebasing or force pushing", | ||
| 164 | behind.len() | ||
| 165 | ) | ||
| 166 | } | ||
| 167 | |||
| 168 | println!( | ||
| 169 | "{} commits ahead. preparing to create creating patch events.", | ||
| 170 | ahead.len() | ||
| 171 | ); | ||
| 172 | |||
| 173 | let (signer, user_ref) = login::launch( | ||
| 174 | &git_repo, | ||
| 175 | &cli_args.bunker_uri, | ||
| 176 | &cli_args.bunker_app_key, | ||
| 177 | &cli_args.nsec, | ||
| 178 | &cli_args.password, | ||
| 179 | Some(&client), | ||
| 180 | false, | ||
| 181 | false, | ||
| 182 | ) | ||
| 183 | .await?; | ||
| 184 | |||
| 185 | let mut patch_events: Vec<nostr::Event> = vec![]; | ||
| 186 | for commit in &ahead { | ||
| 187 | patch_events.push( | ||
| 188 | generate_patch_event( | ||
| 189 | &git_repo, | ||
| 190 | &root_commit, | ||
| 191 | commit, | ||
| 192 | Some(proposal_root_event.id), | ||
| 193 | &signer, | ||
| 194 | &repo_ref, | ||
| 195 | patch_events.last().map(nostr::Event::id), | ||
| 196 | None, | ||
| 197 | None, | ||
| 198 | &None, | ||
| 199 | &[], | ||
| 200 | ) | ||
| 201 | .await | ||
| 202 | .context("cannot make patch event from commit")?, | ||
| 203 | ); | ||
| 204 | } | ||
| 205 | println!("pushing {} commits", ahead.len()); | ||
| 206 | |||
| 207 | client.set_signer(signer).await; | ||
| 208 | |||
| 209 | send_events( | ||
| 210 | &client, | ||
| 211 | git_repo_path, | ||
| 212 | patch_events, | ||
| 213 | user_ref.relays.write(), | ||
| 214 | repo_ref.relays.clone(), | ||
| 215 | !cli_args.disable_cli_spinners, | ||
| 216 | false, | ||
| 217 | ) | ||
| 218 | .await?; | ||
| 219 | |||
| 220 | println!("pushed {} commits", ahead.len()); | ||
| 221 | |||
| 222 | Ok(()) | ||
| 223 | } | ||
diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs new file mode 100644 index 0000000..3c4df9d --- /dev/null +++ b/src/bin/ngit/sub_commands/send.rs | |||
| @@ -0,0 +1,1363 @@ | |||
| 1 | use std::{path::Path, str::FromStr, time::Duration}; | ||
| 2 | |||
| 3 | use anyhow::{bail, Context, Result}; | ||
| 4 | use console::Style; | ||
| 5 | use futures::future::join_all; | ||
| 6 | use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; | ||
| 7 | use nostr::{ | ||
| 8 | nips::{ | ||
| 9 | nip01::Coordinate, | ||
| 10 | nip10::Marker, | ||
| 11 | nip19::{Nip19, Nip19Event}, | ||
| 12 | }, | ||
| 13 | EventBuilder, FromBech32, Tag, TagKind, ToBech32, UncheckedUrl, | ||
| 14 | }; | ||
| 15 | use nostr_sdk::{hashes::sha1::Hash as Sha1Hash, Kind, NostrSigner, TagStandard}; | ||
| 16 | |||
| 17 | use super::list::tag_value; | ||
| 18 | #[cfg(not(test))] | ||
| 19 | use crate::client::Client; | ||
| 20 | #[cfg(test)] | ||
| 21 | use crate::client::MockConnect; | ||
| 22 | use crate::{ | ||
| 23 | cli::Cli, | ||
| 24 | cli_interactor::{ | ||
| 25 | Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms, | ||
| 26 | }, | ||
| 27 | client::{ | ||
| 28 | fetching_with_report, get_events_from_cache, get_repo_ref_from_cache, sign_event, Connect, | ||
| 29 | }, | ||
| 30 | git::{Repo, RepoActions}, | ||
| 31 | login, | ||
| 32 | repo_ref::{get_repo_coordinates, RepoRef}, | ||
| 33 | }; | ||
| 34 | |||
| 35 | #[derive(Debug, clap::Args)] | ||
| 36 | pub struct SubCommandArgs { | ||
| 37 | #[arg(default_value = "")] | ||
| 38 | /// commits to send as proposal; like in `git format-patch` eg. HEAD~2 | ||
| 39 | pub(crate) since_or_range: String, | ||
| 40 | #[clap(long, value_parser, num_args = 0.., value_delimiter = ' ')] | ||
| 41 | /// references to an existing proposal for which this is a new | ||
| 42 | /// version and/or events / npubs to tag as mentions | ||
| 43 | pub(crate) in_reply_to: Vec<String>, | ||
| 44 | /// don't prompt for a cover letter | ||
| 45 | #[arg(long, action)] | ||
| 46 | pub(crate) no_cover_letter: bool, | ||
| 47 | /// optional cover letter title | ||
| 48 | #[clap(short, long)] | ||
| 49 | pub(crate) title: Option<String>, | ||
| 50 | #[clap(short, long)] | ||
| 51 | /// optional cover letter description | ||
| 52 | pub(crate) description: Option<String>, | ||
| 53 | } | ||
| 54 | |||
| 55 | #[allow(clippy::too_many_lines)] | ||
| 56 | pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Result<()> { | ||
| 57 | let git_repo = Repo::discover().context("cannot find a git repository")?; | ||
| 58 | let git_repo_path = git_repo.get_path()?; | ||
| 59 | |||
| 60 | let (main_branch_name, main_tip) = git_repo | ||
| 61 | .get_main_or_master_branch() | ||
| 62 | .context("the default branches (main or master) do not exist")?; | ||
| 63 | |||
| 64 | #[cfg(not(test))] | ||
| 65 | let mut client = Client::default(); | ||
| 66 | #[cfg(test)] | ||
| 67 | let mut client = <MockConnect as std::default::Default>::default(); | ||
| 68 | |||
| 69 | let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; | ||
| 70 | |||
| 71 | if !no_fetch { | ||
| 72 | fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; | ||
| 73 | } | ||
| 74 | |||
| 75 | let (root_proposal_id, mention_tags) = | ||
| 76 | get_root_proposal_id_and_mentions_from_in_reply_to(git_repo.get_path()?, &args.in_reply_to) | ||
| 77 | .await?; | ||
| 78 | |||
| 79 | if let Some(root_ref) = args.in_reply_to.first() { | ||
| 80 | if root_proposal_id.is_some() { | ||
| 81 | println!("creating proposal revision for: {root_ref}"); | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | let mut commits: Vec<Sha1Hash> = { | ||
| 86 | if args.since_or_range.is_empty() { | ||
| 87 | let branch_name = git_repo.get_checked_out_branch_name()?; | ||
| 88 | let proposed_commits = if branch_name.eq(main_branch_name) { | ||
| 89 | vec![main_tip] | ||
| 90 | } else { | ||
| 91 | let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?; | ||
| 92 | ahead | ||
| 93 | }; | ||
| 94 | choose_commits(&git_repo, proposed_commits)? | ||
| 95 | } else { | ||
| 96 | git_repo | ||
| 97 | .parse_starting_commits(&args.since_or_range) | ||
| 98 | .context("cannot parse specified starting commit or range")? | ||
| 99 | } | ||
| 100 | }; | ||
| 101 | |||
| 102 | if commits.is_empty() { | ||
| 103 | bail!("no commits selected"); | ||
| 104 | } | ||
| 105 | println!("creating proposal from {} commits:", commits.len()); | ||
| 106 | |||
| 107 | let dim = Style::new().color256(247); | ||
| 108 | for commit in &commits { | ||
| 109 | println!( | ||
| 110 | "{} {}", | ||
| 111 | dim.apply_to(commit.to_string().chars().take(7).collect::<String>()), | ||
| 112 | git_repo.get_commit_message_summary(commit)? | ||
| 113 | ); | ||
| 114 | } | ||
| 115 | |||
| 116 | let (first_commit_ahead, behind) = | ||
| 117 | git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?; | ||
| 118 | |||
| 119 | // check proposal ahead of origin/main | ||
| 120 | if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm( | ||
| 121 | PromptConfirmParms::default() | ||
| 122 | .with_prompt( | ||
| 123 | format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1) | ||
| 124 | ) | ||
| 125 | .with_default(false) | ||
| 126 | ).context("failed to get confirmation response from interactor confirm")? { | ||
| 127 | bail!("aborting because selected commits were ahead of origin/master"); | ||
| 128 | } | ||
| 129 | |||
| 130 | // check if a selected commit is already in origin | ||
| 131 | if commits.iter().any(|c| c.eq(&main_tip)) { | ||
| 132 | if !Interactor::default().confirm( | ||
| 133 | PromptConfirmParms::default() | ||
| 134 | .with_prompt( | ||
| 135 | format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?") | ||
| 136 | ) | ||
| 137 | .with_default(false) | ||
| 138 | ).context("failed to get confirmation response from interactor confirm")? { | ||
| 139 | bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'"); | ||
| 140 | } | ||
| 141 | } | ||
| 142 | // check proposal isn't behind origin/main | ||
| 143 | else if !behind.is_empty() && !Interactor::default().confirm( | ||
| 144 | PromptConfirmParms::default() | ||
| 145 | .with_prompt( | ||
| 146 | format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len()) | ||
| 147 | ) | ||
| 148 | .with_default(false) | ||
| 149 | ).context("failed to get confirmation response from interactor confirm")? { | ||
| 150 | bail!("aborting so commits can be rebased"); | ||
| 151 | } | ||
| 152 | |||
| 153 | let title = if args.no_cover_letter { | ||
| 154 | None | ||
| 155 | } else { | ||
| 156 | match &args.title { | ||
| 157 | Some(t) => Some(t.clone()), | ||
| 158 | None => { | ||
| 159 | if Interactor::default().confirm( | ||
| 160 | PromptConfirmParms::default() | ||
| 161 | .with_default(false) | ||
| 162 | .with_prompt("include cover letter?"), | ||
| 163 | )? { | ||
| 164 | Some( | ||
| 165 | Interactor::default() | ||
| 166 | .input(PromptInputParms::default().with_prompt("title"))? | ||
| 167 | .clone(), | ||
| 168 | ) | ||
| 169 | } else { | ||
| 170 | None | ||
| 171 | } | ||
| 172 | } | ||
| 173 | } | ||
| 174 | }; | ||
| 175 | |||
| 176 | let cover_letter_title_description = if let Some(title) = title { | ||
| 177 | Some(( | ||
| 178 | title, | ||
| 179 | if let Some(t) = &args.description { | ||
| 180 | t.clone() | ||
| 181 | } else { | ||
| 182 | Interactor::default() | ||
| 183 | .input(PromptInputParms::default().with_prompt("cover letter description"))? | ||
| 184 | .clone() | ||
| 185 | }, | ||
| 186 | )) | ||
| 187 | } else { | ||
| 188 | None | ||
| 189 | }; | ||
| 190 | let (signer, user_ref) = login::launch( | ||
| 191 | &git_repo, | ||
| 192 | &cli_args.bunker_uri, | ||
| 193 | &cli_args.bunker_app_key, | ||
| 194 | &cli_args.nsec, | ||
| 195 | &cli_args.password, | ||
| 196 | Some(&client), | ||
| 197 | false, | ||
| 198 | false, | ||
| 199 | ) | ||
| 200 | .await?; | ||
| 201 | |||
| 202 | client.set_signer(signer.clone()).await; | ||
| 203 | |||
| 204 | let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?; | ||
| 205 | |||
| 206 | // oldest first | ||
| 207 | commits.reverse(); | ||
| 208 | |||
| 209 | let events = generate_cover_letter_and_patch_events( | ||
| 210 | cover_letter_title_description.clone(), | ||
| 211 | &git_repo, | ||
| 212 | &commits, | ||
| 213 | &signer, | ||
| 214 | &repo_ref, | ||
| 215 | &root_proposal_id, | ||
| 216 | &mention_tags, | ||
| 217 | ) | ||
| 218 | .await?; | ||
| 219 | |||
| 220 | println!( | ||
| 221 | "posting {} patch{} {} a covering letter...", | ||
| 222 | if cover_letter_title_description.is_none() { | ||
| 223 | events.len() | ||
| 224 | } else { | ||
| 225 | events.len() - 1 | ||
| 226 | }, | ||
| 227 | if cover_letter_title_description.is_none() && events.len().eq(&1) | ||
| 228 | || cover_letter_title_description.is_some() && events.len().eq(&2) | ||
| 229 | { | ||
| 230 | "" | ||
| 231 | } else { | ||
| 232 | "es" | ||
| 233 | }, | ||
| 234 | if cover_letter_title_description.is_none() { | ||
| 235 | "without" | ||
| 236 | } else { | ||
| 237 | "with" | ||
| 238 | } | ||
| 239 | ); | ||
| 240 | |||
| 241 | send_events( | ||
| 242 | &client, | ||
| 243 | git_repo_path, | ||
| 244 | events.clone(), | ||
| 245 | user_ref.relays.write(), | ||
| 246 | repo_ref.relays.clone(), | ||
| 247 | !cli_args.disable_cli_spinners, | ||
| 248 | false, | ||
| 249 | ) | ||
| 250 | .await?; | ||
| 251 | |||
| 252 | if root_proposal_id.is_none() { | ||
| 253 | if let Some(event) = events.first() { | ||
| 254 | let event_bech32 = if let Some(relay) = repo_ref.relays.first() { | ||
| 255 | Nip19Event::new(event.id(), vec![relay]).to_bech32()? | ||
| 256 | } else { | ||
| 257 | event.id().to_bech32()? | ||
| 258 | }; | ||
| 259 | println!( | ||
| 260 | "{}", | ||
| 261 | dim.apply_to(format!( | ||
| 262 | "view in gitworkshop.dev: https://gitworkshop.dev/repo/{}/proposal/{}", | ||
| 263 | repo_ref.coordinate_with_hint().to_bech32()?, | ||
| 264 | &event_bech32, | ||
| 265 | )) | ||
| 266 | ); | ||
| 267 | println!( | ||
| 268 | "{}", | ||
| 269 | dim.apply_to(format!( | ||
| 270 | "view in another client: https://njump.me/{}", | ||
| 271 | &event_bech32, | ||
| 272 | )) | ||
| 273 | ); | ||
| 274 | } | ||
| 275 | } | ||
| 276 | // TODO check if there is already a similarly named | ||
| 277 | Ok(()) | ||
| 278 | } | ||
| 279 | |||
| 280 | #[allow(clippy::module_name_repetitions)] | ||
| 281 | #[allow(clippy::too_many_lines)] | ||
| 282 | pub async fn send_events( | ||
| 283 | #[cfg(test)] client: &crate::client::MockConnect, | ||
| 284 | #[cfg(not(test))] client: &Client, | ||
| 285 | git_repo_path: &Path, | ||
| 286 | events: Vec<nostr::Event>, | ||
| 287 | my_write_relays: Vec<String>, | ||
| 288 | repo_read_relays: Vec<String>, | ||
| 289 | animate: bool, | ||
| 290 | silent: bool, | ||
| 291 | ) -> Result<()> { | ||
| 292 | let fallback = [ | ||
| 293 | client.get_fallback_relays().clone(), | ||
| 294 | if events | ||
| 295 | .iter() | ||
| 296 | .any(|e| e.kind().eq(&Kind::GitRepoAnnouncement)) | ||
| 297 | { | ||
| 298 | client.get_blaster_relays().clone() | ||
| 299 | } else { | ||
| 300 | vec![] | ||
| 301 | }, | ||
| 302 | ] | ||
| 303 | .concat(); | ||
| 304 | let mut relays: Vec<&String> = vec![]; | ||
| 305 | |||
| 306 | let all = &[ | ||
| 307 | repo_read_relays.clone(), | ||
| 308 | my_write_relays.clone(), | ||
| 309 | fallback.clone(), | ||
| 310 | ] | ||
| 311 | .concat(); | ||
| 312 | // add duplicates first | ||
| 313 | for r in &repo_read_relays { | ||
| 314 | let r_clean = remove_trailing_slash(r); | ||
| 315 | if !my_write_relays | ||
| 316 | .iter() | ||
| 317 | .filter(|x| r_clean.eq(&remove_trailing_slash(x))) | ||
| 318 | .count() | ||
| 319 | > 1 | ||
| 320 | && !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) | ||
| 321 | { | ||
| 322 | relays.push(r); | ||
| 323 | } | ||
| 324 | } | ||
| 325 | |||
| 326 | for r in all { | ||
| 327 | let r_clean = remove_trailing_slash(r); | ||
| 328 | if !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) { | ||
| 329 | relays.push(r); | ||
| 330 | } | ||
| 331 | } | ||
| 332 | |||
| 333 | let m = if silent { | ||
| 334 | MultiProgress::with_draw_target(ProgressDrawTarget::hidden()) | ||
| 335 | } else { | ||
| 336 | MultiProgress::new() | ||
| 337 | }; | ||
| 338 | let pb_style = ProgressStyle::with_template(if animate { | ||
| 339 | " {spinner} {prefix} {bar} {pos}/{len} {msg}" | ||
| 340 | } else { | ||
| 341 | " - {prefix} {bar} {pos}/{len} {msg}" | ||
| 342 | })? | ||
| 343 | .progress_chars("##-"); | ||
| 344 | |||
| 345 | let pb_after_style = | ||
| 346 | |symbol| ProgressStyle::with_template(format!(" {symbol} {}", "{prefix} {msg}",).as_str()); | ||
| 347 | let pb_after_style_succeeded = pb_after_style(if animate { | ||
| 348 | console::style("✔".to_string()) | ||
| 349 | .for_stderr() | ||
| 350 | .green() | ||
| 351 | .to_string() | ||
| 352 | } else { | ||
| 353 | "y".to_string() | ||
| 354 | })?; | ||
| 355 | |||
| 356 | let pb_after_style_failed = pb_after_style(if animate { | ||
| 357 | console::style("✘".to_string()) | ||
| 358 | .for_stderr() | ||
| 359 | .red() | ||
| 360 | .to_string() | ||
| 361 | } else { | ||
| 362 | "x".to_string() | ||
| 363 | })?; | ||
| 364 | |||
| 365 | #[allow(clippy::borrow_deref_ref)] | ||
| 366 | join_all(relays.iter().map(|&relay| async { | ||
| 367 | let relay_clean = remove_trailing_slash(&*relay); | ||
| 368 | let details = format!( | ||
| 369 | "{}{}{} {}", | ||
| 370 | if my_write_relays | ||
| 371 | .iter() | ||
| 372 | .any(|r| relay_clean.eq(&remove_trailing_slash(r))) | ||
| 373 | { | ||
| 374 | " [my-relay]" | ||
| 375 | } else { | ||
| 376 | "" | ||
| 377 | }, | ||
| 378 | if repo_read_relays | ||
| 379 | .iter() | ||
| 380 | .any(|r| relay_clean.eq(&remove_trailing_slash(r))) | ||
| 381 | { | ||
| 382 | " [repo-relay]" | ||
| 383 | } else { | ||
| 384 | "" | ||
| 385 | }, | ||
| 386 | if fallback | ||
| 387 | .iter() | ||
| 388 | .any(|r| relay_clean.eq(&remove_trailing_slash(r))) | ||
| 389 | { | ||
| 390 | " [default]" | ||
| 391 | } else { | ||
| 392 | "" | ||
| 393 | }, | ||
| 394 | relay_clean, | ||
| 395 | ); | ||
| 396 | let pb = m.add( | ||
| 397 | ProgressBar::new(events.len() as u64) | ||
| 398 | .with_prefix(details.to_string()) | ||
| 399 | .with_style(pb_style.clone()), | ||
| 400 | ); | ||
| 401 | if animate { | ||
| 402 | pb.enable_steady_tick(Duration::from_millis(300)); | ||
| 403 | } | ||
| 404 | pb.inc(0); // need to make pb display intially | ||
| 405 | let mut failed = false; | ||
| 406 | for event in &events { | ||
| 407 | match client | ||
| 408 | .send_event_to(git_repo_path, relay.as_str(), event.clone()) | ||
| 409 | .await | ||
| 410 | { | ||
| 411 | Ok(_) => pb.inc(1), | ||
| 412 | Err(e) => { | ||
| 413 | pb.set_style(pb_after_style_failed.clone()); | ||
| 414 | pb.finish_with_message( | ||
| 415 | console::style( | ||
| 416 | e.to_string() | ||
| 417 | .replace("relay pool error:", "error:") | ||
| 418 | .replace("event not published: ", "error: "), | ||
| 419 | ) | ||
| 420 | .for_stderr() | ||
| 421 | .red() | ||
| 422 | .to_string(), | ||
| 423 | ); | ||
| 424 | failed = true; | ||
| 425 | break; | ||
| 426 | } | ||
| 427 | }; | ||
| 428 | } | ||
| 429 | if !failed { | ||
| 430 | pb.set_style(pb_after_style_succeeded.clone()); | ||
| 431 | pb.finish_with_message(""); | ||
| 432 | } | ||
| 433 | })) | ||
| 434 | .await; | ||
| 435 | Ok(()) | ||
| 436 | } | ||
| 437 | |||
| 438 | fn remove_trailing_slash(s: &String) -> String { | ||
| 439 | match s.as_str().strip_suffix('/') { | ||
| 440 | Some(s) => s, | ||
| 441 | None => s, | ||
| 442 | } | ||
| 443 | .to_string() | ||
| 444 | } | ||
| 445 | |||
| 446 | fn choose_commits(git_repo: &Repo, proposed_commits: Vec<Sha1Hash>) -> Result<Vec<Sha1Hash>> { | ||
| 447 | let mut proposed_commits = if proposed_commits.len().gt(&10) { | ||
| 448 | vec![] | ||
| 449 | } else { | ||
| 450 | proposed_commits | ||
| 451 | }; | ||
| 452 | |||
| 453 | let tip_of_head = git_repo.get_tip_of_branch(&git_repo.get_checked_out_branch_name()?)?; | ||
| 454 | let most_recent_commit = proposed_commits.first().unwrap_or(&tip_of_head); | ||
| 455 | |||
| 456 | let mut last_15_commits = vec![*most_recent_commit]; | ||
| 457 | |||
| 458 | while last_15_commits.len().lt(&15) { | ||
| 459 | if let Ok(parent_commit) = git_repo.get_commit_parent(last_15_commits.last().unwrap()) { | ||
| 460 | last_15_commits.push(parent_commit); | ||
| 461 | } else { | ||
| 462 | break; | ||
| 463 | } | ||
| 464 | } | ||
| 465 | |||
| 466 | let term = console::Term::stderr(); | ||
| 467 | let mut printed_error_line = false; | ||
| 468 | |||
| 469 | let selected_commits = 'outer: loop { | ||
| 470 | let selected = Interactor::default().multi_choice( | ||
| 471 | PromptMultiChoiceParms::default() | ||
| 472 | .with_prompt("select commits for proposal") | ||
| 473 | .dont_report() | ||
| 474 | .with_choices( | ||
| 475 | last_15_commits | ||
| 476 | .iter() | ||
| 477 | .map(|h| summarise_commit_for_selection(git_repo, h).unwrap()) | ||
| 478 | .collect(), | ||
| 479 | ) | ||
| 480 | .with_defaults( | ||
| 481 | last_15_commits | ||
| 482 | .iter() | ||
| 483 | .map(|h| proposed_commits.iter().any(|c| c.eq(h))) | ||
| 484 | .collect(), | ||
| 485 | ), | ||
| 486 | )?; | ||
| 487 | proposed_commits = selected.iter().map(|i| last_15_commits[*i]).collect(); | ||
| 488 | |||
| 489 | if printed_error_line { | ||
| 490 | term.clear_last_lines(1)?; | ||
| 491 | } | ||
| 492 | |||
| 493 | if proposed_commits.is_empty() { | ||
| 494 | term.write_line("no commits selected")?; | ||
| 495 | printed_error_line = true; | ||
| 496 | continue; | ||
| 497 | } | ||
| 498 | for (i, selected_i) in selected.iter().enumerate() { | ||
| 499 | if i.gt(&0) && selected_i.ne(&(selected[i - 1] + 1)) { | ||
| 500 | term.write_line("commits must be consecutive. try again.")?; | ||
| 501 | printed_error_line = true; | ||
| 502 | continue 'outer; | ||
| 503 | } | ||
| 504 | } | ||
| 505 | |||
| 506 | break proposed_commits; | ||
| 507 | }; | ||
| 508 | Ok(selected_commits) | ||
| 509 | } | ||
| 510 | |||
| 511 | fn summarise_commit_for_selection(git_repo: &Repo, commit: &Sha1Hash) -> Result<String> { | ||
| 512 | let references = git_repo.get_refs(commit)?; | ||
| 513 | let dim = Style::new().color256(247); | ||
| 514 | let prefix = format!("({})", git_repo.get_commit_author(commit)?[0],); | ||
| 515 | let references_string = if references.is_empty() { | ||
| 516 | String::new() | ||
| 517 | } else { | ||
| 518 | format!( | ||
| 519 | " {}", | ||
| 520 | references | ||
| 521 | .iter() | ||
| 522 | .map(|r| format!("[{r}]")) | ||
| 523 | .collect::<Vec<String>>() | ||
| 524 | .join(" ") | ||
| 525 | ) | ||
| 526 | }; | ||
| 527 | |||
| 528 | Ok(format!( | ||
| 529 | "{} {}{} {}", | ||
| 530 | dim.apply_to(prefix), | ||
| 531 | git_repo.get_commit_message_summary(commit)?, | ||
| 532 | Style::new().magenta().apply_to(references_string), | ||
| 533 | dim.apply_to(commit.to_string().chars().take(7).collect::<String>(),), | ||
| 534 | )) | ||
| 535 | } | ||
| 536 | |||
| 537 | async fn get_root_proposal_id_and_mentions_from_in_reply_to( | ||
| 538 | git_repo_path: &Path, | ||
| 539 | in_reply_to: &[String], | ||
| 540 | ) -> Result<(Option<String>, Vec<nostr::Tag>)> { | ||
| 541 | let root_proposal_id = if let Some(first) = in_reply_to.first() { | ||
| 542 | match event_tag_from_nip19_or_hex(first, "in-reply-to", Marker::Root, true, false)? | ||
| 543 | .as_standardized() | ||
| 544 | { | ||
| 545 | Some(nostr_sdk::TagStandard::Event { | ||
| 546 | event_id, | ||
| 547 | relay_url: _, | ||
| 548 | marker: _, | ||
| 549 | public_key: _, | ||
| 550 | }) => { | ||
| 551 | let events = | ||
| 552 | get_events_from_cache(git_repo_path, vec![nostr::Filter::new().id(*event_id)]) | ||
| 553 | .await?; | ||
| 554 | |||
| 555 | if let Some(first) = events.iter().find(|e| e.id.eq(event_id)) { | ||
| 556 | if event_is_patch_set_root(first) { | ||
| 557 | Some(event_id.to_string()) | ||
| 558 | } else { | ||
| 559 | None | ||
| 560 | } | ||
| 561 | } else { | ||
| 562 | None | ||
| 563 | } | ||
| 564 | } | ||
| 565 | _ => None, | ||
| 566 | } | ||
| 567 | } else { | ||
| 568 | return Ok((None, vec![])); | ||
| 569 | }; | ||
| 570 | |||
| 571 | let mut mention_tags = vec![]; | ||
| 572 | for (i, reply_to) in in_reply_to.iter().enumerate() { | ||
| 573 | if i.ne(&0) || root_proposal_id.is_none() { | ||
| 574 | mention_tags.push( | ||
| 575 | event_tag_from_nip19_or_hex(reply_to, "in-reply-to", Marker::Mention, true, false) | ||
| 576 | .context(format!( | ||
| 577 | "{reply_to} in 'in-reply-to' not a valid nostr reference" | ||
| 578 | ))?, | ||
| 579 | ); | ||
| 580 | } | ||
| 581 | } | ||
| 582 | |||
| 583 | Ok((root_proposal_id, mention_tags)) | ||
| 584 | } | ||
| 585 | |||
| 586 | #[allow(clippy::too_many_lines)] | ||
| 587 | pub async fn generate_cover_letter_and_patch_events( | ||
| 588 | cover_letter_title_description: Option<(String, String)>, | ||
| 589 | git_repo: &Repo, | ||
| 590 | commits: &[Sha1Hash], | ||
| 591 | signer: &NostrSigner, | ||
| 592 | repo_ref: &RepoRef, | ||
| 593 | root_proposal_id: &Option<String>, | ||
| 594 | mentions: &[nostr::Tag], | ||
| 595 | ) -> Result<Vec<nostr::Event>> { | ||
| 596 | let root_commit = git_repo | ||
| 597 | .get_root_commit() | ||
| 598 | .context("failed to get root commit of the repository")?; | ||
| 599 | |||
| 600 | let mut events = vec![]; | ||
| 601 | |||
| 602 | if let Some((title, description)) = cover_letter_title_description { | ||
| 603 | events.push(sign_event(EventBuilder::new( | ||
| 604 | nostr::event::Kind::GitPatch, | ||
| 605 | format!( | ||
| 606 | "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}", | ||
| 607 | commits.last().unwrap(), | ||
| 608 | commits.len() | ||
| 609 | ), | ||
| 610 | [ | ||
| 611 | repo_ref.maintainers.iter().map(|m| Tag::coordinate(Coordinate { | ||
| 612 | kind: nostr::Kind::GitRepoAnnouncement, | ||
| 613 | public_key: *m, | ||
| 614 | identifier: repo_ref.identifier.to_string(), | ||
| 615 | relays: repo_ref.relays.clone(), | ||
| 616 | })).collect::<Vec<Tag>>(), | ||
| 617 | vec![ | ||
| 618 | Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))), | ||
| 619 | Tag::hashtag("cover-letter"), | ||
| 620 | Tag::custom( | ||
| 621 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), | ||
| 622 | vec![format!("git patch cover letter: {}", title.clone())], | ||
| 623 | ), | ||
| 624 | ], | ||
| 625 | if let Some(event_ref) = root_proposal_id.clone() { | ||
| 626 | vec![ | ||
| 627 | Tag::hashtag("root"), | ||
| 628 | Tag::hashtag("revision-root"), | ||
| 629 | // TODO check if id is for a root proposal (perhaps its for an issue?) | ||
| 630 | event_tag_from_nip19_or_hex(&event_ref,"proposal",Marker::Reply, false, false)?, | ||
| 631 | ] | ||
| 632 | } else { | ||
| 633 | vec![ | ||
| 634 | Tag::hashtag("root"), | ||
| 635 | ] | ||
| 636 | }, | ||
| 637 | mentions.to_vec(), | ||
| 638 | // this is not strictly needed but makes for prettier branch names | ||
| 639 | // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding | ||
| 640 | // a change like this, or the removal of this tag will require the actual branch name to be tracked | ||
| 641 | // so pulling and pushing still work | ||
| 642 | if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { | ||
| 643 | if !branch_name.eq("main") | ||
| 644 | && !branch_name.eq("master") | ||
| 645 | && !branch_name.eq("origin/main") | ||
| 646 | && !branch_name.eq("origin/master") | ||
| 647 | { | ||
| 648 | vec![ | ||
| 649 | Tag::custom( | ||
| 650 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), | ||
| 651 | vec![if let Some(branch_name) = branch_name.strip_prefix("pr/") { | ||
| 652 | branch_name.to_string() | ||
| 653 | } else { | ||
| 654 | branch_name | ||
| 655 | }], | ||
| 656 | ), | ||
| 657 | ] | ||
| 658 | } | ||
| 659 | else { vec![] } | ||
| 660 | } else { | ||
| 661 | vec![] | ||
| 662 | }, | ||
| 663 | repo_ref.maintainers | ||
| 664 | .iter() | ||
| 665 | .map(|pk| Tag::public_key(*pk)) | ||
| 666 | .collect(), | ||
| 667 | ].concat(), | ||
| 668 | ), signer).await | ||
| 669 | .context("failed to create cover-letter event")?); | ||
| 670 | } | ||
| 671 | |||
| 672 | for (i, commit) in commits.iter().enumerate() { | ||
| 673 | events.push( | ||
| 674 | generate_patch_event( | ||
| 675 | git_repo, | ||
| 676 | &root_commit, | ||
| 677 | commit, | ||
| 678 | events.first().map(|event| event.id), | ||
| 679 | signer, | ||
| 680 | repo_ref, | ||
| 681 | events.last().map(nostr::Event::id), | ||
| 682 | if events.is_empty() && commits.len().eq(&1) { | ||
| 683 | None | ||
| 684 | } else { | ||
| 685 | Some(((i + 1).try_into()?, commits.len().try_into()?)) | ||
| 686 | }, | ||
| 687 | if events.is_empty() { | ||
| 688 | if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { | ||
| 689 | if !branch_name.eq("main") | ||
| 690 | && !branch_name.eq("master") | ||
| 691 | && !branch_name.eq("origin/main") | ||
| 692 | && !branch_name.eq("origin/master") | ||
| 693 | { | ||
| 694 | Some(if let Some(branch_name) = branch_name.strip_prefix("pr/") { | ||
| 695 | branch_name.to_string() | ||
| 696 | } else { | ||
| 697 | branch_name | ||
| 698 | }) | ||
| 699 | } else { | ||
| 700 | None | ||
| 701 | } | ||
| 702 | } else { | ||
| 703 | None | ||
| 704 | } | ||
| 705 | } else { | ||
| 706 | None | ||
| 707 | }, | ||
| 708 | root_proposal_id, | ||
| 709 | if events.is_empty() { mentions } else { &[] }, | ||
| 710 | ) | ||
| 711 | .await | ||
| 712 | .context("failed to generate patch event")?, | ||
| 713 | ); | ||
| 714 | } | ||
| 715 | Ok(events) | ||
| 716 | } | ||
| 717 | |||
| 718 | fn event_tag_from_nip19_or_hex( | ||
| 719 | reference: &str, | ||
| 720 | reference_name: &str, | ||
| 721 | marker: Marker, | ||
| 722 | allow_npub_reference: bool, | ||
| 723 | prompt_for_correction: bool, | ||
| 724 | ) -> Result<nostr::Tag> { | ||
| 725 | let mut bech32 = reference.to_string(); | ||
| 726 | loop { | ||
| 727 | if bech32.is_empty() { | ||
| 728 | bech32 = Interactor::default().input( | ||
| 729 | PromptInputParms::default().with_prompt(&format!("{reference_name} reference")), | ||
| 730 | )?; | ||
| 731 | } | ||
| 732 | if let Ok(nip19) = Nip19::from_bech32(bech32.clone()) { | ||
| 733 | match nip19 { | ||
| 734 | Nip19::Event(n) => { | ||
| 735 | break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { | ||
| 736 | event_id: n.event_id, | ||
| 737 | relay_url: n.relays.first().map(UncheckedUrl::new), | ||
| 738 | marker: Some(marker), | ||
| 739 | public_key: None, | ||
| 740 | })); | ||
| 741 | } | ||
| 742 | Nip19::EventId(id) => { | ||
| 743 | break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { | ||
| 744 | event_id: id, | ||
| 745 | relay_url: None, | ||
| 746 | marker: Some(marker), | ||
| 747 | public_key: None, | ||
| 748 | })); | ||
| 749 | } | ||
| 750 | Nip19::Coordinate(coordinate) => { | ||
| 751 | break Ok(Tag::coordinate(coordinate)); | ||
| 752 | } | ||
| 753 | Nip19::Profile(profile) => { | ||
| 754 | if allow_npub_reference { | ||
| 755 | break Ok(Tag::public_key(profile.public_key)); | ||
| 756 | } | ||
| 757 | } | ||
| 758 | Nip19::Pubkey(public_key) => { | ||
| 759 | if allow_npub_reference { | ||
| 760 | break Ok(Tag::public_key(public_key)); | ||
| 761 | } | ||
| 762 | } | ||
| 763 | _ => {} | ||
| 764 | } | ||
| 765 | } | ||
| 766 | if let Ok(id) = nostr::EventId::from_str(&bech32) { | ||
| 767 | break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { | ||
| 768 | event_id: id, | ||
| 769 | relay_url: None, | ||
| 770 | marker: Some(marker), | ||
| 771 | public_key: None, | ||
| 772 | })); | ||
| 773 | } | ||
| 774 | if prompt_for_correction { | ||
| 775 | println!("not a valid {reference_name} event reference"); | ||
| 776 | } else { | ||
| 777 | bail!(format!("not a valid {reference_name} event reference")); | ||
| 778 | } | ||
| 779 | |||
| 780 | bech32 = String::new(); | ||
| 781 | } | ||
| 782 | } | ||
| 783 | |||
| 784 | pub struct CoverLetter { | ||
| 785 | pub title: String, | ||
| 786 | pub description: String, | ||
| 787 | pub branch_name: String, | ||
| 788 | pub event_id: Option<nostr::EventId>, | ||
| 789 | } | ||
| 790 | |||
| 791 | impl CoverLetter { | ||
| 792 | pub fn get_branch_name(&self) -> Result<String> { | ||
| 793 | Ok(format!( | ||
| 794 | "pr/{}({})", | ||
| 795 | self.branch_name, | ||
| 796 | &self | ||
| 797 | .event_id | ||
| 798 | .context("proposal root event_id must be know to get it's branch name")? | ||
| 799 | .to_hex() | ||
| 800 | .as_str()[..8], | ||
| 801 | )) | ||
| 802 | } | ||
| 803 | } | ||
| 804 | pub fn event_is_cover_letter(event: &nostr::Event) -> bool { | ||
| 805 | // TODO: look for Subject:[ PATCH 0/n ] but watch out for: | ||
| 806 | // [PATCH v1 0/n ] or | ||
| 807 | // [PATCH subsystem v2 0/n ] | ||
| 808 | event.kind.eq(&Kind::GitPatch) | ||
| 809 | && event.tags().iter().any(|t| t.as_vec()[1].eq("root")) | ||
| 810 | && event | ||
| 811 | .tags() | ||
| 812 | .iter() | ||
| 813 | .any(|t| t.as_vec()[1].eq("cover-letter")) | ||
| 814 | } | ||
| 815 | |||
| 816 | pub fn commit_msg_from_patch(patch: &nostr::Event) -> Result<String> { | ||
| 817 | if let Ok(msg) = tag_value(patch, "description") { | ||
| 818 | Ok(msg) | ||
| 819 | } else { | ||
| 820 | let start_index = patch | ||
| 821 | .content | ||
| 822 | .find("] ") | ||
| 823 | .context("event is not formatted as a patch or cover letter")? | ||
| 824 | + 2; | ||
| 825 | let end_index = patch.content[start_index..] | ||
| 826 | .find("\ndiff --git") | ||
| 827 | .unwrap_or(patch.content.len()); | ||
| 828 | Ok(patch.content[start_index..end_index].to_string()) | ||
| 829 | } | ||
| 830 | } | ||
| 831 | |||
| 832 | pub fn commit_msg_from_patch_oneliner(patch: &nostr::Event) -> Result<String> { | ||
| 833 | Ok(commit_msg_from_patch(patch)? | ||
| 834 | .split('\n') | ||
| 835 | .collect::<Vec<&str>>()[0] | ||
| 836 | .to_string()) | ||
| 837 | } | ||
| 838 | |||
| 839 | pub fn event_to_cover_letter(event: &nostr::Event) -> Result<CoverLetter> { | ||
| 840 | if !event_is_patch_set_root(event) { | ||
| 841 | bail!("event is not a patch set root event (root patch or cover letter)") | ||
| 842 | } | ||
| 843 | |||
| 844 | let title = commit_msg_from_patch_oneliner(event)?; | ||
| 845 | let full = commit_msg_from_patch(event)?; | ||
| 846 | let description = full[title.len()..].trim().to_string(); | ||
| 847 | |||
| 848 | Ok(CoverLetter { | ||
| 849 | title: title.clone(), | ||
| 850 | description, | ||
| 851 | // TODO should this be prefixed by format!("{}-"e.id.to_string()[..5]?) | ||
| 852 | branch_name: if let Ok(name) = match tag_value(event, "branch-name") { | ||
| 853 | Ok(name) => { | ||
| 854 | if !name.eq("main") && !name.eq("master") { | ||
| 855 | Ok(name) | ||
| 856 | } else { | ||
| 857 | Err(()) | ||
| 858 | } | ||
| 859 | } | ||
| 860 | _ => Err(()), | ||
| 861 | } { | ||
| 862 | name | ||
| 863 | } else { | ||
| 864 | let s = title | ||
| 865 | .replace(' ', "-") | ||
| 866 | .chars() | ||
| 867 | .map(|c| { | ||
| 868 | if c.is_ascii_alphanumeric() || c.eq(&'/') { | ||
| 869 | c | ||
| 870 | } else { | ||
| 871 | '-' | ||
| 872 | } | ||
| 873 | }) | ||
| 874 | .collect(); | ||
| 875 | s | ||
| 876 | }, | ||
| 877 | event_id: Some(event.id()), | ||
| 878 | }) | ||
| 879 | } | ||
| 880 | |||
| 881 | pub fn event_is_patch_set_root(event: &nostr::Event) -> bool { | ||
| 882 | event.kind.eq(&Kind::GitPatch) && event.tags().iter().any(|t| t.as_vec()[1].eq("root")) | ||
| 883 | } | ||
| 884 | |||
| 885 | pub fn event_is_revision_root(event: &nostr::Event) -> bool { | ||
| 886 | event.kind.eq(&Kind::GitPatch) | ||
| 887 | && event | ||
| 888 | .tags() | ||
| 889 | .iter() | ||
| 890 | .any(|t| t.as_vec()[1].eq("revision-root")) | ||
| 891 | } | ||
| 892 | |||
| 893 | pub fn patch_supports_commit_ids(event: &nostr::Event) -> bool { | ||
| 894 | event.kind.eq(&Kind::GitPatch) | ||
| 895 | && event | ||
| 896 | .tags() | ||
| 897 | .iter() | ||
| 898 | .any(|t| t.as_vec()[0].eq("commit-pgp-sig")) | ||
| 899 | } | ||
| 900 | |||
| 901 | #[allow(clippy::too_many_arguments)] | ||
| 902 | #[allow(clippy::too_many_lines)] | ||
| 903 | pub async fn generate_patch_event( | ||
| 904 | git_repo: &Repo, | ||
| 905 | root_commit: &Sha1Hash, | ||
| 906 | commit: &Sha1Hash, | ||
| 907 | thread_event_id: Option<nostr::EventId>, | ||
| 908 | signer: &nostr_sdk::NostrSigner, | ||
| 909 | repo_ref: &RepoRef, | ||
| 910 | parent_patch_event_id: Option<nostr::EventId>, | ||
| 911 | series_count: Option<(u64, u64)>, | ||
| 912 | branch_name: Option<String>, | ||
| 913 | root_proposal_id: &Option<String>, | ||
| 914 | mentions: &[nostr::Tag], | ||
| 915 | ) -> Result<nostr::Event> { | ||
| 916 | let commit_parent = git_repo | ||
| 917 | .get_commit_parent(commit) | ||
| 918 | .context("failed to get parent commit")?; | ||
| 919 | let relay_hint = repo_ref.relays.first().map(nostr::UncheckedUrl::from); | ||
| 920 | |||
| 921 | sign_event( | ||
| 922 | EventBuilder::new( | ||
| 923 | nostr::event::Kind::GitPatch, | ||
| 924 | git_repo | ||
| 925 | .make_patch_from_commit(commit, &series_count) | ||
| 926 | .context(format!("cannot make patch for commit {commit}"))?, | ||
| 927 | [ | ||
| 928 | repo_ref | ||
| 929 | .maintainers | ||
| 930 | .iter() | ||
| 931 | .map(|m| { | ||
| 932 | Tag::coordinate(Coordinate { | ||
| 933 | kind: nostr::Kind::GitRepoAnnouncement, | ||
| 934 | public_key: *m, | ||
| 935 | identifier: repo_ref.identifier.to_string(), | ||
| 936 | relays: repo_ref.relays.clone(), | ||
| 937 | }) | ||
| 938 | }) | ||
| 939 | .collect::<Vec<Tag>>(), | ||
| 940 | vec![ | ||
| 941 | Tag::from_standardized(TagStandard::Reference(root_commit.to_string())), | ||
| 942 | // commit id reference is a trade-off. its now | ||
| 943 | // unclear which one is the root commit id but it | ||
| 944 | // enables easier location of code comments againt | ||
| 945 | // code that makes it into the main branch, assuming | ||
| 946 | // the commit id is correct | ||
| 947 | Tag::from_standardized(TagStandard::Reference(commit.to_string())), | ||
| 948 | Tag::custom( | ||
| 949 | TagKind::Custom(std::borrow::Cow::Borrowed("alt")), | ||
| 950 | vec![format!( | ||
| 951 | "git patch: {}", | ||
| 952 | git_repo | ||
| 953 | .get_commit_message_summary(commit) | ||
| 954 | .unwrap_or_default() | ||
| 955 | )], | ||
| 956 | ), | ||
| 957 | ], | ||
| 958 | if let Some(thread_event_id) = thread_event_id { | ||
| 959 | vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { | ||
| 960 | event_id: thread_event_id, | ||
| 961 | relay_url: relay_hint.clone(), | ||
| 962 | marker: Some(Marker::Root), | ||
| 963 | public_key: None, | ||
| 964 | })] | ||
| 965 | } else if let Some(event_ref) = root_proposal_id.clone() { | ||
| 966 | vec![ | ||
| 967 | Tag::hashtag("root"), | ||
| 968 | Tag::hashtag("revision-root"), | ||
| 969 | // TODO check if id is for a root proposal (perhaps its for an issue?) | ||
| 970 | event_tag_from_nip19_or_hex( | ||
| 971 | &event_ref, | ||
| 972 | "proposal", | ||
| 973 | Marker::Reply, | ||
| 974 | false, | ||
| 975 | false, | ||
| 976 | )?, | ||
| 977 | ] | ||
| 978 | } else { | ||
| 979 | vec![Tag::hashtag("root")] | ||
| 980 | }, | ||
| 981 | mentions.to_vec(), | ||
| 982 | if let Some(id) = parent_patch_event_id { | ||
| 983 | vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { | ||
| 984 | event_id: id, | ||
| 985 | relay_url: relay_hint.clone(), | ||
| 986 | marker: Some(Marker::Reply), | ||
| 987 | public_key: None, | ||
| 988 | })] | ||
| 989 | } else { | ||
| 990 | vec![] | ||
| 991 | }, | ||
| 992 | // see comment on branch names in cover letter event creation | ||
| 993 | if let Some(branch_name) = branch_name { | ||
| 994 | if thread_event_id.is_none() { | ||
| 995 | vec![Tag::custom( | ||
| 996 | TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), | ||
| 997 | vec![branch_name.to_string()], | ||
| 998 | )] | ||
| 999 | } else { | ||
| 1000 | vec![] | ||
| 1001 | } | ||
| 1002 | } else { | ||
| 1003 | vec![] | ||
| 1004 | }, | ||
| 1005 | // whilst it is in nip34 draft to tag the maintainers | ||
| 1006 | // I'm not sure it is a good idea because if they are | ||
| 1007 | // interested in all patches then their specialised | ||
| 1008 | // client should subscribe to patches tagged with the | ||
| 1009 | // repo reference. maintainers of large repos will not | ||
| 1010 | // be interested in every patch. | ||
| 1011 | repo_ref | ||
| 1012 | .maintainers | ||
| 1013 | .iter() | ||
| 1014 | .map(|pk| Tag::public_key(*pk)) | ||
| 1015 | .collect(), | ||
| 1016 | vec![ | ||
| 1017 | // a fallback is now in place to extract this from the patch | ||
| 1018 | Tag::custom( | ||
| 1019 | TagKind::Custom(std::borrow::Cow::Borrowed("commit")), | ||
| 1020 | vec![commit.to_string()], | ||
| 1021 | ), | ||
| 1022 | // this is required as patches cannot be relied upon to include the 'base | ||
| 1023 | // commit' | ||
| 1024 | Tag::custom( | ||
| 1025 | TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")), | ||
| 1026 | vec![commit_parent.to_string()], | ||
| 1027 | ), | ||
| 1028 | // this is required to ensure the commit id matches | ||
| 1029 | Tag::custom( | ||
| 1030 | TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")), | ||
| 1031 | vec![ | ||
| 1032 | git_repo | ||
| 1033 | .extract_commit_pgp_signature(commit) | ||
| 1034 | .unwrap_or_default(), | ||
| 1035 | ], | ||
| 1036 | ), | ||
| 1037 | // removing description tag will not cause anything to break | ||
| 1038 | Tag::from_standardized(nostr_sdk::TagStandard::Description( | ||
| 1039 | git_repo.get_commit_message(commit)?.to_string(), | ||
| 1040 | )), | ||
| 1041 | Tag::custom( | ||
| 1042 | TagKind::Custom(std::borrow::Cow::Borrowed("author")), | ||
| 1043 | git_repo.get_commit_author(commit)?, | ||
| 1044 | ), | ||
| 1045 | // this is required to ensure the commit id matches | ||
| 1046 | Tag::custom( | ||
| 1047 | TagKind::Custom(std::borrow::Cow::Borrowed("committer")), | ||
| 1048 | git_repo.get_commit_comitter(commit)?, | ||
| 1049 | ), | ||
| 1050 | ], | ||
| 1051 | ] | ||
| 1052 | .concat(), | ||
| 1053 | ), | ||
| 1054 | signer, | ||
| 1055 | ) | ||
| 1056 | .await | ||
| 1057 | .context("failed to sign event") | ||
| 1058 | } | ||
| 1059 | // TODO | ||
| 1060 | // - find profile | ||
| 1061 | // - file relays | ||
| 1062 | // - find repo events | ||
| 1063 | // - | ||
| 1064 | |||
| 1065 | /** | ||
| 1066 | * returns `(from_branch,to_branch,ahead,behind)` | ||
| 1067 | */ | ||
| 1068 | pub fn identify_ahead_behind( | ||
| 1069 | git_repo: &Repo, | ||
| 1070 | from_branch: &Option<String>, | ||
| 1071 | to_branch: &Option<String>, | ||
| 1072 | ) -> Result<(String, String, Vec<Sha1Hash>, Vec<Sha1Hash>)> { | ||
| 1073 | let (from_branch, from_tip) = match from_branch { | ||
| 1074 | Some(name) => ( | ||
| 1075 | name.to_string(), | ||
| 1076 | git_repo | ||
| 1077 | .get_tip_of_branch(name) | ||
| 1078 | .context(format!("cannot find from_branch '{name}'"))?, | ||
| 1079 | ), | ||
| 1080 | None => ( | ||
| 1081 | if let Ok(name) = git_repo.get_checked_out_branch_name() { | ||
| 1082 | name | ||
| 1083 | } else { | ||
| 1084 | "head".to_string() | ||
| 1085 | }, | ||
| 1086 | git_repo | ||
| 1087 | .get_head_commit() | ||
| 1088 | .context("failed to get head commit") | ||
| 1089 | .context( | ||
| 1090 | "checkout a commit or specify a from_branch. head does not reveal a commit", | ||
| 1091 | )?, | ||
| 1092 | ), | ||
| 1093 | }; | ||
| 1094 | |||
| 1095 | let (to_branch, to_tip) = match to_branch { | ||
| 1096 | Some(name) => ( | ||
| 1097 | name.to_string(), | ||
| 1098 | git_repo | ||
| 1099 | .get_tip_of_branch(name) | ||
| 1100 | .context(format!("cannot find to_branch '{name}'"))?, | ||
| 1101 | ), | ||
| 1102 | None => { | ||
| 1103 | let (name, commit) = git_repo | ||
| 1104 | .get_main_or_master_branch() | ||
| 1105 | .context("the default branches (main or master) do not exist")?; | ||
| 1106 | (name.to_string(), commit) | ||
| 1107 | } | ||
| 1108 | }; | ||
| 1109 | |||
| 1110 | match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) { | ||
| 1111 | Err(e) => { | ||
| 1112 | if e.to_string().contains("is not an ancestor of") { | ||
| 1113 | return Err(e).context(format!( | ||
| 1114 | "'{from_branch}' is not branched from '{to_branch}'" | ||
| 1115 | )); | ||
| 1116 | } | ||
| 1117 | Err(e).context(format!( | ||
| 1118 | "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'" | ||
| 1119 | )) | ||
| 1120 | } | ||
| 1121 | Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)), | ||
| 1122 | } | ||
| 1123 | } | ||
| 1124 | |||
| 1125 | #[cfg(test)] | ||
| 1126 | mod tests { | ||
| 1127 | use test_utils::git::GitTestRepo; | ||
| 1128 | |||
| 1129 | use super::*; | ||
| 1130 | mod identify_ahead_behind { | ||
| 1131 | |||
| 1132 | use super::*; | ||
| 1133 | use crate::git::oid_to_sha1; | ||
| 1134 | |||
| 1135 | #[test] | ||
| 1136 | fn when_from_branch_doesnt_exist_return_error() -> Result<()> { | ||
| 1137 | let test_repo = GitTestRepo::default(); | ||
| 1138 | let git_repo = Repo::from_path(&test_repo.dir)?; | ||
| 1139 | |||
| 1140 | test_repo.populate()?; | ||
| 1141 | let branch_name = "doesnt_exist"; | ||
| 1142 | assert_eq!( | ||
| 1143 | identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None) | ||
| 1144 | .unwrap_err() | ||
| 1145 | .to_string(), | ||
| 1146 | format!("cannot find from_branch '{}'", &branch_name), | ||
| 1147 | ); | ||
| 1148 | Ok(()) | ||
| 1149 | } | ||
| 1150 | |||
| 1151 | #[test] | ||
| 1152 | fn when_to_branch_doesnt_exist_return_error() -> Result<()> { | ||
| 1153 | let test_repo = GitTestRepo::default(); | ||
| 1154 | let git_repo = Repo::from_path(&test_repo.dir)?; | ||
| 1155 | |||
| 1156 | test_repo.populate()?; | ||
| 1157 | let branch_name = "doesnt_exist"; | ||
| 1158 | assert_eq!( | ||
| 1159 | identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string())) | ||
| 1160 | .unwrap_err() | ||
| 1161 | .to_string(), | ||
| 1162 | format!("cannot find to_branch '{}'", &branch_name), | ||
| 1163 | ); | ||
| 1164 | Ok(()) | ||
| 1165 | } | ||
| 1166 | |||
| 1167 | #[test] | ||
| 1168 | fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> { | ||
| 1169 | let test_repo = GitTestRepo::new("notmain")?; | ||
| 1170 | let git_repo = Repo::from_path(&test_repo.dir)?; | ||
| 1171 | |||
| 1172 | test_repo.populate()?; | ||
| 1173 | |||
| 1174 | assert_eq!( | ||
| 1175 | identify_ahead_behind(&git_repo, &None, &None) | ||
| 1176 | .unwrap_err() | ||
| 1177 | .to_string(), | ||
| 1178 | "the default branches (main or master) do not exist", | ||
| 1179 | ); | ||
| 1180 | Ok(()) | ||
| 1181 | } | ||
| 1182 | |||
| 1183 | #[test] | ||
| 1184 | fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> { | ||
| 1185 | let test_repo = GitTestRepo::default(); | ||
| 1186 | let git_repo = Repo::from_path(&test_repo.dir)?; | ||
| 1187 | |||
| 1188 | test_repo.populate()?; | ||
| 1189 | // create feature branch with 1 commit ahead | ||
| 1190 | test_repo.create_branch("feature")?; | ||
| 1191 | test_repo.checkout("feature")?; | ||
| 1192 | std::fs::write(test_repo.dir.join("t3.md"), "some content")?; | ||
| 1193 | let head_oid = test_repo.stage_and_commit("add t3.md")?; | ||
| 1194 | |||
| 1195 | // make feature branch 1 commit behind | ||
| 1196 | test_repo.checkout("main")?; | ||
| 1197 | std::fs::write(test_repo.dir.join("t4.md"), "some content")?; | ||
| 1198 | let main_oid = test_repo.stage_and_commit("add t4.md")?; | ||
| 1199 | |||
| 1200 | let (from_branch, to_branch, ahead, behind) = | ||
| 1201 | identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?; | ||
| 1202 | |||
| 1203 | assert_eq!(from_branch, "feature"); | ||
| 1204 | assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]); | ||
| 1205 | assert_eq!(to_branch, "main"); | ||
| 1206 | assert_eq!(behind, vec![oid_to_sha1(&main_oid)]); | ||
| 1207 | Ok(()) | ||
| 1208 | } | ||
| 1209 | |||
| 1210 | #[test] | ||
| 1211 | fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> { | ||
| 1212 | let test_repo = GitTestRepo::default(); | ||
| 1213 | let git_repo = Repo::from_path(&test_repo.dir)?; | ||
| 1214 | |||
| 1215 | test_repo.populate()?; | ||
| 1216 | // create dev branch with 1 commit ahead | ||
| 1217 | test_repo.create_branch("dev")?; | ||
| 1218 | test_repo.checkout("dev")?; | ||
| 1219 | std::fs::write(test_repo.dir.join("t3.md"), "some content")?; | ||
| 1220 | let dev_oid_first = test_repo.stage_and_commit("add t3.md")?; | ||
| 1221 | |||
| 1222 | // create feature branch with 1 commit ahead of dev | ||
| 1223 | test_repo.create_branch("feature")?; | ||
| 1224 | test_repo.checkout("feature")?; | ||
| 1225 | std::fs::write(test_repo.dir.join("t4.md"), "some content")?; | ||
| 1226 | let feature_oid = test_repo.stage_and_commit("add t4.md")?; | ||
| 1227 | |||
| 1228 | // make feature branch 1 behind | ||
| 1229 | test_repo.checkout("dev")?; | ||
| 1230 | std::fs::write(test_repo.dir.join("t3.md"), "some content")?; | ||
| 1231 | let dev_oid = test_repo.stage_and_commit("add t3.md")?; | ||
| 1232 | |||
| 1233 | let (from_branch, to_branch, ahead, behind) = identify_ahead_behind( | ||
| 1234 | &git_repo, | ||
| 1235 | &Some("feature".to_string()), | ||
| 1236 | &Some("dev".to_string()), | ||
| 1237 | )?; | ||
| 1238 | |||
| 1239 | assert_eq!(from_branch, "feature"); | ||
| 1240 | assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]); | ||
| 1241 | assert_eq!(to_branch, "dev"); | ||
| 1242 | assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]); | ||
| 1243 | |||
| 1244 | let (from_branch, to_branch, ahead, behind) = | ||
| 1245 | identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?; | ||
| 1246 | |||
| 1247 | assert_eq!(from_branch, "feature"); | ||
| 1248 | assert_eq!( | ||
| 1249 | ahead, | ||
| 1250 | vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)] | ||
| 1251 | ); | ||
| 1252 | assert_eq!(to_branch, "main"); | ||
| 1253 | assert_eq!(behind, vec![]); | ||
| 1254 | |||
| 1255 | Ok(()) | ||
| 1256 | } | ||
| 1257 | } | ||
| 1258 | |||
| 1259 | mod event_to_cover_letter { | ||
| 1260 | use super::*; | ||
| 1261 | |||
| 1262 | fn generate_cover_letter(title: &str, description: &str) -> Result<nostr::Event> { | ||
| 1263 | Ok(nostr::event::EventBuilder::new( | ||
| 1264 | nostr::event::Kind::GitPatch, | ||
| 1265 | format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"), | ||
| 1266 | [ | ||
| 1267 | Tag::hashtag("cover-letter"), | ||
| 1268 | Tag::hashtag("root"), | ||
| 1269 | ], | ||
| 1270 | ) | ||
| 1271 | .to_event(&nostr::Keys::generate())?) | ||
| 1272 | } | ||
| 1273 | |||
| 1274 | #[test] | ||
| 1275 | fn basic_title() -> Result<()> { | ||
| 1276 | assert_eq!( | ||
| 1277 | event_to_cover_letter(&generate_cover_letter("the title", "description here")?)? | ||
| 1278 | .title, | ||
| 1279 | "the title", | ||
| 1280 | ); | ||
| 1281 | Ok(()) | ||
| 1282 | } | ||
| 1283 | |||
| 1284 | #[test] | ||
| 1285 | fn basic_description() -> Result<()> { | ||
| 1286 | assert_eq!( | ||
| 1287 | event_to_cover_letter(&generate_cover_letter("the title", "description here")?)? | ||
| 1288 | .description, | ||
| 1289 | "description here", | ||
| 1290 | ); | ||
| 1291 | Ok(()) | ||
| 1292 | } | ||
| 1293 | |||
| 1294 | #[test] | ||
| 1295 | fn description_trimmed() -> Result<()> { | ||
| 1296 | assert_eq!( | ||
| 1297 | event_to_cover_letter(&generate_cover_letter( | ||
| 1298 | "the title", | ||
| 1299 | " \n \ndescription here\n\n " | ||
| 1300 | )?)? | ||
| 1301 | .description, | ||
| 1302 | "description here", | ||
| 1303 | ); | ||
| 1304 | Ok(()) | ||
| 1305 | } | ||
| 1306 | |||
| 1307 | #[test] | ||
| 1308 | fn multi_line_description() -> Result<()> { | ||
| 1309 | assert_eq!( | ||
| 1310 | event_to_cover_letter(&generate_cover_letter( | ||
| 1311 | "the title", | ||
| 1312 | "description here\n\nmore here\nmore" | ||
| 1313 | )?)? | ||
| 1314 | .description, | ||
| 1315 | "description here\n\nmore here\nmore", | ||
| 1316 | ); | ||
| 1317 | Ok(()) | ||
| 1318 | } | ||
| 1319 | |||
| 1320 | #[test] | ||
| 1321 | fn new_lines_in_title_forms_part_of_description() -> Result<()> { | ||
| 1322 | assert_eq!( | ||
| 1323 | event_to_cover_letter(&generate_cover_letter( | ||
| 1324 | "the title\nwith new line", | ||
| 1325 | "description here\n\nmore here\nmore" | ||
| 1326 | )?)? | ||
| 1327 | .title, | ||
| 1328 | "the title", | ||
| 1329 | ); | ||
| 1330 | assert_eq!( | ||
| 1331 | event_to_cover_letter(&generate_cover_letter( | ||
| 1332 | "the title\nwith new line", | ||
| 1333 | "description here\n\nmore here\nmore" | ||
| 1334 | )?)? | ||
| 1335 | .description, | ||
| 1336 | "with new line\n\ndescription here\n\nmore here\nmore", | ||
| 1337 | ); | ||
| 1338 | Ok(()) | ||
| 1339 | } | ||
| 1340 | |||
| 1341 | mod blank_description { | ||
| 1342 | use super::*; | ||
| 1343 | |||
| 1344 | #[test] | ||
| 1345 | fn title_correct() -> Result<()> { | ||
| 1346 | assert_eq!( | ||
| 1347 | event_to_cover_letter(&generate_cover_letter("the title", "")?)?.title, | ||
| 1348 | "the title", | ||
| 1349 | ); | ||
| 1350 | Ok(()) | ||
| 1351 | } | ||
| 1352 | |||
| 1353 | #[test] | ||
| 1354 | fn description_is_empty_string() -> Result<()> { | ||
| 1355 | assert_eq!( | ||
| 1356 | event_to_cover_letter(&generate_cover_letter("the title", "")?)?.description, | ||
| 1357 | "", | ||
| 1358 | ); | ||
| 1359 | Ok(()) | ||
| 1360 | } | ||
| 1361 | } | ||
| 1362 | } | ||
| 1363 | } | ||