From ee17c21dbbef1577da1834b851e5cd56f77df5f3 Mon Sep 17 00:00:00 2001 From: Awiteb Date: Wed, 24 Sep 2025 11:36:57 +0000 Subject: [PATCH] feat: accept patches from stdin in `patch send` command Signed-off-by: Awiteb --- CHANGELOG.md | 1 + docs/patch/send.md | 9 +++++- src/cli/commands/patch/fetch.rs | 2 +- src/cli/commands/patch/mod.rs | 13 +++++++++ src/cli/commands/patch/send.rs | 24 +++++++++++----- src/cli/traits.rs | 46 ++++++++++++++++++++++++++++++ src/cli/utils.rs | 50 ++++++++++++++++++++++++++++++++- src/error.rs | 4 +++ 8 files changed, 139 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d48c821..76b4e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support pull requests - by Awiteb - Add `--personal-fork` flag to `repo announce` command - by Awiteb - Log to stderr and a file - by Awiteb +- Accept patches from stdin in `patch send` command - by Awiteb ### Breaking Change diff --git a/docs/patch/send.md b/docs/patch/send.md index 0526f37..15f77c0 100644 --- a/docs/patch/send.md +++ b/docs/patch/send.md @@ -9,7 +9,7 @@ Send one or more patches to a repository Usage: n34 patch send [OPTIONS] ... Arguments: - ... List of patch files to send (space separated) + ... Space-separated list of patch files to send. Use `-` to read from stdin. Options: --repo Repository addresses @@ -20,3 +20,10 @@ Send your generated patches to the repositories specified using the `--repo` option or retrieved from the `nostr-address` file. When submitting a revision of an existing patch, include the original patch ID to ensure it’s correctly referenced in your revision patch event. + +You can also pass patches from stdin, retrieved from a website or directly from +`git-format-patch`. Simply use `-` as the patch path. For example: + +```bash +git format-patch --stdout --base master master..HEAD | n34 patch send - +``` diff --git a/src/cli/commands/patch/fetch.rs b/src/cli/commands/patch/fetch.rs index 552a8ea..e141ea7 100644 --- a/src/cli/commands/patch/fetch.rs +++ b/src/cli/commands/patch/fetch.rs @@ -117,7 +117,7 @@ impl CommandRunner for FetchArgs { .into_iter() .map(|p| { let patch = super::GitPatch::from_str(&p.content).map_err(|err| { - N34Error::InvalidEvent(format!( + N34Error::InvalidPatch(format!( "Failed to parse the patch `{}`: {err}", p.id.to_bech32().expect("Infallible") )) diff --git a/src/cli/commands/patch/mod.rs b/src/cli/commands/patch/mod.rs index cff1b00..91e96b8 100644 --- a/src/cli/commands/patch/mod.rs +++ b/src/cli/commands/patch/mod.rs @@ -53,6 +53,11 @@ use self::send::SendArgs; use super::{CliOptions, CommandRunner}; use crate::error::{N34Error, N34Result}; +/// Regular expression for checking the first line in the patch. +pub static FROM_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^From [a-f0-9]{40} \w+ \w+ \d{1,2} \d{2}:\d{2}:\d{2} \d{4}$").unwrap() +}); + /// Regular expression for extracting the patch subject. static SUBJECT_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?m)^Subject: (.*(?:\n .*)*)").unwrap()); @@ -138,6 +143,14 @@ impl FromStr for GitPatch { type Err = String; fn from_str(patch_content: &str) -> Result { + if !patch_content + .split("\n") + .next() + .is_some_and(|line| FROM_RE.is_match(line)) + { + return Err("The first line must start with 'From '.".to_owned()); + } + // Regex for subject (handles multi-line subjects) let subject = SUBJECT_RE .captures(patch_content) diff --git a/src/cli/commands/patch/send.rs b/src/cli/commands/patch/send.rs index 88cc00a..c8df6bc 100644 --- a/src/cli/commands/patch/send.rs +++ b/src/cli/commands/patch/send.rs @@ -31,7 +31,7 @@ use crate::{ cli::{ CliOptions, patch::{REVISION_ROOT_HASHTAG_CONTENT, ROOT_HASHTAG_CONTENT}, - traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt}, + traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt, VecPatchesExt}, types::{NaddrOrSet, NostrEvent}, }, error::N34Result, @@ -59,12 +59,12 @@ pub struct SendArgs { value_delimiter = ',' )] naddrs: Option>, - /// List of patch files to send (space separated). + /// Space-separated list of patch files to send. Use `-` to read from stdin. /// - /// For p-tagging users, include them in the cover letter with + /// For p-tagging users, include them in the cover letter using /// `nostr:npub1...`. #[arg(value_name = "PATCH-PATH", required = true, value_parser = parse_patch_path)] - patches: Vec, + patches: Vec>, /// Original patch ID if this is a revision of it #[arg(long, value_name = "EVENT-ID")] original_patch: Option, @@ -77,6 +77,7 @@ impl CommandRunner for SendArgs { &utils::nostr_address_path()?, )?)?; + let patches = self.patches.process_patches().await?; let repo_coordinates = naddrs.clone().into_coordinates(); let relays = options.relays.clone().flat_relays(&options.config.sets)?; let client = NostrClient::init(&options, &relays).await; @@ -97,7 +98,7 @@ impl CommandRunner for SendArgs { let (events, events_write_relays) = make_patch_series( &client, - self.patches, + patches, self.original_patch.as_ref().map(|e| e.event_id), repos.extract_relays().first().cloned(), repo_coordinates, @@ -145,11 +146,20 @@ impl CommandRunner for SendArgs { } } -fn parse_patch_path(patch_path: &str) -> Result { +/// If the patch path is '-', it indicates that patches will be retrieved from +/// stdin. Otherwise, it reads and parses the patch file from the specified +/// path. +fn parse_patch_path(patch_path: &str) -> Result, String> { + if patch_path == "-" { + tracing::info!("Reading patches from standard input"); + return Ok(None); + } + tracing::debug!("Parsing patch file `{patch_path}`"); let patch_content = fs::read_to_string(patch_path) .map_err(|err| format!("Failed to read patch file `{patch_path}`: {err}"))?; - GitPatch::from_str(&patch_content) + + GitPatch::from_str(&patch_content).map(Option::Some) } async fn make_patch_series( diff --git a/src/cli/traits.rs b/src/cli/traits.rs index 7e9a646..2b3bbef 100644 --- a/src/cli/traits.rs +++ b/src/cli/traits.rs @@ -14,14 +14,19 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use std::time::Duration; + use nostr::{event::EventId, nips::nip19::Nip19Coordinate, types::RelayUrl}; +use tokio::io::AsyncReadExt; use super::CliOptions; use crate::{ cli::{ ConfigError, RepoRelaySet, + patch::GitPatch, types::{NaddrOrSet, NostrEvent, RelayOrSet}, + utils, }, error::{N34Error, N34Result}, }; @@ -220,6 +225,47 @@ impl &[RepoRelaySet] { } } +#[easy_ext::ext(VecPatchesExt)] +impl Vec> { + /// Processes a collection of `Option` values. + /// + /// This function performs one of two actions: + /// 1. If all elements are `Some`, it extracts the `GitPatch` values and + /// returns them as a `Vec`. + /// 2. If there is at least one `None`, it reads patches from standard + /// input, parses them as `GitPatch` values from the stdin, and returns + /// them as a `Vec`. + #[allow(async_fn_in_trait)] + pub async fn process_patches(self) -> N34Result> { + if self.iter().all(Option::is_some) { + return Ok(self.into_iter().map(Option::unwrap).collect()); + } + + let mut patches = String::new(); + + match tokio::time::timeout( + Duration::from_secs(1), + tokio::io::stdin().read_to_string(&mut patches), + ) + .await + { + Ok(Ok(bytes)) => { + tracing::debug!("Received {bytes} bytes from stdin for patch processing"); + } + Ok(Err(err)) => return Err(N34Error::from(err)), + Err(_) => return Err(N34Error::EmptyStdin("patches")), + } + + let patches = utils::split_patches(patches)?; + + if patches.is_empty() { + return Err(N34Error::InvalidPatch("No valid patches".to_owned())); + } + + Ok(patches) + } +} + /// Helper function that checks for duplicates in a sorted slice fn duplicate_in_sorted(items: &[T]) -> Option<&T> { items.windows(2).find(|w| w[0] == w[1]).map(|w| &w[0]) diff --git a/src/cli/utils.rs b/src/cli/utils.rs index 9d0cad1..c7d9508 100644 --- a/src/cli/utils.rs +++ b/src/cli/utils.rs @@ -15,11 +15,16 @@ // along with this program. If not, see . use std::{ + fmt::Write as _, fs, io::{self, Write}, + str::FromStr, }; -use crate::error::{N34Error, N34Result}; +use crate::{ + cli::patch::{FROM_RE, GitPatch}, + error::{N34Error, N34Result}, +}; /// Displays the given prompt and reads a line of input from the user. pub fn read_line(prompt: &str) -> io::Result { @@ -84,3 +89,46 @@ pub fn logs_file() -> N34Result { .open(&logs_path) .map_err(N34Error::from) } + +/// Splits a string with multiple git patches into separate patches. Each patch +/// begins with a line formatted as "From \ ...". Ensures the result is +/// never an empty vector. +pub fn split_patches(patches: String) -> N34Result> { + let mut patches = patches.split("\n").peekable(); + let mut result = Vec::new(); + let mut current_patch = String::new(); + + let push_patch = |patch_str: String| -> N34Result { + tracing::trace!("patch content: {patch_str}"); + let patch = GitPatch::from_str(&patch_str).map_err(N34Error::InvalidPatch)?; + tracing::info!( + "Processing patch from stdin: {}", + patch + .filename("") + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "Unnamed patch".to_owned()) + ); + Ok(patch) + }; + + while let Some(line) = patches.next() { + // If we have a non-empty patch and encounter a "From " line followed by a line + // with ":", it indicates a new patch header. Push the current patch and + // clear it for the new one. The "From " line will be added as the first + // line of the new patch after this check. + if !current_patch.is_empty() + && FROM_RE.is_match(line) + && patches.peek().is_some_and(|line| line.contains(":")) + { + result.push(push_patch(current_patch.clone())?); + current_patch.clear() + } + _ = writeln!(&mut current_patch, "{line}"); + } + + if !current_patch.is_empty() { + result.push(push_patch(current_patch)?); + } + + Ok(result) +} diff --git a/src/error.rs b/src/error.rs index cc60957..d7f69e0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -60,6 +60,8 @@ pub enum N34Error { environment variables are missing or unset." )] CanNotFindDataPath, + #[error("Empty STDIN: expected '{0}' to be present in the input")] + EmptyStdin(&'static str), #[error("No editor specified in the `EDITOR` environment variable")] EditorNotFound, #[error("The file you edited is empty. Please save your changes before exiting the editor.")] @@ -76,6 +78,8 @@ pub enum N34Error { InvalidRepoId, #[error("Invalid event: {0}")] InvalidEvent(String), + #[error("Invalid patch: {0}")] + InvalidPatch(String), #[error("Bech32 error: {0}")] Bech32(#[from] nostr::nips::nip19::Error), #[error("Event error: {0}")]