From e6bb9effa194fe63b5e969c090dbe6e93f13d312 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 18 Feb 2026 12:38:37 +0000 Subject: feat: support PR-format proposals in ngit apply Instead of erroring when a proposal uses PR format (kind 1618/1619), ngit apply now fetches the tip commit from git servers, determines the base commit via the merge-base tag or by walking ahead of main, generates patch text for each commit using git2, and applies via git am. Also fixes a bug where clone-tag server hints were silently skipped in the fetch fallback, and refactors the git-am invocation into a shared helper to avoid duplication between the patch and PR code paths. --- src/bin/ngit/sub_commands/apply.rs | 169 ++++++++++++++++++++++++++++++++----- 1 file changed, 146 insertions(+), 23 deletions(-) (limited to 'src/bin/ngit/sub_commands/apply.rs') diff --git a/src/bin/ngit/sub_commands/apply.rs b/src/bin/ngit/sub_commands/apply.rs index 4b13975..3700c37 100644 --- a/src/bin/ngit/sub_commands/apply.rs +++ b/src/bin/ngit/sub_commands/apply.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashSet, io::Write, process::{Command, Stdio}, time::Duration, @@ -8,7 +9,13 @@ use anyhow::{Context, Result, bail}; use indicatif::{ProgressBar, ProgressStyle}; use ngit::{ client::get_all_proposal_patch_pr_pr_update_events_from_cache, - git_events::get_pr_tip_event_or_most_recent_patch_with_ancestors, + 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}; @@ -123,17 +130,15 @@ pub async fn launch(id: &str, stdout: bool, offline: bool) -> Result<()> { 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| { - [ - ngit::git_events::KIND_PULL_REQUEST, - ngit::git_events::KIND_PULL_REQUEST_UPDATE, - ] - .contains(&e.kind) - }) { - bail!( - "this proposal uses PR format (not patches). Use `ngit checkout {}` instead.", - event_id.to_hex() - ); + 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 { @@ -159,16 +164,124 @@ fn parse_event_id(id: &str) -> Result { bail!("invalid event-id or nevent: {id}") } -fn output_patches_to_stdout(mut patches: Vec) { - patches.reverse(); - for patch in patches { - print!("{}\n\n", patch.content); +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 launch_git_am_with_patches(mut patches: Vec) -> Result<()> { +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`"); - patches.reverse(); let mut am = std::process::Command::new("git") .arg("am") @@ -183,15 +296,25 @@ fn launch_git_am_with_patches(mut patches: Vec) -> Result<()> { .as_mut() .context("git am process failed to take stdin")?; - for patch in patches { + for patch in patch_texts { stdin - .write(format!("{}\n\n", patch.content).as_bytes()) + .write(format!("{patch}\n\n").as_bytes()) .context("failed to write patch content into git am stdin buffer")?; } stdin.flush()?; - let output = am - .wait_with_output() + am.wait_with_output() .context("failed to read git am stdout")?; - print!("{:?}", output.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()) +} -- cgit v1.2.3