diff --git a/README.md b/README.md index f14d457..800d4f1 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,22 @@ just like a GitHub PR, split into one mail-formatted patch per commit. With verbatim patch to `.patch`. The same `--out`, `--force`, and `--squash` rules apply, and `--commit` cannot be combined with `--squash`. +### Download from a custom host + +By default `patchsplit` talks to `github.com`, or `gitlab.com` with `--gitlab`. +Pass `--host ` to point it at another host that serves the same patch +endpoints, such as a GitHub Enterprise Server or a self-hosted GitLab instance: + +```sh +patchsplit --host git.corp.example.com owner/repo 42 +patchsplit --gitlab --host gitlab.internal group/project 7 --squash +``` + +The host is a bare hostname with an optional port (for example +`gitlab.internal:8443`); requests always use `https`. Do not include a scheme +or a path. Combining `--host` with `--gitlab` selects the GitLab URL layout on +the custom host, while omitting it selects the GitHub layout. + ### Download through Tor Pass `--tor` to route the download through the Tor network instead of @@ -288,6 +304,7 @@ error: could not reach the Tor SOCKS5 proxy at 127.0.0.1:9050 (curl exit 7): ... | `-f, --force` | Overwrite existing patch files. | | `-s, --squash` | Write the request's net diff as one patch. | | `--gitlab` | Download a merge request or commit from gitlab.com. | +| `--host ` | Use a custom host instead of the platform default. | | `--commit ` | Download one commit's `.patch`; accepts a short or full hash. | | `--tor[=]` | Download through the Tor network via a SOCKS5 proxy (default: `127.0.0.1:9050`). | | `-h, --help` | Show help. | diff --git a/README_zh-cn.md b/README_zh-cn.md index 042d62a..62a635c 100644 --- a/README_zh-cn.md +++ b/README_zh-cn.md @@ -83,6 +83,21 @@ patchsplit --gitlab group/subgroup/project 1 --squash -o mr-1-patches 写入 `.patch`。`--out`、`--force`、`--squash` 的规则与 GitHub 模式相同, `--commit` 不能与 `--squash` 同时使用。 +### 从自定义主机下载 + +默认情况下 `patchsplit` 访问 `github.com`,加 `--gitlab` 时访问 `gitlab.com`。 +传入 `--host ` 可改为访问提供相同补丁接口的其他主机,例如 GitHub +Enterprise Server 或自建的 GitLab 实例: + +```sh +patchsplit --host git.corp.example.com owner/repo 42 +patchsplit --gitlab --host gitlab.internal group/project 7 --squash +``` + +host 为纯主机名,可带端口(例如 `gitlab.internal:8443`),请求始终使用 +`https`,不要包含协议头或路径。`--host` 与 `--gitlab` 同时使用时按 GitLab 的 +URL 结构拼接,不加 `--gitlab` 时按 GitHub 结构拼接。 + ### 通过 Tor 下载 传入 `--tor` 即可让下载经由 Tor 网络,而不是直接连接 GitHub 或 GitLab: @@ -118,6 +133,7 @@ patchsplit zitzhen/patchsplit 16 --tor=[::1]:9050 - `-f, --force`:允许覆盖已存在的 patch 文件。 - `-s, --squash`:将 PR/MR 的最终净变化输出为一个补丁。 - `--gitlab`:改为从 gitlab.com 下载合并请求或单个 commit(项目路径支持子组)。 +- `--host `:使用自定义主机名,而非平台默认主机。 - `--commit `:下载单个 commit 的 `.patch`,接受短哈希或完整哈希。 - `--tor[=]`:通过 SOCKS5 代理经 Tor 网络下载(默认 `127.0.0.1:9050`)。 - `-h, --help`:显示帮助。 diff --git a/debian/patchsplit.1 b/debian/patchsplit.1 index 3918c4b..efcfd24 100644 --- a/debian/patchsplit.1 +++ b/debian/patchsplit.1 @@ -142,6 +142,18 @@ The project argument uses .I namespace/project form and may contain subgroups. .TP +.BR \-\-host " " \fIhost\fR +Download from +.I host +instead of the platform default +.RB ( github.com ", or " gitlab.com " when " \-\-gitlab " is given)." +Use this for a GitHub Enterprise Server or a self-hosted GitLab instance that +serves the same patch endpoints. +.I host +is a bare hostname with an optional port, for example +.IR gitlab.internal:8443 ; +requests always use https. Do not include a scheme or a path. +.TP .BR \-\-commit " " \fIhash\fR Download the .patch of a single commit instead of a pull request. .I hash @@ -211,6 +223,11 @@ Download and split merge request 1 from the GitLab project Download the patch for commit de9ea1a from GitLab and write it to .BR de9ea1a.patch . .TP +.B patchsplit \-\-host git.corp.example.com owner/repo 42 +Download and split pull request 42 from +.B owner/repo +on a GitHub Enterprise host instead of github.com. +.TP .B patchsplit zitzhen/patchsplit 16 \-\-tor Download and split pull request 16 through the default local Tor SOCKS5 proxy. .TP diff --git a/packaging/patchsplit.1 b/packaging/patchsplit.1 index 3918c4b..efcfd24 100644 --- a/packaging/patchsplit.1 +++ b/packaging/patchsplit.1 @@ -142,6 +142,18 @@ The project argument uses .I namespace/project form and may contain subgroups. .TP +.BR \-\-host " " \fIhost\fR +Download from +.I host +instead of the platform default +.RB ( github.com ", or " gitlab.com " when " \-\-gitlab " is given)." +Use this for a GitHub Enterprise Server or a self-hosted GitLab instance that +serves the same patch endpoints. +.I host +is a bare hostname with an optional port, for example +.IR gitlab.internal:8443 ; +requests always use https. Do not include a scheme or a path. +.TP .BR \-\-commit " " \fIhash\fR Download the .patch of a single commit instead of a pull request. .I hash @@ -211,6 +223,11 @@ Download and split merge request 1 from the GitLab project Download the patch for commit de9ea1a from GitLab and write it to .BR de9ea1a.patch . .TP +.B patchsplit \-\-host git.corp.example.com owner/repo 42 +Download and split pull request 42 from +.B owner/repo +on a GitHub Enterprise host instead of github.com. +.TP .B patchsplit zitzhen/patchsplit 16 \-\-tor Download and split pull request 16 through the default local Tor SOCKS5 proxy. .TP diff --git a/po/patchsplit.pot b/po/patchsplit.pot index 934e573..a306e1f 100644 --- a/po/patchsplit.pot +++ b/po/patchsplit.pot @@ -6,9 +6,9 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: patchsplit 1.3.1\n" +"Project-Id-Version: patchsplit 1.4.0\n" "Report-Msgid-Bugs-To: https://github.com/zitzhen/patchsplit/issues\n" -"POT-Creation-Date: 2026-10-07 14:32+0800\n" +"POT-Creation-Date: 2026-11-06 20:21+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -79,6 +79,10 @@ msgstr "" msgid "commit hash must consist of 4 to 40 hexadecimal characters, got {value}" msgstr "" +#: src/main.rs +msgid "host must be a bare hostname with an optional port, got {value}" +msgstr "" + #: src/main.rs msgid "Tor proxy must use host:port form, got {value}" msgstr "" @@ -182,6 +186,8 @@ msgid "" " -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" +" --host Use a custom host, e.g. GitHub Enterprise or self-hosted " +"GitLab\n" " --commit Download one commit's .patch (short or full hash)\n" " --tor[=] Download through the Tor network over SOCKS5\n" " [default: 127.0.0.1:9050]\n" @@ -193,6 +199,7 @@ msgid "" " patchsplit openai codex 42 -o pr-42-patches\n" " patchsplit openai/codex 42 --squash\n" " patchsplit zitzhen patchsplit -commit b430113\n" -" patchsplit zitzhen/patchsplit 1 --tor\n" -" patchsplit --gitlab zitzhen/patchsplit 1" +" patchsplit --gitlab zitzhen/patchsplit 1\n" +" patchsplit --host git.corp.example.com owner/repo 42\n" +" patchsplit zitzhen/patchsplit 1 --tor" msgstr "" diff --git a/po/zh_CN.po b/po/zh_CN.po index b95089f..1249c5e 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -68,7 +68,9 @@ msgstr "需要提供 GitLab 项目,并通过 --commit 指定提交" msgid "" "GitLab project must use namespace/project form (subgroups allowed), got " "{value}" -msgstr "GitLab 项目必须使用 namespace/project 格式(允许子组 subgroup),实际为 {value}" +msgstr "" +"GitLab 项目必须使用 namespace/project 格式(允许子组 subgroup),实际为 " +"{value}" #: src/main.rs msgid "merge request number must be a positive integer, got {value}" @@ -78,6 +80,10 @@ msgstr "合并请求编号必须是正整数,实际为 {value}" msgid "commit hash must consist of 4 to 40 hexadecimal characters, got {value}" msgstr "commit 哈希必须为 4 到 40 位十六进制字符,实际为 {value}" +#: src/main.rs +msgid "host must be a bare hostname with an optional port, got {value}" +msgstr "host 必须是纯主机名(可带端口),实际为 {value}" + #: src/main.rs msgid "Tor proxy must use host:port form, got {value}" msgstr "Tor 代理必须使用 host:port 格式,实际为 {value}" @@ -185,6 +191,8 @@ msgid "" " -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" +" --host Use a custom host, e.g. GitHub Enterprise or self-hosted " +"GitLab\n" " --commit Download one commit's .patch (short or full hash)\n" " --tor[=] Download through the Tor network over SOCKS5\n" " [default: 127.0.0.1:9050]\n" @@ -196,8 +204,9 @@ msgid "" " patchsplit openai codex 42 -o pr-42-patches\n" " patchsplit openai/codex 42 --squash\n" " patchsplit zitzhen patchsplit -commit b430113\n" -" patchsplit zitzhen/patchsplit 1 --tor\n" -" patchsplit --gitlab zitzhen/patchsplit 1" +" patchsplit --gitlab zitzhen/patchsplit 1\n" +" patchsplit --host git.corp.example.com owner/repo 42\n" +" patchsplit zitzhen/patchsplit 1 --tor" msgstr "" "用法:\n" " patchsplit [--out ] [--force] [--squash]\n" @@ -214,6 +223,7 @@ msgstr "" " -f, --force 覆盖已有 patch 文件\n" " -s, --squash 将净变化输出为一个补丁,而不是按 commit 拆分\n" " --gitlab 从 gitlab.com 下载(合并请求和 commit)\n" +" --host 使用自定义主机名,例如 GitHub Enterprise 或自建 GitLab\n" " --commit 下载单个提交的 .patch(短哈希或完整哈希)\n" " --tor[=] 通过 SOCKS5 经 Tor 网络下载\n" " [默认:127.0.0.1:9050]\n" @@ -225,5 +235,6 @@ msgstr "" " patchsplit openai codex 42 -o pr-42-patches\n" " patchsplit openai/codex 42 --squash\n" " patchsplit zitzhen patchsplit -commit b430113\n" -" patchsplit zitzhen/patchsplit 1 --tor\n" -" patchsplit --gitlab zitzhen/patchsplit 1" +" patchsplit --gitlab zitzhen/patchsplit 1\n" +" patchsplit --host git.corp.example.com owner/repo 42\n" +" patchsplit zitzhen/patchsplit 1 --tor" diff --git a/src/main.rs b/src/main.rs index 882ad04..7dc24b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,6 +85,9 @@ struct Config { /// `owner/repo` on GitHub or `namespace/project` (subgroups allowed) on GitLab. project: String, platform: Platform, + /// Host serving the patch endpoints; overridable for GitHub Enterprise or + /// self-hosted GitLab via `--host`. + host: String, target: Target, output_dir: PathBuf, force: bool, @@ -116,6 +119,7 @@ impl Config { let mut gitlab = false; let mut tor_proxy = None; let mut commit = None; + let mut host = None::; let mut positionals = Vec::new(); let mut args = args.into_iter(); @@ -138,6 +142,15 @@ impl Config { value if value.starts_with("--out=") => { output_dir = PathBuf::from(&value["--out=".len()..]); } + "--host" => { + let value = args + .next() + .ok_or(AppError::MissingOptionValue("--host".to_string()))?; + host = Some(value); + } + value if value.starts_with("--host=") => { + host = Some(value["--host=".len()..].to_string()); + } "-commit" | "--commit" => { let value = args .next() @@ -167,6 +180,14 @@ impl Config { Platform::GitHub }; + let host = match host { + Some(value) => { + validate_host(&value)?; + value + } + None => default_host(platform).to_string(), + }; + let (project, target) = match platform { Platform::GitHub => match commit { Some(hash) => { @@ -237,6 +258,7 @@ impl Config { Ok(Self { project, platform, + host, target, output_dir, force, @@ -252,24 +274,27 @@ impl Config { Target::MergeRequest(pull_request) => { let extension = if self.squash { "diff" } else { "patch" }; format!( - "https://github.com/{}/pull/{}.{extension}", - self.project, pull_request + "https://{}/{}/pull/{}.{extension}", + self.host, self.project, pull_request ) } Target::Commit(hash) => { - format!("https://github.com/{}/commit/{hash}.patch", self.project) + format!("https://{}/{}/commit/{hash}.patch", self.host, 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 + "https://{}/{}/-/merge_requests/{}.{extension}", + self.host, self.project, merge_request ) } Target::Commit(hash) => { - format!("https://gitlab.com/{}/-/commit/{hash}.patch", self.project) + format!( + "https://{}/{}/-/commit/{hash}.patch", + self.host, self.project + ) } }, } @@ -396,6 +421,29 @@ fn validate_commit_hash(value: &str) -> Result<(), AppError> { } } +fn default_host(platform: Platform) -> &'static str { + match platform { + Platform::GitHub => "github.com", + Platform::GitLab => "gitlab.com", + } +} + +fn validate_host(value: &str) -> Result<(), AppError> { + // Accept an optional port, e.g. git.corp.example.com or gitlab.internal:8443. + // The scheme is always https, so reject values that carry one or a path. + let valid = !value.is_empty() + && !value.contains("://") + && !value.chars().any(|character| { + character == '/' || character.is_whitespace() || character.is_control() + }); + + if valid { + Ok(()) + } else { + Err(AppError::InvalidHost(value.to_string())) + } +} + /// Default SOCKS5 endpoint of a local Tor daemon. const DEFAULT_TOR_PROXY: &str = "127.0.0.1:9050"; @@ -547,6 +595,8 @@ enum AppError { InvalidMergeRequest(String), #[error("commit hash must consist of 4 to 40 hexadecimal characters, got {0:?}")] InvalidCommitHash(String), + #[error("host must be a bare hostname with an optional port, got {0:?}")] + InvalidHost(String), #[error("Tor proxy must use host:port form, got {0:?}")] InvalidTorProxy(String), #[error("--commit cannot be combined with --squash")] @@ -599,6 +649,7 @@ impl AppError { | Self::InvalidGitLabProject(_) | Self::InvalidMergeRequest(_) | Self::InvalidCommitHash(_) + | Self::InvalidHost(_) | Self::InvalidTorProxy(_) | Self::CommitWithSquash | Self::MissingOptionValue(_) @@ -646,6 +697,10 @@ impl AppError { "commit hash must consist of 4 to 40 hexadecimal characters, got {value}", &[("value", quoted(value))], ), + Self::InvalidHost(value) => tr_args( + "host must be a bare hostname with an optional port, got {value}", + &[("value", quoted(value))], + ), Self::InvalidTorProxy(value) => tr_args( "Tor proxy must use host:port form, got {value}", &[("value", quoted(value))], @@ -747,7 +802,7 @@ 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 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 --tor[=] Download through the Tor network over SOCKS5\n [default: 127.0.0.1:9050]\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 zitzhen/patchsplit 1 --tor\n patchsplit --gitlab zitzhen/patchsplit 1") + 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 --host Use a custom host, e.g. GitHub Enterprise or self-hosted GitLab\n --commit Download one commit's .patch (short or full hash)\n --tor[=] Download through the Tor network over SOCKS5\n [default: 127.0.0.1:9050]\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\n patchsplit --host git.corp.example.com owner/repo 42\n patchsplit zitzhen/patchsplit 1 --tor") } #[cfg(test)] mod tests { @@ -975,6 +1030,45 @@ mod tests { ); } + #[test] + fn host_defaults_per_platform_and_accepts_override() { + assert_eq!(config(&["owner/repo", "42"]).host, "github.com"); + assert_eq!( + config(&["--gitlab", "group/project", "1"]).host, + "gitlab.com" + ); + + let github = config(&["--host", "git.corp.example.com", "owner/repo", "42"]); + assert_eq!( + github.patch_url(), + "https://git.corp.example.com/owner/repo/pull/42.patch" + ); + + let github_commit = config(&[ + "owner/repo", + "--commit", + "b430113", + "--host=ghe.internal:8443", + ]); + assert_eq!( + github_commit.patch_url(), + "https://ghe.internal:8443/owner/repo/commit/b430113.patch" + ); + + let gitlab = config(&[ + "--gitlab", + "--host", + "gitlab.internal", + "group/subgroup/project", + "7", + "--squash", + ]); + assert_eq!( + gitlab.patch_url(), + "https://gitlab.internal/group/subgroup/project/-/merge_requests/7.diff" + ); + } + #[test] fn tor_flag_routes_downloads_through_the_default_socks5_proxy() { let config = config(&["owner/repo", "42", "--tor"]); @@ -997,6 +1091,28 @@ mod tests { ); } + #[test] + fn host_rejects_schemes_paths_and_blank_values() { + fn parse_err(args: &[&str]) -> String { + let error = Config::parse(args.iter().map(|arg| arg.to_string())).unwrap_err(); + format!("{error:?}") + } + + for host in [ + "https://git.corp.example.com", + "git.corp.example.com/owner", + "", + "git corp", + ] { + assert!( + parse_err(&["owner/repo", "42", "--host", host]).contains("InvalidHost"), + "expected InvalidHost for {host:?}" + ); + } + + assert!(parse_err(&["owner/repo", "42", "--host"]).contains("MissingOptionValue")); + } + #[test] fn tor_flag_accepts_a_custom_proxy() { for value in [