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<HashMap<String, (HashMap<String, String>, bool)>>,
title_description: Option<(String, String)>,
git_server_push_options: Vec<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::<Vec<String>>();
let mut git_state_refspecs = refspecs
.iter()
.filter(|r| !r.contains("refs/heads/pr/"))
.cloned()
.collect::<Vec<String>>();
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 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
}
});
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, 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")?;
}
let mut servers_to_push: Vec<(String, Vec<String>)> = 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::<Vec<String>>();
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 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 !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(())
}
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<String>,
proposal_refspecs: &Vec<String>,
client: &mut Client,
existing_state: HashMap<String, String>,
term: &Term,
title_description: Option<&(String, String)>,
git_server_push_options: &[String],
) -> Result<(
Vec<String>,
bool,
Vec<(String, bool)>,
Option<Event>,
Option<EventId>,
)> {
let (signer, mut user_ref, _) = load_existing_login(
&Some(git_repo),
&None,
&None,
&None,
Some(client),
true, false, true, )
.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))
{
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<Event> = None;
let mut new_state_event_id: Option<EventId> = 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 {
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);
}
if let Ok(merged_status_events) = get_merged_status_events(
term,
&repo_ref.to_nostr_git_url(&None),
repo_ref,
git_repo,
&signer,
git_server_refspecs,
)
.await
{
for event in merged_status_events {
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);
}
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<String>,
user_ref: &mut UserRef,
signer: &Arc<dyn NostrSigner>,
term: &Term,
title_description: Option<&(String, String)>,
git_server_push_options: &[String],
) -> Result<(Vec<Event>, Vec<String>)> {
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)?;
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('+') {
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 {
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.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 {
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 {
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<dyn NostrSigner>,
term: &Term,
title_description: Option<&(String, String)>,
git_server_push_options: &[String],
) -> Result<Vec<Event>> {
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")?; 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<String, Vec<String>>;
#[allow(clippy::too_many_lines)]
fn create_rejected_refspecs_and_remotes_refspecs(
term: &console::Term,
git_repo: &Repo,
refspecs: &Vec<String>,
nostr_state: &HashMap<String, String>,
list_outputs: &HashMap<String, (HashMap<String, String>, 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() {
refspecs_for_remote.push(refspec.clone());
}
continue;
}
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 {
} 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 {
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(),
)?;
}
} else {
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) {
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) {
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() {
refspecs_for_remote.push(refspec.clone());
} else {
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() {
refspecs_for_remote.push(refspec.clone());
} else if is_grasp_server {
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 {
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 {
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 {
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() {
refspecs_for_remote.push(refspec.clone());
} else if is_grasp_server {
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} 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 {
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 {
refspecs_for_remote.push(refspec.clone());
}
}
if !refspecs_for_remote.is_empty() {
refspecs_for_remotes.insert(url.to_string(), refspecs_for_remote);
}
}
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 {
if refspec.starts_with('+') || refspec.starts_with(':') {
refspec.to_string() } else {
format!("+{refspec}") }
}
fn generate_updated_state(
git_repo: &Repo,
existing_state: &HashMap<String, String>,
refspecs: &Vec<String>,
) -> Result<HashMap<String, String>> {
let mut new_state = existing_state.clone();
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;
}
if let Ok(oid) = git2::Oid::from_str(&tag_oid) {
if git_repo
.git_repo
.find_object(oid, Some(git2::ObjectType::Tag))
.is_ok()
{
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() {
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)
{
new_state.insert(to.to_string(), annotated_tag.id().to_string());
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 {
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 {
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<dyn NostrSigner>,
refspecs_to_git_server: &Vec<String>,
) -> Result<Option<Event>> {
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() {
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::<Vec<PublicKey>>();
let config_relays = config
.relays
.iter()
.filter_map(|s| RelayUrl::parse(s).ok())
.collect::<Vec<RelayUrl>>();
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<dyn NostrSigner>,
refspecs_to_git_server: &Vec<String>,
) -> Result<Vec<Event>> {
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 {
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),
],
)
.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)
}
type MergedProposalsInfo =
HashMap<EventId, (Option<EventId>, HashMap<Sha1Hash, MergedPRCommitType>)>;
async fn get_merged_proposals_info(
git_repo: &Repo,
ahead: &Vec<Sha1Hash>,
available_patches_prs_pr_updates: &[Event],
) -> Result<MergedProposalsInfo> {
let mut proposals: MergedProposalsInfo = HashMap::new();
for commit_hash in ahead {
let commit = git_repo.git_repo.find_commit(sha1_to_oid(commit_hash)?)?;
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::<Vec<&Event>>()
{
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 {
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::<Vec<&Event>>();
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();
if entry_revision_id == &revision_id {
merged_patches_pr_pr_updates.insert(
*commit_hash,
MergedPRCommitType::PatchCommit {
event_id: patch_event.id,
},
);
}
}
}
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::<Vec<&Event>>();
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();
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<Vec<String>> {
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<dyn NostrSigner>,
merged_proposals_info: &MergedProposalsInfo,
) -> Result<Vec<Event>> {
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()
.map(|h| {
let s = h.to_string();
s[..s.len().min(7)].to_string()
})
.unwrap_or_default(),
)
.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 {
let mut t: Vec<Sha1Hash> = 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<dyn NostrSigner>,
repo_ref: &RepoRef,
proposal: &Event,
revision: Option<&Event>,
merge_commits: Vec<Sha1Hash>,
merged_patches: Vec<EventId>,
applied: bool,
) -> Result<Event> {
let mut public_keys = repo_ref
.maintainers
.iter()
.copied()
.collect::<HashSet<PublicKey>>();
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,
}),
],
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::<Vec<Tag>>(),
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>>(),
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::<Vec<String>>(),
),
],
merge_commits
.iter()
.map(|merge_commit| {
Tag::from_standardized(nostr::TagStandard::Reference(format!(
"{merge_commit}"
)))
})
.collect::<Vec<Tag>>(),
]
.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<EventId>)> {
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(),
)?;
let cached = get_events_from_local_cache(
git_repo.get_path()?,
vec![nostr::Filter::default().id(proposal_or_revision_id)],
)
.await?;
cached
.first()
.ok_or_else(|| {
anyhow::anyhow!(
"proposal or revision root event {proposal_or_revision_id} not found in local cache",
)
})?
.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())
.ok_or_else(|| {
anyhow::anyhow!(
"revision-root patch event {} missing reply tag",
proposal_or_revision.id
)
})?
.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 {
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::<Vec<&str>>();
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<String> {
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<Oid> {
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())
}
fn reference_to_ref_value(git_repo: &Repository, reference: &str) -> Result<String> {
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<Vec<String>> {
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");
}
}
}