From 75df0212e299a5aff0d3d2d8d3a7e533a5dbb062 Mon Sep 17 00:00:00 2001 From: Oliver Lin Date: Sun, 13 Sep 2026 16:31:05 +0800 Subject: [PATCH 1/3] feat(cli): add squash mode for aggregate PR diffs --- po/patchsplit.pot | 14 ++++++--- po/zh_CN.po | 24 ++++++++++----- src/main.rs | 74 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 97 insertions(+), 15 deletions(-) diff --git a/po/patchsplit.pot b/po/patchsplit.pot index 0d14c37..acce23e 100644 --- a/po/patchsplit.pot +++ b/po/patchsplit.pot @@ -89,6 +89,10 @@ msgstr "" msgid "downloaded patch is empty" msgstr "" +#: src/main.rs +msgid "downloaded content is not a Git diff" +msgstr "" + #: src/main.rs msgid "failed to create output directory {path}: {source}" msgstr "" @@ -113,17 +117,19 @@ msgstr "" #: src/main.rs msgid "" "Usage:\n" -" patchsplit [--out ] [--force]\n" -" patchsplit [--out ] [--force]\n" +" patchsplit [--out ] [--force] [--squash]\n" +" patchsplit [--out ] [--force] [--squash]\n" "\n" "Options:\n" -" -o, --out Output directory for split patch files [default: " +" -o, --out Output directory for patch files [default: " "patches]\n" " -f, --force Overwrite existing patch files\n" +" -s, --squash Write the PR's net diff as one patch\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" +" patchsplit openai codex 42 -o pr-42-patches\n" +" patchsplit openai/codex 42 --squash" msgstr "" diff --git a/po/zh_CN.po b/po/zh_CN.po index 1f2ae43..059180f 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -88,6 +88,10 @@ msgstr "下载的 patch 不是有效的 UTF-8 文本:{source}" msgid "downloaded patch is empty" msgstr "下载的 patch 为空" +#: src/main.rs +msgid "downloaded content is not a Git diff" +msgstr "下载的内容不是 Git diff" + #: src/main.rs msgid "failed to create output directory {path}: {source}" msgstr "创建输出目录 {path} 失败:{source}" @@ -112,30 +116,34 @@ msgstr "仓库" #: src/main.rs msgid "" "Usage:\n" -" patchsplit [--out ] [--force]\n" -" patchsplit [--out ] [--force]\n" +" patchsplit [--out ] [--force] [--squash]\n" +" patchsplit [--out ] [--force] [--squash]\n" "\n" "Options:\n" -" -o, --out Output directory for split patch files [default: " +" -o, --out Output directory for patch files [default: " "patches]\n" " -f, --force Overwrite existing patch files\n" +" -s, --squash Write the PR's net diff as one patch\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" +" patchsplit openai codex 42 -o pr-42-patches\n" +" patchsplit openai/codex 42 --squash" msgstr "" "用法:\n" -" patchsplit [--out ] [--force]\n" -" patchsplit [--out ] [--force]\n" +" patchsplit [--out ] [--force] [--squash]\n" +" patchsplit [--out ] [--force] [--squash]\n" "\n" "选项:\n" -" -o, --out 拆分后的 patch 文件输出目录 [默认:patches]\n" +" -o, --out patch 文件输出目录 [默认:patches]\n" " -f, --force 覆盖已有 patch 文件\n" +" -s, --squash 将 PR 的最终净变化输出为一个补丁\n" " -h, --help 显示帮助信息\n" " -V, --version 显示版本\n" "\n" "示例:\n" " patchsplit rust-lang/rust 12345\n" -" patchsplit openai codex 42 -o pr-42-patches" +" patchsplit openai codex 42 -o pr-42-patches\n" +" patchsplit openai/codex 42 --squash" diff --git a/src/main.rs b/src/main.rs index 90dea4c..f65aec9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,7 +42,10 @@ fn run() -> Result<(), AppError> { let config = Config::parse(env::args().skip(1))?; let url = config.patch_url(); let patch = download_patch(&url)?; - let parts = split_patch_by_commit(&patch); + if config.squash && !patch.trim().is_empty() && !patch.starts_with("diff --git ") { + return Err(AppError::InvalidDiff); + } + let parts = config.patch_parts(&patch); if parts.is_empty() { return Err(AppError::EmptyPatch); @@ -75,6 +78,7 @@ struct Config { pull_request: u64, output_dir: PathBuf, force: bool, + squash: bool, } impl Config { @@ -84,6 +88,7 @@ impl Config { { let mut output_dir = PathBuf::from("patches"); let mut force = false; + let mut squash = false; let mut positionals = Vec::new(); let mut args = args.into_iter(); @@ -92,6 +97,7 @@ impl Config { "-h" | "--help" => return Err(AppError::Help), "-V" | "--version" => return Err(AppError::Version), "-f" | "--force" => force = true, + "-s" | "--squash" => squash = true, "-o" | "--out" => { let option = arg.as_str().to_string(); let value = args.next().ok_or(AppError::MissingOptionValue(option))?; @@ -130,15 +136,34 @@ impl Config { pull_request, output_dir, force, + squash, }) } fn patch_url(&self) -> String { + // GitHub's PR diff represents the net change; .patch contains each commit. + let extension = if self.squash { "diff" } else { "patch" }; format!( - "https://github.com/{}/{}/pull/{}.patch", + "https://github.com/{}/{}/pull/{}.{extension}", self.owner, self.repo, self.pull_request ) } + + fn patch_parts(&self, patch: &str) -> Vec { + if !self.squash { + return split_patch_by_commit(patch); + } + if patch.trim().is_empty() { + return Vec::new(); + } + vec![PatchPart { + index: 1, + commit: None, + subject: format!("PR #{}", self.pull_request), + filename: format!("pr-{}.patch", self.pull_request), + content: patch.to_string(), + }] + } } fn parse_repo_spec(value: &str) -> Result<(String, String), AppError> { @@ -273,6 +298,8 @@ enum AppError { PatchNotUtf8(#[from] std::string::FromUtf8Error), #[error("downloaded patch is empty")] EmptyPatch, + #[error("downloaded content is not a Git diff")] + InvalidDiff, #[error("failed to create output directory {path:?}: {source}")] CreateOutputDir { path: PathBuf, @@ -352,6 +379,7 @@ impl AppError { &[("source", source.to_string())], ), Self::EmptyPatch => tr("downloaded patch is empty"), + Self::InvalidDiff => tr("downloaded content is not a Git diff"), Self::CreateOutputDir { path, source } => tr_args( "failed to create output directory {path}: {source}", &[ @@ -391,5 +419,45 @@ fn repo_segment_label(kind: &str) -> String { } fn usage() -> String { - tr("Usage:\n patchsplit [--out ] [--force]\n patchsplit [--out ] [--force]\n\nOptions:\n -o, --out Output directory for split patch files [default: patches]\n -f, --force Overwrite existing patch files\n -h, --help Show this help\n -V, --version Show version\n\nExamples:\n patchsplit rust-lang/rust 12345\n patchsplit openai codex 42 -o pr-42-patches") + tr("Usage:\n patchsplit [--out ] [--force] [--squash]\n patchsplit [--out ] [--force] [--squash]\n\nOptions:\n -o, --out Output directory for patch files [default: patches]\n -f, --force Overwrite existing patch files\n -s, --squash Write the PR's net diff as one patch\n -h, --help Show this help\n -V, --version Show version\n\nExamples:\n patchsplit rust-lang/rust 12345\n patchsplit openai codex 42 -o pr-42-patches\n patchsplit openai/codex 42 --squash") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(args: &[&str]) -> Config { + Config::parse(args.iter().map(|arg| arg.to_string())).unwrap() + } + + #[test] + fn default_still_downloads_per_commit_patches() { + let config = config(&["owner/repo", "42"]); + assert!(!config.squash); + assert_eq!(config.patch_url(), "https://github.com/owner/repo/pull/42.patch"); + let patch = format!( + "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 1/2] First\n\nfirst\nFrom {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 2/2] Second\n\nsecond\n", + "1".repeat(40), "2".repeat(40) + ); + assert_eq!(config.patch_parts(&patch).len(), 2); + } + + #[test] + fn squash_uses_net_diff_for_both_argument_forms() { + for args in [ + vec!["owner/repo", "42", "--squash", "--out=combined", "--force"], + vec!["-s", "owner", "repo", "42", "-o", "combined", "-f"], + ] { + let config = config(&args); + assert_eq!(config.patch_url(), "https://github.com/owner/repo/pull/42.diff"); + assert_eq!(config.output_dir, PathBuf::from("combined")); + assert!(config.force); + let diff = "diff --git a/file b/file\n--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+final\n"; + let parts = config.patch_parts(diff); + assert_eq!(parts.len(), 1); + assert_eq!(parts[0].filename, "pr-42.patch"); + assert_eq!(parts[0].content, diff); + assert!(config.patch_parts(" \n\t").is_empty()); + } + } } From 6149e65ec63fa00f3afe3325210822191ca27fe2 Mon Sep 17 00:00:00 2001 From: Oliver Lin Date: Sun, 13 Sep 2026 16:33:37 +0800 Subject: [PATCH 2/3] test(cli): verify squash patch application and output protection --- .github/workflows/build.yml | 2 + debian/control | 1 + packaging/patchsplit.spec | 1 + tests/squash.rs | 123 ++++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 tests/squash.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e4759f6..335e493 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -108,6 +108,7 @@ jobs: cargo \ debhelper-compat \ dh-cargo \ + git \ dpkg-dev \ librust-thiserror-dev \ python3:native \ @@ -152,6 +153,7 @@ jobs: dnf install -y \ cargo \ gcc \ + git \ make \ redhat-rpm-config \ rpm-build \ diff --git a/debian/control b/debian/control index 0d7e089..463ece3 100644 --- a/debian/control +++ b/debian/control @@ -6,6 +6,7 @@ Build-Depends: debhelper-compat (= 13), dh-cargo, cargo, + git, rustc, python3:native, librust-thiserror-dev (>= 2.0.0) diff --git a/packaging/patchsplit.spec b/packaging/patchsplit.spec index 9fd4797..99acffc 100644 --- a/packaging/patchsplit.spec +++ b/packaging/patchsplit.spec @@ -13,6 +13,7 @@ Source0: %{name}-%{version}.tar.gz BuildRequires: rust BuildRequires: cargo BuildRequires: gcc +BuildRequires: git %description Patchsplit is a command-line tool for splitting patch files into diff --git a/tests/squash.rs b/tests/squash.rs new file mode 100644 index 0000000..d810a86 --- /dev/null +++ b/tests/squash.rs @@ -0,0 +1,123 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +struct Workspace(PathBuf); + +impl Drop for Workspace { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn git(dir: &Path, args: &[&str]) -> Vec { + let output = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + output.stdout +} + +#[test] +fn squash_cli_writes_an_applicable_net_diff_and_protects_existing_files() { + let root = Workspace(std::env::temp_dir().join(format!( + "patchsplit-squash-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + ))); + let repo = root.0.join("repo"); + let bin = root.0.join("bin"); + fs::create_dir_all(&repo).unwrap(); + fs::create_dir_all(&bin).unwrap(); + git(&repo, &["init", "-q"]); + git(&repo, &["config", "user.name", "Test"]); + git(&repo, &["config", "user.email", "test@example.com"]); + fs::write(repo.join("edited"), "original\n").unwrap(); + fs::write(repo.join("reverted"), "unchanged\n").unwrap(); + fs::write(repo.join("old-name"), "rename me\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-qm", "base"]); + fs::write(repo.join("edited"), "intermediate\n").unwrap(); + fs::write(repo.join("reverted"), "temporary\n").unwrap(); + fs::write(repo.join("temporary-file"), "temporary\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-qm", "first"]); + fs::write(repo.join("edited"), "final without newline").unwrap(); + fs::write(repo.join("reverted"), "unchanged\n").unwrap(); + fs::remove_file(repo.join("temporary-file")).unwrap(); + fs::rename(repo.join("old-name"), repo.join("new-name")).unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-qm", "second"]); + let expected_tree = git(&repo, &["rev-parse", "HEAD^{tree}"]); + let diff = git( + &repo, + &["diff", "--no-ext-diff", "--no-textconv", "HEAD~2", "HEAD"], + ); + let diff_text = String::from_utf8_lossy(&diff); + assert!(!diff_text.contains("intermediate")); + assert!(!diff_text.contains("reverted")); + assert!(!diff_text.contains("temporary-file")); + let fixture = root.0.join("response.diff"); + fs::write(&fixture, &diff).unwrap(); + // Substitute only the HTTP boundary; exercise the real CLI and Git application. + let curl = bin.join("curl"); + fs::write(&curl, "#!/bin/sh\nfor arg do url=\"$arg\"; done\n[ \"$url\" = 'https://github.com/owner/repo/pull/42.diff' ] || exit 22\ncat \"$PATCHSPLIT_TEST_DIFF\"\n").unwrap(); + fs::set_permissions(&curl, fs::Permissions::from_mode(0o755)).unwrap(); + let mut paths = vec![bin]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + let path = std::env::join_paths(paths).unwrap(); + let output_dir = root.0.join("patches"); + let run = |extra: &[&str]| -> Output { + Command::new(env!("CARGO_BIN_EXE_patchsplit")) + .args(["owner/repo", "42", "--squash", "--out"]) + .arg(&output_dir) + .args(extra) + .env("PATH", &path) + .env("PATCHSPLIT_TEST_DIFF", &fixture) + .env("PATCHSPLIT_LANGUAGE", "C") + .output() + .unwrap() + }; + assert!(run(&[]).status.success()); + let patch = output_dir.join("pr-42.patch"); + assert_eq!(fs::read_dir(&output_dir).unwrap().count(), 1); + assert_eq!(fs::read(&patch).unwrap(), diff); + git(&repo, &["checkout", "--detach", "HEAD~2"]); + git(&repo, &["apply", "--index", patch.to_str().unwrap()]); + assert_eq!(git(&repo, &["write-tree"]), expected_tree); + + fs::write(&patch, "user edits").unwrap(); + let result = run(&[]); + assert_eq!(result.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&result.stderr).contains("refusing to overwrite")); + assert_eq!(fs::read_to_string(&patch).unwrap(), "user edits"); + assert!(run(&["--force"]).status.success()); + assert_eq!(fs::read(&patch).unwrap(), diff); + + fs::remove_file(&patch).unwrap(); + for (body, error) in [ + (" \n", "downloaded patch is empty"), + ("", "not a Git diff"), + ] { + fs::write(&fixture, body).unwrap(); + let result = run(&[]); + assert_eq!(result.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&result.stderr).contains(error)); + assert!(!patch.exists()); + } +} From c7335817ed91ff689a202bd1a89a1430ba52cd1a Mon Sep 17 00:00:00 2001 From: Oliver Lin Date: Sun, 13 Sep 2026 16:33:52 +0800 Subject: [PATCH 3/3] docs: explain aggregate patch usage and limitations --- README.md | 26 +++++++++++++++++++++++--- README_zh-cn.md | 23 ++++++++++++++++++++--- debian/patchsplit.1 | 14 +++++++++++++- packaging/patchsplit.1 | 14 +++++++++++++- 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d00f452..1134dc7 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ and splits it into one patch file per commit. ## Usage ```sh -patchsplit [--out ] [--force] -patchsplit [--out ] [--force] +patchsplit [--out ] [--force] [--squash] +patchsplit [--out ] [--force] [--squash] ``` Examples: @@ -31,10 +31,27 @@ patches/ Existing output files are not overwritten by default. Pass `--force` to replace them. +### Aggregate all commits + +```sh +patchsplit openai/codex 42 --squash -o pr-42-patches +git apply pr-42-patches/pr-42.patch +``` + +`-s, --squash` downloads GitHub's aggregate PR `.diff` and writes a single +`pr-.patch`. This represents the net change from the PR's merge base +to its head: repeated edits are combined and reverted changes disappear. +It does not concatenate per-commit patches. The output is a raw diff for +`git apply` on the corresponding base, without individual commit messages or +authorship (it is not a `git am` mailbox). An empty net diff is reported as an +empty patch and no file is written. Binary changes are limited to the data +GitHub includes in its diff; binary file contents may not be included. + ## Options -- `-o, --out `: Output directory for split patch files. +- `-o, --out `: Output directory for patch files. - `-f, --force`: Overwrite existing patch files. +- `-s, --squash`: Write the PR's net diff as one patch. - `-h, --help`: Show help. - `-V, --version`: Show version. @@ -63,6 +80,9 @@ extraction are listed in `po/POTFILES.in`. ## Build +Tests (`cargo test`) also require Git on Unix to verify that aggregate patches +apply to the expected file tree. + ```sh cargo build --release ``` diff --git a/README_zh-cn.md b/README_zh-cn.md index 3082d80..b8a08af 100644 --- a/README_zh-cn.md +++ b/README_zh-cn.md @@ -8,8 +8,8 @@ ## 用法 ```sh -patchsplit [--out ] [--force] -patchsplit [--out ] [--force] +patchsplit [--out ] [--force] [--squash] +patchsplit [--out ] [--force] [--squash] ``` 示例: @@ -29,10 +29,25 @@ patches/ 如果输出文件已存在,命令默认拒绝覆盖。需要覆盖时传入 `--force`。 +### 聚合所有 commit + +```sh +patchsplit openai/codex 42 --squash -o pr-42-patches +git apply pr-42-patches/pr-42.patch +``` + +`-s, --squash` 下载 GitHub 提供的 PR 整体 `.diff`,输出一个 +`pr-.patch`。它表示 PR 从共同祖先(merge base)到最终 head 的净变化: +同一文件的多次修改会合并,已撤销的修改会消失,不是把各 commit 的补丁拼接起来。 +输出为可在对应基线上用 `git apply` 应用的原始 diff,不包含各 commit 的提交说明和 +作者信息,不能作为 `git am` 邮件补丁使用。净变化为空时会报补丁为空,不生成文件。 +二进制变更受 GitHub diff 返回内容限制,可能不包含二进制文件内容。 + ## 参数 -- `-o, --out `:指定拆分后 patch 文件的输出目录。 +- `-o, --out `:指定 patch 文件的输出目录。 - `-f, --force`:允许覆盖已存在的 patch 文件。 +- `-s, --squash`:将 PR 的最终净变化输出为一个补丁。 - `-h, --help`:显示帮助。 - `-V, --version`:显示版本。 @@ -59,6 +74,8 @@ scripts/update-pot.sh ## 构建 +在 Unix 上运行测试(`cargo test`)还需要 Git,用于验证聚合补丁应用后的文件树。 + ```sh cargo build --release ``` diff --git a/debian/patchsplit.1 b/debian/patchsplit.1 index d1dbf68..bae1a80 100644 --- a/debian/patchsplit.1 +++ b/debian/patchsplit.1 @@ -26,13 +26,25 @@ patchsplit \- download a GitHub pull request patch and split it by commit downloads the patch for a GitHub pull request and splits it into individual patch files, one for each commit. .PP +With +.BR \-\-squash , +download the aggregate PR diff and write one +.B pr-.patch +containing the net change from the merge base to the PR head. +Repeated edits are combined and reverted changes disappear. +The output is a raw diff for git apply, without commit metadata. +An empty net diff is an error. Binary contents may be omitted by GitHub. +.PP The resulting patch files are written to the .B patches directory by default. .SH OPTIONS .TP +.BR \-s ", " \-\-squash +Write the PR's net diff as one patch instead of splitting by commit. +.TP .BR \-o ", " \-\-out " " \fIdir\fR -Write split patch files to +Write patch files to .IR dir . The default is .BR patches . diff --git a/packaging/patchsplit.1 b/packaging/patchsplit.1 index d1dbf68..bae1a80 100644 --- a/packaging/patchsplit.1 +++ b/packaging/patchsplit.1 @@ -26,13 +26,25 @@ patchsplit \- download a GitHub pull request patch and split it by commit downloads the patch for a GitHub pull request and splits it into individual patch files, one for each commit. .PP +With +.BR \-\-squash , +download the aggregate PR diff and write one +.B pr-.patch +containing the net change from the merge base to the PR head. +Repeated edits are combined and reverted changes disappear. +The output is a raw diff for git apply, without commit metadata. +An empty net diff is an error. Binary contents may be omitted by GitHub. +.PP The resulting patch files are written to the .B patches directory by default. .SH OPTIONS .TP +.BR \-s ", " \-\-squash +Write the PR's net diff as one patch instead of splitting by commit. +.TP .BR \-o ", " \-\-out " " \fIdir\fR -Write split patch files to +Write patch files to .IR dir . The default is .BR patches .