feat: accept patches from stdin in patch send command
Signed-off-by: Awiteb <a@4rs.nl>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Send one or more patches to a repository
|
||||
Usage: n34 patch send [OPTIONS] <PATCH-PATH>...
|
||||
|
||||
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:
|
||||
--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
|
||||
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 -
|
||||
```
|
||||
|
||||
@@ -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")
|
||||
))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
Reference in New Issue
Block a user