diff --git a/README.md b/README.md index c0d4123..7c9a353 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,20 @@ Language: English | [Simplified Chinese](README_zh-cn.md) -Download GitHub pull requests or individual commits as patches, one file per -commit or one combined diff. +Download GitHub pull requests, GitLab merge 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, -sharing, and applying changes locally. +from GitHub or GitLab without cloning the repository, making it useful for +reviewing, 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. +- **GitLab support:** Pass `--gitlab` to fetch merge requests and commits from + gitlab.com, including projects nested under subgroups. +- **Combined diff:** Use `--squash` to export the request's net changes as a single patch. - **Predictable output:** Choose an output directory; existing files are only overwritten when you pass `--force`. - **Localized CLI:** English and built-in Simplified Chinese messages. @@ -142,6 +144,8 @@ patchsplit [--out ] [--force] [--squash] patchsplit [--out ] [--force] [--squash] patchsplit --commit [--out ] [--force] patchsplit --commit [--out ] [--force] +patchsplit --gitlab [--out ] [--force] [--squash] +patchsplit --gitlab --commit [--out ] [--force] ``` ### Split a pull request by commit @@ -193,9 +197,12 @@ git apply /path/to/pr-42-patches/pr-42.patch | Mode | Output | Commit messages and authorship | Apply with | | --- | --- | --- | --- | -| Default | One numbered patch per commit | Preserved | `git am` | -| `--squash` | One `pr-.patch` | Not included | `git apply` | -| `--commit ` | One `.patch` | Preserved | `git am` | +| Default (GitHub PR) | One numbered patch per commit | Preserved | `git am` | +| `--squash` (GitHub PR) | One `pr-.patch` | Not included | `git apply` | +| `--commit ` (GitHub) | One `.patch` | Preserved | `git am` | +| `--gitlab` (GitLab MR) | One numbered patch per commit | Preserved | `git am` | +| `--gitlab --squash` | One `mr-.patch` | Not included | `git apply` | +| `--gitlab --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 @@ -216,13 +223,35 @@ 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`. +### Download from GitLab + +Pass `--gitlab` to download a GitLab merge request or commit from gitlab.com. +The project path uses `namespace/project` form and may contain subgroups, for +example `group/subgroup/project`: + +```sh +patchsplit --gitlab zitzhen/patchsplit 1 +patchsplit --gitlab zitzhen/patchsplit --commit fafbad69af7507f41e786aa6685b2fa29716c85f +patchsplit --gitlab group/subgroup/project 1 --squash -o mr-1-patches +``` + +A merge request is fetched from +`https://gitlab.com///-/merge_requests/.patch` and, +just like a GitHub PR, split into one mail-formatted patch per commit. With +`--squash`, the request's `.diff` net change is written to +`mr-.patch`. A `--commit` download fetches +`https://gitlab.com///-/commit/.patch` and writes the +verbatim patch to `.patch`. The same `--out`, `--force`, and `--squash` +rules apply, and `--commit` cannot be combined with `--squash`. + ### Options | Option | Description | | --- | --- | | `-o, --out ` | Output directory (default: `patches/`). | | `-f, --force` | Overwrite existing patch files. | -| `-s, --squash` | Write the PR's net diff as one patch. | +| `-s, --squash` | Write the request's net diff as one patch. | +| `--gitlab` | Download a merge request or commit from gitlab.com. | | `--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 03caf54..a0d5e12 100644 --- a/README_zh-cn.md +++ b/README_zh-cn.md @@ -2,8 +2,9 @@ 语言:[English](README.md) | 简体中文 -`patchsplit` 是一个 Rust CLI,用来从 GitHub 下载 Pull Request 或单个 commit 的 -`.patch` 文件,PR 补丁可按 commit 拆分成多个独立 patch 文件。 +`patchsplit` 是一个 Rust CLI,用来从 GitHub 下载 Pull Request、或从 GitLab +下载合并请求(Merge Request)及单个 commit 的 `.patch` 文件,PR/MR 补丁可按 +commit 拆分成多个独立 patch 文件。 ## 用法 @@ -12,6 +13,8 @@ patchsplit [--out ] [--force] [--squash] patchsplit [--out ] [--force] [--squash] patchsplit --commit [--out ] [--force] patchsplit --commit [--out ] [--force] +patchsplit --gitlab [--out ] [--force] [--squash] +patchsplit --gitlab --commit [--out ] [--force] ``` 示例: @@ -60,11 +63,32 @@ patchsplit zitzhen/patchsplit --commit b4301133226e5c3a464cff9649de0b321c0b0a2e `patches/b430113.patch`。它和按 commit 拆分的 PR 补丁一样可用 `git am` 应用。 `--commit` 不能与 `--squash` 同时使用。 +### 从 GitLab 下载 + +传入 `--gitlab` 即可从 gitlab.com 下载合并请求(Merge Request)或单个 commit。 +项目路径使用 `namespace/project` 格式,并支持子组(subgroup),例如 +`group/subgroup/project`: + +```sh +patchsplit --gitlab zitzhen/patchsplit 1 +patchsplit --gitlab zitzhen/patchsplit --commit fafbad69af7507f41e786aa6685b2fa29716c85f +patchsplit --gitlab group/subgroup/project 1 --squash -o mr-1-patches +``` + +合并请求从 +`https://gitlab.com///-/merge_requests/<编号>.patch` 下载, +和 GitHub PR 一样会按 commit 拆分成邮件格式补丁。传入 `--squash` 时下载 +`.diff` 净变化并输出为 `mr-<编号>.patch`。`--commit` 则下载 +`https://gitlab.com///-/commit/.patch`,并把补丁原样 +写入 `.patch`。`--out`、`--force`、`--squash` 的规则与 GitHub 模式相同, +`--commit` 不能与 `--squash` 同时使用。 + ## 参数 - `-o, --out `:指定 patch 文件的输出目录。 - `-f, --force`:允许覆盖已存在的 patch 文件。 -- `-s, --squash`:将 PR 的最终净变化输出为一个补丁。 +- `-s, --squash`:将 PR/MR 的最终净变化输出为一个补丁。 +- `--gitlab`:改为从 gitlab.com 下载合并请求或单个 commit(项目路径支持子组)。 - `--commit `:下载单个 commit 的 `.patch`,接受短哈希或完整哈希。 - `-h, --help`:显示帮助。 - `-V, --version`:显示版本。 diff --git a/debian/patchsplit.1 b/debian/patchsplit.1 index b752324..4f68cb6 100644 --- a/debian/patchsplit.1 +++ b/debian/patchsplit.1 @@ -1,6 +1,6 @@ -.TH PATCHSPLIT 1 "August 2026" "patchsplit 1.0.2" "User Commands" +.TH PATCHSPLIT 1 "September 2026" "patchsplit 1.2.0" "User Commands" .SH NAME -patchsplit \- download a GitHub pull request patch, split it by commit, or fetch a single commit patch +patchsplit \- download GitHub pull request or GitLab merge request patches, split them by commit, or fetch a single commit patch .SH SYNOPSIS .B patchsplit .RI < owner/repo > " " @@ -41,6 +41,30 @@ patchsplit \- download a GitHub pull request patch, split it by commit, or fetch [\c .B \-\-force ] .RE +.br +.B patchsplit +.B \-\-gitlab +.RI < namespace/project > " " +.RS +[\c +.B \-\-out +.IR dir ] +[\c +.B \-\-force ] +[\c +.B \-\-squash ] +.RE +.br +.B patchsplit +.B \-\-gitlab +.RI < namespace/project > " \-\-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 @@ -65,13 +89,41 @@ The result is a mail-formatted patch for git am. cannot be combined with .BR \-\-squash . .PP +With +.BR \-\-gitlab , +download patches from GitLab +.RB ( gitlab.com ) +instead of GitHub. +The merge request +.B https://gitlab.com///-/merge_requests/.patch +is split into one patch per commit just like a GitHub pull request, while +.B \-\-squash +downloads the +.B .diff +net change and writes it to +.BR mr-.patch . +The project path uses +.I namespace/project +form and may contain subgroups, for example +.IR group/subgroup/project . +With +.BR \-\-gitlab " " \-\-commit " " \fIhash\fR , +fetch +.BR https://gitlab.com///-/commit/.patch . +.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. +Write the request's net diff as one patch instead of splitting by commit. +.TP +.B \-\-gitlab +Download a merge request or commit patch from gitlab.com instead of GitHub. +The project argument uses +.I namespace/project +form and may contain subgroups. .TP .BR \-\-commit " " \fIhash\fR Download the .patch of a single commit instead of a pull request. @@ -114,11 +166,20 @@ Download the patch for commit b430113 from .BR zitzhen/patchsplit and write it to .BR b430113.patch . +.TP +.B patchsplit \-\-gitlab zitzhen/patchsplit 1 +Download and split merge request 1 from the GitLab project +.BR zitzhen/patchsplit . +.TP +.B patchsplit \-\-gitlab zitzhen/patchsplit \-\-commit de9ea1a +Download the patch for commit de9ea1a from GitLab and write it to +.BR de9ea1a.patch . .SH REQUIREMENTS .B patchsplit uses .B curl -to download pull request and commit patches from GitHub. +to download pull request, merge request, and commit patches from GitHub and +GitLab. .SH SEE ALSO .BR curl (1) .SH AUTHORS diff --git a/packaging/patchsplit.1 b/packaging/patchsplit.1 index b752324..4f68cb6 100644 --- a/packaging/patchsplit.1 +++ b/packaging/patchsplit.1 @@ -1,6 +1,6 @@ -.TH PATCHSPLIT 1 "August 2026" "patchsplit 1.0.2" "User Commands" +.TH PATCHSPLIT 1 "September 2026" "patchsplit 1.2.0" "User Commands" .SH NAME -patchsplit \- download a GitHub pull request patch, split it by commit, or fetch a single commit patch +patchsplit \- download GitHub pull request or GitLab merge request patches, split them by commit, or fetch a single commit patch .SH SYNOPSIS .B patchsplit .RI < owner/repo > " " @@ -41,6 +41,30 @@ patchsplit \- download a GitHub pull request patch, split it by commit, or fetch [\c .B \-\-force ] .RE +.br +.B patchsplit +.B \-\-gitlab +.RI < namespace/project > " " +.RS +[\c +.B \-\-out +.IR dir ] +[\c +.B \-\-force ] +[\c +.B \-\-squash ] +.RE +.br +.B patchsplit +.B \-\-gitlab +.RI < namespace/project > " \-\-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 @@ -65,13 +89,41 @@ The result is a mail-formatted patch for git am. cannot be combined with .BR \-\-squash . .PP +With +.BR \-\-gitlab , +download patches from GitLab +.RB ( gitlab.com ) +instead of GitHub. +The merge request +.B https://gitlab.com///-/merge_requests/.patch +is split into one patch per commit just like a GitHub pull request, while +.B \-\-squash +downloads the +.B .diff +net change and writes it to +.BR mr-.patch . +The project path uses +.I namespace/project +form and may contain subgroups, for example +.IR group/subgroup/project . +With +.BR \-\-gitlab " " \-\-commit " " \fIhash\fR , +fetch +.BR https://gitlab.com///-/commit/.patch . +.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. +Write the request's net diff as one patch instead of splitting by commit. +.TP +.B \-\-gitlab +Download a merge request or commit patch from gitlab.com instead of GitHub. +The project argument uses +.I namespace/project +form and may contain subgroups. .TP .BR \-\-commit " " \fIhash\fR Download the .patch of a single commit instead of a pull request. @@ -114,11 +166,20 @@ Download the patch for commit b430113 from .BR zitzhen/patchsplit and write it to .BR b430113.patch . +.TP +.B patchsplit \-\-gitlab zitzhen/patchsplit 1 +Download and split merge request 1 from the GitLab project +.BR zitzhen/patchsplit . +.TP +.B patchsplit \-\-gitlab zitzhen/patchsplit \-\-commit de9ea1a +Download the patch for commit de9ea1a from GitLab and write it to +.BR de9ea1a.patch . .SH REQUIREMENTS .B patchsplit uses .B curl -to download pull request and commit patches from GitHub. +to download pull request, merge request, and commit patches from GitHub and +GitLab. .SH SEE ALSO .BR curl (1) .SH AUTHORS diff --git a/po/patchsplit.pot b/po/patchsplit.pot index 35c131e..bd5e484 100644 --- a/po/patchsplit.pot +++ b/po/patchsplit.pot @@ -53,6 +53,24 @@ msgstr "" msgid "expected a GitHub repository with --commit " msgstr "" +#: src/main.rs +msgid "expected a GitLab project and merge request number" +msgstr "" + +#: src/main.rs +msgid "expected a GitLab project with --commit " +msgstr "" + +#: src/main.rs +msgid "" +"GitLab project must use namespace/project form (subgroups allowed), got " +"{value}" +msgstr "" + +#: src/main.rs +msgid "merge request number must be a positive integer, got {value}" +msgstr "" + #: src/main.rs msgid "commit hash must consist of 4 to 40 hexadecimal characters, got {value}" msgstr "" @@ -133,12 +151,17 @@ msgid "" " patchsplit [--out ] [--force] [--squash]\n" " patchsplit --commit [--out ] [--force]\n" " patchsplit --commit [--out ] [--force]\n" +" patchsplit --gitlab [--out ] " +"[--force] [--squash]\n" +" patchsplit --gitlab --commit [--out ] " +"[--force]\n" "\n" "Options:\n" -" -o, --out Output directory for patch files [default: " -"patches]\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" +" -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 Download one commit's .patch (short or full hash)\n" " -h, --help Show this help\n" " -V, --version Show version\n" @@ -147,5 +170,6 @@ msgid "" " 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" +" patchsplit zitzhen patchsplit -commit b430113\n" +" patchsplit --gitlab zitzhen/patchsplit 1" msgstr "" diff --git a/po/zh_CN.po b/po/zh_CN.po index da02ca1..b92aec9 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -52,6 +52,24 @@ msgstr "Pull Request 编号必须是正整数,实际为 {value}" msgid "expected a GitHub repository with --commit " msgstr "需要提供 GitHub 仓库,并通过 --commit 指定提交" +#: src/main.rs +msgid "expected a GitLab project and merge request number" +msgstr "需要提供 GitLab 项目和合并请求(Merge Request)编号" + +#: src/main.rs +msgid "expected a GitLab project with --commit " +msgstr "需要提供 GitLab 项目,并通过 --commit 指定提交" + +#: src/main.rs +msgid "" +"GitLab project must use namespace/project form (subgroups allowed), got " +"{value}" +msgstr "GitLab 项目必须使用 namespace/project 格式(允许子组 subgroup),实际为 {value}" + +#: src/main.rs +msgid "merge request number must be a positive integer, got {value}" +msgstr "合并请求编号必须是正整数,实际为 {value}" + #: src/main.rs msgid "commit hash must consist of 4 to 40 hexadecimal characters, got {value}" msgstr "commit 哈希必须为 4 到 40 位十六进制字符,实际为 {value}" @@ -132,12 +150,17 @@ msgid "" " patchsplit [--out ] [--force] [--squash]\n" " patchsplit --commit [--out ] [--force]\n" " patchsplit --commit [--out ] [--force]\n" +" patchsplit --gitlab [--out ] " +"[--force] [--squash]\n" +" patchsplit --gitlab --commit [--out ] " +"[--force]\n" "\n" "Options:\n" -" -o, --out Output directory for patch files [default: " -"patches]\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" +" -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 Download one commit's .patch (short or full hash)\n" " -h, --help Show this help\n" " -V, --version Show version\n" @@ -146,18 +169,24 @@ msgid "" " 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" +" patchsplit zitzhen patchsplit -commit b430113\n" +" patchsplit --gitlab zitzhen/patchsplit 1" msgstr "" "用法:\n" " patchsplit [--out ] [--force] [--squash]\n" " patchsplit [--out ] [--force] [--squash]\n" " patchsplit --commit [--out ] [--force]\n" " patchsplit --commit [--out ] [--force]\n" +" patchsplit --gitlab [--out ] " +"[--force] [--squash]\n" +" patchsplit --gitlab --commit [--out ] " +"[--force]\n" "\n" "选项:\n" " -o, --out patch 文件输出目录 [默认:patches]\n" " -f, --force 覆盖已有 patch 文件\n" -" -s, --squash 将 PR 的最终净变化输出为一个补丁\n" +" -s, --squash 将净变化输出为一个补丁,而不是按 commit 拆分\n" +" --gitlab 从 gitlab.com 下载(合并请求和 commit)\n" " --commit 下载单个提交的 .patch(短哈希或完整哈希)\n" " -h, --help 显示帮助信息\n" " -V, --version 显示版本\n" @@ -166,4 +195,5 @@ msgstr "" " 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" +" patchsplit zitzhen patchsplit -commit b430113\n" +" patchsplit --gitlab zitzhen/patchsplit 1" diff --git a/src/main.rs b/src/main.rs index 148ffad..db06bfe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,17 +73,24 @@ fn run() -> Result<(), AppError> { #[derive(Debug)] struct Config { - owner: String, - repo: String, + /// `owner/repo` on GitHub or `namespace/project` (subgroups allowed) on GitLab. + project: String, + platform: Platform, target: Target, output_dir: PathBuf, force: bool, squash: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Platform { + GitHub, + GitLab, +} + #[derive(Debug)] enum Target { - PullRequest(u64), + MergeRequest(u64), Commit(String), } @@ -95,6 +102,7 @@ impl Config { let mut output_dir = PathBuf::from("patches"); let mut force = false; let mut squash = false; + let mut gitlab = false; let mut commit = None; let mut positionals = Vec::new(); let mut args = args.into_iter(); @@ -105,6 +113,7 @@ impl Config { "-V" | "--version" => return Err(AppError::Version), "-f" | "--force" => force = true, "-s" | "--squash" => squash = true, + "--gitlab" => gitlab = true, "-o" | "--out" => { let option = arg.as_str().to_string(); let value = args.next().ok_or(AppError::MissingOptionValue(option))?; @@ -136,44 +145,82 @@ impl Config { 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)) - } - 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)?) + let platform = if gitlab { + Platform::GitLab + } else { + Platform::GitHub + }; + + let (project, target) = match platform { + Platform::GitHub => 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)?; + (format!("{owner}/{repo}"), Target::Commit(hash)) + } + 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), + }; + ( + format!("{owner}/{repo}"), + Target::MergeRequest(pull_request), + ) + } + }, + Platform::GitLab => { + // GitLab project paths may contain subgroups, e.g. group/subgroup/project. + match commit { + Some(hash) => { + if positionals.is_empty() { + return Err(AppError::InvalidGitLabCommitArguments); + } + let project = positionals.join("/"); + validate_gitlab_project(&project)?; + validate_commit_hash(&hash)?; + (project, 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 Some((merge_request, project_parts)) = positionals.split_last() + else { + return Err(AppError::InvalidGitLabArguments); + }; + if project_parts.is_empty() { + return Err(AppError::InvalidGitLabArguments); + } + let project = project_parts.join("/"); + validate_gitlab_project(&project)?; + let merge_request = parse_merge_request(merge_request)?; + (project, Target::MergeRequest(merge_request)) } - _ => return Err(AppError::InvalidArguments), - }; - (owner, repo, Target::PullRequest(pull_request)) + } } }; Ok(Self { - owner, - repo, + project, + platform, target, output_dir, force, @@ -182,19 +229,32 @@ impl Config { } fn patch_url(&self) -> String { - 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 - ), + match self.platform { + Platform::GitHub => match &self.target { + // GitHub's PR diff represents the net change; .patch contains each commit. + Target::MergeRequest(pull_request) => { + let extension = if self.squash { "diff" } else { "patch" }; + format!( + "https://github.com/{}/pull/{}.{extension}", + self.project, pull_request + ) + } + Target::Commit(hash) => { + format!("https://github.com/{}/commit/{hash}.patch", self.project) + } + }, + Platform::GitLab => match &self.target { + Target::MergeRequest(merge_request) => { + let extension = if self.squash { "diff" } else { "patch" }; + format!( + "https://gitlab.com/{}/-/merge_requests/{}.{extension}", + self.project, merge_request + ) + } + Target::Commit(hash) => { + format!("https://gitlab.com/{}/-/commit/{hash}.patch", self.project) + } + }, } } @@ -213,18 +273,22 @@ impl Config { content: patch.to_string(), }] } - Target::PullRequest(pull_request) => { + Target::MergeRequest(number) => { if !self.squash { return split_patch_by_commit(patch); } if patch.trim().is_empty() { return Vec::new(); } + let (subject, filename) = match self.platform { + Platform::GitHub => (format!("PR #{number}"), format!("pr-{number}.patch")), + Platform::GitLab => (format!("MR #{number}"), format!("mr-{number}.patch")), + }; vec![PatchPart { index: 1, commit: None, - subject: format!("PR #{pull_request}"), - filename: format!("pr-{pull_request}.patch"), + subject, + filename, content: patch.to_string(), }] } @@ -273,6 +337,36 @@ fn parse_pull_request(value: &str) -> Result { } } +fn parse_merge_request(value: &str) -> Result { + let merge_request = value + .parse::() + .map_err(|_| AppError::InvalidMergeRequest(value.to_string()))?; + + if merge_request == 0 { + Err(AppError::InvalidMergeRequest(value.to_string())) + } else { + Ok(merge_request) + } +} + +fn validate_gitlab_project(value: &str) -> Result<(), AppError> { + // GitLab projects live under a namespace and may be nested in subgroups. + let segments: Vec<&str> = value.split('/').collect(); + let valid = segments.len() >= 2 + && segments.iter().all(|segment| { + !segment.is_empty() + && !segment + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + }); + + if valid { + Ok(()) + } else { + Err(AppError::InvalidGitLabProject(value.to_string())) + } +} + fn validate_commit_hash(value: &str) -> Result<(), AppError> { // Git abbreviations are at least 4 hex characters; full SHAs are 40. let valid = @@ -363,6 +457,14 @@ enum AppError { InvalidPullRequest(String), #[error("expected a GitHub repository with --commit ")] InvalidCommitArguments, + #[error("expected a GitLab project and merge request number")] + InvalidGitLabArguments, + #[error("expected a GitLab project with --commit ")] + InvalidGitLabCommitArguments, + #[error("GitLab project must use namespace/project form (subgroups allowed), got {0:?}")] + InvalidGitLabProject(String), + #[error("merge request number must be a positive integer, got {0:?}")] + InvalidMergeRequest(String), #[error("commit hash must consist of 4 to 40 hexadecimal characters, got {0:?}")] InvalidCommitHash(String), #[error("--commit cannot be combined with --squash")] @@ -404,6 +506,10 @@ impl AppError { | Self::InvalidRepoSegment { .. } | Self::InvalidPullRequest(_) | Self::InvalidCommitArguments + | Self::InvalidGitLabArguments + | Self::InvalidGitLabCommitArguments + | Self::InvalidGitLabProject(_) + | Self::InvalidMergeRequest(_) | Self::InvalidCommitHash(_) | Self::CommitWithSquash | Self::MissingOptionValue(_) @@ -433,6 +539,20 @@ impl AppError { Self::InvalidCommitArguments => { tr("expected a GitHub repository with --commit ") } + Self::InvalidGitLabArguments => { + tr("expected a GitLab project and merge request number") + } + Self::InvalidGitLabCommitArguments => { + tr("expected a GitLab project with --commit ") + } + Self::InvalidGitLabProject(value) => tr_args( + "GitLab project must use namespace/project form (subgroups allowed), got {value}", + &[("value", quoted(value))], + ), + Self::InvalidMergeRequest(value) => tr_args( + "merge request number must be a positive integer, got {value}", + &[("value", quoted(value))], + ), Self::InvalidCommitHash(value) => tr_args( "commit hash must consist of 4 to 40 hexadecimal characters, got {value}", &[("value", quoted(value))], @@ -514,10 +634,9 @@ fn repo_segment_label(kind: &str) -> String { } fn usage() -> String { - 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") + tr("Usage:\n patchsplit [--out ] [--force] [--squash]\n patchsplit [--out ] [--force] [--squash]\n patchsplit --commit [--out ] [--force]\n patchsplit --commit [--out ] [--force]\n patchsplit --gitlab [--out ] [--force] [--squash]\n patchsplit --gitlab --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 net diff as one patch instead of splitting by commit\n --gitlab Download from gitlab.com (merge requests and commits)\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\n patchsplit --gitlab zitzhen/patchsplit 1") } - -#[cfg(test)] + #[cfg(test)] mod tests { use super::*; @@ -610,9 +729,136 @@ mod tests { .contains("InvalidCommitArguments") ); assert!(parse_err(&["--commit", "b430113"]).contains("InvalidCommitArguments")); - assert!( - parse_err(&["owner/repo", "42", "--commit", "b430113", "--squash"]) + assert!(parse_err(&["owner/repo", "42", "--commit", "b430113", "--squash"]) .contains("CommitWithSquash") ); } + + #[test] + fn gitlab_merge_request_downloads_per_commit_patches() { + let config = config(&["--gitlab", "zitzhen/patchsplit", "1"]); + assert_eq!(config.platform, Platform::GitLab); + assert_eq!(config.project, "zitzhen/patchsplit"); + assert_eq!( + config.patch_url(), + "https://gitlab.com/zitzhen/patchsplit/-/merge_requests/1.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 gitlab_squash_writes_one_mr_named_diff() { + let config = config(&[ + "--gitlab", + "zitzhen/patchsplit", + "1", + "--squash", + "--force", + ]); + assert_eq!(config.project, "zitzhen/patchsplit"); + assert_eq!( + config.patch_url(), + "https://gitlab.com/zitzhen/patchsplit/-/merge_requests/1.diff" + ); + let diff = "diff --git a/file b/file\n--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new\n"; + let parts = config.patch_parts(diff); + assert_eq!(parts.len(), 1); + assert_eq!(parts[0].subject, "MR #1"); + assert_eq!(parts[0].filename, "mr-1.patch"); + assert_eq!(parts[0].content, diff); + } + + #[test] + fn gitlab_commit_mode_downloads_a_single_commit_patch() { + const FULL_HASH: &str = "fafbad69af7507f41e786aa6685b2fa29716c85f"; + let project = "zitzhen/patchsplit"; + let full_commit_option = format!("--commit={FULL_HASH}"); + let cases: Vec<(Vec<&str>, &str)> = vec![ + ( + vec!["--gitlab", project, "--commit", "fafbad6"], + "fafbad6", + ), + ( + vec![ + "--gitlab", + "zitzhen", + "patchsplit", + "--commit", + "de9ea1a", + ], + "de9ea1a", + ), + ( + vec!["--gitlab", project, &full_commit_option], + FULL_HASH, + ), + ( + vec![ + "--gitlab", + "group/subgroup", + "project", + "--commit", + "de9ea1a", + ], + "de9ea1a", + ), + ]; + 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.platform, Platform::GitLab); + assert_eq!( + config.patch_url(), + format!( + "https://gitlab.com/{}/-/commit/{hash}.patch", + config.project + ) + ); + let parts = config.patch_parts(&patch); + assert_eq!(parts.len(), 1); + assert_eq!(parts[0].filename, format!("{hash}.patch")); + } + } + + #[test] + fn gitlab_mode_validates_project_numbers_and_arguments() { + fn parse_err(args: &[&str]) -> String { + let error = Config::parse(args.iter().map(|arg| arg.to_string())).unwrap_err(); + format!("{error:?}") + } + + assert!(parse_err(&["--gitlab", "project-without-namespace", "363"]) + .contains("InvalidGitLabProject")); + assert!(parse_err(&["--gitlab", "group//project", "363"]).contains("InvalidGitLabProject")); + assert!(parse_err(&["--gitlab", "group/project", "0"]).contains("InvalidMergeRequest")); + assert!(parse_err(&["--gitlab", "group/project", "abc"]) + .contains("InvalidMergeRequest")); + assert!(parse_err(&["--gitlab", "363"]).contains("InvalidGitLabArguments")); + assert!(parse_err(&["--gitlab"]).contains("InvalidGitLabArguments")); + assert!( + parse_err(&["--gitlab", "--commit", "de9ea1a"]) + .contains("InvalidGitLabCommitArguments") + ); + assert!(parse_err(&["--gitlab", "group/project", "--commit", "xyz"]) + .contains("InvalidCommitHash")); + assert!( + parse_err(&[ + "--gitlab", + "group/project", + "363", + "--commit", + "de9ea1a", + "--squash" + ]) + .contains("CommitWithSquash") + ); + } } diff --git a/tests/gitlab.rs b/tests/gitlab.rs new file mode 100644 index 0000000..2d41d4b --- /dev/null +++ b/tests/gitlab.rs @@ -0,0 +1,192 @@ +#![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 PROJECT: &str = "zitzhen/patchsplit"; +const SHORT_HASH: &str = "de9ea1a"; +const FULL_HASH: &str = "de9ea1a4a3f6ad6b0ade271b958bf05142f8be89"; + +fn mr_patch_body() -> String { + format!( + "From {first} Mon Sep 17 00:00:00 2001\n\ +From: Test \n\ +Subject: [PATCH 1/2] First\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\ +From {second} Mon Sep 17 00:00:00 2001\n\ +From: Test \n\ +Subject: [PATCH 2/2] Second\n\ +\n\ +diff --git a/b.txt b/b.txt\n\ +new file mode 100644\n\ +--- /dev/null\n\ ++++ b/b.txt\n\ +@@ -0,0 +1 @@\n\ ++b\n", + first = "1".repeat(40), + second = "2".repeat(40) + ) +} + +fn mr_diff_body() -> String { + "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" + .to_string() +} + +fn commit_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 gitlab_cli_downloads_mr_squash_and_commit_patches() { + let root = Workspace(std::env::temp_dir().join(format!( + "patchsplit-gitlab-{}-{}", + 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 mr_patch = root.0.join("mr.patch"); + let mr_diff = root.0.join("mr.diff"); + let commit_patch = root.0.join("commit.patch"); + fs::write(&mr_patch, mr_patch_body()).unwrap(); + fs::write(&mr_diff, mr_diff_body()).unwrap(); + fs::write(&commit_patch, commit_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://gitlab.com/zitzhen/patchsplit/-/merge_requests/1.patch')\n\ + cat \"$PATCHSPLIT_TEST_MR_PATCH\" ;;\n\ + 'https://gitlab.com/zitzhen/patchsplit/-/merge_requests/1.diff')\n\ + cat \"$PATCHSPLIT_TEST_MR_DIFF\" ;;\n\ + 'https://gitlab.com/zitzhen/patchsplit/-/commit/de9ea1a.patch')\n\ + cat \"$PATCHSPLIT_TEST_COMMIT\" ;;\n\ + *) echo \"unexpected url: $url\" >&2; exit 22 ;;\n\ +esac\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_MR_PATCH", &mr_patch) + .env("PATCHSPLIT_TEST_MR_DIFF", &mr_diff) + .env("PATCHSPLIT_TEST_COMMIT", &commit_patch) + .env("PATCHSPLIT_LANGUAGE", "C") + .output() + .unwrap() + }; + + // Merge request: split one mailbox into per-commit patch files. + let mr_dir = root.0.join("mr"); + let result = run(&[ + "--gitlab", + PROJECT, + "1", + "--out", + mr_dir.to_str().unwrap(), + ]); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let mut names: Vec = fs::read_dir(&mr_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().to_string()) + .collect(); + names.sort(); + assert_eq!(names, vec!["0001-first.patch", "0002-second.patch"]); + + // Squash: one raw diff named after the merge request. + let squash_dir = root.0.join("squash"); + let result = run(&[ + PROJECT, + "1", + "--gitlab", + "--squash", + "--out", + squash_dir.to_str().unwrap(), + ]); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let squashed = squash_dir.join("mr-1.patch"); + assert_eq!(fs::read(&squashed).unwrap(), mr_diff_body().as_bytes()); + + // Single commit by short hash. + let commit_dir = root.0.join("commit"); + let result = run(&[ + "--gitlab", + PROJECT, + "--commit", + SHORT_HASH, + "--out", + commit_dir.to_str().unwrap(), + ]); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!( + fs::read(commit_dir.join(format!("{SHORT_HASH}.patch"))).unwrap(), + commit_patch_body().as_bytes() + ); + + // A project path without a namespace is rejected with exit code 2. + let bad = run(&["--gitlab", "project-only", "1"]); + assert_eq!(bad.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&bad.stderr).contains("namespace/project"), + "{}", + String::from_utf8_lossy(&bad.stderr) + ); +}