git-wip C++ rewrite — over a decade later
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:
- Usability fixes — handle whitespace in filenames, better error messages, finally implement the status and delete commands.
- Performance — the bash script forked a dozen processes per invocation; the new version should be fast enough that you don’t notice it on every
:w - Editor support — the old vim plugin was fragile; the new one should be clean Lua for Neovim
- Testability — the original had very few tests; I wanted proper unit tests and integration tests
- Address GitHub issues — there were about a dozen open issues; many were now fixable with a proper implementation
What I built #
The new git-wip is written in C++23 using libgit2 for all Git operations. I chose C++ because:
- it was modern, familiar, and performant
- libgit2 is the canonical Git library
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:
git wip status -l -f— list WIP commits and show per-commit diff statsgit wip status <ref>— check WIP status for any branchgit wip list -v— list all WIP refs with commit counts and orphaned refsgit wip delete— delete WIP refs, including--cleanupfor orphaned refs
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:
- Runtime API compatibility — the plugin detects Neovim version at runtime and uses
vim.system()(Neovim 0.10+) orvim.fn.system()(older versions) - Configuration options —
gpg_sign,untracked,ignored,filetypes :Wipand:WipAllcommands — manual invocation without needing to save- Quiet mode — uses
--editorflag to silently succeed when there are no changes
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:
- Debian stable and Ubuntu 24.04 LTS
- GCC and Clang compilers
- Release and Debug builds
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