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}