Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions po/POTFILES.in
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
src/main.rs
src/error.rs
116 changes: 58 additions & 58 deletions po/patchsplit.pot
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: patchsplit 0.1.0\n"
"Project-Id-Version: patchsplit 1.2.0\n"
"Report-Msgid-Bugs-To: https://github.com/zitzhen/patchsplit/issues\n"
"POT-Creation-Date: 2026-08-11 15:04+0800\n"
"POT-Creation-Date: 2026-09-19 14:57+0800\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
Expand All @@ -34,142 +34,142 @@ msgid "wrote {count} patch file(s) to {directory}"
msgstr ""

#: src/main.rs
msgid ""
"Usage:\n"
" patchsplit <owner/repo> <pr-number> [--out <dir>] [--force] [--squash]\n"
" patchsplit <owner> <repo> <pr-number> [--out <dir>] [--force] [--squash]\n"
" patchsplit <owner/repo> --commit <hash> [--out <dir>] [--force]\n"
" patchsplit <owner> <repo> --commit <hash> [--out <dir>] [--force]\n"
" patchsplit --gitlab <namespace/project> <mr-number> [--out <dir>] [--"
"force] [--squash]\n"
" patchsplit --gitlab <namespace/project> --commit <hash> [--out <dir>] [--"
"force]\n"
"\n"
"Options:\n"
" -o, --out <dir> Output directory for patch files [default: patches]\n"
" -f, --force Overwrite existing patch files\n"
" -s, --squash Write the net diff as one patch instead of splitting by "
"commit\n"
" --gitlab Download from gitlab.com (merge requests and commits)\n"
" --commit <hash> Download one commit's .patch (short or full hash)\n"
" -h, --help Show this help\n"
" -V, --version Show version\n"
"\n"
"Examples:\n"
" patchsplit rust-lang/rust 12345\n"
" patchsplit openai codex 42 -o pr-42-patches\n"
" patchsplit openai/codex 42 --squash\n"
" patchsplit zitzhen patchsplit -commit b430113\n"
" patchsplit --gitlab zitzhen/patchsplit 1"
msgstr ""

#: src/error.rs
msgid "expected a GitHub repository and pull request number"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "repository must use owner/repo form, got {value}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "invalid GitHub repository {kind}: {value}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "pull request number must be a positive integer, got {value}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "expected a GitHub repository with --commit <hash>"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "expected a GitLab project and merge request number"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "expected a GitLab project with --commit <hash>"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid ""
"GitLab project must use namespace/project form (subgroups allowed), got "
"{value}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "merge request number must be a positive integer, got {value}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "commit hash must consist of 4 to 40 hexadecimal characters, got {value}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "--commit cannot be combined with --squash"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "missing value for {option}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "unknown option {option}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "curl was not found in PATH"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "failed to run curl: {source}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "download failed with status {status}: {message}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "download failed with status {status}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "download failed: {message}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "download failed"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "downloaded patch is not valid UTF-8: {source}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "downloaded patch is empty"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "downloaded content is not a Git diff"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "failed to create output directory {path}: {source}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid ""
"refusing to overwrite existing patch file {path}; pass --force to replace it"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "failed to write patch file {path}: {source}"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "owner"
msgstr ""

#: src/main.rs
#: src/error.rs
msgid "repo"
msgstr ""

#: src/main.rs
msgid ""
"Usage:\n"
" patchsplit <owner/repo> <pr-number> [--out <dir>] [--force] [--squash]\n"
" patchsplit <owner> <repo> <pr-number> [--out <dir>] [--force] [--squash]\n"
" patchsplit <owner/repo> --commit <hash> [--out <dir>] [--force]\n"
" patchsplit <owner> <repo> --commit <hash> [--out <dir>] [--force]\n"
" patchsplit --gitlab <namespace/project> <mr-number> [--out <dir>] "
"[--force] [--squash]\n"
" patchsplit --gitlab <namespace/project> --commit <hash> [--out <dir>] "
"[--force]\n"
"\n"
"Options:\n"
" -o, --out <dir> Output directory for patch files [default: patches]\n"
" -f, --force Overwrite existing patch files\n"
" -s, --squash Write the net diff as one patch instead of splitting by "
"commit\n"
" --gitlab Download from gitlab.com (merge requests and commits)\n"
" --commit <hash> Download one commit's .patch (short or full hash)\n"
" -h, --help Show this help\n"
" -V, --version Show version\n"
"\n"
"Examples:\n"
" patchsplit rust-lang/rust 12345\n"
" patchsplit openai codex 42 -o pr-42-patches\n"
" patchsplit openai/codex 42 --squash\n"
" patchsplit zitzhen patchsplit -commit b430113\n"
" patchsplit --gitlab zitzhen/patchsplit 1"
msgstr ""
150 changes: 150 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//! Hand-rolled command line parsing (zero extra dependencies).
//!
//! [`Options`] holds global flags and grows as new flags are added;
//! [`Config`] pairs them with the resolved [`Host`], project and target.

