bartman's blog

git-wip C++ rewrite — over a decade later

bartman
Table of Contents

Many many years ago I wrote about git-wip, a tool I created to automatically snapshot your working tree on every file save. The original was a bash script, written over a few weekends in 2009. It served me well for over a decade, but it had accumulated technical debt: the bash script had issues with whitespace in filenames, it used external tools like git-sh-setup, it was getting harder to extend, slow on large repos, and so on.

A decade and a half of GitHub issues had piled up. Some were feature requests. Others were bugs. Many were just things that didn’t work on platforms that weren’t my own. I had ignored most of them — the script was good enough for me, and I didn’t really have to pull to work on it in my free time. Sorry.

That changed recently.

What is this git-wip again? #

WIP stands for “Work In Progress”. git-wip is a program that saves all your changes not on a commit (and optionally untracked/ignored files) into wip/* branches. The wip/* branch contains all the changes made in your editor since the last commit. They are meant as means to recover from unwanted changes down the line. If you commit every 5 minutes, this tool is not for you. If you work for hours between commits, this could be quite useful as a means to recover things that have since been deleted.

TL;DR install:

1$ make
2$ make install        # installs git-wip to ~/.local by default

TL;DR manual usage:

1$ edit edit edit
2$ git wip
3$ edit edit edit
4$ git wip
5$ git wip status
6branch master has 20 wip commits on refs/wip/master

But the intended usecase is as a neovim/vim plugin. Every time you :w it will call out to git-wip and it capture your save on a hidden wip/* branch. And you can inspect/recover any change using git tools. You can even push them to another system (or services like gitlab or github).

Why rewrite? #

The spark came when I tried to add a new feature and spent more time fighting the bash script than actually implementing anything. I had some long flights coming up, and I thought: what if I just rewrote this properly?

My goals were:

What I built #

The new git-wip is written in C++23 using libgit2 for all Git operations. I chose C++ because:

The only runtime dependency is libgit2. All other dependencies (like spdlog for logging) are fetched automatically by CMake’s FetchContent.

The codebase is organized as:

 1src/
 2  main.cpp              # argument dispatch
 3  command.hpp/cpp       # base Command class
 4  git_guards.hpp        # RAII wrappers for libgit2 handles
 5  cmd_save.hpp/cpp      # save command
 6  cmd_log.hpp/cpp       # log command
 7  cmd_status.hpp/cpp    # status command
 8  cmd_delete.hpp/cpp    # delete command
 9
10lua/git-wip/init.lua    # Neovim plugin (Lua)
11
12test/
13  unit/                 # C++ unit tests with Google Test
14  cli/                  # Shell-based CLI integration tests
15  nvim/                 # Neovim plugin tests

New commands #

I took the opportunity to add commands that should have been there from the start:

Neovim plugin #

The old vim plugin was a one-liner that called the bash script via system() and could take seconds to complete. The new Neovim plugin is written in Lua. Nicer to work with, takes configuration options in a sane way, integrates with Lazy package manager, and much faster (probably mostly because git-wip uses libgit2 under the hood).

Key features:

1require("git-wip").setup({
2    gpg_sign = false,
3    untracked = true,
4    ignored = false,
5    filetypes = { "lua", "c", "cpp" },
6})

Testing #

The old script had no tests. The new implementation has three test suites:

Unit tests (C++) #

Google Test-based unit tests for the helper functions and Git operations. Includes a fixture that creates a private Git repository to test against real Git data.

1$ make test
2...
3 3/15 Test  #3: unit/test_wip_helpers ............   Passed    0.05 sec

CLI integration tests (shell) #

The legacy test cases from the original bash script, plus new ones. These test the full binary end-to-end by creating real git repos and checking the output.

1 4/15 Test  #4: cli/test_legacy ..................   Passed    0.11 sec
2 5/15 Test  #5: cli/test_spaces ..................   Passed    0.03 sec
3 ...

Neovim plugin tests (shell) #

Tests that run the Lua plugin through nvim --headless. These verify that the autocmd fires, the commands work, and the plugin handles edge cases like buffers without filenames.

113/15 Test #13: nvim/test_nvim_single ............   Passed    1.68 sec
214/15 Test #14: nvim/test_nvim_buffers ...........   Passed    3.76 sec
315/15 Test #15: nvim/test_nvim_windows ...........   Passed    3.23 sec

All tests run in GitHub Actions on a matrix of:

That’s 16 build configurations, all tested on every push. The test artifacts (on failure) are uploaded for 7 days so I can reproduce any CI failure locally.

Performance #

I haven’t done formal benchmarks, but the difference is immediately noticeable. The bash script spawned 12-15 processes per invocation (git rev-parse, git symbolic-ref, git add, git write-tree, git commit-tree, git update-ref, etc.). The C++ version makes the same Git calls through libgit2 in-process.

File saves feel instant. The notification appears as quickly as git itself would run those operations — which is to say, fast.

Building #

On Debian / Ubuntu #

1$ git clone https://github.com/bartman/git-wip.git
2$ cd git-wip
3$ ./dependencies.sh              # install build dependencies
4$ make                           # build → build/src/git-wip
5$ make test                      # run all tests
6$ make install                   # install to ~/.local/bin

On NixOS #

1$ git clone https://github.com/bartman/git-wip.git
2$ cd git-wip
3$ nix develop                   # drop into shell with all deps
4$ make                          # build → build/src/git-wip
5$ make test                     # run all tests
6$ make install                  # install to ~/.local/bin

The flake provides a dev shell with CMake, Ninja, GCC, Clang, libgit2, and Google Test.

What’s next #

The C++ code is now merged to master. Tag v0.2 points to the last bash-based version. I’ll give it a few weeks and release a v0.3.

If you’re already a git-wip user, the new version is a drop-in replacement – you do have to build the executable. If you’ve been waiting for one of those GitHub issues to be fixed — they probably are now.

You can try it.


Related: Original git-wip blog post | wip.rs — a Rust watcher that invokes git-wip automatically

Tags:
Categories: