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}