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

@@ -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")
))

View File

@@ -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<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.
static SUBJECT_RE: LazyLock<Regex> =
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<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)
let subject = SUBJECT_RE
.captures(patch_content)

View File

@@ -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<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...`.
#[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
#[arg(long, value_name = "EVENT-ID")]
original_patch: Option<NostrEvent>,
@@ -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<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}`");
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(

View File

@@ -14,14 +14,19 @@
// 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>.
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<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
fn duplicate_in_sorted<T: PartialEq + Clone>(items: &[T]) -> Option<&T> {
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>.
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<String> {
@@ -84,3 +89,46 @@ pub fn logs_file() -> N34Result<fs::File> {
.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 \<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)
}