use std::path::PathBuf;

use patchsplit::{split_patch_by_commit, PatchPart};

use crate::error::AppError;
use crate::host::{host_for, Host, Platform, Target};

/// Flag-style options shared by every platform and target.
#[derive(Debug)]
pub(crate) struct Options {
pub output_dir: PathBuf,
pub force: bool,
pub squash: bool,
}

impl Default for Options {
fn default() -> Self {
Self {
output_dir: PathBuf::from("patches"),
force: false,
squash: false,
}
}
}

/// Fully resolved invocation: where to download from and how to write output.
#[derive(Debug)]
pub(crate) struct Config {
pub platform: Platform,
pub project: String,
pub target: Target,
pub options: Options,
host: Box<dyn Host>,
}

impl Config {
pub(crate) fn parse<I>(args: I) -> Result<Self, AppError>
where
I: IntoIterator<Item = String>,
{
let mut options = Options::default();
let mut gitlab = false;
let mut commit = None;
let mut positionals = Vec::new();
let mut args = args.into_iter();

while let Some(arg) = args.next() {
match arg.as_str() {
"-h" | "--help" => return Err(AppError::Help),
"-V" | "--version" => return Err(AppError::Version),
"-f" | "--force" => options.force = true,
"-s" | "--squash" => options.squash = true,
"--gitlab" => gitlab = true,
"-o" | "--out" => {
let option = arg.as_str().to_string();
let value = args.next().ok_or(AppError::MissingOptionValue(option))?;
options.output_dir = PathBuf::from(value);
}
value if value.starts_with("--out=") => {
options.output_dir = PathBuf::from(&value["--out=".len()..]);
}
"-commit" | "--commit" => {
let value = args
.next()
.ok_or(AppError::MissingOptionValue("--commit".to_string()))?;
commit = Some(value);
}
value if value.starts_with("--commit=") => {
commit = Some(value["--commit=".len()..].to_string());
}
value if value.starts_with("-commit=") => {
commit = Some(value["-commit=".len()..].to_string());
}
value if value.starts_with('-') => {
return Err(AppError::UnknownOption(value.to_string()));
}
value => positionals.push(value.to_string()),
}
}

if commit.is_some() && options.squash {
return Err(AppError::CommitWithSquash);
}

let platform = if gitlab {
Platform::GitLab
} else {
Platform::GitHub
};
let host = host_for(platform);
let (project, target) = host.resolve(&positionals, commit)?;

Ok(Self {
platform,
project,
target,
options,
host,
})
}

pub(crate) fn patch_url(&self) -> String {
match &self.target {
Target::MergeRequest(number) => {
let extension = if self.options.squash { "diff" } else { "patch" };
self.host.merge_url(&self.project, *number, extension)
}
Target::Commit(hash) => self.host.commit_url(&self.project, hash),
}
}

pub(crate) fn patch_parts(&self, patch: &str) -> Vec<PatchPart> {
match &self.target {
Target::Commit(hash) => {
if patch.trim().is_empty() {
return Vec::new();
}
// A commit .patch is a single mail-formatted patch; keep it verbatim.
vec![PatchPart {
index: 1,
commit: None,
subject: format!("commit {hash}"),
filename: format!("{hash}.patch"),
content: patch.to_string(),
}]
}
Target::MergeRequest(number) => {
if !self.options.squash {
return split_patch_by_commit(patch);
}
if patch.trim().is_empty() {
return Vec::new();
}
let (subject, filename) = self.host.squash_labels(*number);
vec![PatchPart {
index: 1,
commit: None,
subject,
filename,
content: patch.to_string(),
}]
}
}
}
}
Loading
Loading