feat: accept patches from stdin in patch send command

Signed-off-by: Awiteb <a@4rs.nl>
This commit is contained in:
Awiteb
2025-09-24 11:36:57 +00:00
parent df54a53b7c
commit ee17c21dbb
8 changed files with 139 additions and 10 deletions

View File

@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support pull requests - by Awiteb - Support pull requests - by Awiteb
- Add `--personal-fork` flag to `repo announce` command - by Awiteb - Add `--personal-fork` flag to `repo announce` command - by Awiteb
- Log to stderr and a file - by Awiteb - Log to stderr and a file - by Awiteb
- Accept patches from stdin in `patch send` command - by Awiteb
### Breaking Change ### Breaking Change

View File

@@ -9,7 +9,7 @@ Send one or more patches to a repository
Usage: n34 patch send [OPTIONS] <PATCH-PATH>... Usage: n34 patch send [OPTIONS] <PATCH-PATH>...
Arguments: Arguments:
<PATCH-PATH>... List of patch files to send (space separated) <PATCH-PATH>... Space-separated list of patch files to send. Use `-` to read from stdin.
Options: Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses --repo <NADDR-NIP05-OR-SET> 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 option or retrieved from the `nostr-address` file. When submitting a revision
of an existing patch, include the original patch ID to ensure its correctly of an existing patch, include the original patch ID to ensure its correctly
referenced in your revision patch event. 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 -
```

View File

@@ -117,7 +117,7 @@ impl CommandRunner for FetchArgs {
.into_iter() .into_iter()
.map(|p| { .map(|p| {
let patch = super::GitPatch::from_str(&p.content).map_err(|err| { let patch = super::GitPatch::from_str(&p.content).map_err(|err| {
N34Error::InvalidEvent(format!( N34Error::InvalidPatch(format!(
"Failed to parse the patch `{}`: {err}", "Failed to parse the patch `{}`: {err}",
p.id.to_bech32().expect("Infallible") p.id.to_bech32().expect("Infallible")
)) ))

View File

@@ -53,6 +53,11 @@ use self::send::SendArgs;
use super::{CliOptions, CommandRunner}; use super::{CliOptions, CommandRunner};
use crate::error::{N34Error, N34Result}; use crate::error::{N34Error, N34Result};
/// Regular expression for checking the first line in the patch.
pub static FROM_RE: LazyLock<Regex> = 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. /// Regular expression for extracting the patch subject.
static SUBJECT_RE: LazyLock<Regex> = static SUBJECT_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^Subject: (.*(?:\n .*)*)").unwrap()); LazyLock::new(|| Regex::new(r"(?m)^Subject: (.*(?:\n .*)*)").unwrap());
@@ -138,6 +143,14 @@ impl FromStr for GitPatch {
type Err = String; type Err = String;
fn from_str(patch_content: &str) -> Result<Self, Self::Err> { fn from_str(patch_content: &str) -> Result<Self, Self::Err> {
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) // Regex for subject (handles multi-line subjects)
let subject = SUBJECT_RE let subject = SUBJECT_RE
.captures(patch_content) .captures(patch_content)

View File

@@ -31,7 +31,7 @@ use crate::{
cli::{ cli::{
CliOptions, CliOptions,
patch::{REVISION_ROOT_HASHTAG_CONTENT, ROOT_HASHTAG_CONTENT}, patch::{REVISION_ROOT_HASHTAG_CONTENT, ROOT_HASHTAG_CONTENT},
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt}, traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt, VecPatchesExt},
types::{NaddrOrSet, NostrEvent}, types::{NaddrOrSet, NostrEvent},
}, },
error::N34Result, error::N34Result,
@@ -59,12 +59,12 @@ pub struct SendArgs {
value_delimiter = ',' value_delimiter = ','
)] )]
naddrs: Option<Vec<NaddrOrSet>>, naddrs: Option<Vec<NaddrOrSet>>,
/// 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...`. /// `nostr:npub1...`.
#[arg(value_name = "PATCH-PATH", required = true, value_parser = parse_patch_path)] #[arg(value_name = "PATCH-PATH", required = true, value_parser = parse_patch_path)]
patches: Vec<GitPatch>, patches: Vec<Option<GitPatch>>,
/// Original patch ID if this is a revision of it /// Original patch ID if this is a revision of it
#[arg(long, value_name = "EVENT-ID")] #[arg(long, value_name = "EVENT-ID")]
original_patch: Option<NostrEvent>, original_patch: Option<NostrEvent>,
@@ -77,6 +77,7 @@ impl CommandRunner for SendArgs {
&utils::nostr_address_path()?, &utils::nostr_address_path()?,
)?)?; )?)?;
let patches = self.patches.process_patches().await?;
let repo_coordinates = naddrs.clone().into_coordinates(); let repo_coordinates = naddrs.clone().into_coordinates();
let relays = options.relays.clone().flat_relays(&options.config.sets)?; let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await; let client = NostrClient::init(&options, &relays).await;
@@ -97,7 +98,7 @@ impl CommandRunner for SendArgs {
let (events, events_write_relays) = make_patch_series( let (events, events_write_relays) = make_patch_series(
&client, &client,
self.patches, patches,
self.original_patch.as_ref().map(|e| e.event_id), self.original_patch.as_ref().map(|e| e.event_id),
repos.extract_relays().first().cloned(), repos.extract_relays().first().cloned(),
repo_coordinates, repo_coordinates,
@@ -145,11 +146,20 @@ impl CommandRunner for SendArgs {
} }
} }
fn parse_patch_path(patch_path: &str) -> Result<GitPatch, String> { /// 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<Option<GitPatch>, String> {
if patch_path == "-" {
tracing::info!("Reading patches from standard input");
return Ok(None);
}
tracing::debug!("Parsing patch file `{patch_path}`"); tracing::debug!("Parsing patch file `{patch_path}`");
let patch_content = fs::read_to_string(patch_path) let patch_content = fs::read_to_string(patch_path)
.map_err(|err| format!("Failed to read patch file `{patch_path}`: {err}"))?; .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( async fn make_patch_series(

View File

@@ -14,14 +14,19 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>. // along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::time::Duration;
use nostr::{event::EventId, nips::nip19::Nip19Coordinate, types::RelayUrl}; use nostr::{event::EventId, nips::nip19::Nip19Coordinate, types::RelayUrl};
use tokio::io::AsyncReadExt;
use super::CliOptions; use super::CliOptions;
use crate::{ use crate::{
cli::{ cli::{
ConfigError, ConfigError,
RepoRelaySet, RepoRelaySet,
patch::GitPatch,
types::{NaddrOrSet, NostrEvent, RelayOrSet}, types::{NaddrOrSet, NostrEvent, RelayOrSet},
utils,
}, },
error::{N34Error, N34Result}, error::{N34Error, N34Result},
}; };
@@ -220,6 +225,47 @@ impl &[RepoRelaySet] {
} }
} }
#[easy_ext::ext(VecPatchesExt)]
impl Vec<Option<GitPatch>> {
/// Processes a collection of `Option<GitPatch>` 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<GitPatch>`.
/// 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<GitPatch>`.
#[allow(async_fn_in_trait)]
pub async fn process_patches(self) -> N34Result<Vec<GitPatch>> {
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 /// Helper function that checks for duplicates in a sorted slice
fn duplicate_in_sorted<T: PartialEq + Clone>(items: &[T]) -> Option<&T> { fn duplicate_in_sorted<T: PartialEq + Clone>(items: &[T]) -> Option<&T> {
items.windows(2).find(|w| w[0] == w[1]).map(|w| &w[0]) items.windows(2).find(|w| w[0] == w[1]).map(|w| &w[0])

View File

@@ -15,11 +15,16 @@
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>. // along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{ use std::{
fmt::Write as _,
fs, fs,
io::{self, Write}, 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. /// Displays the given prompt and reads a line of input from the user.
pub fn read_line(prompt: &str) -> io::Result<String> { pub fn read_line(prompt: &str) -> io::Result<String> {
@@ -84,3 +89,46 @@ pub fn logs_file() -> N34Result<fs::File> {
.open(&logs_path) .open(&logs_path)
.map_err(N34Error::from) .map_err(N34Error::from)
} }
/// Splits a string with multiple git patches into separate patches. Each patch
/// begins with a line formatted as "From \<hash\> ...". Ensures the result is
/// never an empty vector.
pub fn split_patches(patches: String) -> N34Result<Vec<GitPatch>> {
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<GitPatch> {
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)
}

View File

@@ -60,6 +60,8 @@ pub enum N34Error {
environment variables are missing or unset." environment variables are missing or unset."
)] )]
CanNotFindDataPath, CanNotFindDataPath,
#[error("Empty STDIN: expected '{0}' to be present in the input")]
EmptyStdin(&'static str),
#[error("No editor specified in the `EDITOR` environment variable")] #[error("No editor specified in the `EDITOR` environment variable")]
EditorNotFound, EditorNotFound,
#[error("The file you edited is empty. Please save your changes before exiting the editor.")] #[error("The file you edited is empty. Please save your changes before exiting the editor.")]
@@ -76,6 +78,8 @@ pub enum N34Error {
InvalidRepoId, InvalidRepoId,
#[error("Invalid event: {0}")] #[error("Invalid event: {0}")]
InvalidEvent(String), InvalidEvent(String),
#[error("Invalid patch: {0}")]
InvalidPatch(String),
#[error("Bech32 error: {0}")] #[error("Bech32 error: {0}")]
Bech32(#[from] nostr::nips::nip19::Error), Bech32(#[from] nostr::nips::nip19::Error),
#[error("Event error: {0}")] #[error("Event error: {0}")]