Title: A simple git gutter in vim
Date: 2026-09-07 17:15

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`](https://github.com/airblade/vim-gitgutter)
and [`mhinz/vim-signify`](https://github.com/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](https://github.com/vim/vim/pull/21237) 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
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]({static}/images/gutter.png)

Here is the whole source code:

```vim
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.

