diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2024-08-05 14:15:29 +0100 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2024-08-05 14:15:29 +0100 |
| commit | f238fc8c0a122487f4fb71bb78a2e365e147d747 (patch) | |
| tree | 5e8760c192f62fef67189bc82dcae4eef3ce883a /src/git_remote_helper.rs | |
| parent | e5750b5b3dfe2c0072902c2523fdf32986aa74b8 (diff) | |
feat(remote): `push` handle out-of-sync servers
1. don't attempt to push to a remote which is already up-to-date
2. don't attempt to delete branch on remote if it is already deleted
3. only push when out of sync if remote tip is ancestor of pushed
commit
4. force push to remote if user force pushed and remote is in sync
with nostr
Diffstat (limited to 'src/git_remote_helper.rs')
| -rw-r--r-- | src/git_remote_helper.rs | 339 |
1 files changed, 250 insertions, 89 deletions
diff --git a/src/git_remote_helper.rs b/src/git_remote_helper.rs index 13d6c03..6f645da 100644 --- a/src/git_remote_helper.rs +++ b/src/git_remote_helper.rs | |||
| @@ -78,6 +78,7 @@ async fn main() -> Result<()> { | |||
| 78 | let stdin = io::stdin(); | 78 | let stdin = io::stdin(); |
| 79 | let mut line = String::new(); | 79 | let mut line = String::new(); |
| 80 | 80 | ||
| 81 | let mut list_outputs = None; | ||
| 81 | loop { | 82 | loop { |
| 82 | let tokens = read_line(&stdin, &mut line)?; | 83 | let tokens = read_line(&stdin, &mut line)?; |
| 83 | 84 | ||
| @@ -105,14 +106,15 @@ async fn main() -> Result<()> { | |||
| 105 | &stdin, | 106 | &stdin, |
| 106 | refspec, | 107 | refspec, |
| 107 | &client, | 108 | &client, |
| 109 | list_outputs.clone(), | ||
| 108 | ) | 110 | ) |
| 109 | .await?; | 111 | .await?; |
| 110 | } | 112 | } |
| 111 | ["list"] => { | 113 | ["list"] => { |
| 112 | list(&git_repo, &repo_ref, false).await?; | 114 | list_outputs = Some(list(&git_repo, &repo_ref, false).await?); |
| 113 | } | 115 | } |
| 114 | ["list", "for-push"] => { | 116 | ["list", "for-push"] => { |
| 115 | list(&git_repo, &repo_ref, true).await?; | 117 | list_outputs = Some(list(&git_repo, &repo_ref, true).await?); |
| 116 | } | 118 | } |
| 117 | [] => { | 119 | [] => { |
| 118 | return Ok(()); | 120 | return Ok(()); |
| @@ -351,6 +353,7 @@ fn fetch_from_git_server( | |||
| 351 | Ok(()) | 353 | Ok(()) |
| 352 | } | 354 | } |
| 353 | 355 | ||
| 356 | #[allow(clippy::too_many_lines)] | ||
| 354 | async fn push( | 357 | async fn push( |
| 355 | git_repo: &Repo, | 358 | git_repo: &Repo, |
| 356 | repo_ref: &RepoRef, | 359 | repo_ref: &RepoRef, |
| @@ -359,56 +362,63 @@ async fn push( | |||
| 359 | initial_refspec: &str, | 362 | initial_refspec: &str, |
| 360 | #[cfg(test)] client: &crate::client::MockConnect, | 363 | #[cfg(test)] client: &crate::client::MockConnect, |
| 361 | #[cfg(not(test))] client: &Client, | 364 | #[cfg(not(test))] client: &Client, |
| 365 | list_outputs: Option<HashMap<String, HashMap<String, String>>>, | ||
| 362 | ) -> Result<()> { | 366 | ) -> Result<()> { |
| 363 | // TODO check | 367 | let mut refspecs = get_refspecs_from_push_batch(stdin, initial_refspec)?; |
| 364 | // bail!( | 368 | |
| 365 | // "git server {} tip for branch {} conflicts with nostr and local branch. | 369 | let term = console::Term::stderr(); |
| 366 | // to resolve either:\r\n 1. pull from that git server and resolve\r\n 2. | 370 | |
| 367 | // force push your branch to the git server before pushing to nostr remote" | 371 | let list_outputs = match list_outputs { |
| 368 | // )?; | 372 | Some(outputs) => outputs, |
| 369 | 373 | _ => list_from_remotes(&term, git_repo, &repo_ref.git_server)?, | |
| 370 | // if no state events - create from first git server listed | 374 | }; |
| 371 | let refspecs = get_refspecs_from_push_batch(stdin, initial_refspec)?; | 375 | |
| 372 | let git_server_url = repo_ref | 376 | let nostr_state = get_state_from_cache(git_repo.get_path()?, repo_ref).await; |
| 373 | .git_server | 377 | |
| 374 | .first() | 378 | let existing_state = { |
| 375 | .context("no git server listed in nostr repository announcement")?; | 379 | // if no state events - create from first git server listed |
| 376 | let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_url)?; | 380 | if let Ok(nostr_state) = &nostr_state { |
| 377 | 381 | nostr_state.state.clone() | |
| 378 | let auth = GitAuthenticator::default(); | 382 | } else if let Some(url) = repo_ref |
| 379 | let git_config = git_repo.git_repo.config()?; | 383 | .git_server |
| 380 | let mut push_options = git2::PushOptions::new(); | 384 | .iter() |
| 381 | let mut remote_callbacks = git2::RemoteCallbacks::new(); | 385 | .find(|&url| list_outputs.contains_key(url)) |
| 382 | remote_callbacks.credentials(auth.credentials(&git_config)); | 386 | { |
| 383 | remote_callbacks.push_update_reference(|name, error| { | 387 | list_outputs.get(url).unwrap().to_owned() |
| 384 | if let Some(error) = error { | ||
| 385 | println!("error {name} {error}"); | ||
| 386 | } else { | 388 | } else { |
| 387 | if let Some(refspec) = refspecs | 389 | bail!( |
| 388 | .iter() | 390 | "cannot connect to git servers: {}", |
| 389 | .find(|r| r.contains(format!(":{name}").as_str())) | 391 | repo_ref.git_server.join(" ") |
| 390 | { | 392 | ); |
| 391 | if let Err(e) = | 393 | } |
| 392 | update_remote_refs_pushed(&git_repo.git_repo, refspec, nostr_remote_url) | 394 | }; |
| 393 | .context("could not update remote_ref locally") | 395 | |
| 394 | { | 396 | let (rejected_refspecs, remote_refspecs) = create_rejected_refspecs_and_remotes_refspecs( |
| 395 | return Err(git2::Error::from_str(e.to_string().as_str())); | 397 | &term, |
| 396 | } | 398 | git_repo, |
| 397 | } | 399 | &refspecs, |
| 398 | println!("ok {name}",); | 400 | &existing_state, |
| 401 | &list_outputs, | ||
| 402 | )?; | ||
| 403 | |||
| 404 | refspecs.retain(|refspec| { | ||
| 405 | if let Some(rejected) = rejected_refspecs.get(&refspec.to_string()) { | ||
| 406 | let (_, to) = refspec_to_from_to(refspec).unwrap(); | ||
| 407 | println!("error {to} {} out of sync with nostr", rejected.join(" ")); | ||
| 408 | false | ||
| 409 | } else { | ||
| 410 | true | ||
| 399 | } | 411 | } |
| 400 | Ok(()) | ||
| 401 | }); | 412 | }); |
| 402 | push_options.remote_callbacks(remote_callbacks); | ||
| 403 | git_server_remote.push(&refspecs, Some(&mut push_options))?; | ||
| 404 | git_server_remote.disconnect()?; | ||
| 405 | 413 | ||
| 406 | // TODO check whether push was succesful before proceeding - geting outcome from | 414 | if refspecs.is_empty() { |
| 407 | // callback isn't straightforward | 415 | // all refspecs rejected |
| 416 | println!(); | ||
| 417 | return Ok(()); | ||
| 418 | } | ||
| 408 | 419 | ||
| 409 | let new_state = generate_updated_state(git_repo, repo_ref, &refspecs).await?; | 420 | let new_state = generate_updated_state(git_repo, &existing_state, &refspecs)?; |
| 410 | 421 | ||
| 411 | // TODO enable interactive login | ||
| 412 | let (signer, user_ref) = login::launch( | 422 | let (signer, user_ref) = login::launch( |
| 413 | git_repo, | 423 | git_repo, |
| 414 | &None, | 424 | &None, |
| @@ -420,8 +430,12 @@ async fn push( | |||
| 420 | true, | 430 | true, |
| 421 | ) | 431 | ) |
| 422 | .await?; | 432 | .await?; |
| 433 | |||
| 423 | let new_repo_state = RepoState::build(repo_ref.identifier.clone(), new_state, &signer).await?; | 434 | let new_repo_state = RepoState::build(repo_ref.identifier.clone(), new_state, &signer).await?; |
| 424 | 435 | ||
| 436 | // TODO check whether tip of each branch pushed is on at least one git server | ||
| 437 | // before broadcasting the nostr state | ||
| 438 | |||
| 425 | send_events( | 439 | send_events( |
| 426 | client, | 440 | client, |
| 427 | git_repo.get_path()?, | 441 | git_repo.get_path()?, |
| @@ -433,71 +447,218 @@ async fn push( | |||
| 433 | ) | 447 | ) |
| 434 | .await?; | 448 | .await?; |
| 435 | 449 | ||
| 436 | // silently push to any other git servers | 450 | for refspec in &refspecs { |
| 437 | for (i, git_server_url) in repo_ref.git_server.iter().enumerate() { | 451 | let (_, to) = refspec_to_from_to(refspec)?; |
| 438 | // we have already pushed to the first one | 452 | println!("ok {to}"); |
| 439 | if i.gt(&0) { | 453 | update_remote_refs_pushed(&git_repo.git_repo, refspec, nostr_remote_url) |
| 440 | if let Ok(mut git_server_remote) = git_repo.git_repo.remote_anonymous(git_server_url) { | 454 | .context("could not update remote_ref locally")?; |
| 455 | } | ||
| 456 | |||
| 457 | // TODO make async - check gitlib2 callbacks work async | ||
| 458 | let git_config = git_repo.git_repo.config()?; | ||
| 459 | for (git_server_url, refspecs) in remote_refspecs { | ||
| 460 | if !refspecs.is_empty() { | ||
| 461 | if let Ok(mut git_server_remote) = git_repo.git_repo.remote_anonymous(&git_server_url) { | ||
| 441 | let auth = GitAuthenticator::default(); | 462 | let auth = GitAuthenticator::default(); |
| 442 | let git_config = git_repo.git_repo.config()?; | ||
| 443 | let mut push_options = git2::PushOptions::new(); | 463 | let mut push_options = git2::PushOptions::new(); |
| 444 | let mut remote_callbacks = git2::RemoteCallbacks::new(); | 464 | let mut remote_callbacks = git2::RemoteCallbacks::new(); |
| 445 | remote_callbacks.credentials(auth.credentials(&git_config)); | 465 | remote_callbacks.credentials(auth.credentials(&git_config)); |
| 466 | remote_callbacks.push_update_reference(|name, error| { | ||
| 467 | if let Some(error) = error { | ||
| 468 | term.write_line( | ||
| 469 | format!("WARNING: error pushing {name} to {git_server_url} {error}") | ||
| 470 | .as_str(), | ||
| 471 | ) | ||
| 472 | .unwrap(); | ||
| 473 | } | ||
| 474 | Ok(()) | ||
| 475 | }); | ||
| 446 | push_options.remote_callbacks(remote_callbacks); | 476 | push_options.remote_callbacks(remote_callbacks); |
| 447 | let _ = git_server_remote.push(&refspecs, Some(&mut push_options)); | 477 | let _ = git_server_remote.push(&refspecs, Some(&mut push_options)); |
| 448 | let _ = git_server_remote.disconnect(); | 478 | let _ = git_server_remote.disconnect(); |
| 449 | } | 479 | } |
| 450 | } | 480 | } |
| 451 | } | 481 | } |
| 452 | // todo report on errors | ||
| 453 | |||
| 454 | println!(); | 482 | println!(); |
| 455 | Ok(()) | 483 | Ok(()) |
| 456 | } | 484 | } |
| 457 | 485 | ||
| 458 | async fn generate_updated_state( | 486 | type HashMapUrlRefspecs = HashMap<String, Vec<String>>; |
| 487 | |||
| 488 | #[allow(clippy::too_many_lines)] | ||
| 489 | fn create_rejected_refspecs_and_remotes_refspecs( | ||
| 490 | term: &console::Term, | ||
| 459 | git_repo: &Repo, | 491 | git_repo: &Repo, |
| 460 | repo_ref: &RepoRef, | ||
| 461 | refspecs: &Vec<String>, | 492 | refspecs: &Vec<String>, |
| 462 | ) -> Result<HashMap<String, String>> { | 493 | nostr_state: &HashMap<String, String>, |
| 463 | let new_state = { | 494 | list_outputs: &HashMap<String, HashMap<String, String>>, |
| 464 | if let Ok(mut repo_state) = get_state_from_cache(git_repo.get_path()?, repo_ref).await { | 495 | ) -> Result<(HashMapUrlRefspecs, HashMapUrlRefspecs)> { |
| 465 | for refspec in refspecs { | 496 | let mut refspecs_for_remotes = HashMap::new(); |
| 466 | let (from, to) = refspec_to_from_to(refspec)?; | 497 | |
| 467 | if from.is_empty() { | 498 | let mut rejected_refspecs: HashMapUrlRefspecs = HashMap::new(); |
| 468 | // delete | 499 | |
| 469 | repo_state.state.remove(to); | 500 | for (url, remote_state) in list_outputs { |
| 470 | } else { | 501 | let mut refspecs_for_remote = vec![]; |
| 471 | // add or update | 502 | for refspec in refspecs { |
| 472 | repo_state.state.insert( | 503 | let (from, to) = refspec_to_from_to(refspec)?; |
| 473 | to.to_string(), | 504 | let nostr_value = nostr_state.get(to); |
| 474 | reference_to_ref_value(&git_repo.git_repo, to).unwrap(), | 505 | let remote_value = remote_state.get(to); |
| 475 | ); | 506 | if from.is_empty() { |
| 507 | if remote_value.is_some() { | ||
| 508 | // delete remote branch | ||
| 509 | refspecs_for_remote.push(refspec.clone()); | ||
| 476 | } | 510 | } |
| 511 | continue; | ||
| 477 | } | 512 | } |
| 478 | repo_state.state | 513 | let from_tip = git_repo.get_commit_or_tip_of_reference(from)?; |
| 479 | } else { | 514 | if let Some(nostr_value) = nostr_value { |
| 480 | let mut state = HashMap::new(); | 515 | if let Some(remote_value) = remote_value { |
| 481 | let git_server_url = repo_ref | 516 | if nostr_value.eq(remote_value) { |
| 482 | .git_server | 517 | // in sync - existing branch at same state |
| 483 | .first() | 518 | let is_remote_tip_ancestor_of_commit = if let Ok(remote_value_tip) = |
| 484 | .context("no git server listed in nostr repository announcement")?; | 519 | git_repo.get_commit_or_tip_of_reference(remote_value) |
| 485 | let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_url)?; | 520 | { |
| 486 | git_server_remote.connect(git2::Direction::Fetch)?; | 521 | if let Ok((_, behind)) = |
| 487 | for head in git_server_remote.list()? { | 522 | git_repo.get_commits_ahead_behind(&remote_value_tip, &from_tip) |
| 488 | state.insert( | 523 | { |
| 489 | head.name().to_string(), | 524 | behind.is_empty() |
| 490 | if let Some(symbolic_ref) = head.symref_target() { | 525 | } else { |
| 491 | format!("ref: {symbolic_ref}") | 526 | false |
| 527 | } | ||
| 528 | } else { | ||
| 529 | false | ||
| 530 | }; | ||
| 531 | if is_remote_tip_ancestor_of_commit { | ||
| 532 | refspecs_for_remote.push(refspec.clone()); | ||
| 533 | } else { | ||
| 534 | // this is a force push so we need to force push to git server too | ||
| 535 | if refspec.starts_with('+') { | ||
| 536 | refspecs_for_remote.push(refspec.clone()); | ||
| 537 | } else { | ||
| 538 | refspecs_for_remote.push(format!("+{refspec}")); | ||
| 539 | } | ||
| 540 | } | ||
| 541 | // TODO do we need to force push to this remote? | ||
| 542 | } else if let Ok(remote_value_tip) = | ||
| 543 | git_repo.get_commit_or_tip_of_reference(remote_value) | ||
| 544 | { | ||
| 545 | if from_tip.eq(&remote_value_tip) { | ||
| 546 | // remote already at correct state | ||
| 547 | term.write_line( | ||
| 548 | format!("{to} already at pushed commit state on {url}").as_str(), | ||
| 549 | )?; | ||
| 550 | } | ||
| 551 | let (_, behind) = | ||
| 552 | git_repo.get_commits_ahead_behind(&remote_value_tip, &from_tip)?; | ||
| 553 | if behind.is_empty() { | ||
| 554 | // can soft push | ||
| 555 | refspecs_for_remote.push(refspec.clone()); | ||
| 556 | } else { | ||
| 557 | // cant soft push | ||
| 558 | rejected_refspecs | ||
| 559 | .entry(refspec.to_string()) | ||
| 560 | .and_modify(|a| a.push(url.to_string())) | ||
| 561 | .or_insert(vec![url.to_string()]); | ||
| 562 | term.write_line( | ||
| 563 | format!("ERROR: {to} on {url} conflicts with nostr and is {} 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", behind.len()).as_str(), | ||
| 564 | )?; | ||
| 565 | }; | ||
| 492 | } else { | 566 | } else { |
| 493 | head.oid().to_string() | 567 | // remote_value oid is not present locally |
| 494 | }, | 568 | // TODO can we download the remote reference? |
| 495 | ); | 569 | |
| 570 | // cant soft push | ||
| 571 | rejected_refspecs | ||
| 572 | .entry(refspec.to_string()) | ||
| 573 | .and_modify(|a| a.push(url.to_string())) | ||
| 574 | .or_insert(vec![url.to_string()]); | ||
| 575 | term.write_line( | ||
| 576 | format!("ERROR: {to} on {url} 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(), | ||
| 577 | )?; | ||
| 578 | } | ||
| 579 | } else { | ||
| 580 | // existing nostr branch not on remote | ||
| 581 | // report - creating new branch | ||
| 582 | term.write_line(format!("pushing {to} as new branch on {url}").as_str())?; | ||
| 583 | refspecs_for_remote.push(refspec.clone()); | ||
| 584 | } | ||
| 585 | } else if let Some(remote_value) = remote_value { | ||
| 586 | // new to nostr but on remote | ||
| 587 | if let Ok(remote_value_tip) = git_repo.get_commit_or_tip_of_reference(remote_value) | ||
| 588 | { | ||
| 589 | let (_, behind) = | ||
| 590 | git_repo.get_commits_ahead_behind(&remote_value_tip, &from_tip)?; | ||
| 591 | if behind.is_empty() { | ||
| 592 | // can soft push | ||
| 593 | refspecs_for_remote.push(refspec.clone()); | ||
| 594 | } else { | ||
| 595 | // cant soft push | ||
| 596 | rejected_refspecs | ||
| 597 | .entry(refspec.to_string()) | ||
| 598 | .and_modify(|a| a.push(url.to_string())) | ||
| 599 | .or_insert(vec![url.to_string()]); | ||
| 600 | term.write_line( | ||
| 601 | format!("ERROR: {to} not on nostr but on {url} is {} 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", behind.len()).as_str(), | ||
| 602 | )?; | ||
| 603 | } | ||
| 604 | } else { | ||
| 605 | // havn't fetched oid from remote | ||
| 606 | // TODO fetch oid from remote | ||
| 607 | // cant soft push | ||
| 608 | rejected_refspecs | ||
| 609 | .entry(refspec.to_string()) | ||
| 610 | .and_modify(|a| a.push(url.to_string())) | ||
| 611 | .or_insert(vec![url.to_string()]); | ||
| 612 | term.write_line( | ||
| 613 | format!("ERROR: {to} not on nostr but on {url} 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(), | ||
| 614 | )?; | ||
| 615 | } | ||
| 616 | } else { | ||
| 617 | // in sync - new branch | ||
| 618 | refspecs_for_remote.push(refspec.clone()); | ||
| 496 | } | 619 | } |
| 497 | git_server_remote.disconnect()?; | ||
| 498 | state | ||
| 499 | } | 620 | } |
| 500 | }; | 621 | refspecs_for_remotes.insert(url.to_string(), refspecs_for_remote); |
| 622 | } | ||
| 623 | |||
| 624 | // remove rejected refspecs so they dont get pushed to some remotes | ||
| 625 | let mut remotes_refspecs_without_rejected = HashMap::new(); | ||
| 626 | for (url, value) in &refspecs_for_remotes { | ||
| 627 | remotes_refspecs_without_rejected.insert( | ||
| 628 | url.to_string(), | ||
| 629 | value | ||
| 630 | .iter() | ||
| 631 | .filter(|refspec| !rejected_refspecs.contains_key(*refspec)) | ||
| 632 | .cloned() | ||
| 633 | .collect(), | ||
| 634 | ); | ||
| 635 | } | ||
| 636 | Ok((rejected_refspecs, remotes_refspecs_without_rejected)) | ||
| 637 | } | ||
| 638 | |||
| 639 | fn generate_updated_state( | ||
| 640 | git_repo: &Repo, | ||
| 641 | existing_state: &HashMap<String, String>, | ||
| 642 | refspecs: &Vec<String>, | ||
| 643 | ) -> Result<HashMap<String, String>> { | ||
| 644 | let mut new_state = existing_state.clone(); | ||
| 645 | |||
| 646 | for refspec in refspecs { | ||
| 647 | let (from, to) = refspec_to_from_to(refspec)?; | ||
| 648 | if from.is_empty() { | ||
| 649 | // delete | ||
| 650 | new_state.remove(to); | ||
| 651 | } else { | ||
| 652 | // add or update | ||
| 653 | new_state.insert( | ||
| 654 | to.to_string(), | ||
| 655 | git_repo | ||
| 656 | .get_commit_or_tip_of_reference(from) | ||
| 657 | .unwrap() | ||
| 658 | .to_string(), | ||
| 659 | ); | ||
| 660 | } | ||
| 661 | } | ||
| 501 | Ok(new_state) | 662 | Ok(new_state) |
| 502 | } | 663 | } |
| 503 | 664 | ||