Skip to content

Latest commit

 

History

History
543 lines (417 loc) · 22.6 KB

File metadata and controls

543 lines (417 loc) · 22.6 KB

AGENTS.md - git-wip C++ Rewrite

Guidance from user

  • 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=1 to see debug), use libgit2 for git functionality
  • build with make BUILD=build-agent, test with make BUILD=build-agent test — use BUILD=build-agent to isolate agent builds from user's default build/ directory
  • manage/install dependencies with dependencies.sh script
  • unit tests go into test/unit/test_*.cpp
  • CLI integration tests go into test/cli/test_*.sh — source test/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.md and README.md files with new information, as needed
  • lua/git-wip/init.lua is the plugin for Neovim written in Lua
  • vim/plugin/git-wip.vim is the legacy plugin for Vim written in VimL -- maintained, but not actively improved

Lua Plugin Configuration

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.

Versioning

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 string format

{VERSION}-{YYYYMMDD}-g{HASH}[-dirty]
  • VERSION — contents of the committed VERSION file at the top of the source tree (currently v0.3). This is the canonical, human-managed version and is bumped by tag-new-release.sh.
  • YYYYMMDD — committer date of HEAD (for make) or self.lastModifiedDate truncated to a date (for nix build).
  • HASH — short hash of HEAD / self.shortRev.
  • -dirty — appended when the working tree has uncommitted changes (make path) or when the flake source is dirty (Nix path: self.rev absent).

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.

Files involved

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 paths and what they produce

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.

Cutting a release

./tag-new-release.sh v0.4

The script:

  1. Refuses to run on a dirty tree.
  2. Validates v0.4 is strictly greater (per sort -V) than both the current VERSION file contents and the most recent git tag.
  3. Writes v0.4 to VERSION.
  4. Commits with message Release v0.4.
  5. Creates annotated tag git tag -a -m v0.4 v0.4.

After running the script, push commit + tag with git push --follow-tags.

Implementation notes

  • 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.revCount is intentionally unused. The GitHub flake fetcher (github: URL) does not populate it; the date+hash scheme works under both github: and git+https:// fetchers without conditional logic.
  • Dirty detection in Nix relies on self ? rev. A dirty flake source sets dirtyRev/dirtyShortRev but not rev, so the check if self ? rev then "" else "-dirty" is correct.
  • USE_GIT_WIP_VERSION_H is a CMake variable, not an env var. The Nix derivation appends -DUSE_GIT_WIP_VERSION_H=$PWD/build/git_wip_version.h to cmakeFlagsArray in preConfigure. When set and the file exists, src/CMakeLists.txt skips the GitVersion.sh invocation entirely.

Continuous Integration

.github/workflows/ci.yml defines three jobs:

build (matrix)

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.

nix (single job, no matrix)

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:

  1. Dev shell make buildnix develop --command bash -c 'make && make test'. Exercises the cmake/GitVersion.sh path with the flake's pinned toolchain.
  2. Flake buildnix build --print-build-logs. Exercises the flake.nixpackage.nixpreConfigure version-injection path.
  3. Version sanity check — runs ./result/bin/git-wip --version and asserts the output starts with <contents of VERSION>-. Catches regressions in either the flake's version computation or package.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.

coverage

Single job on debian:stable / gcc / Debug. Runs make TYPE=Debug coverage and uploads the resulting coverage.info to Codecov.

Why no nixos/nix container

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.

Test Infrastructure

test/cli/lib.sh

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 before EXP_text)
  • EXP_none — assert that the last command produced no output
  • EXP_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 pattern
  • create_test_repo — init a fresh git repo in $REPO, checkout branch master
  • handle_error — print diagnostics and exit 1

Required env vars (set by ctest via CMakeLists.txt):

  • GIT_WIP — path to the binary under test
  • TEST_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.

Adding a new CLI test

  1. Create test/cli/test_<name>.sh (executable, chmod +x)
  2. First two lines: #!/usr/bin/env bash then source "$(dirname "$0")/lib.sh"
  3. Write test body using RUN, EXP_*, create_test_repo
  4. End with echo "OK: $TEST_NAME"
  5. Add test_<name> to the foreach list in test/cli/CMakeLists.txt

Quoting in test scripts

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 tokens

Source Layout

src/
  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

Old Shell Script Analysis (Attic/git-wip)

This section captures the analysis of the original shell script implementation and the requirements for backward compatibility with editor plugins.

Commands Implemented (original script)

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

Command Syntax (from old script)

Usage: git wip [ info | save <message> [ --editor | --untracked | --no-gpg-sign ] | log [ --pretty ] | delete ] [ [--] <file>... ]

save command

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.

log command

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

Internal Implementation Details

  1. WIP Branch Naming: refs/wip/<branch_name> where <branch_name> is the current local branch (e.g., refs/wip/master, refs/wip/feature)

  2. 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
  3. 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)
  4. 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-tree to populate the index with the parent tree
    • Uses git add to stage changes:
      • Default: git add --update . (updates tracked files)
      • With --untracked: git add . (includes untracked)
      • With --ignored: git add -f -A . (includes ignored)
    • Uses git write-tree to create the new tree object
  5. Commit Creation:

    • Uses git commit-tree to 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
  6. Reference Update:

    • Uses git update-ref to update the wip branch ref
    • Message format: git-wip: <first line of message>
    • Old ref value is passed for safe update (prevent overwriting)
  7. 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
  8. 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"
  9. Error Handling:

    • Requires a working tree (uses git-sh-setup functions)
    • Requires being on a local branch (not detached HEAD)
    • Reports soft errors in editor mode (exits 0), hard errors otherwise

Editor Plugin Commands (Backward Compatibility)

The C++ implementation MUST accept these exact command formats:

vim (vim/plugin/git-wip.vim, line 53)

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: --editor and optionally --no-gpg-sign
  • File argument after -- delimiter

emacs (emacs/git-wip.el, line 4)

(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

sublime (sublime/gitwip.py, line 12-14)

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

vim check for git-wip (vim/plugin/git-wip.vim, line 24)

silent! !git wip -h >/dev/null 2>&1

The 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)

Implementation Status (C++)

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.hpp/cpp — generic git config wrapper

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.

git_guards.hpp — RAII wrappers

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.

save command (cmd_save.cpp)

Uses libgit2 exclusively (no subprocess spawning).

Algorithm:

  1. Parse args manually — first non-option positional = message, remainder after -- (or bare paths) = files
  2. Open repo with git_repository_open_ext
  3. Resolve HEAD → work branch short name → refs/wip/<branch>
  4. Ensure $GIT_DIR/logs/refs/wip/<branch> exists (for reflog)
  5. Resolve work_last from HEAD
  6. Determine wip_parent:
    • If refs/wip/<branch> exists: compute merge-base; if work_last == base use wip_last, else use work_last
    • Otherwise: use work_last
  7. Build new tree (in-memory, never touches the on-disk index):
    • git_repository_indexgit_index_read_tree from parent commit tree
    • Stage changes: git_index_update_all (default) / git_index_add_all with DEFAULT (untracked) / FORCE (ignored or specific files)
    • git_index_write_tree → OID of new tree object
    • git_index_read(force=1) to restore the real on-disk index
  8. Compare new tree OID vs parent tree OID — equal → "no changes" (exit 0 in editor mode, exit 1 otherwise)
  9. git_commit_create with ref=NULL (does not update any ref yet)
  10. git_reference_create_matching with current_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.

log command (cmd_log.cpp)

Uses libgit2 to compute the range, then spawns git log via std::system().

Algorithm:

  1. Resolve work branch and wip branch; look up work_last and wip_last
  2. git_merge_basebase; stop = base~1 if base has parents, else base
  3. std::system("git log [--graph] [--stat] [--pretty=...] <wip_last> <work_last> ^<stop>")

status command (cmd_status.cpp)

Uses libgit2 for all commit enumeration; spawns git diff --stat via std::system() for the -f/--files output.

Algorithm:

  1. Resolve work_last and wip_last; if no wip ref → print "no wip commits", exit 0
  2. git_merge_base(wip_last, work_last)base
  3. If base != work_last: the work branch has advanced past the wip branch — there are 0 current wip commits (the next save would reset). Print "0 wip commits", exit 0.
  4. Otherwise walk from wip_last hiding work_last (== base) to collect the wip-only commits (newest first via GIT_SORT_TOPOLOGICAL)
  5. Print summary: branch <name> has <N> wip commit(s) on refs/wip/<name>
  6. -l/--list: for each commit print <sha7> - <subject> (<age>)
  7. -f/--files: git diff --stat <work_last> <wip_last>
  8. -l -f combined: per-commit git 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.

main.cpp — No-args behavior

When argc < 2, synthesises argv = ["save", "WIP"] and invokes SaveCmd directly, matching the old shell script's default behaviour.

Required Implementation for Backward Compatibility

1. save command must support:

  • Positional message argument (e.g., git wip save "message")
  • --editor / -e flag
  • --untracked / -u flag
  • --ignored / -i flag
  • --no-gpg-sign flag
  • -- delimiter for file arguments
  • File arguments (optional, multiple allowed)

2. log command must support:

  • --pretty / -p flag
  • --stat / -s flag
  • --reflog / -r flag
  • File arguments (optional, multiple allowed)

3. status command must support:

  • --list / -l flag — one line per wip commit
  • --files / -f flag — 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>

4. Main program must support:

  • No arguments: invoke save with default message "WIP"
  • help, --help, -h: show help
  • Command dispatch to subcommands

5. Must handle edge cases:

  • 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