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:
- Have a dumb
if l:line[0] !=# '@' | continue | endiffilter before the regex, since they are quite slow - Use
sign_placelist()to batch signs placement. Just in case, I took the time to makesign_placelistlinear 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

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.