- 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, test withmake test - 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.
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
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
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 | — | — | Not implemented |
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