Open Local Files and Line Numbers in GitHub and GitLab From Shell or Vim
Tags: git, vim, bash, terminal
Published: 2021-08-25 08:17:20

---
title: Open Local Files and Line Numbers in GitHub and GitLab From Shell or Vim
published: true
description: Minimal, portable implementation of vim-fugitive's ":Gbrowse"
tags: git, vim, bash, terminal
//cover_image: https://direct_url_to_image.jpg
---

## Table of Contents

- [Update](#update)
- [Problem](#problem)
- [Solution](#solution)
  - [Git Subcommand](#git-subcommand)
  - [Vim Command](#vim-command)
- [Conclusion](#conclusion)

---

## Update

I have contributed this solution to [`git-extras`](https://github.com/tj/git-extras/pull/981). 

Now you can enjoy this feature without having to implement & maintain a custom script.

To integrate this new feature with Vim, add the following snippet to your vimrc:

```viml
" GitBrowse takes a dictionary and opens files in the browser at the remote URL.
function! GitBrowse(args) abort
  if a:args.filename ==# ''
    return
  endif
  let l:remote = trim(system('git config branch.'.a:args.branch.'.remote || echo "origin" '))
  if a:args.range == 0
    let l:cmd = 'git browse ' . l:remote . ' ' . a:args.filename
  else
    let l:cmd = 'git browse ' . l:remote . ' ' . a:args.filename . ' ' . a:args.line1 . ' ' . a:args.line2
  endif
  execute 'silent ! ' . l:cmd | redraw!
endfunction

command! -range GB call GitBrowse({
      \ 'branch': trim(system('git rev-parse --abbrev-ref HEAD 2>/dev/null')),
      \ 'filename': trim(system('git ls-files --full-name ' . expand('%'))),
      \ 'range': <range>,
      \ 'line1': <line1>,
      \ 'line2': <line2>,
      \ })

```

---

## Problem

I frequently need to share links and URLs to files in GitHub/GitLab repositories with colleagues.

Traditionally, I did this manually by launching a browser, navigating to the repository and file, then selecting the line numbers in question, and finally copying the permalink or the link of the current branch.

Because this process takes several minutes, it was enough to cause significant context switching and breaks me out of the flow, causing me to lose my train of thought (relevant [comic](https://heeris.id.au/2013/this-is-why-you-shouldnt-interrupt-a-programmer/)).

One feature I appreciated in [vim-fugitive](https://github.com/tpope/vim-fugitive) was the `:Gbrowse` command on a buffer or a range (i.e. `:Gbrowse` on a visual selection) that would open the current file and line number directly on GitHub.

I wanted to implement this feature in my minimal vim setup, but I also wanted to be able to use this in a shell without vim.

In this blog post, I will walk you through the process of implementing this solution one building block at a time.

## Solution

I had an idea of the experience I wanted.

I wanted to be able to call `git` commands from the shell, like:

```sh
# open local repo in GitHub (GH) / GitLab (GL)
$ git browse

# open a file from local repo in GH/GL
# $ git browse <filename>
$ git browse README.md

# open a file & line number from local repo in GH/GL
# $ git browse <filename> <line_number>
$ git browse README.md 1

# open a file & select range of lines from local repo in GH/GL
# $ git browse <filename> <line_from> <line_to>
$ git browse README.md 1 5
```

And I wanted to be able to call the same functionality within vim, like:

```viml
" open current file & line under cursor in GH/GL 
:Gbrowse

" open line range from vim in GH/GL
:1,5Gbrowse
```

With that rough specification in mind, let's dive into the implementation.

### Git Subcommand

If you didn't know, `git` allows you to define your own custom subcommands as long as the file name starts with `git-` prefix and the file is located in your `$PATH`.

With this information in mind, we can start by creating a file called `git-browse` located in `${HOME}/bin` (assuming `${HOME}/bin` is in your `$PATH`)

`git-browse` file will be a straight forward shell script:

- Define shebang and expected inputs:

   ```sh
   #!/usr/bin/env bash

   filename="${1}"
   line1="${2}"
   line2="${3}"
   ```

- Get some additional data we will need, like branch name,  remote name, and remote-url:

   ```sh
   branch=$(git rev-parse --abbrev-ref HEAD 2> /dev/null)
   remote=$(git config branch."${branch}".remote || echo "origin")
   giturl="$(git remote get-url "${remote}")"
   ```

- I always use HTTPS for git URLs, but if you use SSH, then you would want to convert `git@*` to `https://*`. Also, remove the trailing `.git` in the HTTP URL:

   ```sh
   if [[ $giturl = git@* ]]; then
     giturl=$(echo $giturl | sed -E -e 's/:/\//' -e 's/\.git$//' -e 's/.*@(.*)/http:\/\/\1/')
   fi
   
   url="${giturl%.git}"
   ```

- Now we craft the URL with filenames and line numbers based on the domain (GH & GL have slight nuances):

   ```sh
   # GitLab Example: https://gitlab.example.com/group/repo/-/blob/commit_or_branch/path/to/filename#L1-2
   if [[ "${giturl}" =~ gitlab ]]; then
     if [[ -n "${filename}" ]]; then
       commit_hash="$(git log -n1 --format=format:"%H" "${filename}" 2>/dev/null)"
       # append '/-/blob' + commit hash or branch name + '/<filename>'
       url="${url}/-/blob/${commit_hash:-${branch}}/${filename}"
       if [[ -n "${line1}" ]]; then
         # append '#L<LINE1>'
         url="${url}#L${line1}"
	 if [[ -n "${line2}" ]]; then
           # append '-<LINE2>'
	   url="${url}-${line2}"
	 fi
       fi
     fi
   fi
   
   # GitHub Example: https://github.example.com/org/repo/blob/commit_or_branch/path/to/filename#L1-L2
   # Mostly the same as above, except:
   #   - no '/-/'
   #   - '#L1-L2' instead of '#L1-2'
   ```

- Lastly, open the crafted URL:

   ```sh
   hash open 2>/dev/null && open "${url}"
   hash xdg-open 2>/dev/null && xdg-open "${url}"
   ```

- Don't forget to `chmod +x ${HOME}/bin/git-browse`

Now, these commands should work

```sh
$ git browse
$ git browse README.md
$ git browse README.md 1
$ git browse README.md 1 5
```

And that is half the battle!

#### TLDR

```sh
#!/usr/bin/env bash

filename="${1}"
line1="${2}"
line2="${3}"

branch=$(git rev-parse --abbrev-ref HEAD 2> /dev/null)
remote=$(git config branch."${branch}".remote || echo "origin")
giturl="$(git remote get-url "${remote}")"

# base url
url="${giturl%.git}"

# craft gitlab URLs
if [[ "${giturl}" =~ gitlab ]]; then
	if [[ -n "${filename}" ]]; then
		commit_hash="$(git log -n1 --format=format:"%H" "${filename}" 2>/dev/null)"
		url="${url}/-/blob/${commit_hash:-${branch}}/${filename}"
		if [[ -n "${line1}" ]]; then
			url="${url}#L${line1}"
			if [[ -n "${line2}" ]]; then
				url="${url}-${line2}"
			fi
		fi
	fi

# craft github URLs
elif [[ "${giturl}" =~ github ]]; then
	if [[ -n "${filename}" ]]; then
		commit_hash="$(git log -n1 --format=format:"%H" "${filename}" 2>/dev/null)"
		url="${giturl%.*}/blob/${commit_hash:-${branch}}/${filename}"
		if [[ -n "${line1}" ]]; then
			url="${url}#L${line1}"
			if [[ -n "${line2}" ]]; then
				url="${url}-L${line2}"
			fi
		fi
	fi
fi

hash open 2>/dev/null && open "${url}"
hash xdg-open 2>/dev/null && xdg-open "${url}"
```

### Vim Command

I never imaged I would be saying this, but this is the **easy** part!

All we need is a Vim command that accepts a range and executes the correct shell command that we implemented above, like:

```viml
command! -range Gbrowse execute 'silent ! git browse ' . expand('%') . ' ' . <line1> . ' ' . <line2> | checktime | redraw!
```

Breakdown:

- `command! -range` allows us to pass a line range, like `:1,5Gbrowse`, or visual selection. See `:help :command-range` for more info.
- `Gbrowse` is the name of the command.
- `execute '...'` is going to execute whatever string we pass. See `:help :execute` for more info.
- `silent` prevents vim from prompting `Hit ENTER to continue`. See `:help :silent` for more info.
- `!git browse` is calling the external git subcommand we defined above.
- `. expand('%')` concatenates the current buffer path & name to the previous string. See `:help expand()` for more info.
- `' ' . <line1> . ' ' . <line2>` concatenates start of range and end of range to the previous string. For example, `:1,5Gbrowse` translates to `!git browse <filename> 1 5`. See `:help <line1>` and `:help <line2>` for more info.
- `| checktime | redraw!` refreshes the screen and redraws the buffer, otherwise after silently executing an external command, you may see a blank screen in vim.

## Demo

For a demonstration of this in action:

{% youtube MyfYEAG2VNs %}

## Conclusion

What I absolutely love about this is the demonstration of the Unix Philosphy in action. This post illustrates how disparate tools can be composed and used together to create powerful and ergonomic workflows with very little effort.

I hope you enjoyed this post and found some of it useful for your workflows and needs.

If you liked this guide, you may find more useful/interesting things in my [`vimrc`](https://github.com/pbnj/dotfiles/blob/main/vim/.vim/vimrc) and/or in my [custom git subcommands](https://github.com/pbnj/dotfiles/tree/main/git/bin).

For more interesting git subcommands, checkout [git-extras](https://github.com/tj/git-extras).

As always, happy hacking! 🤘🏽