Peter Benjamin (they/them)
Posted on August 25, 2021
Table of Contents
Update
I have contributed this solution to git-extras
.
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:
" 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).
One feature I appreciated in 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:
# 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:
" 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:
#!/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:
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@*
tohttps://*
. Also, remove the trailing.git
in the HTTP URL:
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):
# 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:
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
$ git browse
$ git browse README.md
$ git browse README.md 1
$ git browse README.md 1 5
And that is half the battle!
TLDR
#!/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:
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 promptingHit 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:
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
and/or in my custom git subcommands.
For more interesting git subcommands, checkout git-extras.
As always, happy hacking! 🤘🏽
Posted on August 25, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.