Open Local Files and Line Numbers in GitHub and GitLab From Shell or Vim

pbnj

Peter Benjamin (they/them)

Posted on August 25, 2021

Open Local Files and Line Numbers in GitHub and GitLab From Shell or Vim

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>,
      \ })

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode
  • 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}")"
Enter fullscreen mode Exit fullscreen mode
  • 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:
   if [[ $giturl = git@* ]]; then
     giturl=$(echo $giturl | sed -E -e 's/:/\//' -e 's/\.git$//' -e 's/.*@(.*)/http:\/\/\1/')
   fi

   url="${giturl%.git}"
Enter fullscreen mode Exit fullscreen mode
  • 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'
Enter fullscreen mode Exit fullscreen mode
  • Lastly, open the crafted URL:
   hash open 2>/dev/null && open "${url}"
   hash xdg-open 2>/dev/null && xdg-open "${url}"
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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:

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! 🤘🏽

💖 💪 🙅 🚩
pbnj
Peter Benjamin (they/them)

Posted on August 25, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related