- Use c++23 best practices. Use CamelCase for classes, use snake_case for method names, variable names, etc. Use #pragma once in headers. Use m_ prefix for member variables.
- use manual arg parsing, use spdlog for debug logging (set
WIP_DEBUG=1to see debug), use libgit2 for git functionality - build with
make BUILD=build-agent, test withmake BUILD=build-agent test— useBUILD=build-agentto isolate agent builds from user's defaultbuild/directory - manage/install dependencies with
dependencies.shscript - unit tests go into
test/unit/test_*.cpp - CLI integration tests go into
test/cli/test_*.sh— sourcetest/cli/lib.sh, must be executable - old scripts are in
Attic/subdirectory, we try to be backward compatible (at least for vim/ emacs/ sublime/ plugins) - agent will update
AGENTS.mdandREADME.mdfiles with new information, as needed lua/git-wip/init.luais the plugin for Neovim written in Luavim/plugin/git-wip.vimis the legacy plugin for Vim written in VimL -- maintained, but not actively improved
The Neovim Lua plugin supports the following configuration options (set via opts in lazy.nvim or passed to setup()):
git_wip_path: Path to the git-wip binary (default: "git-wip")gpg_sign: nil (default), true (--gpg-sign), false (--no-gpg-sign)untracked: nil (default), true (--untracked), false (--no-untracked)ignored: nil (default), true (--ignored), false (--no-ignored)background: false (default, sync execution), true (async if Neovim 0.10+, else sync with warning)filetypes: Array of filetypes to enable (default: { "*" } for all)
Async execution uses Neovim's vim.system with on_exit callback for non-blocking saves.
The git-wip --version string is generated at build time and embedded in the
binary as the C string macro GIT_WIP_VERSION (defined in
build/git_wip_version.h, consumed in src/main.cpp).
{VERSION}-{YYYYMMDD}-g{HASH}[-dirty]
VERSION— contents of the committedVERSIONfile at the top of the source tree (currentlyv0.3). This is the canonical, human-managed version and is bumped bytag-new-release.sh.YYYYMMDD— committer date ofHEAD(formake) orself.lastModifiedDatetruncated to a date (fornix build).HASH— short hash ofHEAD/self.shortRev.-dirty— appended when the working tree has uncommitted changes (makepath) or when the flake source is dirty (Nix path:self.revabsent).
Special fallback for make builds with no .git directory available:
{VERSION}-unknown
The format intentionally does not match git describe's
vX.Y-<commits-since-tag>-g<hash> shape, because git describe cannot run
inside the Nix sandbox (.git is stripped from the source tree). The
date+hash form is monotonic, encodes the commit, and works identically under
all three build paths.
| File | Role |
|---|---|
VERSION |
Source of truth. One line, e.g. v0.3. Committed. |
tag-new-release.sh |
Bumps VERSION, commits, creates annotated tag. |
cmake/GitVersion.sh |
Generates git_wip_version.h for make builds. Reads VERSION, queries git. |
cmake/GitVersion.cmake |
CMake wrapper around GitVersion.sh. Adds custom command + target gitversion. |
src/CMakeLists.txt |
Wires gitversion into the git-wip executable. Honours USE_GIT_WIP_VERSION_H=<path> to use a pre-generated header (Nix path). |
flake.nix |
Computes the version string from lib.fileContents ./VERSION, self.lastModifiedDate, and self.shortRev / self.dirtyShortRev. Passes it to nix/package.nix. |
nix/package.nix |
In preConfigure, writes build/git_wip_version.h from the injected version argument and points CMake at it via -DUSE_GIT_WIP_VERSION_H=.... |
| Build invocation | Where version comes from | Example output |
|---|---|---|
make (Debian / NixOS dev shell) |
cmake/GitVersion.sh runs git log -1 --format=%cd + git rev-parse --short HEAD |
v0.3-20260518-g93b99ef |
make with no .git (extracted tarball) |
cmake/GitVersion.sh fallback |
v0.3-unknown |
nix build (flake) |
flake.nix computes from self.*, passed via package.nix preConfigure |
v0.3-20260518-g93b99ef |
All three paths produce byte-identical output for the same clean commit.
./tag-new-release.sh v0.4
The script:
- Refuses to run on a dirty tree.
- Validates
v0.4is strictly greater (persort -V) than both the currentVERSIONfile contents and the most recent git tag. - Writes
v0.4toVERSION. - Commits with message
Release v0.4. - Creates annotated tag
git tag -a -m v0.4 v0.4.
After running the script, push commit + tag with git push --follow-tags.
- No use of
git describe. The previous scheme used it; the rewrite avoids it because (a) it cannot run in the Nix sandbox, and (b) it is unstable when a tarball is extracted without.git. self.revCountis intentionally unused. The GitHub flake fetcher (github:URL) does not populate it; the date+hash scheme works under bothgithub:andgit+https://fetchers without conditional logic.- Dirty detection in Nix relies on
self ? rev. A dirty flake source setsdirtyRev/dirtyShortRevbut notrev, so the checkif self ? rev then "" else "-dirty"is correct. USE_GIT_WIP_VERSION_His a CMake variable, not an env var. The Nix derivation appends-DUSE_GIT_WIP_VERSION_H=$PWD/build/git_wip_version.htocmakeFlagsArrayinpreConfigure. When set and the file exists,src/CMakeLists.txtskips theGitVersion.shinvocation entirely.
.github/workflows/ci.yml defines three jobs:
Distro × compiler × build-type matrix. Runs in distro-native containers on
the ubuntu-latest runner:
| Distro | Compilers | Build types | Static |
|---|---|---|---|
debian:stable |
gcc, clang | Release, Debug | yes |
ubuntu:24.04 |
gcc, clang | Release, Debug | yes |
fedora:latest |
gcc, clang | Release, Debug | no |
archlinux:latest |
gcc, clang | Release, Debug | no |
For each cell: dependencies.sh installs deps, make BUILD=build-so builds
dynamic, make BUILD=build-so test runs tests; then (only on distros where
libgit2.a is available) it repeats with STATIC=1 BUILD=build-a. On
failure, test artifacts from build-so/ and build-a/ are uploaded.
Runs on stock ubuntu-latest (no container). Installs Nix via
cachix/install-nix-action@v31 with nix-command + flakes enabled.
Compiler and dependency versions are pinned by the flake, so a matrix is
unnecessary. Steps:
- Dev shell make build —
nix develop --command bash -c 'make && make test'. Exercises thecmake/GitVersion.shpath with the flake's pinned toolchain. - Flake build —
nix build --print-build-logs. Exercises theflake.nix→package.nix→preConfigureversion-injection path. - Version sanity check — runs
./result/bin/git-wip --versionand asserts the output starts with<contents of VERSION>-. Catches regressions in either the flake's version computation orpackage.nix's header generation.
This job is the canonical guard against the three known regressions in the
Nix path: (a) git describe creeping back in, (b) .git being needed inside
the sandbox, (c) VERSION not being read.
Single job on debian:stable / gcc / Debug. Runs make TYPE=Debug coverage
and uploads the resulting coverage.info to Codecov.
GitHub Actions' container: mechanism requires a node-capable image to host
the runner agent and the JavaScript-based actions (actions/checkout,
actions/cache, actions/upload-artifact). The official nixos/nix image
is intentionally minimal and lacks node and an FHS layout, breaking those
actions. The supported pattern — used by NixOS/nix itself — is to run on
stock ubuntu-latest and install Nix via cachix/install-nix-action.
Functionally this is equivalent to a NixOS container for our purposes
because the flake's mkShell and mkDerivation pin their toolchain
independently of the host distro.
Shared bash harness sourced by every CLI test script. Provides:
RUN <cmd>— run a command, fail the test if it exits non-zero_RUN <cmd>— run a command without checking exit code (use beforeEXP_text)EXP_none— assert that the last command produced no outputEXP_text <string>— assert that the first line of output equals<string>EXP_grep [opts] <pattern>— assert that output matches (or with-v, does not match) a grep patterncreate_test_repo— init a fresh git repo in$REPO, checkout branchmasterhandle_error— print diagnostics and exit 1
Required env vars (set by ctest via CMakeLists.txt):
GIT_WIP— path to the binary under testTEST_TREE— base dir for artifacts; each test gets$TEST_TREE/$TEST_NAME/
TEST_NAME is derived automatically from $(basename "$0" .sh).
Each test cleans its own subdirectory before running and leaves artifacts after for debugging.
- Create
test/cli/test_<name>.sh(executable,chmod +x) - First two lines:
#!/usr/bin/env bashthensource "$(dirname "$0")/lib.sh" - Write test body using
RUN,EXP_*,create_test_repo - End with
echo "OK: $TEST_NAME" - Add
test_<name>to theforeachlist intest/cli/CMakeLists.txt
All commands go through eval "$@" inside _RUN. Multi-word arguments
(commit messages, file names with spaces) must be wrapped in escaped quotes:
RUN "$GIT_WIP" save "\"message with spaces\"" # correct
RUN git commit -m "\"my commit message\"" # correct
RUN "$GIT_WIP" save "message with spaces" # WRONG — splits into tokenssrc/
main.cpp # arg dispatch; no-args → save "WIP"
command.hpp # abstract Command base class
config.hpp/cpp # generic git config wrapper (libgit2)
git_guards.hpp # RAII wrappers for libgit2 handles + git_error_str()
cmd_save.hpp/cpp # save command
cmd_log.hpp/cpp # log command
cmd_status.hpp/cpp# status command
cmd_delete.hpp/cpp# delete command (skeleton)
test/cli/
lib.sh # shared test harness (not executable)
test_legacy.sh # legacy compatibility tests
test_spaces.sh # filenames with spaces
test_status.sh # status command tests
test_status2.sh # status after work-branch advance
test_save_file.sh # save with explicit file arguments
test_log.sh # log command tests
test_save_config.sh # git config options for save command
CMakeLists.txt # registers each test_*.sh with ctest
This section captures the analysis of the original shell script implementation and the requirements for backward compatibility with editor plugins.
| Command | In old script | Notes |
|---|---|---|
| (no args) | Yes | Defaults to save "WIP" |
| save | Yes | Core functionality |
| log | Yes | Shows WIP history |
| info | No | Dies with "info not implemented" |
| delete | No | Dies with "delete not implemented" |
| help | Yes | Shows usage |
Usage: git wip [ info | save <message> [ --editor | --untracked | --no-gpg-sign ] | log [ --pretty ] | delete ] [ [--] <file>... ]
git wip save <message> [options] [--] [files...]
Options:
-e,--editor- Be less verbose, assume called from an editor (suppresses errors when no changes)-u,--untracked- Capture also untracked files-i,--ignored- Capture also ignored files--no-gpg-sign- Do not sign commit (overrides commit.gpgSign=true)
If no files are specified, all changes to tracked files are saved. If files are specified, only those files are saved.
git wip log [options] [files...]
Options:
-p,--pretty- Show a pretty graph with colors-s,--stat- Show diffstat-r,--reflog- Show changes in reflog instead of regular log
-
WIP Branch Naming:
refs/wip/<branch_name>where<branch_name>is the current local branch (e.g.,refs/wip/master,refs/wip/feature) -
First Run Behavior:
- Creates a new commit on
wip/<branch>starting from the current branch HEAD - Captures all changes to tracked files
- Can optionally capture untracked and/or ignored files
- Creates a new commit on
-
Subsequent Run Behavior:
- If the work branch has new commits since last WIP:
- Creates a new WIP commit as a child of the work branch
- This "resets" the WIP branch to follow the work branch
- If no new commits on work branch:
- Continues from the last WIP commit (adds new changes on top)
- If the work branch has new commits since last WIP:
-
Tree Building Process (build_new_tree function):
- Creates a temporary index file
$GIT_DIR/.git-wip.$$-INDEX - Copies the main git index to the temp index
- Uses
git read-treeto populate the index with the parent tree - Uses
git addto stage changes:- Default:
git add --update .(updates tracked files) - With
--untracked:git add .(includes untracked) - With
--ignored:git add -f -A .(includes ignored)
- Default:
- Uses
git write-treeto create the new tree object
- Creates a temporary index file
-
Commit Creation:
- Uses
git commit-treeto create an orphan commit - Parent is the determined wip_parent (either last wip commit or work branch HEAD)
- Commit message is the user-provided message
- Uses
-
Reference Update:
- Uses
git update-refto update the wip branch ref - Message format:
git-wip: <first line of message> - Old ref value is passed for safe update (prevent overwriting)
- Uses
-
Reflog:
- Enables reflog for the wip branch
- Creates
$GIT_DIR/logs/refs/wip/<branch>if it doesn't exist - Allows recovery of "orphaned" WIP commits via
git reflog
-
Editor Mode (
--editor):- In editor mode, if there are no changes, the script exits quietly (exit code 0)
- Without editor mode, it reports an error "no changes"
-
Error Handling:
- Requires a working tree (uses
git-sh-setupfunctions) - Requires being on a local branch (not detached HEAD)
- Reports soft errors in editor mode (exits 0), hard errors otherwise
- Requires a working tree (uses
The C++ implementation MUST accept these exact command formats:
let out = system('cd "' . dir . '" && git wip save "WIP from vim (' . file . ')" ' . wip_opts . ' -- "' . file . '" 2>&1')Full command example:
git wip save "WIP from vim (filename)" --editor --no-gpg-sign -- "filename"
Key observations:
- Uses
git wip(space, not hyphen) - Message format:
WIP from vim (filename) - Options:
--editorand optionally--no-gpg-sign - File argument after
--delimiter
(shell-command (concat "git-wip save \"WIP from emacs: " (buffer-file-name) "\" --editor -- " file-arg))Full command example:
git-wip save "WIP from emacs: /path/to/file.el" --editor -- '/path/to/file.el'
Key observations:
- Uses
git-wip(hyphen, not space) - DIFFERENT FROM OTHERS - Message format:
WIP from emacs: /path/to/file - Option:
--editor - File argument after
--delimiter
p = Popen(["git", "wip", "save",
"WIP from ST3: saving %s" % fname,
"--editor", "--", fname],Full command example:
git wip save "WIP from ST3: saving filename" --editor -- filename
Key observations:
- Uses
git wip(space, not hyphen) - Message format:
WIP from ST3: saving filename - Option:
--editor - File argument after
--delimiter
silent! !git wip -h >/dev/null 2>&1The vim plugin runs git wip -h to check if git-wip is installed. This should:
- Either show help and exit 0
- Or at least not error out (the script checks
v:shell_error)
| Command | Header | Implementation | Status |
|---|---|---|---|
| save | cmd_save.hpp | cmd_save.cpp | Implemented — full libgit2, passes all tests |
| log | cmd_log.hpp | cmd_log.cpp | Implemented — libgit2 range, spawns git log |
| status | cmd_status.hpp | cmd_status.cpp | Implemented — libgit2 revwalk, -l/-f flags |
| delete | cmd_delete.hpp | cmd_delete.cpp | Implemented — delete one/current/cleanup orphaned wip refs |
Config is a generic wrapper around libgit2's git_config API. It provides
typed accessors for reading configuration values from .gitconfig:
Config cfg(repo);
auto str_val = cfg.get_string("git-wip.save"); // std::optional<std::string>
auto bool_val = cfg.get_bool("git-wip.gpg-sign"); // std::optional<bool>
auto int_val = cfg.get_int("some.key"); // std::optional<int>
auto i64_val = cfg.get_int64("some.key"); // std::optional<int64_t>Each accessor returns std::nullopt if the key is not found or on error.
Commands use Config to read their specific settings and interpret the values
themselves.
All libgit2 handle types have lightweight RAII guards in src/git_guards.hpp:
RepoGuard, IndexGuard, TreeGuard, CommitGuard, ReferenceGuard,
SignatureGuard, RevwalkGuard. Each exposes get() and ptr().
Also provides inline git_error_str() for the last libgit2 error message.
Arg parsing is done manually in all commands because the
save command has a "first positional = message, rest = files" pattern that
is awkward in declarative parsers. The same manual style was adopted for
consistency across log and status.
Uses libgit2 exclusively (no subprocess spawning).
Algorithm:
- Parse args manually — first non-option positional = message, remainder after
--(or bare paths) = files - Open repo with
git_repository_open_ext - Resolve HEAD → work branch short name →
refs/wip/<branch> - Ensure
$GIT_DIR/logs/refs/wip/<branch>exists (for reflog) - Resolve
work_lastfrom HEAD - Determine
wip_parent:- If
refs/wip/<branch>exists: compute merge-base; ifwork_last == baseusewip_last, else usework_last - Otherwise: use
work_last
- If
- Build new tree (in-memory, never touches the on-disk index):
git_repository_index→git_index_read_treefrom parent commit tree- Stage changes:
git_index_update_all(default) /git_index_add_allwithDEFAULT(untracked) /FORCE(ignored or specific files) git_index_write_tree→ OID of new tree objectgit_index_read(force=1)to restore the real on-disk index
- Compare new tree OID vs parent tree OID — equal → "no changes" (exit 0 in editor mode, exit 1 otherwise)
git_commit_createwithref=NULL(does not update any ref yet)git_reference_create_matchingwithcurrent_id=wip_last(NULL on first run) and reflog message"git-wip: <first line>"
Specific-file behaviour: when files are listed after --, git_index_add_all
is called with GIT_INDEX_ADD_FORCE and a pathspec of exactly those files.
Only the listed files are updated in the wip tree; all others reflect the
parent wip commit state. Untracked files can be captured this way too.
Uses libgit2 to compute the range, then spawns git log via std::system().
Algorithm:
- Resolve work branch and wip branch; look up
work_lastandwip_last git_merge_base→base; stop =base~1if base has parents, elsebasestd::system("git log [--graph] [--stat] [--pretty=...] <wip_last> <work_last> ^<stop>")
Uses libgit2 for all commit enumeration; spawns git diff --stat via
std::system() for the -f/--files output.
Algorithm:
- Resolve
work_lastandwip_last; if no wip ref → print "no wip commits", exit 0 git_merge_base(wip_last, work_last)→base- If
base != work_last: the work branch has advanced past the wip branch — there are 0 current wip commits (the nextsavewould reset). Print "0 wip commits", exit 0. - Otherwise walk from
wip_lasthidingwork_last(== base) to collect the wip-only commits (newest first viaGIT_SORT_TOPOLOGICAL) - Print summary:
branch <name> has <N> wip commit(s) on refs/wip/<name> -l/--list: for each commit print<sha7> - <subject> (<age>)-f/--files:git diff --stat <work_last> <wip_last>-l -fcombined: per-commitgit diff --stat <commit>^ <commit>after each list line
Key bug that was fixed: originally hid only work_last in the revwalk.
When the work branch advances (new real commit), work_last is no longer an
ancestor of wip_last, so hiding it has no effect and stale wip commits
remain visible. The fix: hide the merge-base, and short-circuit to "0 commits"
when merge_base != work_last.
When argc < 2, synthesises argv = ["save", "WIP"] and invokes SaveCmd
directly, matching the old shell script's default behaviour.
- Positional message argument (e.g.,
git wip save "message") --editor/-eflag--untracked/-uflag--ignored/-iflag--no-gpg-signflag--delimiter for file arguments- File arguments (optional, multiple allowed)
--pretty/-pflag--stat/-sflag--reflog/-rflag- File arguments (optional, multiple allowed)
--list/-lflag — one line per wip commit--files/-fflag — diff --stat of wip changes- Combination of
-l -f— per-commit diff interleaved with list - Optional
<ref>argument (defaults to current branch), where<ref>may be:<branch>wip/<branch>refs/heads/<branch>refs/wip/<branch>
- No arguments: invoke save with default message "WIP"
help,--help,-h: show help- Command dispatch to subcommands
- No changes to save (exit 0 quietly with
--editor, print "no changes" + exit 1 otherwise) - Not on a branch (detached HEAD) — error
- No commits on current branch — error
- WIP branch unrelated to work branch — error
- Work branch has advanced past wip branch — status shows 0 wip commits