1use nils_common::process;
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 && process::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 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 Some(opener) = process::browser_open_command() else {
918 eprintln!("โ Cannot open URL (no open/xdg-open)");
919 return 1;
920 };
921
922 let output = match run_output(opener, &[url]) {
923 Ok(output) => output,
924 Err(err) => {
925 eprintln!("{err}");
926 return 1;
927 }
928 };
929 if !output.status.success() {
930 if process::is_headless_browser_launch_failure(&output.stdout, &output.stderr) {
931 println!("๐ URL: {url}");
932 eprintln!("โ ๏ธ Could not launch a browser in this environment; open the URL manually.");
933 return 0;
934 }
935 emit_output(&output);
936 return exit_code(&output);
937 }
938
939 println!("{label}: {url}");
940 0
941}
942
943fn is_help_token(raw: &str) -> bool {
944 matches!(raw, "-h" | "--help" | "help")
945}
946
947fn print_usage() {
948 println!("Usage:");
949 println!(" git-cli open");
950 println!(" git-cli open repo [remote]");
951 println!(" git-cli open branch [ref]");
952 println!(" git-cli open default-branch [remote]");
953 println!(" git-cli open commit [ref]");
954 println!(" git-cli open compare [base] [head]");
955 println!(" git-cli open pr [number]");
956 println!(" git-cli open pulls [number]");
957 println!(" git-cli open issues [number]");
958 println!(" git-cli open actions [workflow]");
959 println!(" git-cli open releases [tag]");
960 println!(" git-cli open tags [tag]");
961 println!(" git-cli open commits [ref]");
962 println!(" git-cli open file <path> [ref]");
963 println!(" git-cli open blame <path> [ref]");
964 println!();
965 println!("Notes:");
966 println!(" - Uses the upstream remote when available; falls back to origin.");
967 println!(" - Collaboration pages prefer GIT_OPEN_COLLAB_REMOTE when set.");
968 println!(" - `pr` prefers gh when available on GitHub remotes.");
969}
970
971fn run_git_output(args: &[&str]) -> Option<Output> {
972 match run_output("git", args) {
973 Ok(output) => Some(output),
974 Err(err) => {
975 eprintln!("{err}");
976 None
977 }
978 }
979}
980
981fn run_output(cmd: &str, args: &[&str]) -> Result<Output, String> {
982 process::run_output(cmd, args)
983 .map(|output| output.into_std_output())
984 .map_err(|err| format!("spawn {cmd}: {err}"))
985}
986
987fn git_stdout_trimmed(args: &[&str]) -> Result<String, i32> {
988 let output = run_git_output(args).ok_or(1)?;
989 if !output.status.success() {
990 emit_output(&output);
991 return Err(exit_code(&output));
992 }
993 Ok(trim_trailing_newlines(&String::from_utf8_lossy(&output.stdout)).to_string())
994}
995
996fn git_stdout_trimmed_optional(args: &[&str]) -> Option<String> {
997 let output = run_git_output(args)?;
998 if !output.status.success() {
999 return None;
1000 }
1001 let value = trim_trailing_newlines(&String::from_utf8_lossy(&output.stdout)).to_string();
1002 if value.is_empty() { None } else { Some(value) }
1003}
1004
1005fn git_status_success(args: &[&str]) -> bool {
1006 run_git_output(args)
1007 .map(|output| output.status.success())
1008 .unwrap_or(false)
1009}
1010
1011fn trim_trailing_newlines(input: &str) -> &str {
1012 input.trim_end_matches(['\n', '\r'])
1013}
1014
1015fn exit_code(output: &Output) -> i32 {
1016 output.status.code().unwrap_or(1)
1017}
1018
1019fn emit_output(output: &Output) {
1020 let _ = io::stdout().write_all(&output.stdout);
1021 let _ = io::stderr().write_all(&output.stderr);
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use super::*;
1027 use pretty_assertions::assert_eq;
1028
1029 fn args(values: &[&str]) -> Vec<String> {
1030 values.iter().map(|value| (*value).to_string()).collect()
1031 }
1032
1033 #[test]
1034 fn normalize_remote_url_supports_common_git_forms() {
1035 assert_eq!(
1036 normalize_remote_url_from_raw("git@github.com:acme/repo.git"),
1037 Some("https://github.com/acme/repo".to_string())
1038 );
1039 assert_eq!(
1040 normalize_remote_url_from_raw("ssh://git@gitlab.com/group/repo.git"),
1041 Some("https://gitlab.com/group/repo".to_string())
1042 );
1043 assert_eq!(
1044 normalize_remote_url_from_raw("https://github.com/acme/repo.git/"),
1045 Some("https://github.com/acme/repo".to_string())
1046 );
1047 }
1048
1049 #[test]
1050 fn normalize_remote_url_rejects_non_http_like_sources() {
1051 assert_eq!(normalize_remote_url_from_raw("../relative/path"), None);
1052 assert_eq!(normalize_remote_url_from_raw("/tmp/repo.git"), None);
1053 assert_eq!(normalize_remote_url_from_raw(""), None);
1054 }
1055
1056 #[test]
1057 fn provider_detection_matches_hosts() {
1058 assert_eq!(
1059 Provider::from_base_url("https://github.com/acme/repo"),
1060 Provider::Github
1061 );
1062 assert_eq!(
1063 Provider::from_base_url("https://gitlab.com/acme/repo"),
1064 Provider::Gitlab
1065 );
1066 assert_eq!(
1067 Provider::from_base_url("https://gitlab.internal/acme/repo"),
1068 Provider::Gitlab
1069 );
1070 assert_eq!(
1071 Provider::from_base_url("https://code.example.com/acme/repo"),
1072 Provider::Generic
1073 );
1074 }
1075
1076 #[test]
1077 fn github_slug_parses_owner_repo() {
1078 assert_eq!(
1079 github_repo_slug("https://github.com/acme/repo"),
1080 Some("acme/repo".to_string())
1081 );
1082 assert_eq!(
1083 github_repo_slug("https://github.com/acme/repo/sub/path"),
1084 Some("acme/repo".to_string())
1085 );
1086 assert_eq!(github_repo_slug("https://github.com/acme"), None);
1087 }
1088
1089 #[test]
1090 fn percent_encode_supports_paths_and_queries() {
1091 assert_eq!(
1092 percent_encode("docs/read me.md", true),
1093 "docs/read%20me.md".to_string()
1094 );
1095 assert_eq!(
1096 percent_encode("workflow:CI Build", false),
1097 "workflow%3ACI%20Build".to_string()
1098 );
1099 }
1100
1101 #[test]
1102 fn url_builders_follow_provider_conventions() {
1103 assert_eq!(
1104 tree_url(Provider::Github, "https://github.com/acme/repo", "main"),
1105 "https://github.com/acme/repo/tree/main"
1106 );
1107 assert_eq!(
1108 tree_url(Provider::Gitlab, "https://gitlab.com/acme/repo", "main"),
1109 "https://gitlab.com/acme/repo/-/tree/main"
1110 );
1111 assert_eq!(
1112 release_tag_url(Provider::Github, "https://github.com/acme/repo", "v1.2.3"),
1113 "https://github.com/acme/repo/releases/tag/v1.2.3"
1114 );
1115 assert_eq!(
1116 release_tag_url(Provider::Gitlab, "https://gitlab.com/acme/repo", "v1.2.3"),
1117 "https://gitlab.com/acme/repo/-/releases/v1.2.3"
1118 );
1119 }
1120
1121 #[test]
1122 fn parse_positive_number_accepts_hash_prefix() {
1123 assert_eq!(parse_positive_number("#123", "PR"), Ok("123".to_string()));
1124 assert_eq!(parse_positive_number("42", "issue"), Ok("42".to_string()));
1125 assert_eq!(parse_positive_number("abc", "PR"), Err(2));
1126 }
1127
1128 #[test]
1129 fn open_commands_reject_too_many_args_before_git_lookups() {
1130 assert_eq!(open_repo(&args(&["origin", "extra"])), 2);
1131 assert_eq!(open_branch(&args(&["main", "extra"])), 2);
1132 assert_eq!(open_default_branch(&args(&["origin", "extra"])), 2);
1133 assert_eq!(open_commit(&args(&["HEAD", "extra"])), 2);
1134 assert_eq!(open_compare(&args(&["base", "head", "extra"])), 2);
1135 assert_eq!(open_pr(&args(&["1", "extra"])), 2);
1136 assert_eq!(open_pulls(&args(&["1", "extra"])), 2);
1137 assert_eq!(open_issues(&args(&["1", "extra"])), 2);
1138 assert_eq!(open_actions(&args(&["ci.yml", "extra"])), 2);
1139 assert_eq!(open_releases(&args(&["v1.0.0", "extra"])), 2);
1140 assert_eq!(open_tags(&args(&["v1.0.0", "extra"])), 2);
1141 assert_eq!(open_commits(&args(&["main", "extra"])), 2);
1142 assert_eq!(open_file(&args(&["src/lib.rs", "main", "extra"])), 2);
1143 assert_eq!(open_blame(&args(&["src/lib.rs", "main", "extra"])), 2);
1144 }
1145
1146 #[test]
1147 fn open_commands_help_paths_return_zero_without_repo() {
1148 assert_eq!(open_repo(&args(&["--help"])), 0);
1149 assert_eq!(open_branch(&args(&["-h"])), 0);
1150 assert_eq!(open_default_branch(&args(&["help"])), 0);
1151 assert_eq!(open_commit(&args(&["--help"])), 0);
1152 assert_eq!(open_pr(&args(&["help"])), 0);
1153 assert_eq!(open_actions(&args(&["--help"])), 0);
1154 assert_eq!(open_releases(&args(&["--help"])), 0);
1155 assert_eq!(open_tags(&args(&["--help"])), 0);
1156 assert_eq!(open_commits(&args(&["--help"])), 0);
1157 }
1158
1159 #[test]
1160 fn open_file_and_blame_require_path_argument() {
1161 assert_eq!(open_file(&args(&[])), 2);
1162 assert_eq!(open_blame(&args(&[])), 2);
1163 }
1164
1165 #[test]
1166 fn normalize_remote_url_handles_additional_variants() {
1167 assert_eq!(
1168 normalize_remote_url_from_raw("https://token@github.com/acme/repo.git"),
1169 Some("https://github.com/acme/repo".to_string())
1170 );
1171 assert_eq!(
1172 normalize_remote_url_from_raw("ssh://git@gitlab.com"),
1173 Some("https://gitlab.com".to_string())
1174 );
1175 assert_eq!(
1176 normalize_remote_url_from_raw("git://gitlab.com/group/repo.git"),
1177 Some("https://gitlab.com/group/repo".to_string())
1178 );
1179 assert_eq!(
1180 normalize_remote_url_from_raw("git@github.com:acme/repo"),
1181 Some("https://github.com/acme/repo".to_string())
1182 );
1183 assert_eq!(
1184 normalize_remote_url_from_raw("user@gitlab.com/acme/repo.git"),
1185 Some("https://gitlab.com/acme/repo".to_string())
1186 );
1187 assert_eq!(normalize_remote_url_from_raw("ssh://git@/acme/repo"), None);
1188 assert_eq!(normalize_remote_url_from_raw("file:///tmp/repo.git"), None);
1189 }
1190
1191 #[test]
1192 fn helper_functions_cover_url_and_path_variants() {
1193 assert_eq!(normalize_repo_path("./docs/read me.md"), "docs/read me.md");
1194 assert_eq!(normalize_repo_path("/src/lib.rs"), "src/lib.rs");
1195 assert!(is_yaml_workflow("ci.yml"));
1196 assert!(is_yaml_workflow("ci.yaml"));
1197 assert!(!is_yaml_workflow("ci.json"));
1198 assert_eq!(strip_userinfo("git@github.com"), "github.com");
1199 assert_eq!(
1200 host_from_url("HTTPS://User@Example.COM/repo"),
1201 "user@example.com"
1202 );
1203 assert_eq!(trim_trailing_newlines("line\r\n"), "line");
1204 assert!(is_help_token("help"));
1205 assert!(!is_help_token("--version"));
1206 assert_eq!(
1207 github_repo_slug("github.com/acme/repo"),
1208 Some("acme/repo".to_string())
1209 );
1210 assert_eq!(github_repo_slug("https://github.com"), None);
1211 }
1212
1213 #[test]
1214 fn remaining_url_builders_cover_gitlab_and_generic_paths() {
1215 assert_eq!(
1216 commit_url(
1217 Provider::Generic,
1218 "https://code.example.com/acme/repo",
1219 "abc123"
1220 ),
1221 "https://code.example.com/acme/repo/commit/abc123"
1222 );
1223 assert_eq!(
1224 compare_url(
1225 Provider::Gitlab,
1226 "https://gitlab.com/acme/repo",
1227 "main",
1228 "feature"
1229 ),
1230 "https://gitlab.com/acme/repo/-/compare/main...feature"
1231 );
1232 assert_eq!(
1233 blob_url(
1234 Provider::Gitlab,
1235 "https://gitlab.com/acme/repo",
1236 "main",
1237 "dir/file name.rs"
1238 ),
1239 "https://gitlab.com/acme/repo/-/blob/main/dir/file%20name.rs"
1240 );
1241 assert_eq!(
1242 blame_url(
1243 Provider::Generic,
1244 "https://code.example.com/acme/repo",
1245 "main",
1246 "dir/file name.rs"
1247 ),
1248 "https://code.example.com/acme/repo/blame/main/dir/file%20name.rs"
1249 );
1250 assert_eq!(
1251 commits_url(Provider::Gitlab, "https://gitlab.com/acme/repo", "main"),
1252 "https://gitlab.com/acme/repo/-/commits/main"
1253 );
1254 }
1255}