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
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -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 it’s correctly
|
of an existing patch, include the original patch ID to ensure it’s 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 -
|
||||||
|
```
|
||||||
|
|||||||
@@ -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")
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}")]
|
||||||
|
|||||||
Reference in New Issue
Block a user