diff --git a/Cargo.lock b/Cargo.lock index 04c349a..a2d10e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,11 +98,32 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "git-link" version = "0.1.3" dependencies = [ "clap", + "url", ] [[package]] @@ -111,18 +132,142 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -141,6 +286,47 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -158,12 +344,51 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -184,3 +409,86 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 97d4014..2935be1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,4 @@ authors = ["DeflateAwning"] [dependencies] clap = { version = "4.5.54", features = ["derive"] } +url = "2.5.8" diff --git a/src/main.rs b/src/main.rs index 8722ad8..9b01dcf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,12 @@ enum Commands { Mr, } +pub enum RemoteFlavor { + Github, + Gitlab, + Codeberg, +} + fn run_shell_cmd(cmd: &str, args: &[&str], verbose: bool) -> String { if verbose { println!("Running shell command: {} {:?}", cmd, args); @@ -56,14 +62,18 @@ fn run_shell_cmd(cmd: &str, args: &[&str], verbose: bool) -> String { output_str } +/// Normalize a remote URL (SSH, HTTPS) to a standard HTTPS format. pub fn normalize_remote(remote: &str) -> String { // HTTPS: https://host/org/repo(.git) - if let Some(rest) = remote.strip_prefix("https://") { + if let Some(rest) = (remote.strip_prefix("https://")).or_else(|| remote.strip_prefix("http://")) + { + // Note: Always upgrade http to https for security. return format!("https://{}", rest.strip_suffix(".git").unwrap_or(rest)); } // SSH: git@host:org/repo(.git) - if let Some(rest) = remote.strip_prefix("git@") { + if let Some(rest) = (remote.strip_prefix("ssh://git@")).or_else(|| remote.strip_prefix("git@")) + { let rest = rest.strip_suffix(".git").unwrap_or(rest); let mut parts = rest.splitn(2, ':'); @@ -76,28 +86,59 @@ pub fn normalize_remote(remote: &str) -> String { panic!("Unrecognized remote URL format: {}", remote); } -fn is_gitlab_repo(repo_url: &str) -> bool { - repo_url.contains("gitlab") +/// Extract the domain from an HTTP(S) repository URL. +/// +/// On invalid input, returns the original string. +pub fn extract_repo_domain(repo_url: &str) -> String { + let url = url::Url::parse(repo_url).unwrap(); + url.host_str().unwrap_or(repo_url).to_string() +} + +pub fn detect_remote_flavor(repo_url: &str) -> RemoteFlavor { + let repo_url_domain = extract_repo_domain(repo_url).to_lowercase(); + + if repo_url_domain.contains("github") { + RemoteFlavor::Github + } else if repo_url_domain.contains("gitlab") { + // TODO: There may be a better way to detect self-hosted GitLab repos. + RemoteFlavor::Gitlab + } else if repo_url_domain.contains("codeberg") { + RemoteFlavor::Codeberg + } else { + panic!("Unrecognized remote URL format: {}", repo_url); + } } /// Create a Pull Request url, assuming the remote is GitHub, or a URL-compatible site. -pub fn pr_url(repo_url: &str, branch: &str) -> String { +pub fn github_pr_url(repo_url: &str, branch: &str) -> String { format!("{}/pull/new/{}", repo_url, branch) } /// Create a Merge Request url, assuming the remote is GitLab, or a URL-compatible site. -pub fn mr_url(repo_url: &str, branch: &str) -> String { +pub fn gitlab_mr_url(repo_url: &str, branch: &str) -> String { format!( "{}/-/merge_requests/new?merge_request[source_branch]={}", repo_url, branch ) } -fn link_for_command(repo_url: &str, branch: &str) -> String { - if is_gitlab_repo(repo_url) { - mr_url(repo_url, branch) - } else { - pr_url(repo_url, branch) +/// Create a Pull Request url, assuming the remote is Codeberg, or a URL-compatible site. +/// +/// This is the closest to "new PR" which exists on Codeberg. +pub fn codeberg_compare_url(repo_url: &str, branch: &str, default_branch: &str) -> String { + format!("{repo_url}/compare/{default_branch}...{branch}") +} + +/// Create a Pull Request or Merge Request url, depending on the remote type. +pub fn link_for_pr_mr(repo_url: &str, branch: &str) -> String { + let flavor = detect_remote_flavor(repo_url); + match flavor { + RemoteFlavor::Github => github_pr_url(repo_url, branch), + RemoteFlavor::Gitlab => gitlab_mr_url(repo_url, branch), + RemoteFlavor::Codeberg => { + // TODO: Detect default branch better. + codeberg_compare_url(repo_url, branch, "main") + } } } @@ -137,7 +178,12 @@ fn main() { eprintln!("Not on a branch"); exit(1); } - link_for_command(&repo_url, &branch) + + if cli.verbose { + println!("Branch: {}", branch); + } + + link_for_pr_mr(&repo_url, &branch) } None => repo_url, }; @@ -169,6 +215,22 @@ mod tests { assert_eq!(normalize_remote(input), expected); } + #[test] + fn ssh_remote_with_git_suffix_with_ssh_prefix() { + // This is the Codeberg style. + let input = "ssh://git@example.com:org/project.git"; + let expected = "https://example.com/org/project"; + assert_eq!(normalize_remote(input), expected); + } + + #[test] + fn ssh_remote_without_git_suffix_with_ssh_prefix() { + // This is similar to the Codeberg style. + let input = "ssh://git@example.com:org/project"; + let expected = "https://example.com/org/project"; + assert_eq!(normalize_remote(input), expected); + } + #[test] fn https_remote_with_git_suffix() { let input = "https://example.com/org/project.git"; @@ -188,7 +250,7 @@ mod tests { let repo_url = "https://example.com/org/project"; let branch = "feature-branch"; let expected = "https://example.com/org/project/pull/new/feature-branch"; - assert_eq!(pr_url(repo_url, branch), expected); + assert_eq!(github_pr_url(repo_url, branch), expected); } #[test] @@ -198,26 +260,34 @@ mod tests { } #[test] - fn github_pr_url() { + fn test_github_pr_url() { let repo = "https://github.com/org/project"; let branch = "feature-x"; let expected = "https://github.com/org/project/pull/new/feature-x"; - assert_eq!(link_for_command(repo, branch), expected); + assert_eq!(link_for_pr_mr(repo, branch), expected); + } + + #[test] + fn test_codeberg_pr_url() { + let repo = "https://codeberg.org/org/project"; + let branch = "feature-x"; + let expected = "https://codeberg.org/org/project/compare/main...feature-x"; + assert_eq!(link_for_pr_mr(repo, branch), expected); } #[test] - fn gitlab_mr_url() { + fn test_gitlab_mr_url() { let repo = "https://gitlab.com/org/project"; let branch = "feature-x"; let expected = "https://gitlab.com/org/project/-/merge_requests/new?merge_request[source_branch]=feature-x"; - assert_eq!(link_for_command(repo, branch), expected); + assert_eq!(link_for_pr_mr(repo, branch), expected); } #[test] - fn self_hosted_gitlab_mr_url() { + fn test_self_hosted_gitlab_mr_url() { let repo = "https://gitlab.example.com/org/project"; let branch = "dev"; let expected = "https://gitlab.example.com/org/project/-/merge_requests/new?merge_request[source_branch]=dev"; - assert_eq!(link_for_command(repo, branch), expected); + assert_eq!(link_for_pr_mr(repo, branch), expected); } }