use core::str; use std::{ collections::{HashMap, HashSet}, io::Stdin, sync::Arc, }; use anyhow::{Context, Result, bail}; use client::{ delete_event_from_local_cache, 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::{ accept_maintainership::accept_maintainership_with_defaults, client::{ self, get_event_from_cache_by_id, get_filter_state_events, save_event_in_local_cache, }, 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, format_grasp_server_url_as_relay_url, 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::too_many_arguments)] #[allow(clippy::type_complexity)] pub async fn run_push( git_repo: &Repo, repo_ref: &RepoRef, stdin: &Stdin, initial_refspec: &str, client: &mut Client, list_outputs: Option, bool)>>, title_description: Option<(String, String)>, git_server_push_options: Vec, ) -> 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, relay_results, old_state_event, new_state_event_id, ) = create_and_publish_events_and_proposals( git_repo, repo_ref, &git_state_refspecs, &proposal_refspecs, client, // &mut Client existing_state, &term, title_description.as_ref(), &git_server_push_options, ) .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 // Filter out grasp servers whose relay did not receive the state event let mut servers_to_push: Vec<(String, Vec)> = vec![]; for (git_server_url, server_refspecs) in remote_refspecs { let server_refspecs = server_refspecs .iter() .filter(|refspec| git_state_refspecs.contains(refspec)) .cloned() .collect::>(); if is_grasp_server_clone_url(&git_server_url) && !relay_results.is_empty() { if let Ok(relay_url) = format_grasp_server_url_as_relay_url(&git_server_url) { let relay_failed = relay_results .iter() .any(|(url, succeeded)| url == &relay_url && !succeeded); if relay_failed { let short_name = get_short_git_server_name(&git_server_url); eprintln!( "WARNING: skipping {short_name} - state event failed to reach its relay" ); continue; } } } servers_to_push.push((git_server_url, server_refspecs)); } // If all git servers were skipped and there were refspecs to push, // emit error lines for each ref using the git remote helper protocol // and roll back the state event in the local cache if servers_to_push.is_empty() && !git_state_refspecs.is_empty() { for refspec in &git_state_refspecs { let (_, to) = refspec_to_from_to(refspec)?; println!("error {to} state event failed to reach any git server relay"); } if let Some(new_id) = new_state_event_id { rollback_state_event(git_repo.get_path()?, new_id, old_state_event.as_ref()) .await; } } else { let mut any_push_succeeded = false; for (git_server_url, server_refspecs) in &servers_to_push { if !server_refspecs.is_empty() { let push_options_refs: Vec<&str> = git_server_push_options.iter().map(String::as_str).collect(); if push_to_remote( git_repo, git_server_url, &repo_ref.to_nostr_git_url(&None), server_refspecs, &term, is_grasp_server_clone_url(git_server_url), &push_options_refs, ) .is_ok() { any_push_succeeded = true; } } } // If every git server push failed, roll back the state event if !any_push_succeeded && !git_state_refspecs.is_empty() { if let Some(new_id) = new_state_event_id { rollback_state_event( git_repo.get_path()?, new_id, old_state_event.as_ref(), ) .await; } } } } } println!(); Ok(()) } /// Remove the newly-published state event from the local nostr cache and /// restore the previous state event (if any). This prevents a subsequent /// `ngit sync` or push from using a state that no git server ever accepted. async fn rollback_state_event( git_repo_path: &std::path::Path, new_state_event_id: EventId, old_state_event: Option<&Event>, ) { if let Err(e) = delete_event_from_local_cache(git_repo_path, new_state_event_id).await { eprintln!("WARNING: failed to roll back state event from local cache: {e}"); return; } if let Some(old_event) = old_state_event { if let Err(e) = save_event_in_local_cache(git_repo_path, old_event).await { eprintln!("WARNING: failed to restore previous state event in local cache: {e}"); } } } #[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: &mut Client, existing_state: HashMap, term: &Term, title_description: Option<&(String, String)>, git_server_push_options: &[String], ) -> Result<( Vec, bool, Vec<(String, bool)>, Option, Option, )> { 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, vec![], None, None)); } } else if repo_ref .maintainers_without_annoucnement .clone() .is_some_and(|ms| ms.contains(&user_ref.public_key)) { // Auto-accept co-maintainership: publish the user's own announcement // with defaults before proceeding with the push. The announcement is // required (not just for consent, but to prevent scammers from // attributing a person's state events to a fake project with the same // identifier). See docs/design/co-maintainer-announcement-rationale.md. accept_maintainership_with_defaults(git_repo, repo_ref, &user_ref, client, &signer) .await .context("failed to auto-accept co-maintainership")?; } let mut events = vec![]; let mut old_state_event: Option = None; let mut new_state_event_id: Option = None; 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 { // Capture the existing state event before publishing the new one, // so we can restore it if all git server pushes fail. old_state_event = get_events_from_local_cache( git_repo.get_path()?, vec![get_filter_state_events(&repo_ref.coordinates(), true)], ) .await .ok() .and_then(|mut events| { events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); events.into_iter().next() }); let new_repo_state = RepoState::build(repo_ref.identifier.clone(), new_state, &signer).await?; new_state_event_id = Some(new_repo_state.event.id); 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, git_server_push_options, ) .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 let repo_relay_only = if let Ok(Some(v)) = git_repo.get_git_config_item("nostr.repo-relay-only", None) { v == "true" } else { false }; let relay_results = if events.is_empty() { vec![] } else { send_events( client, Some(git_repo.get_path()?), events, if repo_relay_only { vec![] } else { user_ref.relays.write() }, repo_ref.relays.clone(), true, false, ) .await? }; Ok(( rejected_proposal_refspecs, false, relay_results, old_state_event, new_state_event_id, )) } #[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)>, git_server_push_options: &[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, git_server_push_options, ) .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, git_server_push_options, ) .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, git_server_push_options, ) .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)>, git_server_push_options: &[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.last().context("no commits")?; // ahead is oldest first (callers reverse it) let first_commit = ahead.first().context("no commits")?; let push_options_refs: Vec<&str> = git_server_push_options.iter().map(String::as_str).collect(); 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, &push_options_refs, ) .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( title_description.cloned(), 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(); // Backfill missing ^{} peeled refs for any annotated tags already in the // state. State events published before this fix only stored the tag object // OID; without the corresponding ^{} entry git cannot resolve the tag to a // commit and treats it as missing (git fetch --prune deletes it). We fix // this opportunistically on every push so affected repos self-heal without // requiring manual intervention. let tag_refs: Vec<(String, String)> = new_state .iter() .filter(|(k, _)| k.starts_with("refs/tags/") && !k.ends_with("^{}")) .map(|(k, v)| (k.clone(), v.clone())) .collect(); for (ref_name, tag_oid) in tag_refs { let peeled_key = format!("{ref_name}^{{}}"); if new_state.contains_key(&peeled_key) { continue; } // check if the stored OID is a tag object (annotated tag) if let Ok(oid) = git2::Oid::from_str(&tag_oid) { if git_repo .git_repo .find_object(oid, Some(git2::ObjectType::Tag)) .is_ok() { // peel to the commit the annotated tag points to if let Ok(commit_oid) = git_repo.get_commit_or_tip_of_reference(&ref_name) { new_state.insert(peeled_key, commit_oid.to_string()); } } } } 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, to) = 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 { // For annotated tags, store the tag object OID (not the peeled commit) // to match what generate_updated_state puts in the nostr state event. // For branches and lightweight tags, store the commit OID as before. let oid = if to.starts_with("refs/tags/") { if let Ok(tag_obj) = git_repo .find_reference(from) .context(format!("failed to find reference: {from}"))? .peel(git2::ObjectType::Tag) { tag_obj.id() } else { reference_to_commit(git_repo, from) .context(format!("failed to get commit of reference {from}"))? } } else { 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(oid, "updated by nostr remote helper")?; } else { git_repo.reference( &target_ref_name, oid, 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")?; let short_name = if let Some(s) = to.strip_prefix("refs/heads/") { s.to_string() } else if let Some(s) = to.strip_prefix("refs/tags/") { s.to_string() } else { to.to_string() }; Ok(format!( "refs/remotes/{}/{}", nostr_remote.name().context("remote should have a name")?, short_name, )) } 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"); } } }