From 8293af72744bc89beac57a194602938536b429db Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 12 Feb 2026 09:47:43 +0000 Subject: feat: add ngit checkout command for non-interactive proposal checkout Adds a new 'ngit checkout ' command that creates or updates a proposal branch and checks it out. Supports both PRs and patches with parent-commit references. Phase 1 of non-interactive ngit list implementation. --- src/bin/ngit/cli.rs | 5 + src/bin/ngit/main.rs | 1 + src/bin/ngit/sub_commands/checkout.rs | 269 ++++++++++++++++++++++++++++++++++ src/bin/ngit/sub_commands/mod.rs | 1 + 4 files changed, 276 insertions(+) create mode 100644 src/bin/ngit/sub_commands/checkout.rs diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs index 47f4b27..5c1a097 100644 --- a/src/bin/ngit/cli.rs +++ b/src/bin/ngit/cli.rs @@ -106,6 +106,11 @@ pub enum Commands { Send(sub_commands::send::SubCommandArgs), /// list PRs; checkout, apply or download selected List, + /// checkout a proposal branch by event-id or nevent + Checkout { + /// Proposal event-id (hex) or nevent (bech32) + id: String, + }, /// update repo git servers to reflect nostr state (add, update or delete /// remote refs) Sync(sub_commands::sync::SubCommandArgs), diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs index b6b51d0..2c9e10f 100644 --- a/src/bin/ngit/main.rs +++ b/src/bin/ngit/main.rs @@ -53,6 +53,7 @@ async fn main() { } Commands::Send(args) => sub_commands::send::launch(&cli, args, false).await, Commands::Sync(args) => sub_commands::sync::launch(args).await, + Commands::Checkout { id } => sub_commands::checkout::launch(id).await, } } else { // Handle the case where no command is provided diff --git a/src/bin/ngit/sub_commands/checkout.rs b/src/bin/ngit/sub_commands/checkout.rs new file mode 100644 index 0000000..0df1134 --- /dev/null +++ b/src/bin/ngit/sub_commands/checkout.rs @@ -0,0 +1,269 @@ +use std::collections::HashSet; + +use anyhow::{Context, Result, bail}; +use ngit::{ + client::{ + Params, get_all_proposal_patch_pr_pr_update_events_from_cache, + get_proposals_and_revisions_from_cache, + }, + fetch::fetch_from_git_server, + git_events::{ + KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, get_commit_id_from_patch, + 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, str_to_sha1}, + git_events::{event_to_cover_letter, patch_supports_commit_ids}, + repo_ref::get_repo_coordinates_when_remote_unknown, +}; + +pub async fn launch(id: &str) -> 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(Params::with_git_config_relay_defaults(&Some(&git_repo))); + + let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; + + 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 = + 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 cover_letter = event_to_cover_letter(proposal) + .context("failed to extract proposal details from proposal root event")?; + + let commits_events: Vec = get_all_proposal_patch_pr_pr_update_events_from_cache( + git_repo_path, + &repo_ref, + &proposal.id, + ) + .await?; + + let most_recent_proposal_patch_chain_or_pr_or_pr_update = + 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 most_recent_proposal_patch_chain_or_pr_or_pr_update + .iter() + .any(|e| [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&e.kind)) + { + checkout_pr( + &git_repo, + &repo_ref, + &cover_letter, + &most_recent_proposal_patch_chain_or_pr_or_pr_update, + ) + } else { + checkout_patch( + &git_repo, + &cover_letter, + &most_recent_proposal_patch_chain_or_pr_or_pr_update, + ) + } +} + +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 checkout_pr( + git_repo: &Repo, + repo_ref: &RepoRef, + cover_letter: &crate::git_events::CoverLetter, + most_recent_proposal_patch_chain_or_pr_or_pr_update: &[nostr::Event], +) -> Result<()> { + let branch_name = cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?; + let local_branch_tip = git_repo.get_tip_of_branch(&branch_name).ok(); + let proposal_tip_event = most_recent_proposal_patch_chain_or_pr_or_pr_update + .first() + .context("most_recent_proposal_patch_chain_or_pr_or_pr_update will always contain an event with c tag")?; + let proposal_tip = tag_value(proposal_tip_event, "c")?; + + if let Some(local_branch_tip) = local_branch_tip { + git_repo + .checkout(&branch_name) + .context("cannot checkout existing proposal branch")?; + if local_branch_tip.to_string() == proposal_tip { + println!("checked out up-to-date proposal branch '{branch_name}'"); + return Ok(()); + } + if git_repo.does_commit_exist(&proposal_tip)? { + git_repo.create_branch_at_commit(&branch_name, &proposal_tip)?; + git_repo.checkout(&branch_name)?; + println!("checked out proposal branch and updated tip '{branch_name}'"); + return Ok(()); + } + } + + fetch_oid_for_from_servers_for_pr( + &proposal_tip, + git_repo, + repo_ref, + proposal_tip_event, + )?; + git_repo.create_branch_at_commit(&branch_name, &proposal_tip)?; + git_repo.checkout(&branch_name)?; + if local_branch_tip.is_some() { + println!("checked out proposal branch and pulled updates '{branch_name}'"); + } else { + println!("created and checked out proposal branch '{branch_name}'"); + } + Ok(()) +} + +fn checkout_patch( + git_repo: &Repo, + cover_letter: &crate::git_events::CoverLetter, + most_recent_proposal_patch_chain_or_pr_or_pr_update: &[nostr::Event], +) -> Result<()> { + let no_support_for_patches_as_branch = most_recent_proposal_patch_chain_or_pr_or_pr_update + .iter() + .any(|event| !patch_supports_commit_ids(event)); + + if no_support_for_patches_as_branch { + bail!( + "this proposal cannot be checked out as a branch because some patches do not have a parent commit.\n\ + Try `ngit apply --stdout` to apply patches to the current branch, or use `ngit list` for interactive options." + ); + } + + let proposal_base_commit = str_to_sha1(&tag_value( + most_recent_proposal_patch_chain_or_pr_or_pr_update + .last() + .context("there should be at least one patch")?, + "parent-commit", + )?) + .context("failed to get valid parent commit id from patch")?; + + let (main_branch_name, _master_tip) = git_repo.get_main_or_master_branch()?; + + if !git_repo.does_commit_exist(&proposal_base_commit.to_string())? { + bail!( + "the proposal parent commit doesn't exist in your local repository.\n\ + Try running `git pull` on '{main_branch_name}' first, or use `ngit apply --stdout` to apply patches to the current branch." + ); + } + + if git_repo.has_outstanding_changes()? { + bail!( + "working directory is not clean. Discard or stash (un)staged changes and try again." + ); + } + + let branch_name = cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?; + let branch_exists = git_repo + .get_local_branch_names() + .context("failed to get local branch names")? + .iter() + .any(|n| n.eq(&branch_name)); + + if !branch_exists { + let _ = git_repo + .apply_patch_chain(&branch_name, most_recent_proposal_patch_chain_or_pr_or_pr_update.to_vec()) + .context("failed to apply patch chain")?; + println!("checked out proposal as '{branch_name}' branch"); + return Ok(()); + } + + let local_branch_tip = git_repo.get_tip_of_branch(&branch_name)?; + + let proposal_tip = str_to_sha1( + &get_commit_id_from_patch( + most_recent_proposal_patch_chain_or_pr_or_pr_update + .first() + .context("there should be at least one patch")?, + ) + .context("failed to get valid commit_id from patch")?, + ) + .context("failed to get valid commit_id from patch")?; + + if proposal_tip.eq(&local_branch_tip) { + git_repo.checkout(&branch_name)?; + println!("branch '{branch_name}' checked out and up-to-date"); + return Ok(()); + } + + git_repo.create_branch_at_commit(&branch_name, &proposal_base_commit.to_string())?; + git_repo.checkout(&branch_name)?; + let _ = git_repo + .apply_patch_chain(&branch_name, most_recent_proposal_patch_chain_or_pr_or_pr_update.to_vec()) + .context("failed to apply patch chain")?; + println!("checked out updated proposal as '{branch_name}' branch"); + Ok(()) +} + +fn fetch_oid_for_from_servers_for_pr( + oid: &str, + git_repo: &Repo, + repo_ref: &RepoRef, + pr_or_pr_update_event: &nostr::Event, +) -> Result<()> { + let git_servers = { + let mut seen: HashSet = HashSet::new(); + let mut out: Vec = vec![]; + for tag in pr_or_pr_update_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()); + } + } + } + for server in &repo_ref.git_server { + if seen.insert(server.clone()) { + out.push(server.clone()); + } + } + out + }; + + let mut errors = vec![]; + let term = console::Term::stderr(); + + for git_server_url in &git_servers { + if let Err(error) = 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()), + ) { + errors.push(error); + } else { + println!("fetched proposal git data from {git_server_url}"); + break; + } + } + if !git_repo.does_commit_exist(oid)? { + bail!( + "cannot find proposal git data from proposal git server hint or repository git servers" + ) + } + Ok(()) +} diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs index 9c84ef2..e9f91db 100644 --- a/src/bin/ngit/sub_commands/mod.rs +++ b/src/bin/ngit/sub_commands/mod.rs @@ -1,3 +1,4 @@ +pub mod checkout; pub mod create; pub mod export_keys; pub mod init; -- cgit v1.2.3