upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/git_remote_helper.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2024-08-05 14:15:29 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2024-08-05 14:15:29 +0100
commitf238fc8c0a122487f4fb71bb78a2e365e147d747 (patch)
tree5e8760c192f62fef67189bc82dcae4eef3ce883a /src/git_remote_helper.rs
parente5750b5b3dfe2c0072902c2523fdf32986aa74b8 (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.rs339
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)]
354async fn push( 357async 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
458async fn generate_updated_state( 486type HashMapUrlRefspecs = HashMap<String, Vec<String>>;
487
488#[allow(clippy::too_many_lines)]
489fn 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
639fn 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