From c4a7120f37fc1a8aaa44e297a8cc033a3a9f3059 Mon Sep 17 00:00:00 2001 From: Oliver Lin Date: Wed, 16 Sep 2026 02:03:17 +0800 Subject: [PATCH] feat: Add functionality to download a patch for a single commit This update adds the ability to download a GitHub commit patch using the `--commit` parameter in `patchsplit`, supporting both short and full commit hashes: 1. Added the `--commit` (or `-commit`) command-line argument to specify the commit hash to download. 2. Implemented comprehensive argument validation, including hash format verification and checks for parameter conflicts. 3. Updated all documentation: Chinese and English READMEs, Chinese documentation, man pages, and translation files. 4. Added corresponding unit tests and end-to-end test cases. 5. Downloaded commit patches are saved directly as `.patch` and can be applied immediately using `git am`. 6. Prohibited the simultaneous use of the `--commit` and `--squash` arguments. --- README.md | 24 ++++- README_zh-cn.md | 22 +++- debian/patchsplit.1 | 49 ++++++++- packaging/patchsplit.1 | 49 ++++++++- po/patchsplit.pot | 18 +++- po/zh_CN.po | 24 ++++- src/main.rs | 223 ++++++++++++++++++++++++++++++++++------- tests/commit.rs | 130 ++++++++++++++++++++++++ 8 files changed, 495 insertions(+), 44 deletions(-) create mode 100644 tests/commit.rs diff --git a/README.md b/README.md index f098077..c0d4123 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Language: English | [Simplified Chinese](README_zh-cn.md) -Download GitHub pull requests as patches, one file per commit or one combined diff. +Download GitHub pull requests or individual commits as patches, one file per +commit or one combined diff. `patchsplit` is a command-line tool written in Rust. It fetches patches directly from GitHub without cloning the repository, making it useful for reviewing, @@ -10,6 +11,8 @@ sharing, and applying changes locally. - **Per-commit patches:** Keep the original patch content, commit messages, and authorship, with numbered filenames in commit order. +- **Single commit:** Use `--commit ` to download one commit's `.patch` + with either a short or full hash. - **Combined diff:** Use `--squash` to export the PR's net changes as a single patch. - **Predictable output:** Choose an output directory; existing files are only overwritten when you pass `--force`. @@ -137,6 +140,8 @@ Ensure Cargo's binary directory (usually `~/.cargo/bin`) is in your `PATH`. ```sh patchsplit [--out ] [--force] [--squash] patchsplit [--out ] [--force] [--squash] +patchsplit --commit [--out ] [--force] +patchsplit --commit [--out ] [--force] ``` ### Split a pull request by commit @@ -190,11 +195,27 @@ git apply /path/to/pr-42-patches/pr-42.patch | --- | --- | --- | --- | | Default | One numbered patch per commit | Preserved | `git am` | | `--squash` | One `pr-.patch` | Not included | `git apply` | +| `--commit ` | One `.patch` | Preserved | `git am` | The combined output is a raw diff, not a `git am` mailbox. An empty net diff is reported as an error and no file is written. Binary changes are limited to the data GitHub includes in its diff; binary file contents may not be included. +### Download a single commit + +Pass `--commit ` (or `-commit `) with a short or full commit hash +instead of a pull request number: + +```sh +patchsplit zitzhen patchsplit -commit b430113 +patchsplit zitzhen/patchsplit --commit b4301133226e5c3a464cff9649de0b321c0b0a2e +``` + +This fetches `https://github.com///commit/.patch` and writes +the commit's mail-formatted patch verbatim to `.patch` in the output +directory, for example `patches/b430113.patch`. Apply it with `git am` just +like a per-commit PR patch. `--commit` cannot be combined with `--squash`. + ### Options | Option | Description | @@ -202,6 +223,7 @@ the data GitHub includes in its diff; binary file contents may not be included. | `-o, --out ` | Output directory (default: `patches/`). | | `-f, --force` | Overwrite existing patch files. | | `-s, --squash` | Write the PR's net diff as one patch. | +| `--commit ` | Download one commit's `.patch`; accepts a short or full hash. | | `-h, --help` | Show help. | | `-V, --version` | Show version. | diff --git a/README_zh-cn.md b/README_zh-cn.md index 147e575..03caf54 100644 --- a/README_zh-cn.md +++ b/README_zh-cn.md @@ -2,14 +2,16 @@ 语言:[English](README.md) | 简体中文 -`patchsplit` 是一个 Rust CLI,用来从 GitHub 下载 Pull Request 的 `.patch` -文件,并按 commit 拆分成多个独立 patch 文件。 +`patchsplit` 是一个 Rust CLI,用来从 GitHub 下载 Pull Request 或单个 commit 的 +`.patch` 文件,PR 补丁可按 commit 拆分成多个独立 patch 文件。 ## 用法 ```sh patchsplit [--out ] [--force] [--squash] patchsplit [--out ] [--force] [--squash] +patchsplit --commit [--out ] [--force] +patchsplit --commit [--out ] [--force] ``` 示例: @@ -43,11 +45,27 @@ git apply pr-42-patches/pr-42.patch 作者信息,不能作为 `git am` 邮件补丁使用。净变化为空时会报补丁为空,不生成文件。 二进制变更受 GitHub diff 返回内容限制,可能不包含二进制文件内容。 +### 下载单个 commit + +不传 PR 编号,改用 `--commit `(也可写作 `-commit `)传入短哈希或 +完整哈希: + +```sh +patchsplit zitzhen patchsplit -commit b430113 +patchsplit zitzhen/patchsplit --commit b4301133226e5c3a464cff9649de0b321c0b0a2e +``` + +对应下载 `https://github.com///commit/.patch`,并把该 commit +的邮件格式补丁原样写入输出目录下的 `.patch`,例如 +`patches/b430113.patch`。它和按 commit 拆分的 PR 补丁一样可用 `git am` 应用。 +`--commit` 不能与 `--squash` 同时使用。 + ## 参数 - `-o, --out `:指定 patch 文件的输出目录。 - `-f, --force`:允许覆盖已存在的 patch 文件。 - `-s, --squash`:将 PR 的最终净变化输出为一个补丁。 +- `--commit `:下载单个 commit 的 `.patch`,接受短哈希或完整哈希。 - `-h, --help`:显示帮助。 - `-V, --version`:显示版本。 diff --git a/debian/patchsplit.1 b/debian/patchsplit.1 index bae1a80..b752324 100644 --- a/debian/patchsplit.1 +++ b/debian/patchsplit.1 @@ -1,6 +1,6 @@ .TH PATCHSPLIT 1 "August 2026" "patchsplit 1.0.2" "User Commands" .SH NAME -patchsplit \- download a GitHub pull request patch and split it by commit +patchsplit \- download a GitHub pull request patch, split it by commit, or fetch a single commit patch .SH SYNOPSIS .B patchsplit .RI < owner/repo > " " @@ -21,6 +21,26 @@ patchsplit \- download a GitHub pull request patch and split it by commit [\c .B \-\-force ] .RE +.br +.B patchsplit +.RI < owner/repo > " \-\-commit " +.RS +[\c +.B \-\-out +.IR dir ] +[\c +.B \-\-force ] +.RE +.br +.B patchsplit +.RI < owner > " \-\-commit " +.RS +[\c +.B \-\-out +.IR dir ] +[\c +.B \-\-force ] +.RE .SH DESCRIPTION .B patchsplit downloads the patch for a GitHub pull request and splits it into @@ -35,6 +55,16 @@ 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 +With +.BR \-\-commit " " \fIhash\fR , +download the patch of a single commit identified by a short or full hash and +write it verbatim to +.BR .patch . +The result is a mail-formatted patch for git am. +.B \-\-commit +cannot be combined with +.BR \-\-squash . +.PP The resulting patch files are written to the .B patches directory by default. @@ -43,6 +73,15 @@ directory by default. .BR \-s ", " \-\-squash Write the PR's net diff as one patch instead of splitting by commit. .TP +.BR \-\-commit " " \fIhash\fR +Download the .patch of a single commit instead of a pull request. +.I hash +may be a short or full commit hash. +The output file is named +.BR .patch . +Cannot be combined with +.BR \-\-squash . +.TP .BR \-o ", " \-\-out " " \fIdir\fR Write patch files to .IR dir . @@ -69,11 +108,17 @@ Download pull request 42 from .BR openai/codex and write the resulting patches to .BR pr-42-patches . +.TP +.B patchsplit zitzhen patchsplit \-commit b430113 +Download the patch for commit b430113 from +.BR zitzhen/patchsplit +and write it to +.BR b430113.patch . .SH REQUIREMENTS .B patchsplit uses .B curl -to download pull request patches from GitHub. +to download pull request and commit patches from GitHub. .SH SEE ALSO .BR curl (1) .SH AUTHORS diff --git a/packaging/patchsplit.1 b/packaging/patchsplit.1 index bae1a80..b752324 100644 --- a/packaging/patchsplit.1 +++ b/packaging/patchsplit.1 @@ -1,6 +1,6 @@ .TH PATCHSPLIT 1 "August 2026" "patchsplit 1.0.2" "User Commands" .SH NAME -patchsplit \- download a GitHub pull request patch and split it by commit +patchsplit \- download a GitHub pull request patch, split it by commit, or fetch a single commit patch .SH SYNOPSIS .B patchsplit .RI < owner/repo > " " @@ -21,6 +21,26 @@ patchsplit \- download a GitHub pull request patch and split it by commit [\c .B \-\-force ] .RE +.br +.B patchsplit +.RI < owner/repo > " \-\-commit " +.RS +[\c +.B \-\-out +.IR dir ] +[\c +.B \-\-force ] +.RE +.br +.B patchsplit +.RI < owner > " \-\-commit " +.RS +[\c +.B \-\-out +.IR dir ] +[\c +.B \-\-force ] +.RE .SH DESCRIPTION .B patchsplit downloads the patch for a GitHub pull request and splits it into @@ -35,6 +55,16 @@ 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 +With +.BR \-\-commit " " \fIhash\fR , +download the patch of a single commit identified by a short or full hash and +write it verbatim to +.BR .patch . +The result is a mail-formatted patch for git am. +.B \-\-commit +cannot be combined with +.BR \-\-squash . +.PP The resulting patch files are written to the .B patches directory by default. @@ -43,6 +73,15 @@ directory by default. .BR \-s ", " \-\-squash Write the PR's net diff as one patch instead of splitting by commit. .TP +.BR \-\-commit " " \fIhash\fR +Download the .patch of a single commit instead of a pull request. +.I hash +may be a short or full commit hash. +The output file is named +.BR .patch . +Cannot be combined with +.BR \-\-squash . +.TP .BR \-o ", " \-\-out " " \fIdir\fR Write patch files to .IR dir . @@ -69,11 +108,17 @@ Download pull request 42 from .BR openai/codex and write the resulting patches to .BR pr-42-patches . +.TP +.B patchsplit zitzhen patchsplit \-commit b430113 +Download the patch for commit b430113 from +.BR zitzhen/patchsplit +and write it to +.BR b430113.patch . .SH REQUIREMENTS .B patchsplit uses .B curl -to download pull request patches from GitHub. +to download pull request and commit patches from GitHub. .SH SEE ALSO .BR curl (1) .SH AUTHORS diff --git a/po/patchsplit.pot b/po/patchsplit.pot index acce23e..35c131e 100644 --- a/po/patchsplit.pot +++ b/po/patchsplit.pot @@ -49,6 +49,18 @@ msgstr "" msgid "pull request number must be a positive integer, got {value}" msgstr "" +#: src/main.rs +msgid "expected a GitHub repository with --commit " +msgstr "" + +#: src/main.rs +msgid "commit hash must consist of 4 to 40 hexadecimal characters, got {value}" +msgstr "" + +#: src/main.rs +msgid "--commit cannot be combined with --squash" +msgstr "" + #: src/main.rs msgid "missing value for {option}" msgstr "" @@ -119,17 +131,21 @@ msgid "" "Usage:\n" " patchsplit [--out ] [--force] [--squash]\n" " patchsplit [--out ] [--force] [--squash]\n" +" patchsplit --commit [--out ] [--force]\n" +" patchsplit --commit [--out ] [--force]\n" "\n" "Options:\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" +" --commit 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" +" patchsplit openai/codex 42 --squash\n" +" patchsplit zitzhen patchsplit -commit b430113" msgstr "" diff --git a/po/zh_CN.po b/po/zh_CN.po index 059180f..da02ca1 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -48,6 +48,18 @@ msgstr "无效的 GitHub 仓库{kind}:{value}" msgid "pull request number must be a positive integer, got {value}" msgstr "Pull Request 编号必须是正整数,实际为 {value}" +#: src/main.rs +msgid "expected a GitHub repository with --commit " +msgstr "需要提供 GitHub 仓库,并通过 --commit 指定提交" + +#: src/main.rs +msgid "commit hash must consist of 4 to 40 hexadecimal characters, got {value}" +msgstr "commit 哈希必须为 4 到 40 位十六进制字符,实际为 {value}" + +#: src/main.rs +msgid "--commit cannot be combined with --squash" +msgstr "--commit 不能与 --squash 同时使用" + #: src/main.rs msgid "missing value for {option}" msgstr "缺少 {option} 的值" @@ -118,32 +130,40 @@ msgid "" "Usage:\n" " patchsplit [--out ] [--force] [--squash]\n" " patchsplit [--out ] [--force] [--squash]\n" +" patchsplit --commit [--out ] [--force]\n" +" patchsplit --commit [--out ] [--force]\n" "\n" "Options:\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" +" --commit 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" +" patchsplit openai/codex 42 --squash\n" +" patchsplit zitzhen patchsplit -commit b430113" msgstr "" "用法:\n" " patchsplit [--out ] [--force] [--squash]\n" " patchsplit [--out ] [--force] [--squash]\n" +" patchsplit --commit [--out ] [--force]\n" +" patchsplit --commit [--out ] [--force]\n" "\n" "选项:\n" " -o, --out patch 文件输出目录 [默认:patches]\n" " -f, --force 覆盖已有 patch 文件\n" " -s, --squash 将 PR 的最终净变化输出为一个补丁\n" +" --commit 下载单个提交的 .patch(短哈希或完整哈希)\n" " -h, --help 显示帮助信息\n" " -V, --version 显示版本\n" "\n" "示例:\n" " patchsplit rust-lang/rust 12345\n" " patchsplit openai codex 42 -o pr-42-patches\n" -" patchsplit openai/codex 42 --squash" +" patchsplit openai/codex 42 --squash\n" +" patchsplit zitzhen patchsplit -commit b430113" diff --git a/src/main.rs b/src/main.rs index f65aec9..148ffad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,12 +75,18 @@ fn run() -> Result<(), AppError> { struct Config { owner: String, repo: String, - pull_request: u64, + target: Target, output_dir: PathBuf, force: bool, squash: bool, } +#[derive(Debug)] +enum Target { + PullRequest(u64), + Commit(String), +} + impl Config { fn parse(args: I) -> Result where @@ -89,6 +95,7 @@ impl Config { let mut output_dir = PathBuf::from("patches"); let mut force = false; let mut squash = false; + let mut commit = None; let mut positionals = Vec::new(); let mut args = args.into_iter(); @@ -106,6 +113,18 @@ impl Config { value if value.starts_with("--out=") => { 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())); } @@ -113,27 +132,49 @@ impl Config { } } - let (owner, repo, pull_request) = match positionals.as_slice() { - [repo_spec, pull_request] => { - let (owner, repo) = parse_repo_spec(repo_spec)?; - (owner, repo, parse_pull_request(pull_request)?) + if commit.is_some() && squash { + return Err(AppError::CommitWithSquash); + } + + let (owner, repo, target) = match commit { + Some(hash) => { + let (owner, repo) = match positionals.as_slice() { + [repo_spec] => parse_repo_spec(repo_spec)?, + [owner, repo] => { + validate_repo_segment("owner", owner)?; + validate_repo_segment("repo", repo)?; + (owner.clone(), repo.clone()) + } + _ => return Err(AppError::InvalidCommitArguments), + }; + validate_commit_hash(&hash)?; + (owner, repo, Target::Commit(hash)) } - [owner, repo, pull_request] => { - validate_repo_segment("owner", owner)?; - validate_repo_segment("repo", repo)?; - ( - owner.clone(), - repo.clone(), - parse_pull_request(pull_request)?, - ) + None => { + let (owner, repo, pull_request) = match positionals.as_slice() { + [repo_spec, pull_request] => { + let (owner, repo) = parse_repo_spec(repo_spec)?; + (owner, repo, parse_pull_request(pull_request)?) + } + [owner, repo, pull_request] => { + validate_repo_segment("owner", owner)?; + validate_repo_segment("repo", repo)?; + ( + owner.clone(), + repo.clone(), + parse_pull_request(pull_request)?, + ) + } + _ => return Err(AppError::InvalidArguments), + }; + (owner, repo, Target::PullRequest(pull_request)) } - _ => return Err(AppError::InvalidArguments), }; Ok(Self { owner, repo, - pull_request, + target, output_dir, force, squash, @@ -141,28 +182,53 @@ impl Config { } 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/{}.{extension}", - self.owner, self.repo, self.pull_request - ) + match &self.target { + // GitHub's PR diff represents the net change; .patch contains each commit. + Target::PullRequest(pull_request) => { + let extension = if self.squash { "diff" } else { "patch" }; + format!( + "https://github.com/{}/{}/pull/{}.{extension}", + self.owner, self.repo, pull_request + ) + } + Target::Commit(hash) => format!( + "https://github.com/{}/{}/commit/{}.patch", + self.owner, self.repo, hash + ), + } } fn patch_parts(&self, patch: &str) -> Vec { - if !self.squash { - return split_patch_by_commit(patch); - } - if patch.trim().is_empty() { - return Vec::new(); + 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::PullRequest(pull_request) => { + 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 #{pull_request}"), + filename: format!("pr-{pull_request}.patch"), + content: patch.to_string(), + }] + } } - vec![PatchPart { - index: 1, - commit: None, - subject: format!("PR #{}", self.pull_request), - filename: format!("pr-{}.patch", self.pull_request), - content: patch.to_string(), - }] } } @@ -207,6 +273,18 @@ fn parse_pull_request(value: &str) -> Result { } } +fn validate_commit_hash(value: &str) -> Result<(), AppError> { + // Git abbreviations are at least 4 hex characters; full SHAs are 40. + let valid = + (4..=40).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_hexdigit()); + + if valid { + Ok(()) + } else { + Err(AppError::InvalidCommitHash(value.to_string())) + } +} + fn download_patch(url: &str) -> Result { // Rust's standard library has no HTTPS client; calling curl keeps downloads simple. let output = Command::new("curl") @@ -283,6 +361,12 @@ enum AppError { InvalidRepoSegment { kind: &'static str, value: String }, #[error("pull request number must be a positive integer, got {0:?}")] InvalidPullRequest(String), + #[error("expected a GitHub repository with --commit ")] + InvalidCommitArguments, + #[error("commit hash must consist of 4 to 40 hexadecimal characters, got {0:?}")] + InvalidCommitHash(String), + #[error("--commit cannot be combined with --squash")] + CommitWithSquash, #[error("missing value for {0}")] MissingOptionValue(String), #[error("unknown option {0}")] @@ -319,6 +403,9 @@ impl AppError { | Self::InvalidRepoSpec(_) | Self::InvalidRepoSegment { .. } | Self::InvalidPullRequest(_) + | Self::InvalidCommitArguments + | Self::InvalidCommitHash(_) + | Self::CommitWithSquash | Self::MissingOptionValue(_) | Self::UnknownOption(_) => 2, _ => 1, @@ -343,6 +430,14 @@ impl AppError { "pull request number must be a positive integer, got {value}", &[("value", quoted(value))], ), + Self::InvalidCommitArguments => { + tr("expected a GitHub repository with --commit ") + } + Self::InvalidCommitHash(value) => tr_args( + "commit hash must consist of 4 to 40 hexadecimal characters, got {value}", + &[("value", quoted(value))], + ), + Self::CommitWithSquash => tr("--commit cannot be combined with --squash"), Self::MissingOptionValue(option) => { tr_args("missing value for {option}", &[("option", option.clone())]) } @@ -419,7 +514,7 @@ fn repo_segment_label(kind: &str) -> String { } fn usage() -> String { - 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") + tr("Usage:\n patchsplit [--out ] [--force] [--squash]\n patchsplit [--out ] [--force] [--squash]\n patchsplit --commit [--out ] [--force]\n patchsplit --commit [--out ] [--force]\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 --commit Download one commit's .patch (short or full hash)\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\n patchsplit zitzhen patchsplit -commit b430113") } #[cfg(test)] @@ -460,4 +555,64 @@ mod tests { assert!(config.patch_parts(" \n\t").is_empty()); } } + + #[test] + fn commit_mode_downloads_a_single_commit_patch() { + const FULL_HASH: &str = "b4301133226e5c3a464cff9649de0b321c0b0a2e"; + let full_double_dash = format!("--commit={FULL_HASH}"); + let full_single_dash = format!("-commit={FULL_HASH}"); + let cases: Vec<(Vec<&str>, &str)> = vec![ + (vec!["zitzhen/patchsplit", "-commit", "b430113"], "b430113"), + ( + vec!["zitzhen", "patchsplit", "--commit", "b430113"], + "b430113", + ), + (vec!["zitzhen/patchsplit", &full_double_dash], FULL_HASH), + (vec!["zitzhen/patchsplit", &full_single_dash], FULL_HASH), + ]; + let patch = format!( + "From {FULL_HASH} Mon Sep 17 00:00:00 2001\nSubject: [PATCH] One\n\ndiff --git a/a b/a\n" + ); + + for (args, hash) in cases { + let config = config(&args); + assert_eq!( + config.patch_url(), + format!("https://github.com/zitzhen/patchsplit/commit/{hash}.patch") + ); + let parts = config.patch_parts(&patch); + assert_eq!(parts.len(), 1); + assert_eq!(parts[0].filename, format!("{hash}.patch")); + assert_eq!(parts[0].content, patch); + assert!(config.patch_parts(" \n").is_empty()); + } + } + + #[test] + fn commit_mode_validates_hash_and_arguments() { + fn parse_err(args: &[&str]) -> String { + let error = Config::parse(args.iter().map(|arg| arg.to_string())).unwrap_err(); + format!("{error:?}") + } + + let non_hex = "g".repeat(7); + for args in [ + vec!["owner/repo", "--commit", "xyz"], + vec!["owner/repo", "--commit", "abc"], + vec!["owner/repo", "--commit", non_hex.as_str()], + ] { + assert!(parse_err(&args).contains("InvalidCommitHash")); + } + + assert!(parse_err(&["owner/repo", "--commit"]).contains("MissingOptionValue")); + assert!( + parse_err(&["owner", "repo", "42", "--commit", "b430113"]) + .contains("InvalidCommitArguments") + ); + assert!(parse_err(&["--commit", "b430113"]).contains("InvalidCommitArguments")); + assert!( + parse_err(&["owner/repo", "42", "--commit", "b430113", "--squash"]) + .contains("CommitWithSquash") + ); + } } diff --git a/tests/commit.rs b/tests/commit.rs new file mode 100644 index 0000000..1db67c1 --- /dev/null +++ b/tests/commit.rs @@ -0,0 +1,130 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::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); + } +} + +const SHORT_HASH: &str = "b430113"; +const FULL_HASH: &str = "b4301133226e5c3a464cff9649de0b321c0b0a2e"; + +fn patch_body() -> String { + format!( + "From {FULL_HASH} Mon Sep 17 00:00:00 2001\n\ +From: Test \n\ +Subject: [PATCH] Single commit\n\ +\n\ +diff --git a/a.txt b/a.txt\n\ +new file mode 100644\n\ +--- /dev/null\n\ ++++ b/a.txt\n\ +@@ -0,0 +1 @@\n\ ++a\n" + ) +} + +#[test] +fn commit_cli_downloads_one_commit_patch_for_short_and_full_hash() { + let root = Workspace(std::env::temp_dir().join(format!( + "patchsplit-commit-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + ))); + let bin = root.0.join("bin"); + fs::create_dir_all(&bin).unwrap(); + let fixture = root.0.join("response.patch"); + fs::write(&fixture, patch_body()).unwrap(); + + // Substitute only the HTTP boundary; exercise the real CLI end to end. + let curl = bin.join("curl"); + fs::write( + &curl, + "#!/bin/sh\n\ +for arg do url=\"$arg\"; done\n\ +case \"$url\" in\n\ + 'https://github.com/zitzhen/patchsplit/commit/b430113.patch') ;;\n\ + 'https://github.com/zitzhen/patchsplit/commit/b4301133226e5c3a464cff9649de0b321c0b0a2e.patch') ;;\n\ + *) echo \"unexpected url: $url\" >&2; exit 22 ;;\n\ +esac\n\ +cat \"$PATCHSPLIT_TEST_PATCH\"\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 run = |args: &[&str]| -> Output { + Command::new(env!("CARGO_BIN_EXE_patchsplit")) + .args(args) + .env("PATH", &path) + .env("PATCHSPLIT_TEST_PATCH", &fixture) + .env("PATCHSPLIT_LANGUAGE", "C") + .output() + .unwrap() + }; + + let short_dir = root.0.join("short"); + let result = run(&[ + "zitzhen/patchsplit", + "-commit", + SHORT_HASH, + "--out", + short_dir.to_str().unwrap(), + ]); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let short_patch = short_dir.join(format!("{SHORT_HASH}.patch")); + assert_eq!(fs::read_dir(&short_dir).unwrap().count(), 1); + assert_eq!(fs::read(&short_patch).unwrap(), patch_body().as_bytes()); + + let full_dir = root.0.join("full"); + let result = run(&[ + "zitzhen", + "patchsplit", + "--commit", + FULL_HASH, + "--out", + full_dir.to_str().unwrap(), + ]); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let full_patch = full_dir.join(format!("{FULL_HASH}.patch")); + assert_eq!(fs::read(&full_patch).unwrap(), patch_body().as_bytes()); + + let bad = run(&["zitzhen/patchsplit", "--commit", "xyz"]); + assert_eq!(bad.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&bad.stderr).contains("hexadecimal characters") + ); + + let conflict = run(&[ + "zitzhen/patchsplit", + "42", + "--commit", + SHORT_HASH, + "--squash", + ]); + assert_eq!(conflict.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&conflict.stderr) + .contains("--commit cannot be combined with --squash") + ); +}