A simple git gutter in vim
Mon 07 September 2026 — download

One thing I do like very much about VSCode is the git gutter, the small indicators in the margin showing the added/modified/deleted lines equivalent to a git diff. In vim-land, the go-to solutions are airblade/vim-gitgutter and mhinz/vim-signify, but the amount of code in them is off-putting, owing to the fact that they come with so many features that absolutely I don't care about. The idea is to call git diff on the current file, and place signs accordingly, how hard could it be to write one?

Here are some notes about my implementation, feel free to skip to the end of the blogpost if you're only interested in the final result.

Git-wise, there isn't anything fancy, git diff with -C to specify the working directory, --no-pager and --no-color just in case, and --unified=0 to remove context and thus only have what changed. Should the current file not be in a git repo, the command exists nonzero and v:shell_error allows us to bail cleanly.

Parsing the output isn't hard, as unified-diff hunk headers are looking like @@ -old_start,old_count +new_start,new_count @@ optional context, so a regex does the job. The only thing to be mindful of is that ,old_count and ,new_count are empty when they're equal to 1.

Performance-wise, on a 5k lines file with ⅓ changed, aka ~1666 hunks, a full refresh takes less than 30ms on my machine. It's so fast that I didn't bother making things asynchronous, as the complexity isn't worth it. The data is only refreshed on file read and write, so there shouldn't be any noticeable lag or performance impact. Besides keeping the code simple, there are two performance-related tricks:

  1. Have a dumb if l:line[0] !=# '@' | continue | endif filter before the regex, since they are quite slow
  2. Use sign_placelist() to batch signs placement. Just in case, I took the time to make sign_placelist linear instead of quadratic, even though it shouldn't really make a difference in practice.

Feature-wise, besides adding signs in the gutter, the only thing I added is the ability to jump from changes to changes, via ]c and [c, the same bindings as vimdiff. Nothing ground-breaking implementation-wise: collect all the chunk starts, sort them by line number, take the one before/after the one where the cursor is while handling wraparound. Doing some kind of fancy binary-search is way slower, as doing things in vimscript instead of chaining a handful native functions is exceedingly slow.

Finally, I don't have a lot of screen real-estate, so my vim doesn't show line numbers nor the sign column by default. But some people do, so I took care of keeping the sign column polite, by showing it when the git gutter is enabled, and reverting it to whatever state it was when disabled.

In practice, for a diff like this:

diff --git i/test.md w/test.md
index 17c4ae8..3c38268 100644
--- i/test.md
+++ w/test.md
@@ -1,4 +1,8 @@
-Hello
+Hellooooo
 World
-Hello
 Again
+Banana

It looks like this in vim

gigutter in action

Here is the whole source code:

if exists('g:loaded_gitsigns') | finish | endif
let g:loaded_gitsigns = 1

let s:group = 'gitsigns'
sign define GitSignsAdded   text=+ texthl=Added
sign define GitSignsChanged text=~ texthl=Changed
sign define GitSignsRemoved text=- texthl=Removed

function! s:Clear() abort
  call sign_unplace(s:group, {'buffer': bufnr('%')})
  if exists('b:gitsigns_scl')
    let &l:signcolumn = b:gitsigns_scl
    unlet b:gitsigns_scl
  endif
endfunction

function! gitsigns#Refresh() abort
  let l:buf = bufnr('%')
  call s:Clear()
  let l:out = systemlist(printf('git -C %s --no-pager diff --unified=0 --no-color -- %s',
        \ shellescape(expand('%:p:h')), shellescape(expand('%:p'))))
  if v:shell_error | return | endif
  " In a repo: reserve the column locally so signs don't shift text.
  let b:gitsigns_scl = &l:signcolumn
  setlocal signcolumn=yes
  let l:list = []
  for l:line in l:out
    " Header: @@ -old_start,old_count +new_start,new_count @@ …
    if l:line[0] !=# '@' | continue | endif
    let l:m = matchlist(l:line, '^@@ -\d\+,\?\(\d*\) +\(\d\+\),\?\(\d*\)')
    if empty(l:m) | continue | endif
    let l:start = str2nr(l:m[2])
    let l:new = l:m[3] ==# '' ? 1 : str2nr(l:m[3])
    if l:new == 0
      let [l:new, l:start, l:name] = [1, max([1, l:start]), 'GitSignsRemoved']
    else
      let l:name = l:m[1] ==# '0' ? 'GitSignsAdded' : 'GitSignsChanged'
    endif
    for l:i in range(l:new)
      call add(l:list, {'buffer': l:buf, 'group': s:group, 'name': l:name, 'lnum': l:start + l:i})
    endfor
  endfor
  if !empty(l:list) | call sign_placelist(l:list) | endif
endfunction

function! gitsigns#Toggle() abort
  if get(b:, 'gitsigns_enabled', 1) | call s:Clear() | else | call gitsigns#Refresh() | endif
endfunction

command! GitSignsToggle call gitsigns#Toggle()

augroup gitsigns
  autocmd!
  autocmd BufReadPost,BufWritePost * if get(b:, 'gitsigns_enabled', 1) | call gitsigns#Refresh() | endif
augroup END

" Jump to the next (dir>0) or previous (dir<0) changed chunk, wrapping around.
function! gitsigns#Jump(dir) abort
  " First line of each contiguous run of signed lines.
  let l:starts = []
  let l:prev = -2
  for l:lnum in sort(map(sign_getplaced(bufnr('%'), {'group': s:group})[0].signs,
        \ {_, s -> s.lnum}), 'n')
    if l:lnum > l:prev + 1 | call add(l:starts, l:lnum) | endif
    let l:prev = l:lnum
  endfor
  if empty(l:starts) | return | endif

  let l:cur = line('.')
  if a:dir > 0
    let l:wrap = l:starts[0]
    let l:ahead = filter(l:starts, {_, n -> n > l:cur})
    let l:target = empty(l:ahead) ? l:wrap : l:ahead[0]
  else
    let l:wrap = l:starts[-1]
    let l:behind = filter(l:starts, {_, n -> n < l:cur})
    let l:target = empty(l:behind) ? l:wrap : l:behind[-1]
  endif
  execute 'normal! ' . l:target . 'G'
endfunction

command! GitSignsNext   call gitsigns#Jump(1)
command! GitSignsPrev   call gitsigns#Jump(-1)

if empty(maparg(']c', 'n')) | nnoremap <silent> ]c :call gitsigns#Jump(1)<CR> | endif
if empty(maparg('[c', 'n')) | nnoremap <silent> [c :call gitsigns#Jump(-1)<CR> | endif

Drop it into your ~/.config/nvim/plugin/gitsigns.vim or ~/.vim/plugin/ and it loads on the next start. Signs refresh whenever you open or save a file, :GitSignsToggle flips them per buffer.