use nils_common::process;
use std::io::{self, Write};
use std::process::Output;
pub fn dispatch(cmd: &str, args: &[String]) -> Option<i32> {
match cmd {
"repo" => Some(open_repo(args)),
"branch" => Some(open_branch(args)),
"default" | "default-branch" => Some(open_default_branch(args)),
"commit" => Some(open_commit(args)),
"compare" => Some(open_compare(args)),
"pr" | "pull-request" | "mr" | "merge-request" => Some(open_pr(args)),
"pulls" | "prs" | "merge-requests" | "mrs" => Some(open_pulls(args)),
"issue" | "issues" => Some(open_issues(args)),
"action" | "actions" => Some(open_actions(args)),
"release" | "releases" => Some(open_releases(args)),
"tag" | "tags" => Some(open_tags(args)),
"commits" | "history" => Some(open_commits(args)),
"file" | "blob" => Some(open_file(args)),
"blame" => Some(open_blame(args)),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Provider {
Github,
Gitlab,
Generic,
}
impl Provider {
fn from_base_url(base_url: &str) -> Self {
let host = host_from_url(base_url);
match host.as_str() {
"github.com" => Self::Github,
"gitlab.com" => Self::Gitlab,
_ => {
if host.contains("gitlab") {
Self::Gitlab
} else if host.contains("github") {
Self::Github
} else {
Self::Generic
}
}
}
}
}
#[derive(Debug, Clone)]
struct OpenContext {
base_url: String,
remote: String,
remote_branch: String,
provider: Provider,
}
#[derive(Debug, Clone)]
struct CollabContext {
base_url: String,
remote: String,
provider: Provider,
}
fn open_repo(args: &[String]) -> i32 {
if args.len() > 1 {
eprintln!("❌ git-cli open repo takes at most one remote name");
print_usage();
return 2;
}
if args.first().is_some_and(|arg| is_help_token(arg)) {
print_usage();
return 0;
}
let base_url = if let Some(remote) = args.first() {
match normalize_remote_url(remote) {
Ok(url) => url,
Err(code) => return code,
}
} else {
match resolve_context() {
Ok(ctx) => ctx.base_url,
Err(code) => return code,
}
};
open_url(&base_url, "🌐 Opened")
}
fn open_branch(args: &[String]) -> i32 {
if args.len() > 1 {
eprintln!("❌ git-cli open branch takes at most one ref");
print_usage();
return 2;
}
if args.first().is_some_and(|arg| is_help_token(arg)) {
print_usage();
return 0;
}
let ctx = match resolve_context() {
Ok(ctx) => ctx,
Err(code) => return code,
};
let reference = args
.first()
.map(ToOwned::to_owned)
.unwrap_or_else(|| ctx.remote_branch.clone());
let url = tree_url(ctx.provider, &ctx.base_url, &reference);
open_url(&url, "🌿 Opened")
}
fn open_default_branch(args: &[String]) -> i32 {
if args.len() > 1 {
eprintln!("❌ git-cli open default-branch takes at most one remote name");
print_usage();
return 2;
}
if args.first().is_some_and(|arg| is_help_token(arg)) {
print_usage();
return 0;
}
let (base_url, remote, provider) = if let Some(remote) = args.first() {
let base_url = match normalize_remote_url(remote) {
Ok(url) => url,
Err(code) => return code,
};
let provider = Provider::from_base_url(&base_url);
(base_url, remote.to_string(), provider)
} else {
let ctx = match resolve_context() {
Ok(ctx) => ctx,
Err(code) => return code,
};
(ctx.base_url, ctx.remote, ctx.provider)
};
let default_branch = match default_branch_name(&remote) {
Ok(branch) => branch,
Err(code) => return code,
};
let url = tree_url(provider, &base_url, &default_branch);
open_url(&url, "🌿 Opened")
}
fn open_commit(args: &[String]) -> i32 {
if args.len() > 1 {
eprintln!("❌ git-cli open commit takes at most one ref");
print_usage();
return 2;
}
if args.first().is_some_and(|arg| is_help_token(arg)) {
print_usage();
return 0;
}
let reference = args.first().map(String::as_str).unwrap_or("HEAD");
let ctx = match resolve_context() {
Ok(ctx) => ctx,
Err(code) => return code,
};
let commit_ref = format!("{reference}^{{commit}}");
let commit = match git_stdout_trimmed(&["rev-parse", &commit_ref]) {
Ok(value) => value,
Err(_) => {
eprintln!("❌ Invalid commit/tag/branch: {reference}");
return 1;
}
};
let url = commit_url(ctx.provider, &ctx.base_url, &commit);
open_url(&url, "🔗 Opened")
}
fn open_compare(args: &[String]) -> i32 {
if args.len() > 2 {
eprintln!("❌ git-cli open compare takes at most two refs");
print_usage();
return 2;
}
let ctx = match resolve_context() {
Ok(ctx) => ctx,
Err(code) => return code,
};
let (base, head) = match args.len() {
0 => {
let base = match default_branch_name(&ctx.remote) {
Ok(value) => value,
Err(code) => return code,
};
(base, ctx.remote_branch)
}
1 => (args[0].to_string(), ctx.remote_branch),
_ => (args[0].to_string(), args[1].to_string()),
};
let url = compare_url(ctx.provider, &ctx.base_url, &base, &head);
open_url(&url, "🔀 Opened")
}
fn open_pr(args: &[String]) -> i32 {
if args.len() > 1 {
eprintln!("❌ git-cli open pr takes at most one number");
print_usage();
return 2;
}
if args.first().is_some_and(|arg| is_help_token(arg)) {
print_usage();
return 0;
}
let ctx = match resolve_context() {
Ok(ctx) => ctx,
Err(code) => return code,
};
let collab = match resolve_collab_context(&ctx) {
Ok(value) => value,
Err(code) => return code,
};
if let Some(raw_number) = args.first() {
let pr_number = match parse_positive_number(raw_number, "PR") {
Ok(value) => value,
Err(code) => return code,
};
let url = match collab.provider {
Provider::Github => format!("{}/pull/{pr_number}", collab.base_url),
Provider::Gitlab => format!("{}/-/merge_requests/{pr_number}", collab.base_url),
Provider::Generic => {
eprintln!("❗ pr <number> is only supported for GitHub/GitLab remotes.");
return 1;
}
};
return open_url(&url, "🧷 Opened");
}
if collab.provider == Provider::Github
&& process::cmd_exists("gh")
&& try_open_pr_with_gh(&ctx, &collab)
{
return 0;
}
match collab.provider {
Provider::Github => {
let base = match default_branch_name(&collab.remote) {
Ok(value) => value,
Err(code) => return code,
};
let mut head_ref = ctx.remote_branch.clone();
if collab.base_url != ctx.base_url
&& let Some(slug) = github_repo_slug(&ctx.base_url)
&& let Some((owner, _)) = slug.split_once('/')
{
head_ref = format!("{owner}:{}", ctx.remote_branch);
}
let url = format!(
"{}/compare/{}...{}?expand=1",
collab.base_url, base, head_ref
);
open_url(&url, "🧷 Opened")
}
Provider::Gitlab => {
let base = match default_branch_name(&collab.remote) {
Ok(value) => value,
Err(code) => return code,
};
let source_enc = percent_encode(&ctx.remote_branch, false);
let target_enc = percent_encode(&base, false);
let url = format!(
"{}/-/merge_requests/new?merge_request[source_branch]={source_enc}&merge_request[target_branch]={target_enc}",
collab.base_url
);
open_url(&url, "🧷 Opened")
}
Provider::Generic => {
let base = match default_branch_name(&collab.remote) {
Ok(value) => value,
Err(code) => return code,
};
let url = format!(
"{}/compare/{}...{}",
collab.base_url, base, ctx.remote_branch
);
open_url(&url, "🧷 Opened")
}
}
}
fn open_pulls(args: &[String]) -> i32 {
if args.len() > 1 {
eprintln!("❌ git-cli open pulls takes at most one number");
print_usage();
return 2;
}
if let Some(value) = args.first() {
return open_pr(&[value.to_string()]);
}
let ctx = match resolve_context() {
Ok(ctx) => ctx,
Err(code) => return code,
};
let collab = match resolve_collab_context(&ctx) {
Ok(value) => value,
Err(code) => return code,
};
let url = match collab.provider {
Provider::Gitlab => format!("{}/-/merge_requests", collab.base_url),
_ => format!("{}/pulls", collab.base_url),
};
open_url(&url, "📌 Opened")
}
fn open_issues(args: &[String]) -> i32 {
if args.len() > 1 {
eprintln!("❌ git-cli open issues takes at most one number");
print_usage();
return 2;
}
let ctx = match resolve_context() {
Ok(ctx) => ctx,
Err(code) => return code,
};
let collab = match resolve_collab_context(&ctx) {
Ok(value) => value,
Err(code) => return code,
};
let url = if let Some(raw_number) = args.first() {
let issue_number = match parse_positive_number(raw_number, "issue") {
Ok(value) => value,
Err(code) => return code,
};
match collab.provider {
Provider::Gitlab => format!("{}/-/issues/{issue_number}", collab.base_url),
_ => format!("{}/issues/{issue_number}", collab.base_url),
}
} else {
match collab.provider {
Provider::Gitlab => format!("{}/-/issues", collab.base_url),
_ => format!("{}/issues", collab.base_url),
}
};
open_url(&url, "📌 Opened")
}
fn open_actions(args: &[String]) -> i32 {
if args.len() > 1 {
eprintln!("❌ git-cli open actions takes at most one workflow");
print_usage();
return 2;
}
if args.first().is_some_and(|arg| is_help_token(arg)) {
print_usage();
return 0;
}
let ctx = match resolve_context() {
Ok(ctx) => ctx,
Err(code) => return code,
};
let collab = match resolve_collab_context(&ctx) {
Ok(value) => value,
Err(code) => return code,
};
if collab.provider != Provider::Github {
eprintln!("❗ actions is only supported for GitHub remotes.");
return 1;
}
let url = if let Some(workflow) = args.first() {
if is_yaml_workflow(workflow) {
let encoded = percent_encode(workflow, false);
format!("{}/actions/workflows/{encoded}", collab.base_url)
} else {
let q = format!("workflow:{workflow}");
let encoded = percent_encode(&q, false);
format!("{}/actions?query={encoded}", collab.base_url)
}
} else {
format!("{}/actions", collab.base_url)
};
open_url(&url, "📌 Opened")
}
fn open_releases(args: &[String]) -> i32 {
if args.len() > 1 {
eprintln!("❌ git-cli open releases takes at most one tag");
print_usage();
return 2;
}
if args.first().is_some_and(|arg| is_help_token(arg)) {
print_usage();
return 0;
}
let ctx = match resolve_context() {
Ok(ctx) => ctx,
Err(code) => return code,
};
let collab = match resolve_collab_context(&ctx) {
Ok(value) => value,
Err(code) => return code,
};
let url = if let Some(tag) = args.first() {
release_tag_url(collab.provider, &collab.base_url, tag)
} else {
match collab.provider {
Provider::Gitlab => format!("{}/-/releases", collab.base_url),
_ => format!("{}/releases", collab.base_url),
}
};
open_url(&url, "📌 Opened")
}
fn open_tags(args: &[String]) -> i32 {
if args.len() > 1 {
eprintln!("❌ git-cli open tags takes at most one tag");
print_usage();
return 2;
}
if args.first().is_some_and(|arg| is_help_token(arg)) {
print_usage();
return 0;
}
let ctx = match resolve_context() {
Ok(ctx) => ctx,
Err(code) => return code,
};
let collab = match resolve_collab_context(&ctx) {
Ok(value) => value,
Err(code) => return code,
};
let url = if let Some(tag) = args.first() {
release_tag_url(collab.provider, &collab.base_url, tag)
} else {
match collab.provider {
Provider::Gitlab => format!("{}/-/tags", collab.base_url),
_ => format!("{}/tags", collab.base_url),
}
};
open_url(&url, "📌 Opened")
}
fn open_commits(args: &[String]) -> i32 {
if args.len() > 1 {
eprintln!("❌ git-cli open commits takes at most one ref");
print_usage();
return 2;
}
if args.first().is_some_and(|arg| is_help_token(arg)) {
print_usage();
return 0;
}
let ctx = match resolve_context() {
Ok(ctx) => ctx,
Err(code) => return code,
};
let reference = args
.first()
.map(ToOwned::to_owned)
.unwrap_or_else(|| ctx.remote_branch.clone());
let url = commits_url(ctx.provider, &ctx.base_url, &reference);
open_url(&url, "📜 Opened")
}
fn open_file(args: &[String]) -> i32 {
if args.is_empty() || args.len() > 2 {
eprintln!("❌ Usage: git-cli open file <path> [ref]");
return 2;
}
let ctx = match resolve_context() {
Ok(ctx) => ctx,
Err(code) => return code,
};
let path = normalize_repo_path(&args[0]);
let reference = args
.get(1)
.map(ToOwned::to_owned)
.unwrap_or_else(|| ctx.remote_branch.clone());
let url = blob_url(ctx.provider, &ctx.base_url, &reference, &path);
open_url(&url, "📄 Opened")
}
fn open_blame(args: &[String]) -> i32 {
if args.is_empty() || args.len() > 2 {
eprintln!("❌ Usage: git-cli open blame <path> [ref]");
return 2;
}
let ctx = match resolve_context() {
Ok(ctx) => ctx,
Err(code) => return code,
};
let path = normalize_repo_path(&args[0]);
let reference = args
.get(1)
.map(ToOwned::to_owned)
.unwrap_or_else(|| ctx.remote_branch.clone());
let url = blame_url(ctx.provider, &ctx.base_url, &reference, &path);
open_url(&url, "🕵️ Opened")
}
fn try_open_pr_with_gh(ctx: &OpenContext, collab: &CollabContext) -> bool {
let mut candidates: Vec<Option<String>> = Vec::new();
if let Some(slug) = github_repo_slug(&collab.base_url) {
candidates.push(Some(slug));
}
if let Some(slug) = github_repo_slug(&ctx.base_url)
&& !candidates
.iter()
.any(|value| value.as_deref() == Some(&slug))
{
candidates.push(Some(slug));
}
candidates.push(None);
for repo in candidates {
if run_gh_pr_view(repo.as_deref(), Some(&ctx.remote_branch)) {
return true;
}
}
false
}
fn run_gh_pr_view(repo: Option<&str>, selector: Option<&str>) -> bool {
let mut owned_args: Vec<String> = vec!["pr".into(), "view".into(), "--web".into()];
if let Some(repo) = repo {
owned_args.push("--repo".into());
owned_args.push(repo.to_string());
}
if let Some(selector) = selector {
owned_args.push(selector.to_string());
}
let args: Vec<&str> = owned_args.iter().map(String::as_str).collect();
let output = match run_output("gh", &args) {
Ok(output) => output,
Err(_) => return false,
};
if output.status.success() {
println!("🧷 Opened PR via gh");
true
} else {
false
}
}
fn resolve_context() -> Result<OpenContext, i32> {
let (remote, remote_branch) = resolve_upstream()?;
let base_url = normalize_remote_url(&remote)?;
let provider = Provider::from_base_url(&base_url);
Ok(OpenContext {
base_url,
remote,
remote_branch,
provider,
})
}
fn resolve_collab_context(ctx: &OpenContext) -> Result<CollabContext, i32> {
let env_remote = std::env::var("GIT_OPEN_COLLAB_REMOTE")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if let Some(remote) = env_remote
&& let Ok(base_url) = normalize_remote_url(&remote)
{
let provider = Provider::from_base_url(&base_url);
return Ok(CollabContext {
base_url,
remote,
provider,
});
}
Ok(CollabContext {
base_url: ctx.base_url.clone(),
remote: ctx.remote.clone(),
provider: ctx.provider,
})
}
fn resolve_upstream() -> Result<(String, String), i32> {
if !git_status_success(&["rev-parse", "--is-inside-work-tree"]) {
eprintln!("❌ Not in a git repository");
return Err(1);
}
let branch = match git_stdout_trimmed(&["rev-parse", "--abbrev-ref", "HEAD"]) {
Ok(value) => value,
Err(_) => {
eprintln!("❌ Unable to resolve current branch");
return Err(1);
}
};
let upstream =
git_stdout_trimmed_optional(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
.unwrap_or_default();
let mut remote = String::new();
let mut remote_branch = String::new();
if !upstream.is_empty()
&& upstream != branch
&& let Some((upstream_remote, upstream_branch)) = upstream.split_once('/')
{
remote = upstream_remote.to_string();
remote_branch = upstream_branch.to_string();
}
if remote.is_empty() {
remote = "origin".to_string();
}
if remote_branch.is_empty() || remote_branch == "HEAD" {
remote_branch = branch;
}
Ok((remote, remote_branch))
}
fn normalize_remote_url(remote: &str) -> Result<String, i32> {
if remote.trim().is_empty() {
eprintln!("❌ Missing remote name");
return Err(1);
}
let raw = match git_stdout_trimmed(&["remote", "get-url", remote]) {
Ok(value) => value,
Err(_) => {
eprintln!("❌ Failed to resolve remote URL for {remote}");
return Err(1);
}
};
match normalize_remote_url_from_raw(&raw) {
Some(value) => Ok(value),
None => {
eprintln!("❌ Unable to normalize remote URL for {remote}");
Err(1)
}
}
}
fn normalize_remote_url_from_raw(raw: &str) -> Option<String> {
let input = raw.trim();
if input.is_empty() {
return None;
}
let normalized = if let Some((scheme, rest)) = input.split_once("://") {
let (host_with_auth, path) = match rest.split_once('/') {
Some((host, path)) => (host, path),
None => (rest, ""),
};
let host = strip_userinfo(host_with_auth);
if host.is_empty() {
return None;
}
match scheme {
"ssh" | "git" => {
if path.is_empty() {
format!("https://{host}")
} else {
format!("https://{host}/{path}")
}
}
"http" | "https" => {
if path.is_empty() {
format!("{scheme}://{host}")
} else {
format!("{scheme}://{host}/{path}")
}
}
_ => {
if path.is_empty() {
format!("{scheme}://{host}")
} else {
format!("{scheme}://{host}/{path}")
}
}
}
} else if input.contains(':') {
if let Some((host_part, path_part)) = input.rsplit_once(':') {
let host = strip_userinfo(host_part);
if !host.is_empty()
&& !path_part.is_empty()
&& !host.contains('/')
&& !path_part.starts_with('/')
{
format!("https://{host}/{path_part}")
} else {
input.to_string()
}
} else {
input.to_string()
}
} else if let Some((host_part, path_part)) = input.split_once('/') {
if host_part.contains('@') {
let host = strip_userinfo(host_part);
if host.is_empty() {
return None;
}
format!("https://{host}/{path_part}")
} else {
input.to_string()
}
} else {
input.to_string()
};
if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
return None;
}
let trimmed = normalized.trim_end_matches('/').trim_end_matches(".git");
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn default_branch_name(remote: &str) -> Result<String, i32> {
let symbolic = format!("refs/remotes/{remote}/HEAD");
if let Some(value) =
git_stdout_trimmed_optional(&["symbolic-ref", "--quiet", "--short", &symbolic])
&& let Some((_, branch)) = value.split_once('/')
&& !branch.trim().is_empty()
{
return Ok(branch.to_string());
}
let output = match run_git_output(&["remote", "show", remote]) {
Some(output) => output,
None => return Err(1),
};
if !output.status.success() {
emit_output(&output);
return Err(exit_code(&output));
}
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
if line.contains("HEAD branch") {
let value = line
.split_once(':')
.map(|(_, tail)| tail.trim())
.unwrap_or("");
if !value.is_empty() && value != "(unknown)" {
return Ok(value.to_string());
}
}
}
eprintln!("❌ Failed to resolve default branch for {remote}");
Err(1)
}
fn parse_positive_number(raw: &str, kind: &str) -> Result<String, i32> {
let cleaned = raw.trim_start_matches('#');
if cleaned.chars().all(|ch| ch.is_ascii_digit()) && !cleaned.is_empty() {
Ok(cleaned.to_string())
} else {
eprintln!("❌ Invalid {kind} number: {raw}");
Err(2)
}
}
fn normalize_repo_path(path: &str) -> String {
path.trim_start_matches("./")
.trim_start_matches('/')
.to_string()
}
fn is_yaml_workflow(value: &str) -> bool {
value.ends_with(".yml") || value.ends_with(".yaml")
}
fn tree_url(provider: Provider, base_url: &str, reference: &str) -> String {
match provider {
Provider::Gitlab => format!("{base_url}/-/tree/{reference}"),
_ => format!("{base_url}/tree/{reference}"),
}
}
fn commit_url(provider: Provider, base_url: &str, commit: &str) -> String {
match provider {
Provider::Gitlab => format!("{base_url}/-/commit/{commit}"),
_ => format!("{base_url}/commit/{commit}"),
}
}
fn compare_url(provider: Provider, base_url: &str, base: &str, head: &str) -> String {
match provider {
Provider::Gitlab => format!("{base_url}/-/compare/{base}...{head}"),
_ => format!("{base_url}/compare/{base}...{head}"),
}
}
fn blob_url(provider: Provider, base_url: &str, reference: &str, path: &str) -> String {
let encoded_path = percent_encode(path, true);
match provider {
Provider::Gitlab => format!("{base_url}/-/blob/{reference}/{encoded_path}"),
_ => format!("{base_url}/blob/{reference}/{encoded_path}"),
}
}
fn blame_url(provider: Provider, base_url: &str, reference: &str, path: &str) -> String {
let encoded_path = percent_encode(path, true);
match provider {
Provider::Gitlab => format!("{base_url}/-/blame/{reference}/{encoded_path}"),
_ => format!("{base_url}/blame/{reference}/{encoded_path}"),
}
}
fn commits_url(provider: Provider, base_url: &str, reference: &str) -> String {
match provider {
Provider::Gitlab => format!("{base_url}/-/commits/{reference}"),
_ => format!("{base_url}/commits/{reference}"),
}
}
fn release_tag_url(provider: Provider, base_url: &str, tag: &str) -> String {
let encoded = percent_encode(tag, false);
match provider {
Provider::Gitlab => format!("{base_url}/-/releases/{encoded}"),
_ => format!("{base_url}/releases/tag/{encoded}"),
}
}
fn github_repo_slug(base_url: &str) -> Option<String> {
let without_scheme = base_url
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(base_url);
let (_, path) = without_scheme.split_once('/')?;
let mut parts = path.split('/').filter(|part| !part.is_empty());
let owner = parts.next()?;
let repo = parts.next()?;
Some(format!("{owner}/{repo}"))
}
fn host_from_url(url: &str) -> String {
let without_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
without_scheme
.split('/')
.next()
.unwrap_or("")
.to_ascii_lowercase()
}
fn strip_userinfo(host: &str) -> &str {
host.rsplit_once('@').map(|(_, tail)| tail).unwrap_or(host)
}
fn percent_encode(value: &str, keep_slash: bool) -> String {
let mut out = String::new();
for byte in value.as_bytes() {
let is_unreserved =
matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~');
if is_unreserved || (keep_slash && *byte == b'/') {
out.push(*byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
fn open_url(url: &str, label: &str) -> i32 {
if url.is_empty() {
eprintln!("❌ Missing URL");
return 1;
}
let Some(opener) = process::browser_open_command() else {
eprintln!("❌ Cannot open URL (no open/xdg-open)");
return 1;
};
let output = match run_output(opener, &[url]) {
Ok(output) => output,
Err(err) => {
eprintln!("{err}");
return 1;
}
};
if !output.status.success() {
if process::is_headless_browser_launch_failure(&output.stdout, &output.stderr) {
println!("🔗 URL: {url}");
eprintln!("⚠️ Could not launch a browser in this environment; open the URL manually.");
return 0;
}
emit_output(&output);
return exit_code(&output);
}
println!("{label}: {url}");
0
}
fn is_help_token(raw: &str) -> bool {
matches!(raw, "-h" | "--help" | "help")
}
fn print_usage() {
println!("Usage:");
println!(" git-cli open");
println!(" git-cli open repo [remote]");
println!(" git-cli open branch [ref]");
println!(" git-cli open default-branch [remote]");
println!(" git-cli open commit [ref]");
println!(" git-cli open compare [base] [head]");
println!(" git-cli open pr [number]");
println!(" git-cli open pulls [number]");
println!(" git-cli open issues [number]");
println!(" git-cli open actions [workflow]");
println!(" git-cli open releases [tag]");
println!(" git-cli open tags [tag]");
println!(" git-cli open commits [ref]");
println!(" git-cli open file <path> [ref]");
println!(" git-cli open blame <path> [ref]");
println!();
println!("Notes:");
println!(" - Uses the upstream remote when available; falls back to origin.");
println!(" - Collaboration pages prefer GIT_OPEN_COLLAB_REMOTE when set.");
println!(" - `pr` prefers gh when available on GitHub remotes.");
}
fn run_git_output(args: &[&str]) -> Option<Output> {
match run_output("git", args) {
Ok(output) => Some(output),
Err(err) => {
eprintln!("{err}");
None
}
}
}
fn run_output(cmd: &str, args: &[&str]) -> Result<Output, String> {
process::run_output(cmd, args)
.map(|output| output.into_std_output())
.map_err(|err| format!("spawn {cmd}: {err}"))
}
fn git_stdout_trimmed(args: &[&str]) -> Result<String, i32> {
let output = run_git_output(args).ok_or(1)?;
if !output.status.success() {
emit_output(&output);
return Err(exit_code(&output));
}
Ok(trim_trailing_newlines(&String::from_utf8_lossy(&output.stdout)).to_string())
}
fn git_stdout_trimmed_optional(args: &[&str]) -> Option<String> {
let output = run_git_output(args)?;
if !output.status.success() {
return None;
}
let value = trim_trailing_newlines(&String::from_utf8_lossy(&output.stdout)).to_string();
if value.is_empty() { None } else { Some(value) }
}
fn git_status_success(args: &[&str]) -> bool {
run_git_output(args)
.map(|output| output.status.success())
.unwrap_or(false)
}
fn trim_trailing_newlines(input: &str) -> &str {
input.trim_end_matches(['\n', '\r'])
}
fn exit_code(output: &Output) -> i32 {
output.status.code().unwrap_or(1)
}
fn emit_output(output: &Output) {
let _ = io::stdout().write_all(&output.stdout);
let _ = io::stderr().write_all(&output.stderr);
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn normalize_remote_url_supports_common_git_forms() {
assert_eq!(
normalize_remote_url_from_raw("git@github.com:acme/repo.git"),
Some("https://github.com/acme/repo".to_string())
);
assert_eq!(
normalize_remote_url_from_raw("ssh://git@gitlab.com/group/repo.git"),
Some("https://gitlab.com/group/repo".to_string())
);
assert_eq!(
normalize_remote_url_from_raw("https://github.com/acme/repo.git/"),
Some("https://github.com/acme/repo".to_string())
);
}
#[test]
fn normalize_remote_url_rejects_non_http_like_sources() {
assert_eq!(normalize_remote_url_from_raw("../relative/path"), None);
assert_eq!(normalize_remote_url_from_raw("/tmp/repo.git"), None);
assert_eq!(normalize_remote_url_from_raw(""), None);
}
#[test]
fn provider_detection_matches_hosts() {
assert_eq!(
Provider::from_base_url("https://github.com/acme/repo"),
Provider::Github
);
assert_eq!(
Provider::from_base_url("https://gitlab.com/acme/repo"),
Provider::Gitlab
);
assert_eq!(
Provider::from_base_url("https://gitlab.internal/acme/repo"),
Provider::Gitlab
);
assert_eq!(
Provider::from_base_url("https://code.example.com/acme/repo"),
Provider::Generic
);
}
#[test]
fn github_slug_parses_owner_repo() {
assert_eq!(
github_repo_slug("https://github.com/acme/repo"),
Some("acme/repo".to_string())
);
assert_eq!(
github_repo_slug("https://github.com/acme/repo/sub/path"),
Some("acme/repo".to_string())
);
assert_eq!(github_repo_slug("https://github.com/acme"), None);
}
#[test]
fn percent_encode_supports_paths_and_queries() {
assert_eq!(
percent_encode("docs/read me.md", true),
"docs/read%20me.md".to_string()
);
assert_eq!(
percent_encode("workflow:CI Build", false),
"workflow%3ACI%20Build".to_string()
);
}
#[test]
fn url_builders_follow_provider_conventions() {
assert_eq!(
tree_url(Provider::Github, "https://github.com/acme/repo", "main"),
"https://github.com/acme/repo/tree/main"
);
assert_eq!(
tree_url(Provider::Gitlab, "https://gitlab.com/acme/repo", "main"),
"https://gitlab.com/acme/repo/-/tree/main"
);
assert_eq!(
release_tag_url(Provider::Github, "https://github.com/acme/repo", "v1.2.3"),
"https://github.com/acme/repo/releases/tag/v1.2.3"
);
assert_eq!(
release_tag_url(Provider::Gitlab, "https://gitlab.com/acme/repo", "v1.2.3"),
"https://gitlab.com/acme/repo/-/releases/v1.2.3"
);
}
#[test]
fn parse_positive_number_accepts_hash_prefix() {
assert_eq!(parse_positive_number("#123", "PR"), Ok("123".to_string()));
assert_eq!(parse_positive_number("42", "issue"), Ok("42".to_string()));
assert_eq!(parse_positive_number("abc", "PR"), Err(2));
}
#[test]
fn open_commands_reject_too_many_args_before_git_lookups() {
assert_eq!(open_repo(&args(&["origin", "extra"])), 2);
assert_eq!(open_branch(&args(&["main", "extra"])), 2);
assert_eq!(open_default_branch(&args(&["origin", "extra"])), 2);
assert_eq!(open_commit(&args(&["HEAD", "extra"])), 2);
assert_eq!(open_compare(&args(&["base", "head", "extra"])), 2);
assert_eq!(open_pr(&args(&["1", "extra"])), 2);
assert_eq!(open_pulls(&args(&["1", "extra"])), 2);
assert_eq!(open_issues(&args(&["1", "extra"])), 2);
assert_eq!(open_actions(&args(&["ci.yml", "extra"])), 2);
assert_eq!(open_releases(&args(&["v1.0.0", "extra"])), 2);
assert_eq!(open_tags(&args(&["v1.0.0", "extra"])), 2);
assert_eq!(open_commits(&args(&["main", "extra"])), 2);
assert_eq!(open_file(&args(&["src/lib.rs", "main", "extra"])), 2);
assert_eq!(open_blame(&args(&["src/lib.rs", "main", "extra"])), 2);
}
#[test]
fn open_commands_help_paths_return_zero_without_repo() {
assert_eq!(open_repo(&args(&["--help"])), 0);
assert_eq!(open_branch(&args(&["-h"])), 0);
assert_eq!(open_default_branch(&args(&["help"])), 0);
assert_eq!(open_commit(&args(&["--help"])), 0);
assert_eq!(open_pr(&args(&["help"])), 0);
assert_eq!(open_actions(&args(&["--help"])), 0);
assert_eq!(open_releases(&args(&["--help"])), 0);
assert_eq!(open_tags(&args(&["--help"])), 0);
assert_eq!(open_commits(&args(&["--help"])), 0);
}
#[test]
fn open_file_and_blame_require_path_argument() {
assert_eq!(open_file(&args(&[])), 2);
assert_eq!(open_blame(&args(&[])), 2);
}
#[test]
fn normalize_remote_url_handles_additional_variants() {
assert_eq!(
normalize_remote_url_from_raw("https://token@github.com/acme/repo.git"),
Some("https://github.com/acme/repo".to_string())
);
assert_eq!(
normalize_remote_url_from_raw("ssh://git@gitlab.com"),
Some("https://gitlab.com".to_string())
);
assert_eq!(
normalize_remote_url_from_raw("git://gitlab.com/group/repo.git"),
Some("https://gitlab.com/group/repo".to_string())
);
assert_eq!(
normalize_remote_url_from_raw("git@github.com:acme/repo"),
Some("https://github.com/acme/repo".to_string())
);
assert_eq!(
normalize_remote_url_from_raw("user@gitlab.com/acme/repo.git"),
Some("https://gitlab.com/acme/repo".to_string())
);
assert_eq!(normalize_remote_url_from_raw("ssh://git@/acme/repo"), None);
assert_eq!(normalize_remote_url_from_raw("file:///tmp/repo.git"), None);
}
#[test]
fn helper_functions_cover_url_and_path_variants() {
assert_eq!(normalize_repo_path("./docs/read me.md"), "docs/read me.md");
assert_eq!(normalize_repo_path("/src/lib.rs"), "src/lib.rs");
assert!(is_yaml_workflow("ci.yml"));
assert!(is_yaml_workflow("ci.yaml"));
assert!(!is_yaml_workflow("ci.json"));
assert_eq!(strip_userinfo("git@github.com"), "github.com");
assert_eq!(
host_from_url("HTTPS://User@Example.COM/repo"),
"user@example.com"
);
assert_eq!(trim_trailing_newlines("line\r\n"), "line");
assert!(is_help_token("help"));
assert!(!is_help_token("--version"));
assert_eq!(
github_repo_slug("github.com/acme/repo"),
Some("acme/repo".to_string())
);
assert_eq!(github_repo_slug("https://github.com"), None);
}
#[test]
fn remaining_url_builders_cover_gitlab_and_generic_paths() {
assert_eq!(
commit_url(
Provider::Generic,
"https://code.example.com/acme/repo",
"abc123"
),
"https://code.example.com/acme/repo/commit/abc123"
);
assert_eq!(
compare_url(
Provider::Gitlab,
"https://gitlab.com/acme/repo",
"main",
"feature"
),
"https://gitlab.com/acme/repo/-/compare/main...feature"
);
assert_eq!(
blob_url(
Provider::Gitlab,
"https://gitlab.com/acme/repo",
"main",
"dir/file name.rs"
),
"https://gitlab.com/acme/repo/-/blob/main/dir/file%20name.rs"
);
assert_eq!(
blame_url(
Provider::Generic,
"https://code.example.com/acme/repo",
"main",
"dir/file name.rs"
),
"https://code.example.com/acme/repo/blame/main/dir/file%20name.rs"
);
assert_eq!(
commits_url(Provider::Gitlab, "https://gitlab.com/acme/repo", "main"),
"https://gitlab.com/acme/repo/-/commits/main"
);
}
}