use core::str; use std::{ collections::{HashMap, HashSet}, io::Stdin, sync::Arc, }; use anyhow::{Context, Result, bail}; use client::{get_events_from_local_cache, get_state_from_cache, send_events, sign_event}; use console::Term; use git::{RepoActions, sha1_to_oid}; use git_events::{ generate_cover_letter_and_patch_events, generate_patch_event, get_commit_id_from_patch, }; use git2::{Oid, Repository}; use ngit::{ client::{self, get_event_from_cache_by_id}, git::{self, nostr_url::NostrUrlDecoded}, git_events::{ self, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_to_cover_letter, get_event_root, }, list::list_from_remotes, login::{existing::load_existing_login, user::UserRef}, push::{push_to_remote, select_servers_push_refs_and_generate_pr_or_pr_update_event}, repo_ref::{self, get_repo_config_from_yaml, is_grasp_server_clone_url}, repo_state, utils::{ find_proposal_and_patches_by_branch_name, get_all_proposals, get_remote_name_by_url, get_short_git_server_name, read_line, }, }; use nostr::nips::{ nip10::Marker, nip19::ToBech32, nip22::{CommentTarget, extract_root}, }; use nostr_sdk::{ Event, EventBuilder, EventId, Kind, NostrSigner, PublicKey, RelayUrl, Tag, TagStandard, hashes::sha1::Hash as Sha1Hash, }; use repo_ref::RepoRef; use repo_state::RepoState; use crate::{client::Client, git::Repo}; #[allow(clippy::too_many_lines)] #[allow(clippy::type_complexity)] pub async fn run_push( git_repo: &Repo, repo_ref: &RepoRef, stdin: &Stdin, initial_refspec: &str, client: &Client, list_outputs: Option, bool)>>, title_description: Option<(String, String)>, ) -> Result<()> { let refspecs = get_refspecs_from_push_batch(stdin, initial_refspec)?; let proposal_refspecs = refspecs .iter() .filter(|r| r.contains("refs/heads/pr/")) .cloned() .collect::>(); let mut git_state_refspecs = refspecs .iter() .filter(|r| !r.contains("refs/heads/pr/")) .cloned() .collect::>(); let term = console::Term::stderr(); let list_outputs = if let Some(outputs) = list_outputs { outputs } else { list_from_remotes( &term, git_repo, &repo_ref.git_server, &repo_ref.to_nostr_git_url(&None), None, ) .await }; let existing_state = { // if no state events - create from first git server listed if let Ok(nostr_state) = &get_state_from_cache(Some(git_repo.get_path()?), repo_ref).await { nostr_state.state.clone() } else if let Some(url) = repo_ref .git_server .iter() .find(|&url| list_outputs.contains_key(url)) { let (state, _is_grasp_server) = list_outputs.get(url).unwrap().to_owned(); state } else { bail!( "failed to connect to git servers: {}", repo_ref.git_server.join(" ") ); } }; let (rejected_refspecs, remote_refspecs) = create_rejected_refspecs_and_remotes_refspecs( &term, git_repo, &git_state_refspecs, &existing_state, &list_outputs, )?; git_state_refspecs.retain(|refspec| { if let Some(rejected) = rejected_refspecs.get(&refspec.to_string()) { let (_, to) = refspec_to_from_to(refspec).unwrap(); println!("error {to} {} out of sync with nostr", rejected.join(" ")); false } else { true } }); // all refspecs aren't rejected if !(git_state_refspecs.is_empty() && proposal_refspecs.is_empty()) { let (rejected_proposal_refspecs, rejected) = create_and_publish_events_and_proposals( git_repo, repo_ref, &git_state_refspecs, &proposal_refspecs, client, existing_state, &term, title_description.as_ref(), ) .await?; if !rejected { for refspec in git_state_refspecs.iter().chain(proposal_refspecs.iter()) { if rejected_proposal_refspecs.contains(refspec) { continue; } let (_, to) = refspec_to_from_to(refspec)?; println!("ok {to}"); update_remote_refs_pushed( &git_repo.git_repo, refspec, &repo_ref.to_nostr_git_url(&None).to_string(), ) .context("could not update remote_ref locally")?; } // TODO make async - check gitlib2 callbacks work async for (git_server_url, remote_refspecs) in remote_refspecs { let remote_refspecs = remote_refspecs .iter() .filter(|refspec| git_state_refspecs.contains(refspec)) .cloned() .collect::>(); if !refspecs.is_empty() { let _ = push_to_remote( git_repo, &git_server_url, &repo_ref.to_nostr_git_url(&None), &remote_refspecs, &term, is_grasp_server_clone_url(&git_server_url), ); } } } } println!(); Ok(()) } #[allow(clippy::too_many_lines)] #[allow(clippy::too_many_arguments)] async fn create_and_publish_events_and_proposals( git_repo: &Repo, repo_ref: &RepoRef, git_server_refspecs: &Vec, proposal_refspecs: &Vec, client: &Client, existing_state: HashMap, term: &Term, title_description: Option<&(String, String)>, ) -> Result<(Vec, bool)> { let (signer, mut user_ref, _) = load_existing_login( &Some(git_repo), &None, &None, &None, Some(client), true, // silent false, // prompt_for_password - MUST be false for non-interactive true, // fetch_profile_updates ) .await .context("Authentication required. Run 'ngit account login' first, then try again.")?; if !repo_ref.maintainers.contains(&user_ref.public_key) { for refspec in git_server_refspecs { let (_, to) = refspec_to_from_to(refspec).unwrap(); eprintln!( "error {to} your nostr account {} isn't listed as a maintainer of the repo", user_ref.metadata.name ); } if proposal_refspecs.is_empty() { return Ok((vec![], true)); } } else if repo_ref .maintainers_without_annoucnement .clone() .is_some_and(|ms| ms.contains(&user_ref.public_key)) { for refspec in git_server_refspecs { let (_, to) = refspec_to_from_to(refspec).unwrap(); eprintln!( "error {to} you must run `ngit init` before pushing updates. you've been offered maintainership but you must accept it before pushing", ); } if proposal_refspecs.is_empty() { return Ok((vec![], true)); } } let mut events = vec![]; if !git_server_refspecs.is_empty() { let new_state = generate_updated_state(git_repo, &existing_state, git_server_refspecs)?; let store_state = if let Ok(Some(nostate)) = git_repo.get_git_config_item("nostr.nostate", None) { !nostate.eq("true") } else { true }; if store_state { let new_repo_state = RepoState::build(repo_ref.identifier.clone(), new_state, &signer).await?; events.push(new_repo_state.event); } for event in get_merged_status_events( term, &repo_ref.to_nostr_git_url(&None), repo_ref, git_repo, &signer, git_server_refspecs, ) .await? { events.push(event); } if let Ok(Some(repo_ref_event)) = get_maintainers_yaml_update( term, &repo_ref.to_nostr_git_url(&None), repo_ref, git_repo, &signer, git_server_refspecs, ) .await { events.push(repo_ref_event); } } let (proposal_events, rejected_proposal_refspecs) = process_proposal_refspecs( client, git_repo, repo_ref, proposal_refspecs, &mut user_ref, &signer, term, title_description, ) .await?; for e in proposal_events { events.push(e); } // TODO check whether tip of each branch pushed is on at least one git server // before broadcasting the nostr state if !events.is_empty() { send_events( client, Some(git_repo.get_path()?), events, user_ref.relays.write(), repo_ref.relays.clone(), true, false, ) .await?; } Ok((rejected_proposal_refspecs, false)) } #[allow(clippy::too_many_lines)] #[allow(clippy::too_many_arguments)] async fn process_proposal_refspecs( client: &Client, git_repo: &Repo, repo_ref: &RepoRef, proposal_refspecs: &Vec, user_ref: &mut UserRef, signer: &Arc, term: &Term, title_description: Option<&(String, String)>, ) -> Result<(Vec, Vec)> { let mut events = vec![]; let mut rejected_proposal_refspecs = vec![]; if proposal_refspecs.is_empty() { return Ok((events, rejected_proposal_refspecs)); } let all_proposals = get_all_proposals(git_repo, repo_ref).await?; let current_user = user_ref.public_key; for refspec in proposal_refspecs { let (from, to) = refspec_to_from_to(refspec).unwrap(); let tip_of_pushed_branch = git_repo.get_commit_or_tip_of_reference(from)?; // this failed to find existing PR from user if let Some((_, (proposal, patches))) = find_proposal_and_patches_by_branch_name(to, &all_proposals, Some(¤t_user)) { if [repo_ref.maintainers.clone(), vec![proposal.pubkey]] .concat() .contains(&user_ref.public_key) { if refspec.starts_with('+') { // force push let (main_branch_name, main_tip) = git_repo.get_main_or_master_branch()?; let (mut ahead, _) = git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?; ahead.reverse(); if ahead.is_empty() { bail!( "cannot push '{from}' as proposal as branch isn't ahead of '{main_branch_name}'" ); } for patch in generate_patches_or_pr_event_or_pr_updates( client, git_repo, repo_ref, &ahead, user_ref, Some(proposal), signer, term, title_description, ) .await? { events.push(patch); } } else { // fast forward push let tip_patch = patches.first().unwrap(); let tip_of_proposal = get_commit_id_from_patch(tip_patch)?; let tip_of_proposal_commit = git_repo.get_commit_or_tip_of_reference(&tip_of_proposal)?; let (mut ahead, behind) = git_repo .get_commits_ahead_behind(&tip_of_proposal_commit, &tip_of_pushed_branch)?; if behind.is_empty() { let thread_id = if let Ok(root_event_id) = get_event_root(tip_patch) { root_event_id } else { // tip patch is the root proposal tip_patch.id }; let mut parent_patch = tip_patch.clone(); ahead.reverse(); if ahead.is_empty() { bail!( "cannot push '{from}' as proposal as branch isn't ahead of proposal on nostr" ); } if proposal.kind.eq(&KIND_PULL_REQUEST) || git_repo.are_commits_too_big_for_patches(&ahead) { for event in generate_patches_or_pr_event_or_pr_updates( client, git_repo, repo_ref, &ahead, user_ref, Some(proposal), signer, term, title_description, ) .await? { events.push(event); } } else { for (i, commit) in ahead.iter().enumerate() { let new_patch = generate_patch_event( git_repo, &git_repo.get_root_commit()?, commit, Some(thread_id), signer, repo_ref, Some(parent_patch.id), Some(( (patches.len() + i + 1).try_into().unwrap(), (patches.len() + ahead.len()).try_into().unwrap(), )), None, &None, &[], ) .await .context("failed to make patch event from commit")?; events.push(new_patch.clone()); parent_patch = new_patch; } } } else { // we shouldn't get here term.write_line( format!( "WARNING: failed to push {from} as nostr proposal. Try and force push ", ) .as_str(), ) .unwrap(); println!( "error {to} failed to fastforward as newer patches found on proposal" ); rejected_proposal_refspecs.push(refspec.to_string()); } } } else { println!( "error {to} permission denied. you are not the proposal author or a repo maintainer" ); rejected_proposal_refspecs.push(refspec.to_string()); } } else { // TODO new proposal / couldn't find exisiting proposal let (main_branch_name, main_tip) = git_repo.get_main_or_master_branch()?; let (mut ahead, _) = git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?; ahead.reverse(); if ahead.is_empty() { bail!( "cannot push '{from}' as proposal as branch isn't ahead of '{main_branch_name}'" ); } for event in generate_patches_or_pr_event_or_pr_updates( client, git_repo, repo_ref, &ahead, user_ref, None, signer, term, title_description, ) .await? { events.push(event); } } } Ok((events, rejected_proposal_refspecs)) } #[allow(clippy::too_many_lines)] #[allow(clippy::too_many_arguments)] async fn generate_patches_or_pr_event_or_pr_updates( client: &Client, git_repo: &Repo, repo_ref: &RepoRef, ahead: &[Sha1Hash], user_ref: &mut UserRef, root_proposal: Option<&Event>, signer: &Arc, term: &Term, title_description: Option<&(String, String)>, ) -> Result> { let parent_is_pr = root_proposal.is_some_and(|proposal| proposal.kind.eq(&KIND_PULL_REQUEST)); let use_pr = parent_is_pr || git_repo.are_commits_too_big_for_patches(ahead); if use_pr { let tip = ahead.first().context("no commits")?; // ahead is youngest first let first_commit = ahead.last().context("no commits")?; select_servers_push_refs_and_generate_pr_or_pr_update_event( client, git_repo, repo_ref, tip, first_commit, git_repo.get_commit_parent(first_commit).ok().as_ref(), user_ref, root_proposal, &title_description.map(|(t, d)| (t.clone(), d.clone())), signer, false, term, ) .await .context(format!( "{} run `ngit send` for more options.", if parent_is_pr { "couldn't generate PR update event." } else { "a commit in your proposal is too big for a nostr patch so we tried to create it as a nostr PR instead. Unfortunately this failed." }, )) } else { generate_cover_letter_and_patch_events( None, git_repo, ahead, signer, repo_ref, &root_proposal.map(|proposal| proposal.id.to_string()), &[], ) .await } } type HashMapUrlRefspecs = HashMap>; #[allow(clippy::too_many_lines)] fn create_rejected_refspecs_and_remotes_refspecs( term: &console::Term, git_repo: &Repo, refspecs: &Vec, nostr_state: &HashMap, list_outputs: &HashMap, bool)>, ) -> Result<(HashMapUrlRefspecs, HashMapUrlRefspecs)> { let mut refspecs_for_remotes = HashMap::new(); let mut rejected_refspecs: HashMapUrlRefspecs = HashMap::new(); for (url, (remote_state, is_grasp_server)) in list_outputs { let is_grasp_server = is_grasp_server.to_owned(); let short_name = get_short_git_server_name(url); let mut refspecs_for_remote = vec![]; for refspec in refspecs { let (from, to) = refspec_to_from_to(refspec)?; let nostr_value = nostr_state.get(to); let remote_value = remote_state.get(to); if from.is_empty() { if remote_value.is_some() { // delete remote branch refspecs_for_remote.push(refspec.clone()); } continue; } // handle annotated tags if let Ok(annotated_tag) = git_repo .git_repo .find_reference(from) .context(format!("cannot find ref {from}"))? .peel(git2::ObjectType::Tag) { if let Some(remote_value) = remote_value { if annotated_tag.id().to_string() == *remote_value { // remote already at correct state } else if is_grasp_server { refspecs_for_remote.push(ensure_force_push_refspec(refspec)); } else if refspec.starts_with('+') || refspec.starts_with(':') { refspecs_for_remote.push(refspec.clone()); } else { // reject rejected_refspecs .entry(refspec.to_string()) .and_modify(|a| a.push(url.to_string())) .or_insert(vec![url.to_string()]); // TODO should we reject or or just warn? term.write_line( format!( "ERROR: {short_name} {to} exists with a different reference. someone else may have pushed new updates. options:\r\n 1. review and integrate remote's tip available via `git checkout {remote_value}` \r\n 2. align remote state with nostr via `ngit sync --ref-name {to} --force` and try to push again", ).as_str(), )?; } } else { // push new tag refspecs_for_remote.push(refspec.clone()); } continue; } else if let Some(remote_value) = remote_value { if let Ok(oid) = Oid::from_str(refspec) { if git_repo .git_repo .find_object(oid, Some(git2::ObjectType::Tag)) .is_ok() { if is_grasp_server { refspecs_for_remote.push(ensure_force_push_refspec(refspec)); } else if refspec.starts_with('+') || refspec.starts_with(':') { refspecs_for_remote.push(refspec.clone()); } else { rejected_refspecs .entry(refspec.to_string()) .and_modify(|a| a.push(url.to_string())) .or_insert(vec![url.to_string()]); term.write_line( format!( "ERROR: {short_name} {to} exists with a different reference. someone else may have pushed new updates. options:\r\n 1. review and integrate remote's tip available via `git checkout {remote_value}` \r\n 2. align remote state with nostr via `ngit sync --ref-name {to} --force` and try to push again", ).as_str(), )?; } continue; } } } let from_tip = git_repo.get_commit_or_tip_of_reference(from)?; if let Some(nostr_value) = nostr_value { if let Some(remote_value) = remote_value { if nostr_value.eq(remote_value) { // in sync - existing branch at same state let is_remote_tip_ancestor_of_commit = if let Ok(remote_value_tip) = git_repo.get_commit_or_tip_of_reference(remote_value) { if let Ok((_, behind)) = git_repo.get_commits_ahead_behind(&remote_value_tip, &from_tip) { behind.is_empty() } else { false } } else { false }; if is_remote_tip_ancestor_of_commit { refspecs_for_remote.push(refspec.clone()); } else { refspecs_for_remote.push(ensure_force_push_refspec(refspec)); } } else if let Ok(remote_value_tip) = git_repo.get_commit_or_tip_of_reference(remote_value) { if from_tip.eq(&remote_value_tip) { // remote already at correct state term.write_line( format!("{short_name} {to} already up-to-date").as_str(), )?; } let (ahead_of_local, behind_local) = git_repo.get_commits_ahead_behind(&from_tip, &remote_value_tip)?; if ahead_of_local.is_empty() { // can soft push refspecs_for_remote.push(refspec.clone()); } else { // cant soft push let (ahead_of_nostr, behind_nostr) = git_repo .get_commits_ahead_behind( &git_repo.get_commit_or_tip_of_reference(nostr_value)?, &remote_value_tip, )?; if ahead_of_nostr.is_empty() { // ancestor of nostr and we are force pushing anyway... refspecs_for_remote.push(refspec.clone()); } else if is_grasp_server { // a grasp server can only be pushed to via nostr so can force push refspecs_for_remote.push(ensure_force_push_refspec(refspec)); } else { rejected_refspecs .entry(refspec.to_string()) .and_modify(|a| a.push(url.to_string())) .or_insert(vec![url.to_string()]); term.write_line( format!( "ERROR: {short_name} {to} conflicts with nostr ({} ahead {} behind) and local ({} ahead {} behind). someone else may have pushed new updates. options:\r\n 1. review and integrate remote's tip available via `git checkout {remote_value}` \r\n 2. align remote state with nostr via `ngit sync --ref-name {to} --force` and try to push again", ahead_of_nostr.len(), behind_nostr.len(), ahead_of_local.len(), behind_local.len(), ).as_str(), )?; } } } else if is_grasp_server { refspecs_for_remote.push(ensure_force_push_refspec(refspec)); } else { // remote_value oid is not present locally // TODO can we download the remote reference? // cant soft push rejected_refspecs .entry(refspec.to_string()) .and_modify(|a| a.push(url.to_string())) .or_insert(vec![url.to_string()]); term.write_line( format!("ERROR: {short_name} {to} conflicts with nostr and is not an ancestor of local branch. someone else may have pushed new updates. options:\r\n 1. review and integrate remote's tip available via `git checkout {remote_value}` \r\n 2. align remote state with nostr via `ngit sync --ref-name {to} --force` and try to push again").as_str(), )?; } } else { // existing nostr branch not on remote // report - creating new branch term.write_line( format!( "{short_name} {to} doesn't exist and will be added as a new branch" ) .as_str(), )?; refspecs_for_remote.push(refspec.clone()); } } else if let Some(remote_value) = remote_value { // new to nostr but on remote if let Ok(remote_value_tip) = git_repo.get_commit_or_tip_of_reference(remote_value) { let (ahead, behind) = git_repo.get_commits_ahead_behind(&from_tip, &remote_value_tip)?; if ahead.is_empty() { // can soft push refspecs_for_remote.push(refspec.clone()); } else if is_grasp_server { refspecs_for_remote.push(ensure_force_push_refspec(refspec)); } else { // cant soft push rejected_refspecs .entry(refspec.to_string()) .and_modify(|a| a.push(url.to_string())) .or_insert(vec![url.to_string()]); term.write_line( format!( "ERROR: {short_name} already contains {to} {} ahead and {} behind local branch. someone else may have pushed new updates. options:\r\n 1. review and integrate remote's tip available via `git checkout {remote_value}` \r\n 2. align remote state with nostr via `ngit sync --ref-name {to} --force` and try to push again", ahead.len(), behind.len(), ).as_str(), )?; } } else if is_grasp_server { refspecs_for_remote.push(ensure_force_push_refspec(refspec)); } else { // havn't fetched oid from remote // TODO fetch oid from remote // cant soft push rejected_refspecs .entry(refspec.to_string()) .and_modify(|a| a.push(url.to_string())) .or_insert(vec![url.to_string()]); term.write_line( format!("ERROR: {short_name} already contains {to} at {remote_value} which is not an ancestor of local branch. someone else may have pushed new updates. options:\r\n 1. review and integrate remote's tip available via `git checkout {remote_value}` \r\n 2. align remote state with nostr via `ngit sync --ref-name {to} --force` and try to push again").as_str(), )?; } } else { // in sync - new branch refspecs_for_remote.push(refspec.clone()); } } if !refspecs_for_remote.is_empty() { refspecs_for_remotes.insert(url.to_string(), refspecs_for_remote); } } // remove rejected refspecs so they dont get pushed to some remotes let mut remotes_refspecs_without_rejected = HashMap::new(); for (url, value) in &refspecs_for_remotes { remotes_refspecs_without_rejected.insert( url.to_string(), value .iter() .filter(|refspec| !rejected_refspecs.contains_key(*refspec)) .cloned() .collect(), ); } Ok((rejected_refspecs, remotes_refspecs_without_rejected)) } fn ensure_force_push_refspec(refspec: &str) -> String { // Check if the refspec starts with '+' or ':' if refspec.starts_with('+') || refspec.starts_with(':') { refspec.to_string() // Return as is } else { format!("+{refspec}") // Add '+' prefix } } fn generate_updated_state( git_repo: &Repo, existing_state: &HashMap, refspecs: &Vec, ) -> Result> { let mut new_state = existing_state.clone(); for refspec in refspecs { let (from, to) = refspec_to_from_to(refspec)?; if from.is_empty() { // delete new_state.remove(to); if to.contains("refs/tags") { new_state.remove(&format!("{to}{}", "^{}")); } } else if to.contains("refs/tags") { if let Ok(annotated_tag) = git_repo .git_repo .find_reference(from) .context(format!("cannot find ref {from} to push to {to}"))? .peel(git2::ObjectType::Tag) { // this is an anotated tag so there is a tag oid // ref points to tag oid new_state.insert(to.to_string(), annotated_tag.id().to_string()); // dereferenced tags ref points to commit at its head new_state.insert( format!("{to}{}", "^{}"), git_repo .get_commit_or_tip_of_reference(from) .context(format!( "cannot find commit from annotated tag ref {from} to push to {to}" ))? .to_string(), ); } else { // this is a lightweight tag so there is no tag oid new_state.insert( to.to_string(), git_repo .get_commit_or_tip_of_reference(from) .context(format!( "cannot find commit from annotated tag ref {from} to push to {to}" ))? .to_string(), ); } } else { // add or update new_state.insert( to.to_string(), git_repo .get_commit_or_tip_of_reference(from) .context(format!( "cannot find commit from ref {from} to push to {to}" ))? .to_string(), ); } } Ok(new_state) } async fn get_maintainers_yaml_update( term: &console::Term, decoded_nostr_url: &NostrUrlDecoded, repo_ref: &RepoRef, git_repo: &Repo, signer: &Arc, refspecs_to_git_server: &Vec, ) -> Result> { for refspec in refspecs_to_git_server { let (from, to) = refspec_to_from_to(refspec)?; if to.eq("refs/heads/main") || to.eq("refs/heads/master") { let tip_of_pushed_branch = git_repo.get_commit_or_tip_of_reference(from)?; let tip_of_remote_branch = git_repo.get_commit_or_tip_of_reference(&refspec_remote_ref_name( &git_repo.git_repo, refspec, &decoded_nostr_url.original_string, )?)?; let diff = git_repo.git_repo.diff_tree_to_tree( Some( &git_repo .git_repo .find_commit(sha1_to_oid(&tip_of_pushed_branch)?)? .tree()?, ), Some( &git_repo .git_repo .find_commit(sha1_to_oid(&tip_of_remote_branch)?)? .tree()?, ), None, )?; for delta in diff.deltas() { // File was added or updated if let Some(path) = delta.new_file().path() { if path.to_string_lossy() == "maintainers.yaml" { let config = get_repo_config_from_yaml(git_repo)?; if config.identifier == Some(repo_ref.identifier.clone()) || config.identifier.is_none() { let config_maintainers = config .maintainers .iter() .filter_map(|s| PublicKey::parse(s).ok()) .collect::>(); let config_relays = config .relays .iter() .filter_map(|s| RelayUrl::parse(s).ok()) .collect::>(); if repo_ref.maintainers != config_maintainers || repo_ref.relays != config_relays { let mut repo_ref = repo_ref.clone(); repo_ref.maintainers = config_maintainers; repo_ref.relays = config_relays; term.write_line("maintainers.yaml update detected so publishing repo announcement update")?; return Ok(Some(repo_ref.to_event(signer).await?)); } } } } } } } Ok(None) } async fn get_merged_status_events( term: &console::Term, decoded_nostr_url: &NostrUrlDecoded, repo_ref: &RepoRef, git_repo: &Repo, signer: &Arc, refspecs_to_git_server: &Vec, ) -> Result> { let mut events = vec![]; for refspec in refspecs_to_git_server { let (from, to) = refspec_to_from_to(refspec)?; if to.eq("refs/heads/main") || to.eq("refs/heads/master") { let tip_of_pushed_branch = git_repo.get_commit_or_tip_of_reference(from)?; let Ok(tip_of_remote_branch) = git_repo.get_commit_or_tip_of_reference(&refspec_remote_ref_name( &git_repo.git_repo, refspec, &decoded_nostr_url.original_string, )?) else { // branch not on remote continue; }; let (ahead, _) = git_repo.get_commits_ahead_behind(&tip_of_remote_branch, &tip_of_pushed_branch)?; let commit_events = get_events_from_local_cache( git_repo.get_path()?, vec![ nostr::Filter::default().kind(nostr::Kind::GitPatch), nostr::Filter::default().kind(KIND_PULL_REQUEST), nostr::Filter::default().kind(KIND_PULL_REQUEST_UPDATE), // TODO: limit by repo_ref ], ) .await?; let merged_proposals_info = get_merged_proposals_info(git_repo, &ahead, &commit_events).await?; for event in create_merge_events(term, git_repo, repo_ref, signer, &merged_proposals_info) .await? { events.push(event); } } } Ok(events) } /// (`proposal_id`, `revision_id`) type MergedProposalsInfo = HashMap, HashMap)>; async fn get_merged_proposals_info( git_repo: &Repo, ahead: &Vec, available_patches_prs_pr_updates: &[Event], ) -> Result { let mut proposals: MergedProposalsInfo = HashMap::new(); for commit_hash in ahead { let commit = git_repo.git_repo.find_commit(sha1_to_oid(commit_hash)?)?; // three-way merge - just to set merge commit id as the merged branch commits // are in ahead if commit.parent_count() > 1 { for parent in commit.parents() { for event in available_patches_prs_pr_updates .iter() .filter(|e| { e.tags.iter().any(|t| { t.as_slice().len() > 1 && (t.as_slice()[0].eq("commit") || t.as_slice()[0].eq("c")) && t.as_slice()[1].eq(&parent.id().to_string()) }) }) .collect::>() { if let Ok((proposal_id, revision_id)) = get_proposal_and_revision_root_from_patch_or_pr_or_pr_update( git_repo, event, ) .await { let (entry_revision_id, merged_patches) = proposals.entry(proposal_id).or_default(); if entry_revision_id == &revision_id { merged_patches.insert(*commit_hash, MergedPRCommitType::MergeCommit); } } } } } else { // three way merge or fast forward merge commits // note: ahead included commits of three-way merged branches let mut matching_patches_prs_pr_updates = available_patches_prs_pr_updates .iter() .filter(|e| { e.tags.iter().any(|t| { t.as_slice().len() > 1 && (t.as_slice()[0].eq("commit") || t.as_slice()[0].eq("c")) && t.as_slice()[1].eq(&commit_hash.to_string()) }) }) .collect::>(); for patch_event in &matching_patches_prs_pr_updates { if let Ok((proposal_id, revision_id)) = get_proposal_and_revision_root_from_patch_or_pr_or_pr_update( git_repo, patch_event, ) .await { let (entry_revision_id, merged_patches_pr_pr_updates) = proposals.entry(proposal_id).or_default(); // ignore revisions without all the merged commits if entry_revision_id == &revision_id { merged_patches_pr_pr_updates.insert( *commit_hash, MergedPRCommitType::PatchCommit { event_id: patch_event.id, }, ); } } } // applied commits - this is done after so that merged revisions take priority if matching_patches_prs_pr_updates.is_empty() { let author = git_repo.get_commit_author(commit_hash)?; matching_patches_prs_pr_updates = available_patches_prs_pr_updates .iter() .filter(|e| { if let Ok(patch_author) = get_patch_author(e) { patch_author == author } else { false } }) .collect::>(); for patch_event in matching_patches_prs_pr_updates { if let Ok((proposal_id, revision_id)) = get_proposal_and_revision_root_from_patch_or_pr_or_pr_update( git_repo, patch_event, ) .await { let (entry_revision_id, merged_patches) = proposals.entry(proposal_id).or_default(); // ignore revisions without all the applied commits if entry_revision_id == &revision_id { merged_patches.insert( *commit_hash, MergedPRCommitType::PatchApplied { event_id: patch_event.id, }, ); } } } } } } Ok(proposals) } fn get_patch_author(event: &Event) -> Result> { for t in event.tags.clone() { match t.as_slice() { [tag, name, email, unixtime, offset] if tag == "author" => { return Ok(vec![ name.to_string(), email.to_string(), unixtime.to_string(), offset.to_string(), ]); } _ => (), } } bail!("could not find valid author tag") } async fn create_merge_events( term: &console::Term, git_repo: &Repo, repo_ref: &RepoRef, signer: &Arc, merged_proposals_info: &MergedProposalsInfo, ) -> Result> { let mut events = vec![]; for (proposal_id, (revision_id, merged_patches)) in merged_proposals_info { let proposal = get_event_from_cache_by_id(git_repo, proposal_id).await?; if merged_patches .values() .any(|m| *m == MergedPRCommitType::MergeCommit) { term.write_line( format!( "merge commit {}: create nostr proposal status event", &merged_patches.keys().next().unwrap().to_string()[..7], ) .as_str(), )?; } else if merged_patches .values() .any(|m| matches!(m, MergedPRCommitType::PatchApplied { .. })) { term.write_line( format!( "applied commits from proposal: create nostr proposal status event for {}", event_to_cover_letter(&proposal)? .get_branch_name_with_pr_prefix_and_shorthand_id()?, ) .as_str(), )?; } else { term.write_line( format!( "fast-forward merge: create nostr proposal status event for {}", event_to_cover_letter(&proposal)? .get_branch_name_with_pr_prefix_and_shorthand_id()?, ) .as_str(), )?; } events.push( create_merge_status( signer, repo_ref, &proposal, if let Some(revision_id) = revision_id { Some(get_event_from_cache_by_id(git_repo, revision_id).await?) } else { None } .as_ref(), if let Some((commit, _)) = merged_patches .iter() .find(|(_, m)| **m == MergedPRCommitType::MergeCommit) { vec![*commit] } else { // child commits were added to merged_patches first so we reverse it let mut t: Vec = merged_patches.keys().copied().collect(); t.reverse(); t }, merged_patches .values() .filter_map(|m| match m { MergedPRCommitType::MergeCommit => None, MergedPRCommitType::PatchApplied { event_id } | MergedPRCommitType::PatchCommit { event_id } => Some(*event_id), }) .collect(), !merged_patches .iter() .any(|(_, m)| *m == MergedPRCommitType::MergeCommit) && merged_patches .values() .any(|m| matches!(m, MergedPRCommitType::PatchApplied { .. })), ) .await?, ); } Ok(events) } #[derive(PartialEq, Debug)] enum MergedPRCommitType { MergeCommit, PatchCommit { event_id: EventId }, PatchApplied { event_id: EventId }, } async fn create_merge_status( signer: &Arc, repo_ref: &RepoRef, proposal: &Event, revision: Option<&Event>, merge_commits: Vec, merged_patches: Vec, applied: bool, ) -> Result { let mut public_keys = repo_ref .maintainers .iter() .copied() .collect::>(); public_keys.insert(proposal.pubkey); if let Some(revision) = revision { public_keys.insert(revision.pubkey); } sign_event( EventBuilder::new(nostr::event::Kind::GitStatusApplied, String::new()).tags( [ vec![ Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), vec!["git proposal merged / applied".to_string()], ), Tag::from_standardized(nostr::TagStandard::Event { event_id: proposal.id, relay_url: repo_ref.relays.first().cloned(), marker: Some(Marker::Root), public_key: None, uppercase: false, }), ], // Tags for merged patches merged_patches .iter() .map(|merged_patch| { Tag::from_standardized(nostr::TagStandard::Quote { event_id: *merged_patch, relay_url: repo_ref.relays.first().cloned(), public_key: None, }) }) .collect::>(), if let Some(revision) = revision { vec![Tag::from_standardized(nostr::TagStandard::Event { event_id: revision.id, relay_url: repo_ref.relays.first().cloned(), marker: Some(Marker::Root), public_key: None, uppercase: false, })] } else { vec![] }, public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(), repo_ref .coordinates() .iter() .map(|c| { Tag::from_standardized(TagStandard::Coordinate { coordinate: c.coordinate.clone(), relay_url: c.relays.first().cloned(), uppercase: false, }) }) .collect::>(), vec![ Tag::from_standardized(nostr::TagStandard::Reference( repo_ref.root_commit.to_string(), )), Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed(if applied { "applied-as-commits" } else { "merge-commit-id" })), merge_commits .iter() .map(|merge_commit| format!("{merge_commit}")) .collect::>(), ), ], merge_commits .iter() .map(|merge_commit| { Tag::from_standardized(nostr::TagStandard::Reference(format!( "{merge_commit}" ))) }) .collect::>(), ] .concat(), ), signer, "PR merge".to_string(), ) .await } async fn get_proposal_and_revision_root_from_patch_or_pr_or_pr_update( git_repo: &Repo, event: &Event, ) -> Result<(EventId, Option)> { if event.kind.eq(&KIND_PULL_REQUEST) { return Ok((event.id, None)); } else if event.kind.eq(&KIND_PULL_REQUEST_UPDATE) { if let Some(root) = extract_root(event) { if let CommentTarget::Event { id, relay_hint: _, pubkey_hint: _, kind, } = root { if let Some(kind) = kind { if !kind.eq(&KIND_PULL_REQUEST) { bail!( "pull request update {} root event is {} and not a pull request kind", { event.id.to_bech32()? }, kind ); } } return Ok((id, None)); } bail!( "pull request update {} root event is not a pull request event", event.id.to_bech32()? ); } bail!( "pull request update {} root event is not a pull request event", { event.id.to_bech32()? } ); } let proposal_or_revision = if event .tags .iter() .any(|t| t.as_slice().len() > 1 && t.as_slice()[1].eq("root")) { event.clone() } else { let proposal_or_revision_id = EventId::parse( &if let Some(t) = event.tags.iter().find(|t| t.is_root()) { t.clone() } else if let Some(t) = event.tags.iter().find(|t| t.is_reply()) { t.clone() } else { Tag::event(event.id) } .as_slice()[1] .clone(), )?; get_events_from_local_cache( git_repo.get_path()?, vec![nostr::Filter::default().id(proposal_or_revision_id)], ) .await? .first() .unwrap() .clone() }; if !proposal_or_revision.kind.eq(&Kind::GitPatch) { bail!("thread root is not a git patch"); } if proposal_or_revision.tags.iter().any(|t| { t.as_slice().len() > 1 && ["revision-root", "root-revision"].contains(&t.as_slice()[1].as_str()) }) { Ok(( EventId::parse( &proposal_or_revision .tags .iter() .find(|t| t.is_reply()) .unwrap() .as_slice()[1], )?, Some(proposal_or_revision.id), )) } else { Ok((proposal_or_revision.id, None)) } } fn update_remote_refs_pushed( git_repo: &Repository, refspec: &str, nostr_remote_url: &str, ) -> Result<()> { let (from, _) = refspec_to_from_to(refspec)?; let target_ref_name = refspec_remote_ref_name(git_repo, refspec, nostr_remote_url)?; if from.is_empty() { if let Ok(mut remote_ref) = git_repo.find_reference(&target_ref_name) { remote_ref.delete()?; } } else { let commit = reference_to_commit(git_repo, from) .context(format!("failed to get commit of reference {from}"))?; if let Ok(mut remote_ref) = git_repo.find_reference(&target_ref_name) { remote_ref.set_target(commit, "updated by nostr remote helper")?; } else { git_repo.reference( &target_ref_name, commit, false, "created by nostr remote helper", )?; } } Ok(()) } fn refspec_to_from_to(refspec: &str) -> Result<(&str, &str)> { if !refspec.contains(':') { bail!("refspec should contain a colon (:) but consists of: {refspec}"); } let parts = refspec.split(':').collect::>(); Ok(( if parts.first().unwrap().starts_with('+') { &parts.first().unwrap()[1..] } else { parts.first().unwrap() }, parts.get(1).unwrap(), )) } fn refspec_remote_ref_name( git_repo: &Repository, refspec: &str, nostr_remote_url: &str, ) -> Result { let (_, to) = refspec_to_from_to(refspec)?; let nostr_remote = git_repo .find_remote(&get_remote_name_by_url(git_repo, nostr_remote_url)?) .context("we should have just located this remote")?; Ok(format!( "refs/remotes/{}/{}", nostr_remote.name().context("remote should have a name")?, to.replace("refs/heads/", ""), /* TODO only replace if it begins with this * TODO what about tags? */ )) } fn reference_to_commit(git_repo: &Repository, reference: &str) -> Result { Ok(git_repo .find_reference(reference) .context(format!("failed to find reference: {reference}"))? .peel_to_commit() .context(format!("failed to get commit from reference: {reference}"))? .id()) } // this maybe a commit id or a ref: pointer fn reference_to_ref_value(git_repo: &Repository, reference: &str) -> Result { let reference_obj = git_repo .find_reference(reference) .context(format!("failed to find reference: {reference}"))?; if let Some(symref) = reference_obj.symbolic_target() { Ok(symref.to_string()) } else { Ok(reference_obj .peel_to_commit() .context(format!("failed to get commit from reference: {reference}"))? .id() .to_string()) } } fn get_refspecs_from_push_batch(stdin: &Stdin, initial_refspec: &str) -> Result> { let mut line = String::new(); let mut refspecs = vec![initial_refspec.to_string()]; loop { let tokens = read_line(stdin, &mut line)?; match tokens.as_slice() { ["push", spec] => { refspecs.push((*spec).to_string()); } [] => break, _ => { bail!("after a `push` command we are only expecting another push or an empty line") } } } Ok(refspecs) } #[cfg(test)] mod tests { use super::*; mod refspec_to_from_to { use super::*; #[test] fn trailing_plus_stripped() { let (from, _) = refspec_to_from_to("+testing:testingb").unwrap(); assert_eq!(from, "testing"); } } }