use std::{ collections::HashSet, io::Write, process::{Command, Stdio}, time::Duration, }; use anyhow::{Context, Result, bail}; use indicatif::{ProgressBar, ProgressStyle}; use ngit::{ client::get_all_proposal_patch_pr_pr_update_events_from_cache, fetch::fetch_from_git_server, git::str_to_sha1, git_events::{ KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, get_pr_tip_event_or_most_recent_patch_with_ancestors, tag_value, }, repo_ref::{RepoRef, is_grasp_server_in_list}, }; use nostr::nips::nip19::Nip19; use nostr_sdk::{EventId, FromBech32}; use crate::{ client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache}, git::{Repo, RepoActions}, repo_ref::get_repo_coordinates_when_remote_unknown, }; fn run_git_fetch(remote_name: &str) -> Result<()> { let verbose = ngit::client::is_verbose(); if verbose { println!("fetching from {remote_name}..."); } let spinner = if verbose { None } else { let pb = ProgressBar::new_spinner() .with_style( ProgressStyle::with_template("{spinner} {msg}") .unwrap() .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"), ) .with_message(format!("Fetching from {remote_name}...")); pb.enable_steady_tick(Duration::from_millis(100)); Some(pb) }; let output = Command::new("git") .args(["fetch", remote_name]) .stdout(if verbose { Stdio::inherit() } else { Stdio::piped() }) .stderr(if verbose { Stdio::inherit() } else { Stdio::piped() }) .output() .context("failed to run git fetch")?; if let Some(spinner) = spinner { spinner.finish_and_clear(); } if !output.status.success() { if !verbose { let stderr = String::from_utf8_lossy(&output.stderr); if !stderr.is_empty() { eprintln!("{stderr}"); } } bail!( "git fetch {remote_name} exited with error: {}", output.status ); } Ok(()) } pub async fn launch(id: &str, stdout: bool, offline: bool) -> Result<()> { let event_id = parse_event_id(id)?; let git_repo = Repo::discover().context("failed to find a git repository")?; let git_repo_path = git_repo.get_path()?; let client = Client::new(ngit::client::Params::with_git_config_relay_defaults(&Some( &git_repo, ))); let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; let nostr_remote = git_repo .get_first_nostr_remote_when_in_ngit_binary() .await .ok() .flatten(); if !offline { if let Some((remote_name, _)) = &nostr_remote { run_git_fetch(remote_name)?; } else { fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; } } let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?; let proposals_and_revisions: Vec = ngit::client::get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()) .await?; let proposal = proposals_and_revisions .iter() .find(|e| e.id == event_id) .context(format!( "proposal with id {} not found in cache", event_id.to_hex() ))?; let commits_events: Vec = get_all_proposal_patch_pr_pr_update_events_from_cache( git_repo_path, &repo_ref, &proposal.id, ) .await?; let patches = get_pr_tip_event_or_most_recent_patch_with_ancestors(commits_events.clone()) .context("failed to find any PR or patch events on this proposal")?; if patches .iter() .any(|e| [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&e.kind)) { let pr_event = patches .first() .context("patch chain should contain at least one event")?; apply_pr(&git_repo, &repo_ref, pr_event, stdout)?; return Ok(()); } if stdout { output_patches_to_stdout(patches); } else { launch_git_am_with_patches(patches)?; } Ok(()) } fn parse_event_id(id: &str) -> Result { if let Ok(nip19) = Nip19::from_bech32(id) { match nip19 { Nip19::Event(e) => return Ok(e.event_id), Nip19::EventId(event_id) => return Ok(event_id), _ => {} } } if let Ok(event_id) = EventId::from_hex(id) { return Ok(event_id); } bail!("invalid event-id or nevent: {id}") } fn fetch_oid_for_pr( oid: &str, git_repo: &Repo, repo_ref: &RepoRef, pr_event: &nostr::Event, ) -> Result<()> { let git_servers = { let mut seen: HashSet = HashSet::new(); let mut out: Vec = vec![]; for tag in pr_event.tags.as_slice() { if tag.kind().eq(&nostr::event::TagKind::Clone) { for clone_url in tag.as_slice().iter().skip(1) { seen.insert(clone_url.clone()); out.push(clone_url.clone()); } } } for server in &repo_ref.git_server { if seen.insert(server.clone()) { out.push(server.clone()); } } out }; let term = console::Term::stderr(); for git_server_url in &git_servers { if fetch_from_git_server( git_repo, &[oid.to_string()], git_server_url, &repo_ref.to_nostr_git_url(&None), &term, is_grasp_server_in_list(git_server_url, &repo_ref.grasp_servers()), ) .is_ok() { return Ok(()); } } if !git_repo.does_commit_exist(oid)? { bail!( "cannot find proposal git data from proposal git server hint or repository git servers" ); } Ok(()) } fn apply_pr( git_repo: &Repo, repo_ref: &RepoRef, pr_event: &nostr::Event, stdout: bool, ) -> Result<()> { let tip_oid = tag_value(pr_event, "c").context("PR event is missing 'c' (tip commit) tag")?; // Ensure the tip commit is available locally if !git_repo.does_commit_exist(&tip_oid)? { fetch_oid_for_pr(&tip_oid, git_repo, repo_ref, pr_event)?; } let tip = str_to_sha1(&tip_oid).context("invalid tip commit OID in PR event")?; // Determine the base commit: prefer the merge-base tag, fall back to // computing the divergence point from main/master. let base = if let Ok(merge_base_oid) = tag_value(pr_event, "merge-base") { str_to_sha1(&merge_base_oid).context("invalid merge-base OID in PR event")? } else { let (_, main_tip) = git_repo .get_main_or_master_branch() .context("could not determine main branch to compute PR base commit")?; let (ahead, _behind) = git_repo .get_commits_ahead_behind(&main_tip, &tip) .context("failed to compute commits between main and PR tip")?; // ahead is youngest-first; the last element is the oldest PR commit, // whose parent is the effective base. let oldest_pr_commit = ahead .last() .context("no commits found between main and PR tip")?; git_repo .get_commit_parent(oldest_pr_commit) .context("failed to get parent of the oldest PR commit")? }; // Collect commits from base..tip (youngest-first from get_commits_ahead_behind) let (commits_youngest_first, _) = git_repo .get_commits_ahead_behind(&base, &tip) .context("failed to enumerate commits in PR")?; if commits_youngest_first.is_empty() { bail!("no commits found between base and PR tip"); } let total = commits_youngest_first.len() as u64; // Generate patches oldest-first let mut patch_texts: Vec = Vec::with_capacity(commits_youngest_first.len()); for (i, commit) in commits_youngest_first.iter().rev().enumerate() { let series_count = Some((i as u64 + 1, total)); let patch = git_repo .make_patch_from_commit(commit, &series_count) .with_context(|| format!("failed to generate patch for commit {commit}"))?; patch_texts.push(patch); } if stdout { for patch in &patch_texts { print!("{patch}\n\n"); } } else { apply_patch_texts(patch_texts)?; } Ok(()) } fn apply_patch_texts(patch_texts: Vec) -> Result<()> { println!("applying to current branch with `git am`"); let mut am = std::process::Command::new("git") .arg("am") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .spawn() .context("failed to spawn git am")?; let stdin = am .stdin .as_mut() .context("git am process failed to take stdin")?; for patch in patch_texts { stdin .write(format!("{patch}\n\n").as_bytes()) .context("failed to write patch content into git am stdin buffer")?; } stdin.flush()?; am.wait_with_output() .context("failed to read git am stdout")?; Ok(()) } fn output_patches_to_stdout(mut patches: Vec) { patches.reverse(); for patch in patches { print!("{}\n\n", patch.content); } } fn launch_git_am_with_patches(mut patches: Vec) -> Result<()> { patches.reverse(); apply_patch_texts(patches.into_iter().map(|p| p.content).collect()) }