Skip to main content

git_cli/
open.rs

1use crate::util;
2use std::io::{self, Write};
3use std::process::Output;
4
5pub fn dispatch(cmd: &str, args: &[String]) -> Option<i32> {
6    match cmd {
7        "repo" => Some(open_repo(args)),
8        "branch" => Some(open_branch(args)),
9        "default" | "default-branch" => Some(open_default_branch(args)),
10        "commit" => Some(open_commit(args)),
11        "compare" => Some(open_compare(args)),
12        "pr" | "pull-request" | "mr" | "merge-request" => Some(open_pr(args)),
13        "pulls" | "prs" | "merge-requests" | "mrs" => Some(open_pulls(args)),
14        "issue" | "issues" => Some(open_issues(args)),
15        "action" | "actions" => Some(open_actions(args)),
16        "release" | "releases" => Some(open_releases(args)),
17        "tag" | "tags" => Some(open_tags(args)),
18        "commits" | "history" => Some(open_commits(args)),
19        "file" | "blob" => Some(open_file(args)),
20        "blame" => Some(open_blame(args)),
21        _ => None,
22    }
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26enum Provider {
27    Github,
28    Gitlab,
29    Generic,
30}
31
32impl Provider {
33    fn from_base_url(base_url: &str) -> Self {
34        let host = host_from_url(base_url);
35        match host.as_str() {
36            "github.com" => Self::Github,
37            "gitlab.com" => Self::Gitlab,
38            _ => {
39                if host.contains("gitlab") {
40                    Self::Gitlab
41                } else if host.contains("github") {
42                    Self::Github
43                } else {
44                    Self::Generic
45                }
46            }
47        }
48    }
49}
50
51#[derive(Debug, Clone)]
52struct OpenContext {
53    base_url: String,
54    remote: String,
55    remote_branch: String,
56    provider: Provider,
57}
58
59#[derive(Debug, Clone)]
60struct CollabContext {
61    base_url: String,
62    remote: String,
63    provider: Provider,
64}
65
66fn open_repo(args: &[String]) -> i32 {
67    if args.len() > 1 {
68        eprintln!("โŒ git-cli open repo takes at most one remote name");
69        print_usage();
70        return 2;
71    }
72
73    if args.first().is_some_and(|arg| is_help_token(arg)) {
74        print_usage();
75        return 0;
76    }
77
78    let base_url = if let Some(remote) = args.first() {
79        match normalize_remote_url(remote) {
80            Ok(url) => url,
81            Err(code) => return code,
82        }
83    } else {
84        match resolve_context() {
85            Ok(ctx) => ctx.base_url,
86            Err(code) => return code,
87        }
88    };
89
90    open_url(&base_url, "๐ŸŒ Opened")
91}
92
93fn open_branch(args: &[String]) -> i32 {
94    if args.len() > 1 {
95        eprintln!("โŒ git-cli open branch takes at most one ref");
96        print_usage();
97        return 2;
98    }
99
100    if args.first().is_some_and(|arg| is_help_token(arg)) {
101        print_usage();
102        return 0;
103    }
104
105    let ctx = match resolve_context() {
106        Ok(ctx) => ctx,
107        Err(code) => return code,
108    };
109
110    let reference = args
111        .first()
112        .map(ToOwned::to_owned)
113        .unwrap_or_else(|| ctx.remote_branch.clone());
114    let url = tree_url(ctx.provider, &ctx.base_url, &reference);
115    open_url(&url, "๐ŸŒฟ Opened")
116}
117
118fn open_default_branch(args: &[String]) -> i32 {
119    if args.len() > 1 {
120        eprintln!("โŒ git-cli open default-branch takes at most one remote name");
121        print_usage();
122        return 2;
123    }
124
125    if args.first().is_some_and(|arg| is_help_token(arg)) {
126        print_usage();
127        return 0;
128    }
129
130    let (base_url, remote, provider) = if let Some(remote) = args.first() {
131        let base_url = match normalize_remote_url(remote) {
132            Ok(url) => url,
133            Err(code) => return code,
134        };
135        let provider = Provider::from_base_url(&base_url);
136        (base_url, remote.to_string(), provider)
137    } else {
138        let ctx = match resolve_context() {
139            Ok(ctx) => ctx,
140            Err(code) => return code,
141        };
142        (ctx.base_url, ctx.remote, ctx.provider)
143    };
144
145    let default_branch = match default_branch_name(&remote) {
146        Ok(branch) => branch,
147        Err(code) => return code,
148    };
149    let url = tree_url(provider, &base_url, &default_branch);
150    open_url(&url, "๐ŸŒฟ Opened")
151}
152
153fn open_commit(args: &[String]) -> i32 {
154    if args.len() > 1 {
155        eprintln!("โŒ git-cli open commit takes at most one ref");
156        print_usage();
157        return 2;
158    }
159
160    if args.first().is_some_and(|arg| is_help_token(arg)) {
161        print_usage();
162        return 0;
163    }
164
165    let reference = args.first().map(String::as_str).unwrap_or("HEAD");
166    let ctx = match resolve_context() {
167        Ok(ctx) => ctx,
168        Err(code) => return code,
169    };
170
171    let commit_ref = format!("{reference}^{{commit}}");
172    let commit = match git_stdout_trimmed(&["rev-parse", &commit_ref]) {
173        Ok(value) => value,
174        Err(_) => {
175            eprintln!("โŒ Invalid commit/tag/branch: {reference}");
176            return 1;
177        }
178    };
179
180    let url = commit_url(ctx.provider, &ctx.base_url, &commit);
181    open_url(&url, "๐Ÿ”— Opened")
182}
183
184fn open_compare(args: &[String]) -> i32 {
185    if args.len() > 2 {
186        eprintln!("โŒ git-cli open compare takes at most two refs");
187        print_usage();
188        return 2;
189    }
190
191    let ctx = match resolve_context() {
192        Ok(ctx) => ctx,
193        Err(code) => return code,
194    };
195
196    let (base, head) = match args.len() {
197        0 => {
198            let base = match default_branch_name(&ctx.remote) {
199                Ok(value) => value,
200                Err(code) => return code,
201            };
202            (base, ctx.remote_branch)
203        }
204        1 => (args[0].to_string(), ctx.remote_branch),
205        _ => (args[0].to_string(), args[1].to_string()),
206    };
207
208    let url = compare_url(ctx.provider, &ctx.base_url, &base, &head);
209    open_url(&url, "๐Ÿ”€ Opened")
210}
211
212fn open_pr(args: &[String]) -> i32 {
213    if args.len() > 1 {
214        eprintln!("โŒ git-cli open pr takes at most one number");
215        print_usage();
216        return 2;
217    }
218
219    if args.first().is_some_and(|arg| is_help_token(arg)) {
220        print_usage();
221        return 0;
222    }
223
224    let ctx = match resolve_context() {
225        Ok(ctx) => ctx,
226        Err(code) => return code,
227    };
228    let collab = match resolve_collab_context(&ctx) {
229        Ok(value) => value,
230        Err(code) => return code,
231    };
232
233    if let Some(raw_number) = args.first() {
234        let pr_number = match parse_positive_number(raw_number, "PR") {
235            Ok(value) => value,
236            Err(code) => return code,
237        };
238
239        let url = match collab.provider {
240            Provider::Github => format!("{}/pull/{pr_number}", collab.base_url),
241            Provider::Gitlab => format!("{}/-/merge_requests/{pr_number}", collab.base_url),
242            Provider::Generic => {
243                eprintln!("โ— pr <number> is only supported for GitHub/GitLab remotes.");
244                return 1;
245            }
246        };
247        return open_url(&url, "๐Ÿงท Opened");
248    }
249
250    if collab.provider == Provider::Github
251        && util::cmd_exists("gh")
252        && try_open_pr_with_gh(&ctx, &collab)
253    {
254        return 0;
255    }
256
257    match collab.provider {
258        Provider::Github => {
259            let base = match default_branch_name(&collab.remote) {
260                Ok(value) => value,
261                Err(code) => return code,
262            };
263
264            let mut head_ref = ctx.remote_branch.clone();
265            if collab.base_url != ctx.base_url
266                && let Some(slug) = github_repo_slug(&ctx.base_url)
267                && let Some((owner, _)) = slug.split_once('/')
268            {
269                head_ref = format!("{owner}:{}", ctx.remote_branch);
270            }
271
272            let url = format!(
273                "{}/compare/{}...{}?expand=1",
274                collab.base_url, base, head_ref
275            );
276            open_url(&url, "๐Ÿงท Opened")
277        }
278        Provider::Gitlab => {
279            let base = match default_branch_name(&collab.remote) {
280                Ok(value) => value,
281                Err(code) => return code,
282            };
283            let source_enc = percent_encode(&ctx.remote_branch, false);
284            let target_enc = percent_encode(&base, false);
285            let url = format!(
286                "{}/-/merge_requests/new?merge_request[source_branch]={source_enc}&merge_request[target_branch]={target_enc}",
287                collab.base_url
288            );
289            open_url(&url, "๐Ÿงท Opened")
290        }
291        Provider::Generic => {
292            let base = match default_branch_name(&collab.remote) {
293                Ok(value) => value,
294                Err(code) => return code,
295            };
296            let url = format!(
297                "{}/compare/{}...{}",
298                collab.base_url, base, ctx.remote_branch
299            );
300            open_url(&url, "๐Ÿงท Opened")
301        }
302    }
303}
304
305fn open_pulls(args: &[String]) -> i32 {
306    if args.len() > 1 {
307        eprintln!("โŒ git-cli open pulls takes at most one number");
308        print_usage();
309        return 2;
310    }
311
312    if let Some(value) = args.first() {
313        return open_pr(&[value.to_string()]);
314    }
315
316    let ctx = match resolve_context() {
317        Ok(ctx) => ctx,
318        Err(code) => return code,
319    };
320    let collab = match resolve_collab_context(&ctx) {
321        Ok(value) => value,
322        Err(code) => return code,
323    };
324
325    let url = match collab.provider {
326        Provider::Gitlab => format!("{}/-/merge_requests", collab.base_url),
327        _ => format!("{}/pulls", collab.base_url),
328    };
329    open_url(&url, "๐Ÿ“Œ Opened")
330}
331
332fn open_issues(args: &[String]) -> i32 {
333    if args.len() > 1 {
334        eprintln!("โŒ git-cli open issues takes at most one number");
335        print_usage();
336        return 2;
337    }
338
339    let ctx = match resolve_context() {
340        Ok(ctx) => ctx,
341        Err(code) => return code,
342    };
343    let collab = match resolve_collab_context(&ctx) {
344        Ok(value) => value,
345        Err(code) => return code,
346    };
347
348    let url = if let Some(raw_number) = args.first() {
349        let issue_number = match parse_positive_number(raw_number, "issue") {
350            Ok(value) => value,
351            Err(code) => return code,
352        };
353        match collab.provider {
354            Provider::Gitlab => format!("{}/-/issues/{issue_number}", collab.base_url),
355            _ => format!("{}/issues/{issue_number}", collab.base_url),
356        }
357    } else {
358        match collab.provider {
359            Provider::Gitlab => format!("{}/-/issues", collab.base_url),
360            _ => format!("{}/issues", collab.base_url),
361        }
362    };
363    open_url(&url, "๐Ÿ“Œ Opened")
364}
365
366fn open_actions(args: &[String]) -> i32 {
367    if args.len() > 1 {
368        eprintln!("โŒ git-cli open actions takes at most one workflow");
369        print_usage();
370        return 2;
371    }
372
373    if args.first().is_some_and(|arg| is_help_token(arg)) {
374        print_usage();
375        return 0;
376    }
377
378    let ctx = match resolve_context() {
379        Ok(ctx) => ctx,
380        Err(code) => return code,
381    };
382    let collab = match resolve_collab_context(&ctx) {
383        Ok(value) => value,
384        Err(code) => return code,
385    };
386
387    if collab.provider != Provider::Github {
388        eprintln!("โ— actions is only supported for GitHub remotes.");
389        return 1;
390    }
391
392    let url = if let Some(workflow) = args.first() {
393        if is_yaml_workflow(workflow) {
394            let encoded = percent_encode(workflow, false);
395            format!("{}/actions/workflows/{encoded}", collab.base_url)
396        } else {
397            let q = format!("workflow:{workflow}");
398            let encoded = percent_encode(&q, false);
399            format!("{}/actions?query={encoded}", collab.base_url)
400        }
401    } else {
402        format!("{}/actions", collab.base_url)
403    };
404
405    open_url(&url, "๐Ÿ“Œ Opened")
406}
407
408fn open_releases(args: &[String]) -> i32 {
409    if args.len() > 1 {
410        eprintln!("โŒ git-cli open releases takes at most one tag");
411        print_usage();
412        return 2;
413    }
414
415    if args.first().is_some_and(|arg| is_help_token(arg)) {
416        print_usage();
417        return 0;
418    }
419
420    let ctx = match resolve_context() {
421        Ok(ctx) => ctx,
422        Err(code) => return code,
423    };
424    let collab = match resolve_collab_context(&ctx) {
425        Ok(value) => value,
426        Err(code) => return code,
427    };
428
429    let url = if let Some(tag) = args.first() {
430        release_tag_url(collab.provider, &collab.base_url, tag)
431    } else {
432        match collab.provider {
433            Provider::Gitlab => format!("{}/-/releases", collab.base_url),
434            _ => format!("{}/releases", collab.base_url),
435        }
436    };
437    open_url(&url, "๐Ÿ“Œ Opened")
438}
439
440fn open_tags(args: &[String]) -> i32 {
441    if args.len() > 1 {
442        eprintln!("โŒ git-cli open tags takes at most one tag");
443        print_usage();
444        return 2;
445    }
446
447    if args.first().is_some_and(|arg| is_help_token(arg)) {
448        print_usage();
449        return 0;
450    }
451
452    let ctx = match resolve_context() {
453        Ok(ctx) => ctx,
454        Err(code) => return code,
455    };
456    let collab = match resolve_collab_context(&ctx) {
457        Ok(value) => value,
458        Err(code) => return code,
459    };
460
461    let url = if let Some(tag) = args.first() {
462        release_tag_url(collab.provider, &collab.base_url, tag)
463    } else {
464        match collab.provider {
465            Provider::Gitlab => format!("{}/-/tags", collab.base_url),
466            _ => format!("{}/tags", collab.base_url),
467        }
468    };
469    open_url(&url, "๐Ÿ“Œ Opened")
470}
471
472fn open_commits(args: &[String]) -> i32 {
473    if args.len() > 1 {
474        eprintln!("โŒ git-cli open commits takes at most one ref");
475        print_usage();
476        return 2;
477    }
478
479    if args.first().is_some_and(|arg| is_help_token(arg)) {
480        print_usage();
481        return 0;
482    }
483
484    let ctx = match resolve_context() {
485        Ok(ctx) => ctx,
486        Err(code) => return code,
487    };
488    let reference = args
489        .first()
490        .map(ToOwned::to_owned)
491        .unwrap_or_else(|| ctx.remote_branch.clone());
492    let url = commits_url(ctx.provider, &ctx.base_url, &reference);
493    open_url(&url, "๐Ÿ“œ Opened")
494}
495
496fn open_file(args: &[String]) -> i32 {
497    if args.is_empty() || args.len() > 2 {
498        eprintln!("โŒ Usage: git-cli open file <path> [ref]");
499        return 2;
500    }
501
502    let ctx = match resolve_context() {
503        Ok(ctx) => ctx,
504        Err(code) => return code,
505    };
506
507    let path = normalize_repo_path(&args[0]);
508    let reference = args
509        .get(1)
510        .map(ToOwned::to_owned)
511        .unwrap_or_else(|| ctx.remote_branch.clone());
512
513    let url = blob_url(ctx.provider, &ctx.base_url, &reference, &path);
514    open_url(&url, "๐Ÿ“„ Opened")
515}
516
517fn open_blame(args: &[String]) -> i32 {
518    if args.is_empty() || args.len() > 2 {
519        eprintln!("โŒ Usage: git-cli open blame <path> [ref]");
520        return 2;
521    }
522
523    let ctx = match resolve_context() {
524        Ok(ctx) => ctx,
525        Err(code) => return code,
526    };
527
528    let path = normalize_repo_path(&args[0]);
529    let reference = args
530        .get(1)
531        .map(ToOwned::to_owned)
532        .unwrap_or_else(|| ctx.remote_branch.clone());
533
534    let url = blame_url(ctx.provider, &ctx.base_url, &reference, &path);
535    open_url(&url, "๐Ÿ•ต๏ธ Opened")
536}
537
538fn try_open_pr_with_gh(ctx: &OpenContext, collab: &CollabContext) -> bool {
539    let mut candidates: Vec<Option<String>> = Vec::new();
540
541    if let Some(slug) = github_repo_slug(&collab.base_url) {
542        candidates.push(Some(slug));
543    }
544    if let Some(slug) = github_repo_slug(&ctx.base_url)
545        && !candidates
546            .iter()
547            .any(|value| value.as_deref() == Some(&slug))
548    {
549        candidates.push(Some(slug));
550    }
551    candidates.push(None);
552
553    for repo in candidates {
554        if run_gh_pr_view(repo.as_deref(), Some(&ctx.remote_branch)) {
555            return true;
556        }
557    }
558
559    false
560}
561
562fn run_gh_pr_view(repo: Option<&str>, selector: Option<&str>) -> bool {
563    let mut owned_args: Vec<String> = vec!["pr".into(), "view".into(), "--web".into()];
564    if let Some(repo) = repo {
565        owned_args.push("--repo".into());
566        owned_args.push(repo.to_string());
567    }
568    if let Some(selector) = selector {
569        owned_args.push(selector.to_string());
570    }
571    let args: Vec<&str> = owned_args.iter().map(String::as_str).collect();
572
573    let output = match util::run_output("gh", &args) {
574        Ok(output) => output,
575        Err(_) => return false,
576    };
577
578    if output.status.success() {
579        println!("๐Ÿงท Opened PR via gh");
580        true
581    } else {
582        false
583    }
584}
585
586fn resolve_context() -> Result<OpenContext, i32> {
587    let (remote, remote_branch) = resolve_upstream()?;
588    let base_url = normalize_remote_url(&remote)?;
589    let provider = Provider::from_base_url(&base_url);
590    Ok(OpenContext {
591        base_url,
592        remote,
593        remote_branch,
594        provider,
595    })
596}
597
598fn resolve_collab_context(ctx: &OpenContext) -> Result<CollabContext, i32> {
599    let env_remote = std::env::var("GIT_OPEN_COLLAB_REMOTE")
600        .ok()
601        .map(|value| value.trim().to_string())
602        .filter(|value| !value.is_empty());
603
604    if let Some(remote) = env_remote
605        && let Ok(base_url) = normalize_remote_url(&remote)
606    {
607        let provider = Provider::from_base_url(&base_url);
608        return Ok(CollabContext {
609            base_url,
610            remote,
611            provider,
612        });
613    }
614
615    Ok(CollabContext {
616        base_url: ctx.base_url.clone(),
617        remote: ctx.remote.clone(),
618        provider: ctx.provider,
619    })
620}
621
622fn resolve_upstream() -> Result<(String, String), i32> {
623    if !git_status_success(&["rev-parse", "--is-inside-work-tree"]) {
624        eprintln!("โŒ Not in a git repository");
625        return Err(1);
626    }
627
628    let branch = match git_stdout_trimmed(&["rev-parse", "--abbrev-ref", "HEAD"]) {
629        Ok(value) => value,
630        Err(_) => {
631            eprintln!("โŒ Unable to resolve current branch");
632            return Err(1);
633        }
634    };
635
636    let upstream =
637        git_stdout_trimmed_optional(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
638            .unwrap_or_default();
639
640    let mut remote = String::new();
641    let mut remote_branch = String::new();
642
643    if !upstream.is_empty()
644        && upstream != branch
645        && let Some((upstream_remote, upstream_branch)) = upstream.split_once('/')
646    {
647        remote = upstream_remote.to_string();
648        remote_branch = upstream_branch.to_string();
649    }
650
651    if remote.is_empty() {
652        remote = "origin".to_string();
653    }
654
655    if remote_branch.is_empty() || remote_branch == "HEAD" {
656        remote_branch = branch;
657    }
658
659    Ok((remote, remote_branch))
660}
661
662fn normalize_remote_url(remote: &str) -> Result<String, i32> {
663    if remote.trim().is_empty() {
664        eprintln!("โŒ Missing remote name");
665        return Err(1);
666    }
667
668    let raw = match git_stdout_trimmed(&["remote", "get-url", remote]) {
669        Ok(value) => value,
670        Err(_) => {
671            eprintln!("โŒ Failed to resolve remote URL for {remote}");
672            return Err(1);
673        }
674    };
675
676    match normalize_remote_url_from_raw(&raw) {
677        Some(value) => Ok(value),
678        None => {
679            eprintln!("โŒ Unable to normalize remote URL for {remote}");
680            Err(1)
681        }
682    }
683}
684
685fn normalize_remote_url_from_raw(raw: &str) -> Option<String> {
686    let input = raw.trim();
687    if input.is_empty() {
688        return None;
689    }
690
691    let normalized = if let Some((scheme, rest)) = input.split_once("://") {
692        let (host_with_auth, path) = match rest.split_once('/') {
693            Some((host, path)) => (host, path),
694            None => (rest, ""),
695        };
696        let host = strip_userinfo(host_with_auth);
697        if host.is_empty() {
698            return None;
699        }
700
701        match scheme {
702            "ssh" | "git" => {
703                if path.is_empty() {
704                    format!("https://{host}")
705                } else {
706                    format!("https://{host}/{path}")
707                }
708            }
709            "http" | "https" => {
710                if path.is_empty() {
711                    format!("{scheme}://{host}")
712                } else {
713                    format!("{scheme}://{host}/{path}")
714                }
715            }
716            _ => {
717                if path.is_empty() {
718                    format!("{scheme}://{host}")
719                } else {
720                    format!("{scheme}://{host}/{path}")
721                }
722            }
723        }
724    } else if input.contains(':') {
725        if let Some((host_part, path_part)) = input.rsplit_once(':') {
726            let host = strip_userinfo(host_part);
727            if !host.is_empty()
728                && !path_part.is_empty()
729                && !host.contains('/')
730                && !path_part.starts_with('/')
731            {
732                format!("https://{host}/{path_part}")
733            } else {
734                input.to_string()
735            }
736        } else {
737            input.to_string()
738        }
739    } else if let Some((host_part, path_part)) = input.split_once('/') {
740        if host_part.contains('@') {
741            let host = strip_userinfo(host_part);
742            if host.is_empty() {
743                return None;
744            }
745            format!("https://{host}/{path_part}")
746        } else {
747            input.to_string()
748        }
749    } else {
750        input.to_string()
751    };
752
753    if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
754        return None;
755    }
756
757    let trimmed = normalized.trim_end_matches('/').trim_end_matches(".git");
758    if trimmed.is_empty() {
759        None
760    } else {
761        Some(trimmed.to_string())
762    }
763}
764
765fn default_branch_name(remote: &str) -> Result<String, i32> {
766    let symbolic = format!("refs/remotes/{remote}/HEAD");
767    if let Some(value) =
768        git_stdout_trimmed_optional(&["symbolic-ref", "--quiet", "--short", &symbolic])
769        && let Some((_, branch)) = value.split_once('/')
770        && !branch.trim().is_empty()
771    {
772        return Ok(branch.to_string());
773    }
774
775    let output = match run_git_output(&["remote", "show", remote]) {
776        Some(output) => output,
777        None => return Err(1),
778    };
779    if !output.status.success() {
780        emit_output(&output);
781        return Err(exit_code(&output));
782    }
783    let text = String::from_utf8_lossy(&output.stdout);
784    for line in text.lines() {
785        if line.contains("HEAD branch") {
786            let value = line
787                .split_once(':')
788                .map(|(_, tail)| tail.trim())
789                .unwrap_or("");
790            if !value.is_empty() && value != "(unknown)" {
791                return Ok(value.to_string());
792            }
793        }
794    }
795
796    eprintln!("โŒ Failed to resolve default branch for {remote}");
797    Err(1)
798}
799
800fn parse_positive_number(raw: &str, kind: &str) -> Result<String, i32> {
801    let cleaned = raw.trim_start_matches('#');
802    if cleaned.chars().all(|ch| ch.is_ascii_digit()) && !cleaned.is_empty() {
803        Ok(cleaned.to_string())
804    } else {
805        eprintln!("โŒ Invalid {kind} number: {raw}");
806        Err(2)
807    }
808}
809
810fn normalize_repo_path(path: &str) -> String {
811    path.trim_start_matches("./")
812        .trim_start_matches('/')
813        .to_string()
814}
815
816fn is_yaml_workflow(value: &str) -> bool {
817    value.ends_with(".yml") || value.ends_with(".yaml")
818}
819
820fn tree_url(provider: Provider, base_url: &str, reference: &str) -> String {
821    match provider {
822        Provider::Gitlab => format!("{base_url}/-/tree/{reference}"),
823        _ => format!("{base_url}/tree/{reference}"),
824    }
825}
826
827fn commit_url(provider: Provider, base_url: &str, commit: &str) -> String {
828    match provider {
829        Provider::Gitlab => format!("{base_url}/-/commit/{commit}"),
830        _ => format!("{base_url}/commit/{commit}"),
831    }
832}
833
834fn compare_url(provider: Provider, base_url: &str, base: &str, head: &str) -> String {
835    match provider {
836        Provider::Gitlab => format!("{base_url}/-/compare/{base}...{head}"),
837        _ => format!("{base_url}/compare/{base}...{head}"),
838    }
839}
840
841fn blob_url(provider: Provider, base_url: &str, reference: &str, path: &str) -> String {
842    let encoded_path = percent_encode(path, true);
843    match provider {
844        Provider::Gitlab => format!("{base_url}/-/blob/{reference}/{encoded_path}"),
845        _ => format!("{base_url}/blob/{reference}/{encoded_path}"),
846    }
847}
848
849fn blame_url(provider: Provider, base_url: &str, reference: &str, path: &str) -> String {
850    let encoded_path = percent_encode(path, true);
851    match provider {
852        Provider::Gitlab => format!("{base_url}/-/blame/{reference}/{encoded_path}"),
853        _ => format!("{base_url}/blame/{reference}/{encoded_path}"),
854    }
855}
856
857fn commits_url(provider: Provider, base_url: &str, reference: &str) -> String {
858    match provider {
859        Provider::Gitlab => format!("{base_url}/-/commits/{reference}"),
860        _ => format!("{base_url}/commits/{reference}"),
861    }
862}
863
864fn release_tag_url(provider: Provider, base_url: &str, tag: &str) -> String {
865    let encoded = percent_encode(tag, false);
866    match provider {
867        Provider::Gitlab => format!("{base_url}/-/releases/{encoded}"),
868        _ => format!("{base_url}/releases/tag/{encoded}"),
869    }
870}
871
872fn github_repo_slug(base_url: &str) -> Option<String> {
873    let without_scheme = base_url
874        .split_once("://")
875        .map(|(_, rest)| rest)
876        .unwrap_or(base_url);
877    let (_, path) = without_scheme.split_once('/')?;
878    let mut parts = path.split('/').filter(|part| !part.is_empty());
879    let owner = parts.next()?;
880    let repo = parts.next()?;
881    Some(format!("{owner}/{repo}"))
882}
883
884fn host_from_url(url: &str) -> String {
885    let without_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
886    without_scheme
887        .split('/')
888        .next()
889        .unwrap_or("")
890        .to_ascii_lowercase()
891}
892
893fn strip_userinfo(host: &str) -> &str {
894    host.rsplit_once('@').map(|(_, tail)| tail).unwrap_or(host)
895}
896
897fn percent_encode(value: &str, keep_slash: bool) -> String {
898    let mut out = String::new();
899    for byte in value.as_bytes() {
900        let is_unreserved =
901            matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~');
902        if is_unreserved || (keep_slash && *byte == b'/') {
903            out.push(*byte as char);
904        } else {
905            out.push_str(&format!("%{byte:02X}"));
906        }
907    }
908    out
909}
910
911fn open_url(url: &str, label: &str) -> i32 {
912    if url.is_empty() {
913        eprintln!("โŒ Missing URL");
914        return 1;
915    }
916
917    let opener = if util::cmd_exists("open") {
918        "open"
919    } else if util::cmd_exists("xdg-open") {
920        "xdg-open"
921    } else {
922        eprintln!("โŒ Cannot open URL (no open/xdg-open)");
923        return 1;
924    };
925
926    let output = match util::run_output(opener, &[url]) {
927        Ok(output) => output,
928        Err(err) => {
929            eprintln!("{err}");
930            return 1;
931        }
932    };
933    if !output.status.success() {
934        emit_output(&output);
935        return exit_code(&output);
936    }
937
938    println!("{label}: {url}");
939    0
940}
941
942fn is_help_token(raw: &str) -> bool {
943    matches!(raw, "-h" | "--help" | "help")
944}
945
946fn print_usage() {
947    println!("Usage:");
948    println!("  git-cli open");
949    println!("  git-cli open repo [remote]");
950    println!("  git-cli open branch [ref]");
951    println!("  git-cli open default-branch [remote]");
952    println!("  git-cli open commit [ref]");
953    println!("  git-cli open compare [base] [head]");
954    println!("  git-cli open pr [number]");
955    println!("  git-cli open pulls [number]");
956    println!("  git-cli open issues [number]");
957    println!("  git-cli open actions [workflow]");
958    println!("  git-cli open releases [tag]");
959    println!("  git-cli open tags [tag]");
960    println!("  git-cli open commits [ref]");
961    println!("  git-cli open file <path> [ref]");
962    println!("  git-cli open blame <path> [ref]");
963    println!();
964    println!("Notes:");
965    println!("  - Uses the upstream remote when available; falls back to origin.");
966    println!("  - Collaboration pages prefer GIT_OPEN_COLLAB_REMOTE when set.");
967    println!("  - `pr` prefers gh when available on GitHub remotes.");
968}
969
970fn run_git_output(args: &[&str]) -> Option<Output> {
971    match util::run_output("git", args) {
972        Ok(output) => Some(output),
973        Err(err) => {
974            eprintln!("{err}");
975            None
976        }
977    }
978}
979
980fn git_stdout_trimmed(args: &[&str]) -> Result<String, i32> {
981    let output = run_git_output(args).ok_or(1)?;
982    if !output.status.success() {
983        emit_output(&output);
984        return Err(exit_code(&output));
985    }
986    Ok(trim_trailing_newlines(&String::from_utf8_lossy(&output.stdout)).to_string())
987}
988
989fn git_stdout_trimmed_optional(args: &[&str]) -> Option<String> {
990    let output = run_git_output(args)?;
991    if !output.status.success() {
992        return None;
993    }
994    let value = trim_trailing_newlines(&String::from_utf8_lossy(&output.stdout)).to_string();
995    if value.is_empty() { None } else { Some(value) }
996}
997
998fn git_status_success(args: &[&str]) -> bool {
999    run_git_output(args)
1000        .map(|output| output.status.success())
1001        .unwrap_or(false)
1002}
1003
1004fn trim_trailing_newlines(input: &str) -> &str {
1005    input.trim_end_matches(['\n', '\r'])
1006}
1007
1008fn exit_code(output: &Output) -> i32 {
1009    output.status.code().unwrap_or(1)
1010}
1011
1012fn emit_output(output: &Output) {
1013    let _ = io::stdout().write_all(&output.stdout);
1014    let _ = io::stderr().write_all(&output.stderr);
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019    use super::*;
1020    use pretty_assertions::assert_eq;
1021
1022    fn args(values: &[&str]) -> Vec<String> {
1023        values.iter().map(|value| (*value).to_string()).collect()
1024    }
1025
1026    #[test]
1027    fn normalize_remote_url_supports_common_git_forms() {
1028        assert_eq!(
1029            normalize_remote_url_from_raw("git@github.com:acme/repo.git"),
1030            Some("https://github.com/acme/repo".to_string())
1031        );
1032        assert_eq!(
1033            normalize_remote_url_from_raw("ssh://git@gitlab.com/group/repo.git"),
1034            Some("https://gitlab.com/group/repo".to_string())
1035        );
1036        assert_eq!(
1037            normalize_remote_url_from_raw("https://github.com/acme/repo.git/"),
1038            Some("https://github.com/acme/repo".to_string())
1039        );
1040    }
1041
1042    #[test]
1043    fn normalize_remote_url_rejects_non_http_like_sources() {
1044        assert_eq!(normalize_remote_url_from_raw("../relative/path"), None);
1045        assert_eq!(normalize_remote_url_from_raw("/tmp/repo.git"), None);
1046        assert_eq!(normalize_remote_url_from_raw(""), None);
1047    }
1048
1049    #[test]
1050    fn provider_detection_matches_hosts() {
1051        assert_eq!(
1052            Provider::from_base_url("https://github.com/acme/repo"),
1053            Provider::Github
1054        );
1055        assert_eq!(
1056            Provider::from_base_url("https://gitlab.com/acme/repo"),
1057            Provider::Gitlab
1058        );
1059        assert_eq!(
1060            Provider::from_base_url("https://gitlab.internal/acme/repo"),
1061            Provider::Gitlab
1062        );
1063        assert_eq!(
1064            Provider::from_base_url("https://code.example.com/acme/repo"),
1065            Provider::Generic
1066        );
1067    }
1068
1069    #[test]
1070    fn github_slug_parses_owner_repo() {
1071        assert_eq!(
1072            github_repo_slug("https://github.com/acme/repo"),
1073            Some("acme/repo".to_string())
1074        );
1075        assert_eq!(
1076            github_repo_slug("https://github.com/acme/repo/sub/path"),
1077            Some("acme/repo".to_string())
1078        );
1079        assert_eq!(github_repo_slug("https://github.com/acme"), None);
1080    }
1081
1082    #[test]
1083    fn percent_encode_supports_paths_and_queries() {
1084        assert_eq!(
1085            percent_encode("docs/read me.md", true),
1086            "docs/read%20me.md".to_string()
1087        );
1088        assert_eq!(
1089            percent_encode("workflow:CI Build", false),
1090            "workflow%3ACI%20Build".to_string()
1091        );
1092    }
1093
1094    #[test]
1095    fn url_builders_follow_provider_conventions() {
1096        assert_eq!(
1097            tree_url(Provider::Github, "https://github.com/acme/repo", "main"),
1098            "https://github.com/acme/repo/tree/main"
1099        );
1100        assert_eq!(
1101            tree_url(Provider::Gitlab, "https://gitlab.com/acme/repo", "main"),
1102            "https://gitlab.com/acme/repo/-/tree/main"
1103        );
1104        assert_eq!(
1105            release_tag_url(Provider::Github, "https://github.com/acme/repo", "v1.2.3"),
1106            "https://github.com/acme/repo/releases/tag/v1.2.3"
1107        );
1108        assert_eq!(
1109            release_tag_url(Provider::Gitlab, "https://gitlab.com/acme/repo", "v1.2.3"),
1110            "https://gitlab.com/acme/repo/-/releases/v1.2.3"
1111        );
1112    }
1113
1114    #[test]
1115    fn parse_positive_number_accepts_hash_prefix() {
1116        assert_eq!(parse_positive_number("#123", "PR"), Ok("123".to_string()));
1117        assert_eq!(parse_positive_number("42", "issue"), Ok("42".to_string()));
1118        assert_eq!(parse_positive_number("abc", "PR"), Err(2));
1119    }
1120
1121    #[test]
1122    fn open_commands_reject_too_many_args_before_git_lookups() {
1123        assert_eq!(open_repo(&args(&["origin", "extra"])), 2);
1124        assert_eq!(open_branch(&args(&["main", "extra"])), 2);
1125        assert_eq!(open_default_branch(&args(&["origin", "extra"])), 2);
1126        assert_eq!(open_commit(&args(&["HEAD", "extra"])), 2);
1127        assert_eq!(open_compare(&args(&["base", "head", "extra"])), 2);
1128        assert_eq!(open_pr(&args(&["1", "extra"])), 2);
1129        assert_eq!(open_pulls(&args(&["1", "extra"])), 2);
1130        assert_eq!(open_issues(&args(&["1", "extra"])), 2);
1131        assert_eq!(open_actions(&args(&["ci.yml", "extra"])), 2);
1132        assert_eq!(open_releases(&args(&["v1.0.0", "extra"])), 2);
1133        assert_eq!(open_tags(&args(&["v1.0.0", "extra"])), 2);
1134        assert_eq!(open_commits(&args(&["main", "extra"])), 2);
1135        assert_eq!(open_file(&args(&["src/lib.rs", "main", "extra"])), 2);
1136        assert_eq!(open_blame(&args(&["src/lib.rs", "main", "extra"])), 2);
1137    }
1138
1139    #[test]
1140    fn open_commands_help_paths_return_zero_without_repo() {
1141        assert_eq!(open_repo(&args(&["--help"])), 0);
1142        assert_eq!(open_branch(&args(&["-h"])), 0);
1143        assert_eq!(open_default_branch(&args(&["help"])), 0);
1144        assert_eq!(open_commit(&args(&["--help"])), 0);
1145        assert_eq!(open_pr(&args(&["help"])), 0);
1146        assert_eq!(open_actions(&args(&["--help"])), 0);
1147        assert_eq!(open_releases(&args(&["--help"])), 0);
1148        assert_eq!(open_tags(&args(&["--help"])), 0);
1149        assert_eq!(open_commits(&args(&["--help"])), 0);
1150    }
1151
1152    #[test]
1153    fn open_file_and_blame_require_path_argument() {
1154        assert_eq!(open_file(&args(&[])), 2);
1155        assert_eq!(open_blame(&args(&[])), 2);
1156    }
1157
1158    #[test]
1159    fn normalize_remote_url_handles_additional_variants() {
1160        assert_eq!(
1161            normalize_remote_url_from_raw("https://token@github.com/acme/repo.git"),
1162            Some("https://github.com/acme/repo".to_string())
1163        );
1164        assert_eq!(
1165            normalize_remote_url_from_raw("ssh://git@gitlab.com"),
1166            Some("https://gitlab.com".to_string())
1167        );
1168        assert_eq!(
1169            normalize_remote_url_from_raw("git://gitlab.com/group/repo.git"),
1170            Some("https://gitlab.com/group/repo".to_string())
1171        );
1172        assert_eq!(
1173            normalize_remote_url_from_raw("git@github.com:acme/repo"),
1174            Some("https://github.com/acme/repo".to_string())
1175        );
1176        assert_eq!(
1177            normalize_remote_url_from_raw("user@gitlab.com/acme/repo.git"),
1178            Some("https://gitlab.com/acme/repo".to_string())
1179        );
1180        assert_eq!(normalize_remote_url_from_raw("ssh://git@/acme/repo"), None);
1181        assert_eq!(normalize_remote_url_from_raw("file:///tmp/repo.git"), None);
1182    }
1183
1184    #[test]
1185    fn helper_functions_cover_url_and_path_variants() {
1186        assert_eq!(normalize_repo_path("./docs/read me.md"), "docs/read me.md");
1187        assert_eq!(normalize_repo_path("/src/lib.rs"), "src/lib.rs");
1188        assert!(is_yaml_workflow("ci.yml"));
1189        assert!(is_yaml_workflow("ci.yaml"));
1190        assert!(!is_yaml_workflow("ci.json"));
1191        assert_eq!(strip_userinfo("git@github.com"), "github.com");
1192        assert_eq!(
1193            host_from_url("HTTPS://User@Example.COM/repo"),
1194            "user@example.com"
1195        );
1196        assert_eq!(trim_trailing_newlines("line\r\n"), "line");
1197        assert!(is_help_token("help"));
1198        assert!(!is_help_token("--version"));
1199        assert_eq!(
1200            github_repo_slug("github.com/acme/repo"),
1201            Some("acme/repo".to_string())
1202        );
1203        assert_eq!(github_repo_slug("https://github.com"), None);
1204    }
1205
1206    #[test]
1207    fn remaining_url_builders_cover_gitlab_and_generic_paths() {
1208        assert_eq!(
1209            commit_url(
1210                Provider::Generic,
1211                "https://code.example.com/acme/repo",
1212                "abc123"
1213            ),
1214            "https://code.example.com/acme/repo/commit/abc123"
1215        );
1216        assert_eq!(
1217            compare_url(
1218                Provider::Gitlab,
1219                "https://gitlab.com/acme/repo",
1220                "main",
1221                "feature"
1222            ),
1223            "https://gitlab.com/acme/repo/-/compare/main...feature"
1224        );
1225        assert_eq!(
1226            blob_url(
1227                Provider::Gitlab,
1228                "https://gitlab.com/acme/repo",
1229                "main",
1230                "dir/file name.rs"
1231            ),
1232            "https://gitlab.com/acme/repo/-/blob/main/dir/file%20name.rs"
1233        );
1234        assert_eq!(
1235            blame_url(
1236                Provider::Generic,
1237                "https://code.example.com/acme/repo",
1238                "main",
1239                "dir/file name.rs"
1240            ),
1241            "https://code.example.com/acme/repo/blame/main/dir/file%20name.rs"
1242        );
1243        assert_eq!(
1244            commits_url(Provider::Gitlab, "https://gitlab.com/acme/repo", "main"),
1245            "https://gitlab.com/acme/repo/-/commits/main"
1246        );
1247    }
1248}