Skip to main content

git_cli/
open.rs

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