[
  {
    "path": ".codecov.yml",
    "content": "codecov:\n  branch: master\n  require_ci_to_pass: yes\n  notify:\n    wait_for_ci: yes\n\ncoverage:\n  precision: 2\n  round: down\n  range: \"70...100\"\n  status:\n    project:\n      default:\n        target: auto\n        threshold: 1%\n    patch:\n      default:\n        target: auto\n        threshold: 1%\n\nignore:\n  - \"build/_deps/**/*\"\n  - \"test/**/*\"\n\ncomment:\n  layout: \"reach,diff,flags,files,footer\"\n  behavior: default\n  require_changes: no\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [\"**\"]\n  pull_request:\n    branches: [\"**\"]\n\njobs:\n  build:\n    name: ${{ matrix.os }} / ${{ matrix.compiler }} / ${{ matrix.build_type }}\n\n    strategy:\n      fail-fast: false   # let all matrix cells run even if one fails\n      matrix:\n        include:\n          # ── Debian stable ──────────────────────────────────────────────────\n          # static: true — libgit2-dev ships libgit2.a on Debian\n          - os: debian:stable\n            runner: ubuntu-latest\n            compiler: gcc\n            cc: gcc\n            cxx: g++\n            build_type: Release\n            static: true\n\n          - os: debian:stable\n            runner: ubuntu-latest\n            compiler: gcc\n            cc: gcc\n            cxx: g++\n            build_type: Debug\n            static: true\n\n          - os: debian:stable\n            runner: ubuntu-latest\n            compiler: clang\n            cc: clang\n            cxx: clang++\n            build_type: Release\n            static: true\n\n          - os: debian:stable\n            runner: ubuntu-latest\n            compiler: clang\n            cc: clang\n            cxx: clang++\n            build_type: Debug\n            static: true\n\n          # ── Ubuntu LTS (24.04) ─────────────────────────────────────────────\n          # static: true — libgit2-dev ships libgit2.a on Ubuntu\n          - os: ubuntu:24.04\n            runner: ubuntu-latest\n            compiler: gcc\n            cc: gcc\n            cxx: g++\n            build_type: Release\n            static: true\n\n          - os: ubuntu:24.04\n            runner: ubuntu-latest\n            compiler: gcc\n            cc: gcc\n            cxx: g++\n            build_type: Debug\n            static: true\n\n          - os: ubuntu:24.04\n            runner: ubuntu-latest\n            compiler: clang\n            cc: clang\n            cxx: clang++\n            build_type: Release\n            static: true\n\n          - os: ubuntu:24.04\n            runner: ubuntu-latest\n            compiler: clang\n            cc: clang\n            cxx: clang++\n            build_type: Debug\n            static: true\n\n          # ── Fedora (latest) ───────────────────────────────────────────────────\n          # static: false — Fedora does not ship libgit2.a / libssh2.a / libssl.a\n          - os: fedora:latest\n            runner: ubuntu-latest\n            compiler: gcc\n            cc: gcc\n            cxx: g++\n            build_type: Release\n            static: false\n\n          - os: fedora:latest\n            runner: ubuntu-latest\n            compiler: gcc\n            cc: gcc\n            cxx: g++\n            build_type: Debug\n            static: false\n\n          - os: fedora:latest\n            runner: ubuntu-latest\n            compiler: clang\n            cc: clang\n            cxx: clang++\n            build_type: Release\n            static: false\n\n          - os: fedora:latest\n            runner: ubuntu-latest\n            compiler: clang\n            cc: clang\n            cxx: clang++\n            build_type: Debug\n            static: false\n\n          # ── Arch Linux (stable) ───────────────────────────────────────────────\n          # static: false — Arch does not ship libgit2.a\n          - os: archlinux:latest\n            runner: ubuntu-latest\n            compiler: gcc\n            cc: gcc\n            cxx: g++\n            build_type: Release\n            static: false\n\n          - os: archlinux:latest\n            runner: ubuntu-latest\n            compiler: gcc\n            cc: gcc\n            cxx: g++\n            build_type: Debug\n            static: false\n\n          - os: archlinux:latest\n            runner: ubuntu-latest\n            compiler: clang\n            cc: clang\n            cxx: clang++\n            build_type: Release\n            static: false\n\n          - os: archlinux:latest\n            runner: ubuntu-latest\n            compiler: clang\n            cc: clang\n            cxx: clang++\n            build_type: Debug\n            static: false\n\n    runs-on: ${{ matrix.runner }}\n\n    container:\n      image: ${{ matrix.os }}\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n\n      - name: Install dependencies\n        env:\n          DEBIAN_FRONTEND: noninteractive\n        run: bash dependencies.sh --compiler=${{ matrix.compiler }}\n\n      - name: Cache CMake FetchContent (spdlog, clipp, fmt)\n        uses: actions/cache@v5\n        with:\n          path: build-so/_deps\n          key: fetchcontent-${{ matrix.os }}-${{ matrix.compiler }}-${{ hashFiles('CMakeLists.txt') }}\n          restore-keys: |\n            fetchcontent-${{ matrix.os }}-${{ matrix.compiler }}-\n            fetchcontent-${{ matrix.os }}-\n\n      - name: Configure git identity (needed by tests that create commits)\n        run: |\n          git config --global user.email \"ci@github-actions\"\n          git config --global user.name  \"GitHub Actions\"\n          git config --global init.defaultBranch master\n\n      - name: Build dynamic\n        env:\n          CC:  ${{ matrix.cc }}\n          CXX: ${{ matrix.cxx }}\n          CI:  \"1\"\n        run: make BUILD=build-so TYPE=${{ matrix.build_type }}\n\n      - name: Test dynamic\n        env:\n          CC:  ${{ matrix.cc }}\n          CXX: ${{ matrix.cxx }}\n          CI:  \"1\"\n        run: make BUILD=build-so TYPE=${{ matrix.build_type }} test\n\n      - name: Install static dependencies\n        if: matrix.static\n        env:\n          DEBIAN_FRONTEND: noninteractive\n        run: bash dependencies.sh --compiler=${{ matrix.compiler }} --static\n\n      - name: Build static\n        if: matrix.static\n        env:\n          CC:  ${{ matrix.cc }}\n          CXX: ${{ matrix.cxx }}\n          CI:  \"1\"\n        run: make BUILD=build-a STATIC=1 TYPE=${{ matrix.build_type }}\n\n      - name: Test static\n        if: matrix.static\n        env:\n          CC:  ${{ matrix.cc }}\n          CXX: ${{ matrix.cxx }}\n          CI:  \"1\"\n        run: make BUILD=build-a STATIC=1 TYPE=${{ matrix.build_type }} test\n\n      - name: Compute artifact name\n        if: failure()\n        id: artifact-name\n        shell: bash\n        run: |\n          # Colons and slashes are not allowed in artifact names; replace with dashes.\n          raw=\"test-artifacts-${{ matrix.os }}-${{ matrix.compiler }}-${{ matrix.build_type }}\"\n          echo \"name=${raw//[:\\\\/]/-}\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Upload test artifacts on failure\n        if: failure()\n        uses: actions/upload-artifact@v7\n        with:\n          name: ${{ steps.artifact-name.outputs.name }}\n          path: |\n            build-so/test/\n            build-so/Testing/\n            build-a/test/\n            build-a/Testing/\n          retention-days: 7\n\n  coverage:\n    name: Coverage (debian:stable / gcc / Debug)\n\n    runs-on: ubuntu-latest\n\n    container:\n      image: debian:stable\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n\n      - name: Install dependencies\n        env:\n          DEBIAN_FRONTEND: noninteractive\n        run: bash dependencies.sh --compiler=gcc --coverage\n\n      - name: Cache CMake FetchContent (spdlog, clipp, fmt)\n        uses: actions/cache@v5\n        with:\n          path: build/_deps\n          key: fetchcontent-coverage-${{ hashFiles('CMakeLists.txt') }}\n          restore-keys: |\n            fetchcontent-coverage-\n\n      - name: Configure git identity (needed by tests that create commits)\n        run: |\n          git config --global user.email \"ci@github-actions\"\n          git config --global user.name  \"GitHub Actions\"\n          git config --global init.defaultBranch master\n\n      - name: Build, test, and collect coverage\n        env:\n          CC:  gcc\n          CXX: g++\n          CI:  \"1\"\n        run: make CC=gcc CXX=g++ TYPE=Debug coverage REBUILD=1\n\n      - name: Upload to Codecov\n        uses: codecov/codecov-action@v5\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          files: ./coverage.info\n          flags: unittests\n          name: debian-stable-gcc-debug\n          verbose: true\n          fail_ci_if_error: true\n"
  },
  {
    "path": ".gitignore",
    "content": "*~\nbuild/\n.cache/\n.opencode/\n/git-wip\n\ncoverage-report/\ncoverage.xml\ncoverage.info\n\n### C++\n# Prerequisites\n*.d\n\n# Compiled Object files\n*.slo\n*.lo\n*.o\n*.obj\n\n# Precompiled Headers\n*.gch\n*.pch\n\n# Compiled Dynamic libraries\n*.so\n*.dylib\n*.dll\n\n# Fortran module files\n*.mod\n*.smod\n\n# Compiled Static libraries\n*.lai\n*.la\n*.a\n*.lib\n\n# Executables\n*.exe\n*.out\n*.app\n\n### CMake\nCMakeLists.txt.user\nCMakeCache.txt\nCMakeFiles\nCMakeScripts\nTesting\nMakefile\ncmake_install.cmake\ninstall_manifest.txt\ncompile_commands.json\nCTestTestfile.cmake\n_deps\nflake.lock\n\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md - git-wip C++ Rewrite\n\n## Guidance from user\n\n- 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.\n- use manual arg parsing, use spdlog for debug logging (set `WIP_DEBUG=1` to see debug), use libgit2 for git functionality\n- build with `make`, test with `make test`\n- manage/install dependencies with `dependencies.sh` script\n- unit tests go into `test/unit/test_*.cpp`\n- CLI integration tests go into `test/cli/test_*.sh` — source `test/cli/lib.sh`, must be executable\n- old scripts are in `Attic/` subdirectory, we try to be backward compatible (at least for vim/ emacs/ sublime/ plugins)\n- agent will update `AGENTS.md` and `README.md` files with new information, as needed\n- `lua/git-wip/init.lua` is the plugin for Neovim written in Lua\n- `vim/plugin/git-wip.vim` is the legacy plugin for Vim written in VimL -- maintained, but not actively improved\n\n## Lua Plugin Configuration\n\nThe Neovim Lua plugin supports the following configuration options (set via `opts` in lazy.nvim or passed to `setup()`):\n\n- `git_wip_path`: Path to the git-wip binary (default: \"git-wip\")\n- `gpg_sign`: nil (default), true (--gpg-sign), false (--no-gpg-sign)\n- `untracked`: nil (default), true (--untracked), false (--no-untracked)\n- `ignored`: nil (default), true (--ignored), false (--no-ignored)\n- `background`: false (default, sync execution), true (async if Neovim 0.10+, else sync with warning)\n- `filetypes`: Array of filetypes to enable (default: { \"*\" } for all)\n\nAsync execution uses Neovim's `vim.system` with `on_exit` callback for non-blocking saves.\n\n## Test Infrastructure\n\n### test/cli/lib.sh\n\nShared bash harness sourced by every CLI test script.  Provides:\n\n- `RUN <cmd>` — run a command, fail the test if it exits non-zero\n- `_RUN <cmd>` — run a command without checking exit code (use before `EXP_text`)\n- `EXP_none` — assert that the last command produced no output\n- `EXP_text <string>` — assert that the first line of output equals `<string>`\n- `EXP_grep [opts] <pattern>` — assert that output matches (or with `-v`, does not match) a grep pattern\n- `create_test_repo` — init a fresh git repo in `$REPO`, checkout branch `master`\n- `handle_error` — print diagnostics and exit 1\n\nRequired env vars (set by ctest via `CMakeLists.txt`):\n- `GIT_WIP` — path to the binary under test\n- `TEST_TREE` — base dir for artifacts; each test gets `$TEST_TREE/$TEST_NAME/`\n\n`TEST_NAME` is derived automatically from `$(basename \"$0\" .sh)`.\n\nEach test cleans its own subdirectory before running and leaves artifacts after for debugging.\n\n### Adding a new CLI test\n\n1. Create `test/cli/test_<name>.sh` (executable, `chmod +x`)\n2. First two lines: `#!/usr/bin/env bash` then `source \"$(dirname \"$0\")/lib.sh\"`\n3. Write test body using `RUN`, `EXP_*`, `create_test_repo`\n4. End with `echo \"OK: $TEST_NAME\"`\n5. Add `test_<name>` to the `foreach` list in `test/cli/CMakeLists.txt`\n\n### Quoting in test scripts\n\nAll commands go through `eval \"$@\"` inside `_RUN`.  Multi-word arguments\n(commit messages, file names with spaces) must be wrapped in escaped quotes:\n\n```bash\nRUN \"$GIT_WIP\" save \"\\\"message with spaces\\\"\"   # correct\nRUN git commit -m \"\\\"my commit message\\\"\"        # correct\nRUN \"$GIT_WIP\" save \"message with spaces\"        # WRONG — splits into tokens\n```\n\n## Source Layout\n\n```\nsrc/\n  main.cpp          # arg dispatch; no-args → save \"WIP\"\n  command.hpp       # abstract Command base class\n  git_guards.hpp    # RAII wrappers for libgit2 handles + git_error_str()\n  cmd_save.hpp/cpp  # save command\n  cmd_log.hpp/cpp   # log command\n  cmd_status.hpp/cpp# status command\n  cmd_delete.hpp/cpp# delete command (skeleton)\ntest/cli/\n  lib.sh            # shared test harness (not executable)\n  test_legacy.sh    # legacy compatibility tests\n  test_spaces.sh    # filenames with spaces\n  test_status.sh    # status command tests\n  test_status2.sh   # status after work-branch advance\n  test_save_file.sh # save with explicit file arguments\n  CMakeLists.txt    # registers each test_*.sh with ctest\n```\n\n## Old Shell Script Analysis (Attic/git-wip)\n\nThis section captures the analysis of the original shell script implementation and the requirements for backward compatibility with editor plugins.\n\n### Commands Implemented (original script)\n\n| Command | In old script | Notes |\n|---------|--------------|-------|\n| (no args) | Yes | Defaults to `save \"WIP\"` |\n| save | Yes | Core functionality |\n| log | Yes | Shows WIP history |\n| info | **No** | Dies with \"info not implemented\" |\n| delete | **No** | Dies with \"delete not implemented\" |\n| help | Yes | Shows usage |\n\n### Command Syntax (from old script)\n\n```\nUsage: git wip [ info | save <message> [ --editor | --untracked | --no-gpg-sign ] | log [ --pretty ] | delete ] [ [--] <file>... ]\n```\n\n#### save command\n\n```\ngit wip save <message> [options] [--] [files...]\n```\n\nOptions:\n- `-e`, `--editor` - Be less verbose, assume called from an editor (suppresses errors when no changes)\n- `-u`, `--untracked` - Capture also untracked files\n- `-i`, `--ignored` - Capture also ignored files\n- `--no-gpg-sign` - Do not sign commit (overrides commit.gpgSign=true)\n\nIf no files are specified, all changes to tracked files are saved. If files are specified, only those files are saved.\n\n#### log command\n\n```\ngit wip log [options] [files...]\n```\n\nOptions:\n- `-p`, `--pretty` - Show a pretty graph with colors\n- `-s`, `--stat` - Show diffstat\n- `-r`, `--reflog` - Show changes in reflog instead of regular log\n\n### Internal Implementation Details\n\n1. **WIP Branch Naming**: `refs/wip/<branch_name>` where `<branch_name>` is the current local branch (e.g., `refs/wip/master`, `refs/wip/feature`)\n\n2. **First Run Behavior**:\n   - Creates a new commit on `wip/<branch>` starting from the current branch HEAD\n   - Captures all changes to tracked files\n   - Can optionally capture untracked and/or ignored files\n\n3. **Subsequent Run Behavior**:\n   - If the work branch has new commits since last WIP:\n     - Creates a new WIP commit as a child of the work branch\n     - This \"resets\" the WIP branch to follow the work branch\n   - If no new commits on work branch:\n     - Continues from the last WIP commit (adds new changes on top)\n\n4. **Tree Building Process** (build_new_tree function):\n   - Creates a temporary index file `$GIT_DIR/.git-wip.$$-INDEX`\n   - Copies the main git index to the temp index\n   - Uses `git read-tree` to populate the index with the parent tree\n   - Uses `git add` to stage changes:\n     - Default: `git add --update .` (updates tracked files)\n     - With `--untracked`: `git add .` (includes untracked)\n     - With `--ignored`: `git add -f -A .` (includes ignored)\n   - Uses `git write-tree` to create the new tree object\n\n5. **Commit Creation**:\n   - Uses `git commit-tree` to create an orphan commit\n   - Parent is the determined wip_parent (either last wip commit or work branch HEAD)\n   - Commit message is the user-provided message\n\n6. **Reference Update**:\n   - Uses `git update-ref` to update the wip branch ref\n   - Message format: `git-wip: <first line of message>`\n   - Old ref value is passed for safe update (prevent overwriting)\n\n7. **Reflog**:\n   - Enables reflog for the wip branch\n   - Creates `$GIT_DIR/logs/refs/wip/<branch>` if it doesn't exist\n   - Allows recovery of \"orphaned\" WIP commits via `git reflog`\n\n8. **Editor Mode** (`--editor`):\n   - In editor mode, if there are no changes, the script exits quietly (exit code 0)\n   - Without editor mode, it reports an error \"no changes\"\n\n9. **Error Handling**:\n   - Requires a working tree (uses `git-sh-setup` functions)\n   - Requires being on a local branch (not detached HEAD)\n   - Reports soft errors in editor mode (exits 0), hard errors otherwise\n\n## Editor Plugin Commands (Backward Compatibility)\n\nThe C++ implementation MUST accept these exact command formats:\n\n### vim (vim/plugin/git-wip.vim, line 53)\n\n```vim\nlet out = system('cd \"' . dir . '\" && git wip save \"WIP from vim (' . file . ')\" ' . wip_opts . ' -- \"' . file . '\" 2>&1')\n```\n\nFull command example:\n```\ngit wip save \"WIP from vim (filename)\" --editor --no-gpg-sign -- \"filename\"\n```\n\nKey observations:\n- Uses `git wip` (space, not hyphen)\n- Message format: `WIP from vim (filename)`\n- Options: `--editor` and optionally `--no-gpg-sign`\n- File argument after `--` delimiter\n\n### emacs (emacs/git-wip.el, line 4)\n\n```lisp\n(shell-command (concat \"git-wip save \\\"WIP from emacs: \" (buffer-file-name) \"\\\" --editor -- \" file-arg))\n```\n\nFull command example:\n```\ngit-wip save \"WIP from emacs: /path/to/file.el\" --editor -- '/path/to/file.el'\n```\n\nKey observations:\n- Uses `git-wip` (hyphen, not space) - DIFFERENT FROM OTHERS\n- Message format: `WIP from emacs: /path/to/file`\n- Option: `--editor`\n- File argument after `--` delimiter\n\n### sublime (sublime/gitwip.py, line 12-14)\n\n```python\np = Popen([\"git\", \"wip\", \"save\",\n           \"WIP from ST3: saving %s\" % fname,\n           \"--editor\", \"--\", fname],\n```\n\nFull command example:\n```\ngit wip save \"WIP from ST3: saving filename\" --editor -- filename\n```\n\nKey observations:\n- Uses `git wip` (space, not hyphen)\n- Message format: `WIP from ST3: saving filename`\n- Option: `--editor`\n- File argument after `--` delimiter\n\n### vim check for git-wip (vim/plugin/git-wip.vim, line 24)\n\n```vim\nsilent! !git wip -h >/dev/null 2>&1\n```\n\nThe vim plugin runs `git wip -h` to check if git-wip is installed. This should:\n- Either show help and exit 0\n- Or at least not error out (the script checks `v:shell_error`)\n\n## Implementation Status (C++)\n\n| Command | Header | Implementation | Status |\n|---------|--------|----------------|--------|\n| save | cmd_save.hpp | cmd_save.cpp | **Implemented** — full libgit2, passes all tests |\n| log | cmd_log.hpp | cmd_log.cpp | **Implemented** — libgit2 range, spawns `git log` |\n| status | cmd_status.hpp | cmd_status.cpp | **Implemented** — libgit2 revwalk, `-l`/`-f` flags |\n| delete | cmd_delete.hpp | cmd_delete.cpp | **Implemented** — delete one/current/cleanup orphaned wip refs |\n| config | — | — | Not implemented |\n\n### git_guards.hpp — RAII wrappers\n\nAll libgit2 handle types have lightweight RAII guards in `src/git_guards.hpp`:\n`RepoGuard`, `IndexGuard`, `TreeGuard`, `CommitGuard`, `ReferenceGuard`,\n`SignatureGuard`, `RevwalkGuard`.  Each exposes `get()` and `ptr()`.\nAlso provides `inline git_error_str()` for the last libgit2 error message.\n\nArg parsing is done **manually** in all commands because the\n`save` command has a \"first positional = message, rest = files\" pattern that\nis awkward in declarative parsers.  The same manual style was adopted for\nconsistency across `log` and `status`.\n\n### save command (cmd_save.cpp)\n\nUses libgit2 exclusively (no subprocess spawning).\n\n**Algorithm:**\n1. Parse args manually — first non-option positional = message, remainder after `--` (or bare paths) = files\n2. Open repo with `git_repository_open_ext`\n3. Resolve HEAD → work branch short name → `refs/wip/<branch>`\n4. Ensure `$GIT_DIR/logs/refs/wip/<branch>` exists (for reflog)\n5. Resolve `work_last` from HEAD\n6. Determine `wip_parent`:\n   - If `refs/wip/<branch>` exists: compute merge-base; if `work_last == base` use `wip_last`, else use `work_last`\n   - Otherwise: use `work_last`\n7. Build new tree (in-memory, never touches the on-disk index):\n   - `git_repository_index` → `git_index_read_tree` from parent commit tree\n   - Stage changes: `git_index_update_all` (default) / `git_index_add_all` with `DEFAULT` (untracked) / `FORCE` (ignored or specific files)\n   - `git_index_write_tree` → OID of new tree object\n   - `git_index_read(force=1)` to restore the real on-disk index\n8. Compare new tree OID vs parent tree OID — equal → \"no changes\" (exit 0 in editor mode, exit 1 otherwise)\n9. `git_commit_create` with `ref=NULL` (does not update any ref yet)\n10. `git_reference_create_matching` with `current_id=wip_last` (NULL on first run) and reflog message `\"git-wip: <first line>\"`\n\n**Specific-file behaviour:** when files are listed after `--`, `git_index_add_all`\nis called with `GIT_INDEX_ADD_FORCE` and a pathspec of exactly those files.\nOnly the listed files are updated in the wip tree; all others reflect the\nparent wip commit state.  Untracked files can be captured this way too.\n\n### log command (cmd_log.cpp)\n\nUses libgit2 to compute the range, then spawns `git log` via `std::system()`.\n\n**Algorithm:**\n1. Resolve work branch and wip branch; look up `work_last` and `wip_last`\n2. `git_merge_base` → `base`; stop = `base~1` if base has parents, else `base`\n3. `std::system(\"git log [--graph] [--stat] [--pretty=...] <wip_last> <work_last> ^<stop>\")`\n\n### status command (cmd_status.cpp)\n\nUses libgit2 for all commit enumeration; spawns `git diff --stat` via\n`std::system()` for the `-f`/`--files` output.\n\n**Algorithm:**\n1. Resolve `work_last` and `wip_last`; if no wip ref → print \"no wip commits\", exit 0\n2. `git_merge_base(wip_last, work_last)` → `base`\n3. 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.\n4. Otherwise walk from `wip_last` hiding `work_last` (== base) to collect the wip-only commits (newest first via `GIT_SORT_TOPOLOGICAL`)\n5. Print summary: `branch <name> has <N> wip commit(s) on refs/wip/<name>`\n6. `-l`/`--list`: for each commit print `<sha7> - <subject> (<age>)`\n7. `-f`/`--files`: `git diff --stat <work_last> <wip_last>`\n8. `-l -f` combined: per-commit `git diff --stat <commit>^ <commit>` after each list line\n\n**Key bug that was fixed:** originally hid only `work_last` in the revwalk.\nWhen the work branch advances (new real commit), `work_last` is no longer an\nancestor of `wip_last`, so hiding it has no effect and stale wip commits\nremain visible.  The fix: hide the merge-base, and short-circuit to \"0 commits\"\nwhen `merge_base != work_last`.\n\n### main.cpp — No-args behavior\n\nWhen `argc < 2`, synthesises `argv = [\"save\", \"WIP\"]` and invokes `SaveCmd`\ndirectly, matching the old shell script's default behaviour.\n\n## Required Implementation for Backward Compatibility\n\n### 1. save command must support:\n\n- Positional message argument (e.g., `git wip save \"message\"`)\n- `--editor` / `-e` flag\n- `--untracked` / `-u` flag\n- `--ignored` / `-i` flag\n- `--no-gpg-sign` flag\n- `--` delimiter for file arguments\n- File arguments (optional, multiple allowed)\n\n### 2. log command must support:\n\n- `--pretty` / `-p` flag\n- `--stat` / `-s` flag\n- `--reflog` / `-r` flag\n- File arguments (optional, multiple allowed)\n\n### 3. status command must support:\n\n- `--list` / `-l` flag — one line per wip commit\n- `--files` / `-f` flag — diff --stat of wip changes\n- Combination of `-l -f` — per-commit diff interleaved with list\n- Optional `<ref>` argument (defaults to current branch), where `<ref>` may be:\n  - `<branch>`\n  - `wip/<branch>`\n  - `refs/heads/<branch>`\n  - `refs/wip/<branch>`\n\n### 4. Main program must support:\n\n- No arguments: invoke save with default message \"WIP\"\n- `help`, `--help`, `-h`: show help\n- Command dispatch to subcommands\n\n### 5. Must handle edge cases:\n\n- No changes to save (exit 0 quietly with `--editor`, print \"no changes\" + exit 1 otherwise)\n- Not on a branch (detached HEAD) — error\n- No commits on current branch — error\n- WIP branch unrelated to work branch — error\n- Work branch has advanced past wip branch — status shows 0 wip commits\n"
  },
  {
    "path": "Attic/README.markdown",
    "content": "# About\n\ngit-wip is a script that will manage Work In Progress (or WIP) branches.\nWIP branches are mostly throw away but identify points of development\nbetween commits.  The intent is to tie this script into your editor so\nthat each time you save your file, the git-wip script captures that\nstate in git.  git-wip also helps you return back to a previous state of\ndevelopment.\n\nLatest git-wip can be obtained from [github.com](http://github.com/bartman/git-wip).\ngit-wip was written by [Bart Trojanowski](mailto:bart@jukie.net).\nYou can find out more from the original [blog post](http://www.jukie.net/bart/blog/save-everything-with-git-wip).\n\n# WIP branches\n\nWip branches are named after the branch that is being worked on, but are\nprefixed with 'wip/'.  For example if you are working on a branch named\n'feature' then the git-wip script will only manipulate the 'wip/feature'\nbranch.\n\nWhen you run git-wip for the first time, it will capture all changes to\ntracked files and all untracked (but not ignored) files, create a\ncommit, and make a new wip/*topic* branch point to it.\n\n    --- * --- * --- *          <-- topic\n                     \\\n                      *        <-- wip/topic\n\nThe next invocation of git-wip after a commit is made will continue to\nevolve the work from the last wip/*topic* point.\n\n    --- * --- * --- *          <-- topic\n                     \\\n                      *\n                       \\\n                        *      <-- wip/topic\n\nWhen git-wip is invoked after a commit is made, the state of the\nwip/*topic* branch will be reset back to your *topic* branch and the new\nchanges to the working tree will be caputred on a new commit.\n\n    --- * --- * --- * --- *    <-- topic\n                     \\     \\\n                      *     *  <-- wip/topic\n                       \\\n                        *\n\nWhile the old wip/*topic* work is no longer accessible directly, it can\nalways be recovered from git-reflog.  In the above example you could use\n`wip/topic@{1}` to access the dangling references.\n\n# git-wip command\n\nThe git-wip command can be invoked in several different ways.\n\n* `git wip`\n  \n  In this mode, git-wip will create a new commit on the wip/*topic*\n  branch (creating it if needed) as described above.\n\n* `git wip save \"description\"`\n  \n  Similar to `git wip`, but allows for a custom commit message.\n\n* `git wip log`\n  \n  Show the list of the work that leads upto the last WIP commit.  This\n  is similar to invoking:\n  \n  `git log --stat wip/$branch...$(git merge-base wip/$branch $branch)`\n\n# Installation\n\nDownload the script from the GitHub page:\n\n    git clone git://github.com/bartman/git-wip.git\n\nAdd `git-wip` to your `$PATH`:\n\n    mkdir -p ~/bin\n    cp git-wip/git-wip ~/bin/\n\n# editor hooking\n\nTo use git-wip effectively, you should tie it into your editor so you\ndon't have to remember to run git-wip manually.\n\n## vim\n\nTo add git-wip support to vim you can install the provided vim plugin.  There\nare a few ways to do this.\n\n**(1)** If you're using [Vundle](https://github.com/gmarik/Vundle.vim), you\njust need to include the following line in your `.vimrc`.\n\n    Bundle 'bartman/git-wip', {'rtp': 'vim/'}\n\n**(2)** You can slo copy the `git-wip.vim` into your vim runtime:\n\n    cp vim/plugin/git-wip ~/.vim/plugin/git-wip\n\n**(3)** Alternatively, you can add the following to your `.vimrc`.  Doing so\nwill make it be invoked after every `:w` operation.\n\n    augroup git-wip\n      autocmd!\n      autocmd BufWritePost * :silent !cd \"`dirname \"%\"`\" && git wip save \"WIP from vim\" --editor -- \"`basename \"%\"`\"\n    augroup END\n\nThe `--editor` option puts git-wip into a special mode that will make it\nmore quiet and not report errors if there were no changes made to the\nfile.\n\n## emacs\n\nTo add git-wip support to emacs add the following to your `.emacs`. Doing\nso will make it be invoked after every `save-buffer` operation.\n\n    (load \"/{path_to_git-wip}/emacs/git-wip.el\")\n\nOr you may also copy the content of git-wip.el in your `.emacs`.\n\n## sublime\n\nA sublime plugin was contributed as well.  You will find it in the `sublime`\ndirectory.\n\n# recovery\n\nShould you discover that you made some really bad changes in your code,\nfrom which you want to recover, here is what to do.\n\nFirst we need to find the commit we are interested in.  If it's the most recent\nthen it can be referenced with `wip/master` (assuming your branch is `master`),\notherwise you may need to find the one you want using:\n\n    git reflog show wip/master\n\nI personally prefer to inspect the reflog with `git log -g`, and sometimes \nwith `-p` also:\n\n    git log -g -p wip/master\n\nOnce you've picked a commit, you need to checkout the files, note that we are not\nswitching the commit that your branch points to (HEAD will continue to reference\nthe last real commit on the branch).  We are just checking out the files:\n\n    git checkout ref -- .\n\nHere `ref` could be a SHA1 or `wip/master`.  If you only want to recover one file,\nthen use it's path instead of the *dot*.\n\nThe changes will be staged in the index and checked out into the working tree, to\nreview what the differences are between the last commit, use:\n\n    git diff --cached\n\nIf you want, you can unstage all or some with `git reset`, optionally specifying a\nfilename to unstage.  You can then stage them again using `git add` or `git add -p`.\nFinally, when you're happy with the changes, commit them.\n\n# related\n\n- [wip.rs](https://github.com/dlight/wip.rs) is a Rust executable that watches for changes in your work, and invokes `git-wip` as needed. (by [dlight](https://github.com/dlight)).\n\n<!-- vim: set ft=markdown -->\n"
  },
  {
    "path": "Attic/git-wip",
    "content": "#!/usr/bin/env bash\n#\n# Copyright Bart Trojanowski <bart@jukie.net>\n#\n# git-wip is a script that will manage Work In Progress (or WIP) branches.\n# WIP branches are mostly throw away but identify points of development\n# between commits.  The intent is to tie this script into your editor so\n# that each time you save your file, the git-wip script captures that\n# state in git.  git-wip also helps you return back to a previous state of\n# development.\n#\n# See also http://github.com/bartman/git-wip\n#\n# The code is licensed as GPL v2 or, at your option, any later version.\n# Please see http://www.gnu.org/licenses/gpl-2.0.txt\n#\n\nUSAGE='[ info | save <message> [ --editor | --untracked | --no-gpg-sign ] | log [ --pretty ] | delete ] [ [--] <file>... ]'\nLONG_USAGE=\"Manage Work In Progress branches\n\nCommands:\n        git wip                   - create a new WIP commit\n        git wip save <message>    - create a new WIP commit with custom message\n        git wip info [<branch>]   - brief WIP info\n        git wip log [<branch>]    - show changes on the WIP branch\n        git wip delete [<branch>] - delete a WIP branch\n\nOptions for save:\n        -e --editor               - be less verbose, assume called from an editor\n        -u --untracked            - capture also untracked files\n        -i --ignored              - capture also ignored files\n        --no-gpg-sign             - do not sign commit; that is, countermand\n                                    'commit.gpgSign = true'\n\nOptions for log:\n        -p --pretty               - show a pretty graph\n\t-r --reflog               - show changes in reflog\n\t-s --stat                 - show diffstat\n\"\n\nSUBDIRECTORY_OK=Yes\nOPTIONS_SPEC=\n\n. \"$(git --exec-path)/git-sh-setup\"\n\nrequire_work_tree\n\nTMP=\"$GIT_DIR/.git-wip.$$\"\ntrap 'rm -f \"$TMP-*\"' 0\n\nWIP_INDEX=\"$TMP-INDEX\"\n\nWIP_PREFIX=refs/wip/\nWIP_COMMAND=\nWIP_MESSAGE=WIP\nEDITOR_MODE=false\n\ndbg() {\n\tif test -n \"$WIP_DEBUG\"\n\tthen\n\t\tprintf '# %s\\n' \"$*\"\n\tfi\n}\n\n# some errors are not worth reporting in --editor mode\nreport_soft_error () {\n\t$EDITOR_MODE && exit 0\n\tdie \"$@\"\n}\n\ncleanup () {\n\trm -f \"$TMP-*\"\n}\n\nget_work_branch () {\n\tref=$(git symbolic-ref -q HEAD) \\\n\t|| report_soft_error \"git-wip requires a branch\"\n\n\n\tbranch=${ref#refs/heads/}\n\tif [ $branch = $ref ] ; then\n\t\tdie \"git-wip requires a local branch\"\n\tfi\n\n\techo $branch\n}\n\nget_wip_branch () {\n\treturn 0\n}\n\ncheck_files () {\n\tlocal -a files=( \"$@\" )\n\n\tfor f in \"${files[@]}\"\n\tdo\n\t\t[ -f \"$f\" -o -d \"$f\" ] || die \"$f: No such file or directory.\"\n\tdone\n}\n\nbuild_new_tree () {\n\tlocal untracked=$1 ; shift\n\tlocal ignored=$1 ; shift\n\tlocal -a files=( \"$@\" )\n\n\t(\n\tset -e\n\trm -f \"$WIP_INDEX\"\n\tcp -p \"$GIT_DIR/index\" \"$WIP_INDEX\"\n\texport GIT_INDEX_FILE=\"$WIP_INDEX\"\n\tgit read-tree $wip_parent\n\tif test \"${#files[@]}\" -gt 0\n\tthen\n\t\tgit add -f \"${files[@]}\"\n\telse\n\t\tgit add --update .\n\tfi\n\t[ -n \"$untracked\" ] && git add .\n\t[ -n \"$ignored\" ] && git add -f -A .\n\tgit write-tree\n\trm -f \"$WIP_INDEX\"\n\t)\n}\n\ndo_save () {\n\tlocal msg=\"$1\" ; shift\n\tlocal add_untracked=\n\tlocal add_ignored=\n\tlocal no_sign=\n\n\twhile test $# != 0\n\tdo\n\t\tcase \"$1\" in\n\t\t-e|--editor)\n\t\t\tEDITOR_MODE=true\n\t\t\t;;\n\t\t-u|--untracked)\n\t\t\tadd_untracked=t\n\t\t\t;;\n\t\t-i|--ignored)\n\t\t\tadd_ignored=t\n\t\t\t;;\n\t\t--no-gpg-sign)\n\t\t\tno_sign=--no-gpg-sign\n\t\t\t;;\n\t\t--)\n\t\t\tshift\n\t\t\tbreak\n\t\t\t;;\n\t\t*)\n\t\t\t[ -f \"$1\" ] && break\n\t\t\tdie \"Unknown option '$1'.\"\n\t\t\t;;\n\t\tesac\n\t\tshift\n\tdone\n\tlocal -a files=( \"$@\" )\n\tlocal \"add_untracked=$add_untracked\"\n\tlocal \"add_ignored=$add_ignored\"\n\n\tif test \"${#files[@]}\" -gt 0\n\tthen\n\t\tdbg \"check_files ${files[@]}\"\n\t\tcheck_files \"${files[@]}\"\n\tfi\n\n\tdbg \"msg=$msg\"\n\tdbg \"files=$files\"\n\n\tlocal work_branch=$(get_work_branch)\n\tlocal wip_branch=\"$WIP_PREFIX$work_branch\"\n\n\tdbg \"work_branch=$work_branch\"\n\tdbg \"wip_branch=$wip_branch\"\n\n\t# enable reflog\n\tlocal wip_branch_reflog=\"$GIT_DIR/logs/$wip_branch\"\n\tdbg \"wip_branch_reflog=$wip_branch_reflog\"\n\tmkdir -p \"$(dirname \"$wip_branch_reflog\")\"\n\t: >>\"$wip_branch_reflog\"\n\n\tif ! work_last=$(git rev-parse --verify $work_branch)\n\tthen\n\t\treport_soft_error \"'$work_branch' branch has no commits.\"\n\tfi\n\n\tdbg \"work_last=$work_last\"\n\n\tif wip_last=$(git rev-parse --quiet --verify $wip_branch)\n\tthen\n\t\tlocal base=$(git merge-base $wip_last $work_last) \\\n\t\t|| die \"'work_branch' and '$wip_branch' are unrelated.\"\n\n\t\tif [ $base = $work_last ] ; then\n\t\t\twip_parent=$wip_last\n\t\telse\n\t\t\twip_parent=$work_last\n\t\tfi\n\telse\n\t\t# remove empty/corrupt wip branch file\n\t\tlocal wip_branch_file=\"$GIT_DIR/$wip_branch\"\n\t\tif test -e \"$wip_branch_file\"\n\t\tthen\n\t\t\tdbg \"removing $wip_branch_file\"\n\t\t\trm -f \"$wip_branch_file\"\n\t\tfi\n\t\t# use the working branch for parent\n\t\twip_parent=$work_last\n\tfi\n\n\tdbg \"wip_parent=$wip_parent\"\n\n\tnew_tree=$( build_new_tree \"$add_untracked\" \"$add_ignored\" \"${files[@]}\" ) \\\n\t|| die \"Cannot save the current worktree state.\"\n\n\tdbg \"new_tree=$new_tree\"\n\n\tif git diff-tree --exit-code --quiet $new_tree $wip_parent ; then\n\t\treport_soft_error \"no changes\"\n\tfi\n\n\tdbg \"... has changes\"\n\n\tnew_wip=$(printf '%s\\n' \"$msg\" | git commit-tree $no_sign $new_tree -p $wip_parent 2>/dev/null) \\\n\t|| die \"Cannot record working tree state\"\n\n\tdbg \"new_wip=$new_wip\"\n\n\tmsg1=$(printf '%s\\n' \"$msg\" | sed -e 1q)\n\tgit update-ref -m \"git-wip: $msg1\" $wip_branch $new_wip $wip_last\n\n\tdbg \"SUCCESS\"\n}\n\ndo_info () {\n\tlocal branch=$1\n\n\tdie \"info not implemented\"\n}\n\ndo_log () {\n\tlocal work_branch=$1\n\t[ -z $branch ] && work_branch=$(get_work_branch)\n\tlocal wip_branch=\"$WIP_PREFIX$work_branch\"\n\n\tlocal log_cmd=\"log\"\n\tlocal graph=\"\"\n\tlocal pretty=\"\"\n\tlocal stat=\"\"\n\twhile [ -n \"$1\" ]\n\tdo\n\t\tcase \"$1\" in\n\t\t\t-p|--pretty)\n\t\t\t\tgraph=\"--graph\"\n\t\t\t\tpretty=\"--pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr)%Creset' --abbrev-commit --date=relative\"\n\t\t\t\t;;\n\t\t\t-s|--stat)\n\t\t\t\tstat=\"--stat\"\n\t\t\t\t;;\n\t\t\t-r|--reflog)\n\t\t\t\tlog_cmd=\"reflog\"\n\t\t\t\t;;\n\t\t\t*)\n\t\t\t\tbreak\n\t\t\t\t;;\n\t\tesac\n\t\tshift\n\tdone\n\n\tif [ $log_cmd = reflog ]\n\tthen\n\t\techo git reflog $stat $pretty $wip_branch | sh\n\t\treturn $?\n\tfi\n\n\tif ! work_last=$(git rev-parse --verify $work_branch)\n\tthen\n\t\tdie \"'$work_branch' branch has no commits.\"\n\tfi\n\n\tdbg work_last=$work_last\n\n\tif ! wip_last=$(git rev-parse --quiet --verify $wip_branch)\n\tthen\n\t\tdie \"'$work_branch' branch has no commits.\"\n\tfi\n\n\tdbg wip_last=$wip_last\n\n\tlocal base=$(git merge-base $wip_last $work_last)\n\n\tdbg base=$base\n\n\tlocal stop=$base\n\tif git cat-file commit $base | grep -q '^parent' ; then\n\t\tstop=\"$base~1\"\n\tfi\n\n\tdbg stop=$stop\n\n\techo git log $graph $stat $pretty \"$@\" $wip_last $work_last \"^$stop\" | sh\n}\n\ndo_delete () {\n\tlocal branch=$1\n\n\tdie \"delete not implemented\"\n}\n\ndo_help () {\n\tlocal rc=$1\n\n\tcat <<END\nUsage: git wip $USAGE\n\n$LONG_USAGE\nEND\n\texit $rc\n}\n\n\nif test $# -eq 0\nthen\n\tdbg \"no arguments\"\n\n\tdo_save \"WIP\"\n\texit $?\nfi\n\ndbg \"args: $@\"\n\ncase \"$1\" in\nsave)\n\tWIP_COMMAND=$1\n\tshift\n\tif [ -n \"$1\" ] && [[ \"$1\" != -* ]]\n\tthen\n\t\tWIP_MESSAGE=\"$1\"\n\t\tshift\n\tfi\n\t;;\ninfo|log|delete)\n\tWIP_COMMAND=$1\n\tshift\n\t;;\nhelp)\n\tdo_help 0\n\t;;\n--*)\n\t;;\n*)\n\t[ -f \"$1\" ] || die \"Unknown command '$1'.\"\n\t;;\nesac\n\ncase $WIP_COMMAND in\nsave)\n\tdo_save \"$WIP_MESSAGE\" \"$@\"\n\t;;\ninfo)\n\tdo_info \"$@\"\n\t;;\nlog)\n\tdo_log \"$@\"\n\t;;\ndelete)\n\tdo_delete \"$@\"\n\t;;\n*)\n\tusage\n\texit 1\n\t;;\nesac\n\n# vim: set noet sw=8\n"
  },
  {
    "path": "Attic/tests/test-git-wip.sh",
    "content": "#!/bin/bash\n\nset -e\n\n# split the script name into BASE directory, and the name it calls itSELF\nBASE=$(realpath ${0%/*})\nSELF=${0##*/}\n\n# this tells git where to find our copy of git-wip script\nexport PATH=\"$BASE:$PATH\"\n\n# keep state for each test in this temporary tree\nTEST_TREE=/tmp/git-wip-test-$$\nREPO=\"$TEST_TREE/repo\"\nCMD=\"$TEST_TREE/cmd\"\nOUT=\"$TEST_TREE/out\"\nRC=\"$TEST_TREE/rc\"\n\n# ------------------------------------------------------------------------\n# some helpful utility functions\n\ndie() { echo >&2 \"ERROR: $@\" ; exit 1 ; }\nwarn() { echo >&2 \"WARNING: $@\" ; }\ncomment() { echo >&2 \"# $@\" ; }\n\n_RUN() {\n\tcomment \"$@\"\n\t[ `pwd` = \"$REPO\" ] || die \"expecting to be in $REPO, not `pwd`\"\n\n\tset +e\n\techo \"$@\" >\"$CMD\"\n\teval \"$@\" >\"$OUT\" 2>&1\n\techo \"$?\" >\"$RC\"\n\tset -e\n}\n\nRUN() {\n\t_RUN \"$@\"\n\tlocal rc=\"`cat $RC`\"\n\t[ \"$rc\" = 0 ] || handle_error\n}\n\nEXP_none() {\n\tlocal out=\"$(head -n1 \"$OUT\")\"\n\tif [ -n \"$out\" ] ; then\n\t\twarn \"out: $out\"\n\t\thandle_error\n\tfi\n}\n\nEXP_text() {\n\tlocal exp=\"$1\"\n\tlocal out=\"$(head -n1 \"$OUT\")\"\n\tif [ \"$out\" != \"$exp\" ] ; then\n\t\twarn \"exp: $exp\"\n\t\twarn \"out: $out\"\n\t\thandle_error\n\tfi\n}\n\nEXP_grep() {\n\tif ! grep -q \"$@\" < \"$OUT\" ; then\n\t\twarn \"grep: $@\"\n\t\thandle_error\n\tfi\n}\n\ncreate_test_tree() {\n\trm -rf \"$REPO\"\n\tmkdir -p \"$REPO\"\n\tcd \"$REPO\"\n\tRUN git init\n}\n\ncleanup_test_tree() {\n\trm -rf \"$TEST_TREE\"\n}\n\nhandle_error() {\n\tset +e\n\twarn \"CMD='`cat $CMD`' RC=`cat $RC`\"\n\tcat >&1 \"$OUT\"\n\tcleanup_test_tree\n\texit 1\n}\n\ntrap cleanup_test_tree EXIT\ntrap handle_error INT TERM ERR\n\n# ------------------------------------------------------------------------\n# tests\n\ntest_general() {\n\t# init\n\n\tcreate_test_tree\n\tRUN \"echo 1 >README\"\n\tRUN git add README\n\tRUN git commit -m README\n\n\t# run wip w/o changes\n\n\t_RUN git wip save\n\tEXP_text \"no changes\"\n\n\tRUN git wip save --editor\n\tEXP_none\n\n\tRUN git wip save -e\n\tEXP_none\n\n\t# expecting a master branch\n\n\tRUN git branch\n\tEXP_grep \"^\\* master$\"\n\tEXP_grep -v \"wip\"\n\n\t# not expecting  wip ref at this time\n\n\tRUN git for-each-ref\n\tEXP_grep -v \"commit.refs/wip/master$\"\n\n\t# make changes, store wip\n\n\tRUN \"echo 2 >README\"\n\tRUN git wip save --editor\n\tEXP_none\n\n\t# expecting a wip ref\n\n\tRUN git for-each-ref\n\tEXP_grep \"commit.refs/wip/master$\"\n\n\t# expecting a log entry\n\n\tRUN git wip log\n\tEXP_grep \"^commit \"\n\tEXP_grep \"^\\s\\+WIP$\"\n\n\t# there should be no wip branch\n\n\tRUN git branch\n\tEXP_grep -v \"wip\"\n\n\t# make changes, store wip\n\n\tRUN \"echo 3 >README\"\n\tRUN git wip save \"\\\"message2\\\"\"\n\tEXP_none\n\n\t# expecting both log entries\n\n\tRUN git wip log\n\tEXP_grep \"^commit \"\n\tEXP_grep \"^\\s\\+WIP$\"\n\tEXP_grep \"^\\s\\+message2$\"\n\n\t# make a commit\n\n\tRUN git add -u README\n\tRUN git commit -m README.2\n\n\t# make changes, store wip\n\n\tRUN \"echo 4 >UNTRACKED\"\n\tRUN \"echo 4 >README\"\n\tRUN git wip save \"\\\"message3\\\"\"\n\tEXP_none\n\n\t# expecting message3, not message2 or original WIP\n\n\tRUN git wip log\n\tEXP_grep \"^commit \"\n\tEXP_grep -v \"^\\s\\+WIP$\"\n\tEXP_grep -v \"^\\s\\+message2$\"\n\tEXP_grep \"^\\s\\+message3$\"\n\n\t# expecting file changes to README, not UNTRACKED\n\n\tRUN git wip log --stat\n\tEXP_grep \"^commit \"\n\tEXP_grep \"^ README | 2\"\n\tEXP_grep -v \"UNTRACKED\"\n\n\t# need to be able to extract latest data from git wip branch\n\n\tRUN git show HEAD:README\n\tEXP_grep '^3$'\n\tEXP_grep -v '^4$'\n\n\tRUN git show wip/master:README\n\tEXP_grep -v '^3$'\n\tEXP_grep '^4$'\n}\n\ntest_spaces() {\n\t# init\n\n\tcreate_test_tree\n\tRUN \"echo 1 >\\\"s p a c e s\\\"\"\n\tRUN git add \"\\\"s p a c e s\\\"\"\n\tRUN git commit -m \"\\\"s p a c e s\\\"\"\n\n\t# make changes, store wip\n\n\tRUN \"echo 2 >\\\"s p a c e s\\\"\"\n\tRUN git wip save \"\\\"message with spaces\\\"\"\n\tEXP_none\n\n\t# expecting a wip ref\n\n\tRUN git for-each-ref\n\tEXP_grep \"commit.refs/wip/master$\"\n\n\t# expecting a log entry\n\n\tRUN git wip log\n\tEXP_grep \"^commit \"\n\tEXP_grep \"^\\s\\+message with spaces$\"\n}\n\n# ------------------------------------------------------------------------\n# run tests\n\nTESTS=( test_general test_spaces )\n\nfor TEST in \"${TESTS[@]}\" ; do\n\techo \"-- $TEST\"\n\t$TEST\n\techo \"OK\"\ndone\n\ntrap - INT TERM ERR\ncleanup_test_tree\necho \"DONE\"\n"
  },
  {
    "path": "CMakeLists.txt",
    "content": "cmake_minimum_required(VERSION 3.26)\nproject(git-wip LANGUAGES CXX)\n\nset(CMAKE_CXX_STANDARD 23)\nset(CMAKE_CXX_STANDARD_REQUIRED ON)\nset(CMAKE_CXX_EXTENSIONS OFF)\n\n# Enable generation of compile_commands.json\nset(CMAKE_EXPORT_COMPILE_COMMANDS ON)\n\noption(WIP_COVERAGE \"Enable code coverage instrumentation\" OFF)\noption(WIP_STATIC  \"Build a fully static binary\"           OFF)\n\ninclude(FetchContent)\ninclude(CheckCXXSourceCompiles)\n\nFetchContent_Declare(\n    spdlog\n    GIT_REPOSITORY https://github.com/gabime/spdlog.git\n    GIT_TAG v1.15.3\n)\n\nFetchContent_MakeAvailable(spdlog)\n\n# Code coverage options — applied AFTER FetchContent so that third-party\n# dependencies (spdlog, fmt, …) are NOT instrumented.  Mixing gcov versions\n# between the host compiler and a pre-built dep causes \"version mismatch\"\n# errors at runtime that pollute test output.\nif(WIP_COVERAGE)\n    add_compile_options(-fprofile-arcs -ftest-coverage)\n    add_link_options(-fprofile-arcs -ftest-coverage)\nendif()\n\n# Detect whether the compiler's standard library ships <print> (C++23 P2093).\n# GCC < 14 and some older clangs lack it even with -std=c++23.\ncheck_cxx_source_compiles(\"\n    #include <print>\n    int main() { std::println(\\\"ok\\\"); }\n\" WIP_HAVE_STD_PRINT)\n\nif(WIP_HAVE_STD_PRINT)\n    message(STATUS \"std::print available — using <print>\")\nelse()\n    message(STATUS \"std::print not available — fetching {fmt} as fallback\")\n    FetchContent_Declare(\n        fmt\n        GIT_REPOSITORY https://github.com/fmtlib/fmt.git\n        GIT_TAG 11.1.4\n    )\n    FetchContent_MakeAvailable(fmt)\nendif()\n\ninclude(FindPkgConfig)\n\nif(WIP_STATIC)\n    message(STATUS \"WIP_STATIC=ON — building a mostly-static binary (libgit2 and all its deps statically linked; glibc stays shared)\")\n    pkg_check_modules(LIBGIT2 REQUIRED libgit2)\n\n    # Collect the full transitive static link flags from pkg-config.\n    # We split them into two groups so CMake can handle each token cleanly:\n    #   LIBGIT2_STATIC_LIBS  — -lfoo tokens (library names, no -L prefix)\n    #   LIBGIT2_STATIC_DIRS  — -L/path tokens (search paths)\n    execute_process(\n        COMMAND pkg-config --static --libs libgit2\n        OUTPUT_VARIABLE _git2_raw\n        OUTPUT_STRIP_TRAILING_WHITESPACE\n    )\n    execute_process(\n        COMMAND pkg-config --static --libs-only-l libgit2\n        OUTPUT_VARIABLE _git2_libs_raw\n        OUTPUT_STRIP_TRAILING_WHITESPACE\n    )\n    execute_process(\n        COMMAND pkg-config --static --libs-only-L libgit2\n        OUTPUT_VARIABLE _git2_dirs_raw\n        OUTPUT_STRIP_TRAILING_WHITESPACE\n    )\n    # Also pull in the transitive deps of libssh2 (uses OpenSSL on Debian)\n    execute_process(\n        COMMAND pkg-config --static --libs-only-l libssh2\n        OUTPUT_VARIABLE _ssh2_libs_raw\n        OUTPUT_STRIP_TRAILING_WHITESPACE\n    )\n    string(REPLACE \"-l\" \"\" _git2_libs_stripped \"${_git2_libs_raw}\")\n    string(REPLACE \"-L\" \"\" _git2_dirs_stripped \"${_git2_dirs_raw}\")\n    string(REPLACE \"-l\" \"\" _ssh2_libs_stripped \"${_ssh2_libs_raw}\")\n    separate_arguments(LIBGIT2_STATIC_LIBS UNIX_COMMAND \"${_git2_libs_stripped}\")\n    separate_arguments(LIBGIT2_STATIC_DIRS UNIX_COMMAND \"${_git2_dirs_stripped}\")\n    separate_arguments(SSH2_STATIC_LIBS    UNIX_COMMAND \"${_ssh2_libs_stripped}\")\n    # Deduplicate (SSH2 libs are a subset)\n    list(APPEND LIBGIT2_STATIC_LIBS ${SSH2_STATIC_LIBS})\n    list(REMOVE_DUPLICATES LIBGIT2_STATIC_LIBS)\nelse()\n    pkg_check_modules(LIBGIT2 REQUIRED libgit2)\nendif()\n\n# Optional: GTest/GMock for unit tests (only available on some distros)\nfind_package(GTest QUIET)\n\nenable_testing()\n\nif(GTest_FOUND)\n    message(STATUS \"GTest found — building unit tests\")\n    add_subdirectory(test/unit)\nelse()\n    message(STATUS \"GTest not found — skipping unit tests\")\nendif()\n\nadd_subdirectory(test/cli)\nadd_subdirectory(test/nvim)\n\n# the executable\nadd_subdirectory(src)\n"
  },
  {
    "path": "Dockerfile-deb",
    "content": "# vim: set ft=dockerfile\nFROM debian:testing\n\nWORKDIR /home/git-wip\nCOPY dependencies.sh dependencies.sh\n\n# Install necessary packages\nRUN apt-get update \\\n    && apt-get install -y sudo procps bash \\\n    && bash dependencies.sh \\\n    && apt-get clean \\\n    && rm -rf /var/lib/apt/lists/*\n\n# this lets us have the same UID:GID in the container\nARG UID\nARG GID\nARG USERNAME\nRUN getent group users || groupadd -g $GID users\nRUN useradd -u $UID -g $GID -m -s /bin/bash $USERNAME\nRUN echo \"$USERNAME  ALL=(ALL:ALL)  NOPASSWD:SETENV: ALL\" > \"/etc/sudoers.d/$USERNAME\"\nUSER $USERNAME\n\n# Create ~/.local/bin directory, add it to the PATH\nRUN mkdir -p /home/${USERNAME}/.local/bin\nRUN echo 'export PATH=$HOME/.local/bin:$PATH' >> /home/${USERNAME}/.bashrc\n\n# Make git usable inside container\nRUN git config --global --add safe.directory /home/git-wip\n\nARG GIT_EMAIL\nARG GIT_NAME\nENV GIT_EMAIL=${GIT_EMAIL}\nENV GIT_NAME=${GIT_NAME}\nRUN git config --global user.email \"${GIT_EMAIL}\"\nRUN git config --global user.name \"${GIT_NAME}\"\n\n# Map the current directory to the container's ~/git-wip\nWORKDIR /home/git-wip\n\n# Keep running\nCMD [\"tail\", \"-f\", \"/dev/null\"]\n\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 2, June 1991\n\n Copyright (C) 1989, 1991 Free Software Foundation, Inc.,\n 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The licenses for most software are designed to take away your\nfreedom to share and change it.  By contrast, the GNU General Public\nLicense is intended to guarantee your freedom to share and change free\nsoftware--to make sure the software is free for all its users.  This\nGeneral Public License applies to most of the Free Software\nFoundation's software and to any other program whose authors commit to\nusing it.  (Some other Free Software Foundation software is covered by\nthe GNU Lesser General Public License instead.)  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthis service if you wish), that you receive source code or can get it\nif you want it, that you can change the software or use pieces of it\nin new free programs; and that you know you can do these things.\n\n  To protect your rights, we need to make restrictions that forbid\nanyone to deny you these rights or to ask you to surrender the rights.\nThese restrictions translate to certain responsibilities for you if you\ndistribute copies of the software, or if you modify it.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must give the recipients all the rights that\nyou have.  You must make sure that they, too, receive or can get the\nsource code.  And you must show them these terms so they know their\nrights.\n\n  We protect your rights with two steps: (1) copyright the software, and\n(2) offer you this license which gives you legal permission to copy,\ndistribute and/or modify the software.\n\n  Also, for each author's protection and ours, we want to make certain\nthat everyone understands that there is no warranty for this free\nsoftware.  If the software is modified by someone else and passed on, we\nwant its recipients to know that what they have is not the original, so\nthat any problems introduced by others will not reflect on the original\nauthors' reputations.\n\n  Finally, any free program is threatened constantly by software\npatents.  We wish to avoid the danger that redistributors of a free\nprogram will individually obtain patent licenses, in effect making the\nprogram proprietary.  To prevent this, we have made it clear that any\npatent must be licensed for everyone's free use or not licensed at all.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                    GNU GENERAL PUBLIC LICENSE\n   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION\n\n  0. This License applies to any program or other work which contains\na notice placed by the copyright holder saying it may be distributed\nunder the terms of this General Public License.  The \"Program\", below,\nrefers to any such program or work, and a \"work based on the Program\"\nmeans either the Program or any derivative work under copyright law:\nthat is to say, a work containing the Program or a portion of it,\neither verbatim or with modifications and/or translated into another\nlanguage.  (Hereinafter, translation is included without limitation in\nthe term \"modification\".)  Each licensee is addressed as \"you\".\n\nActivities other than copying, distribution and modification are not\ncovered by this License; they are outside its scope.  The act of\nrunning the Program is not restricted, and the output from the Program\nis covered only if its contents constitute a work based on the\nProgram (independent of having been made by running the Program).\nWhether that is true depends on what the Program does.\n\n  1. You may copy and distribute verbatim copies of the Program's\nsource code as you receive it, in any medium, provided that you\nconspicuously and appropriately publish on each copy an appropriate\ncopyright notice and disclaimer of warranty; keep intact all the\nnotices that refer to this License and to the absence of any warranty;\nand give any other recipients of the Program a copy of this License\nalong with the Program.\n\nYou may charge a fee for the physical act of transferring a copy, and\nyou may at your option offer warranty protection in exchange for a fee.\n\n  2. You may modify your copy or copies of the Program or any portion\nof it, thus forming a work based on the Program, and copy and\ndistribute such modifications or work under the terms of Section 1\nabove, provided that you also meet all of these conditions:\n\n    a) You must cause the modified files to carry prominent notices\n    stating that you changed the files and the date of any change.\n\n    b) You must cause any work that you distribute or publish, that in\n    whole or in part contains or is derived from the Program or any\n    part thereof, to be licensed as a whole at no charge to all third\n    parties under the terms of this License.\n\n    c) If the modified program normally reads commands interactively\n    when run, you must cause it, when started running for such\n    interactive use in the most ordinary way, to print or display an\n    announcement including an appropriate copyright notice and a\n    notice that there is no warranty (or else, saying that you provide\n    a warranty) and that users may redistribute the program under\n    these conditions, and telling the user how to view a copy of this\n    License.  (Exception: if the Program itself is interactive but\n    does not normally print such an announcement, your work based on\n    the Program is not required to print an announcement.)\n\nThese requirements apply to the modified work as a whole.  If\nidentifiable sections of that work are not derived from the Program,\nand can be reasonably considered independent and separate works in\nthemselves, then this License, and its terms, do not apply to those\nsections when you distribute them as separate works.  But when you\ndistribute the same sections as part of a whole which is a work based\non the Program, the distribution of the whole must be on the terms of\nthis License, whose permissions for other licensees extend to the\nentire whole, and thus to each and every part regardless of who wrote it.\n\nThus, it is not the intent of this section to claim rights or contest\nyour rights to work written entirely by you; rather, the intent is to\nexercise the right to control the distribution of derivative or\ncollective works based on the Program.\n\nIn addition, mere aggregation of another work not based on the Program\nwith the Program (or with a work based on the Program) on a volume of\na storage or distribution medium does not bring the other work under\nthe scope of this License.\n\n  3. You may copy and distribute the Program (or a work based on it,\nunder Section 2) in object code or executable form under the terms of\nSections 1 and 2 above provided that you also do one of the following:\n\n    a) Accompany it with the complete corresponding machine-readable\n    source code, which must be distributed under the terms of Sections\n    1 and 2 above on a medium customarily used for software interchange; or,\n\n    b) Accompany it with a written offer, valid for at least three\n    years, to give any third party, for a charge no more than your\n    cost of physically performing source distribution, a complete\n    machine-readable copy of the corresponding source code, to be\n    distributed under the terms of Sections 1 and 2 above on a medium\n    customarily used for software interchange; or,\n\n    c) Accompany it with the information you received as to the offer\n    to distribute corresponding source code.  (This alternative is\n    allowed only for noncommercial distribution and only if you\n    received the program in object code or executable form with such\n    an offer, in accord with Subsection b above.)\n\nThe source code for a work means the preferred form of the work for\nmaking modifications to it.  For an executable work, complete source\ncode means all the source code for all modules it contains, plus any\nassociated interface definition files, plus the scripts used to\ncontrol compilation and installation of the executable.  However, as a\nspecial exception, the source code distributed need not include\nanything that is normally distributed (in either source or binary\nform) with the major components (compiler, kernel, and so on) of the\noperating system on which the executable runs, unless that component\nitself accompanies the executable.\n\nIf distribution of executable or object code is made by offering\naccess to copy from a designated place, then offering equivalent\naccess to copy the source code from the same place counts as\ndistribution of the source code, even though third parties are not\ncompelled to copy the source along with the object code.\n\n  4. You may not copy, modify, sublicense, or distribute the Program\nexcept as expressly provided under this License.  Any attempt\notherwise to copy, modify, sublicense or distribute the Program is\nvoid, and will automatically terminate your rights under this License.\nHowever, parties who have received copies, or rights, from you under\nthis License will not have their licenses terminated so long as such\nparties remain in full compliance.\n\n  5. You are not required to accept this License, since you have not\nsigned it.  However, nothing else grants you permission to modify or\ndistribute the Program or its derivative works.  These actions are\nprohibited by law if you do not accept this License.  Therefore, by\nmodifying or distributing the Program (or any work based on the\nProgram), you indicate your acceptance of this License to do so, and\nall its terms and conditions for copying, distributing or modifying\nthe Program or works based on it.\n\n  6. Each time you redistribute the Program (or any work based on the\nProgram), the recipient automatically receives a license from the\noriginal licensor to copy, distribute or modify the Program subject to\nthese terms and conditions.  You may not impose any further\nrestrictions on the recipients' exercise of the rights granted herein.\nYou are not responsible for enforcing compliance by third parties to\nthis License.\n\n  7. If, as a consequence of a court judgment or allegation of patent\ninfringement or for any other reason (not limited to patent issues),\nconditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot\ndistribute so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you\nmay not distribute the Program at all.  For example, if a patent\nlicense would not permit royalty-free redistribution of the Program by\nall those who receive copies directly or indirectly through you, then\nthe only way you could satisfy both it and this License would be to\nrefrain entirely from distribution of the Program.\n\nIf any portion of this section is held invalid or unenforceable under\nany particular circumstance, the balance of the section is intended to\napply and the section as a whole is intended to apply in other\ncircumstances.\n\nIt is not the purpose of this section to induce you to infringe any\npatents or other property right claims or to contest validity of any\nsuch claims; this section has the sole purpose of protecting the\nintegrity of the free software distribution system, which is\nimplemented by public license practices.  Many people have made\ngenerous contributions to the wide range of software distributed\nthrough that system in reliance on consistent application of that\nsystem; it is up to the author/donor to decide if he or she is willing\nto distribute software through any other system and a licensee cannot\nimpose that choice.\n\nThis section is intended to make thoroughly clear what is believed to\nbe a consequence of the rest of this License.\n\n  8. If the distribution and/or use of the Program is restricted in\ncertain countries either by patents or by copyrighted interfaces, the\noriginal copyright holder who places the Program under this License\nmay add an explicit geographical distribution limitation excluding\nthose countries, so that distribution is permitted only in or among\ncountries not thus excluded.  In such case, this License incorporates\nthe limitation as if written in the body of this License.\n\n  9. The Free Software Foundation may publish revised and/or new versions\nof the General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\nEach version is given a distinguishing version number.  If the Program\nspecifies a version number of this License which applies to it and \"any\nlater version\", you have the option of following the terms and conditions\neither of that version or of any later version published by the Free\nSoftware Foundation.  If the Program does not specify a version number of\nthis License, you may choose any version ever published by the Free Software\nFoundation.\n\n  10. If you wish to incorporate parts of the Program into other free\nprograms whose distribution conditions are different, write to the author\nto ask for permission.  For software which is copyrighted by the Free\nSoftware Foundation, write to the Free Software Foundation; we sometimes\nmake exceptions for this.  Our decision will be guided by the two goals\nof preserving the free status of all derivatives of our free software and\nof promoting the sharing and reuse of software generally.\n\n                            NO WARRANTY\n\n  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY\nFOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN\nOTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES\nPROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED\nOR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS\nTO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE\nPROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,\nREPAIR OR CORRECTION.\n\n  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR\nREDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,\nINCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING\nOUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED\nTO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY\nYOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER\nPROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE\nPOSSIBILITY OF SUCH DAMAGES.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nconvey the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software; you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation; either version 2 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License along\n    with this program; if not, write to the Free Software Foundation, Inc.,\n    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.\n\nAlso add information on how to contact you by electronic and paper mail.\n\nIf the program is interactive, make it output a short notice like this\nwhen it starts in an interactive mode:\n\n    Gnomovision version 69, Copyright (C) year name of author\n    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, the commands you use may\nbe called something other than `show w' and `show c'; they could even be\nmouse-clicks or menu items--whatever suits your program.\n\nYou should also get your employer (if you work as a programmer) or your\nschool, if any, to sign a \"copyright disclaimer\" for the program, if\nnecessary.  Here is a sample; alter the names:\n\n  Yoyodyne, Inc., hereby disclaims all copyright interest in the program\n  `Gnomovision' (which makes passes at compilers) written by James Hacker.\n\n  <signature of Ty Coon>, 1 April 1989\n  Ty Coon, President of Vice\n\nThis General Public License does not permit incorporating your program into\nproprietary programs.  If your program is a subroutine library, you may\nconsider it more useful to permit linking proprietary applications with the\nlibrary.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.\n"
  },
  {
    "path": "README.md",
    "content": "# About\n\n[![CI](https://github.com/bartman/git-wip/actions/workflows/ci.yml/badge.svg)](https://github.com/bartman/git-wip/actions)\n[![Codecov](https://codecov.io/gh/bartman/git-wip/branch/master/graph/badge.svg)](https://codecov.io/gh/bartman/git-wip)\n\n`git-wip` manages **Work In Progress** (or **WIP**) branches.\nWIP branches are mostly throw-away but capture points of development\nbetween commits.  The intent is to tie `git-wip` into your editor so\nthat each time you save a file the current working-tree state is\nsnapshotted in git.  `git-wip` also helps you return to a previous\nstate of development.\n\nLatest `git-wip` can be obtained from [github.com](http://github.com/bartman/git-wip).\n`git-wip` was written by [Bart Trojanowski](mailto:bart@jukie.net).\nYou can find out more from the original [blog post](http://www.jukie.net/bart/blog/save-everything-with-git-wip).\n\n> **Note:** `git-wip` was originally a bash script (2009).\n> This repository is a [C++ rewrite](http://www.jukie.net/bart/blog/git-wip-cpp-rewrite/);\n> tag [v0.2](https://github.com/bartman/git-wip/releases/tag/v0.2) is the last bash version.\n> The script was moved to `Attic/` and is no longer maintained.\n\n---\n\n## TL;DR\n\n```sh\n$ make\n$ ./dependencies.sh         # assumes you're on a Debian-based distro, and you have `sudo`\n$ make install              # installs to ~/.local by default\n$ cd my-project\n$ git wip                   # snapshot working tree → wip/master\n```\n\n---\n\n## How WIP branches work\n\nWIP branches are named after the current branch, prefixed with `wip/`.\nIf you are working on `feature`, the WIP branch is `wip/feature` (stored\nas `refs/wip/feature`).\n\n**First snapshot** — creates a commit on `wip/<branch>` rooted at the\ncurrent branch HEAD:\n\n```\n--- * --- * --- *          ← feature\n                 \\\n                  W        ← wip/feature\n```\n\n**Subsequent snapshots** — stacks new WIP commits on top:\n\n```\n--- * --- * --- *          ← feature\n                 \\\n                  W\n                   \\\n                    W      ← wip/feature\n```\n\n**After a real commit** — the next `git wip` detects that the work branch\nhas advanced and resets the WIP branch to start from the new HEAD:\n\n```\n--- * --- * --- * --- *    ← feature\n                 \\     \\\n                  W     W  ← wip/feature\n                   \\\n                    W  (reachable via reflog as wip/feature@{1})\n```\n\nOld WIP commits are never deleted; they remain reachable through\n`git reflog show wip/<branch>`.\n\n---\n\n## Commands\n\n### `git wip`\n\nSnapshot the working tree with the default message `\"WIP\"`.\nEquivalent to `git wip save \"WIP\"`.\n\n### `git wip [--version | -v | version]`\n\nShow the version string (from `git describe --tags --dirty=-dirty` at build time).\n\n```\n$ git wip --version\nv0.2-83-g95a6648-dirty\n```\n\n### `git wip save [<message>] [options] [-- <file>...]`\n\nCreate a new WIP commit.\n\n| Option | Description |\n|---|---|\n| `-e`, `--editor` | Quiet mode — exit 0 silently when there are no changes (for editor hooks) |\n| `-u`, `--untracked` | Also capture untracked files |\n| `-i`, `--ignored` | Also capture ignored files |\n| `--no-gpg-sign` | Do not GPG-sign the commit (overrides `commit.gpgSign = true`) |\n\nIf `<file>...` arguments are given, only those files are snapshotted.\nOtherwise all tracked files are updated.\n\n### `git wip status [-l] [-f] [<ref>]`\n\nShow the status of the WIP branch for the current work branch, or another branch.\n\n```\n$ git wip status\nbranch master has 5 wip commits on refs/wip/master\n```\n\n| Option | Description |\n|---|---|\n| `-l`, `--list` | List each WIP commit: short SHA, subject, and age |\n| `-f`, `--files` | Show a `git diff --stat` of changes relative to the work branch HEAD |\n\n`<ref>` is optional and can be any of:\n\n- `<branch>`\n- `wip/<branch>`\n- `refs/heads/<branch>`\n- `refs/wip/<branch>`\n\nIf `<ref>` is omitted, `git wip status` uses the current branch.\n\n`-l` and `-f` can be combined; each commit line is then followed by its\nown per-commit diff stat.\n\n```\n$ git wip status -l\nbranch master has 5 wip commits on refs/wip/master\n1d146bf - WIP (53 minutes ago)\na3f901c - save file.c (50 minutes ago)\n...\n\n$ git wip status -f\nbranch master has 5 wip commits on refs/wip/master\n src/main.c | 14 ++++++++------\n 1 file changed, 8 insertions(+), 6 deletions(-)\n\n$ git wip status -l -f\nbranch master has 5 wip commits on refs/wip/master\n1d146bf - WIP (53 minutes ago)\n src/main.c |  2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n...\n```\n\n### `git wip log [options]`\n\nShow the git log for the current WIP branch.\n\n| Option | Description |\n|---|---|\n| `-p`, `--pretty` | Compact colourised graph output |\n| `-s`, `--stat` | Include per-commit diff stat |\n| `-r`, `--reflog` | Show the reflog instead of the commit graph (useful for recovering old WIP stacks) |\n\n### `git wip list [-v]`\n\nList all WIP refs in the repository.\n\n```\n$ git wip list\nwip/master\nwip/feature\n```\n\n| Option | Description |\n|---|---|\n| `-v`, `--verbose` | Show how many WIP commits are ahead of the matching work branch; report orphaned WIP refs |\n\nVerbose output example:\n\n```\n$ git wip list -v\nwip/master has 5 commits ahead of master\nwip/feature has 1 commit ahead of feature\nwip/old-branch is orphaned\n```\n\n### `git wip delete [--yes] [<ref>]`\n\nDelete WIP refs.\n\n- With `<ref>`, deletes that branch's WIP ref (same ref formats as `status`):\n  - `<branch>`\n  - `wip/<branch>`\n  - `refs/heads/<branch>`\n  - `refs/wip/<branch>`\n- With no `<ref>`, deletes the current branch's WIP ref and asks for confirmation.\n- `--yes` skips the confirmation prompt (only relevant when `<ref>` is omitted).\n\n```\n$ git wip delete\nAbout to delete wip/master [Y/n]\n```\n\n### `git wip delete --cleanup`\n\nDelete orphaned WIP refs (any `refs/wip/<branch>` where `refs/heads/<branch>`\ndoes not exist).\n\n---\n\n## Building\n\nRequires: a C++23 compiler, CMake ≥ 3.26, Ninja, and `libgit2-dev`.\n\n```sh\n$ ./dependencies.sh # install packages needed to build\n$ make              # Release build → build/src/git-wip\n$ make TYPE=Debug   # Debug build\n$ make test         # Build + run all tests (unit + CLI integration)\n$ make install      # Install to ~/.local  (override with PREFIX=...)\n```\n\nDependencies (`spdlog`) are fetched automatically by CMake via\n`FetchContent`.  `libgit2` must be installed system-wide (e.g.\n`apt install libgit2-dev`).\n\nIf you'd rather build a static binary (tested on Debian/Ubuntu), you can use:\n\n```sh\n$ ./dependencies.sh --static  # install extra dependencies\n$ make STATIC=1               # build the static binary\n```\n\n---\n\n## Installation\n\n```sh\n$ git clone https://github.com/bartman/git-wip.git\n$ cd git-wip\n$ ./dependencies.sh\n$ make\n$ make install          # → ~/.local/bin/git-wip\n```\n\nOr copy the binary manually:\n\n```sh\n$ cp build/src/git-wip ~/bin/\n```\n\n---\n\n## Editor integration\n\n### vim\n\nThe vim plugin shells out to `git wip` on every file save, so the `git-wip`\nbinary must be installed and on your `PATH` before the plugin will do anything.\nEasiest way to do this is to run `make install`.\n\nVerify you're ready with:\n\n```sh\n$ git wip -h\n```\n\n**(1)** With [lazy.nvim](https://github.com/folke/lazy.nvim) (Neovim only):\n\n```lua\n{\n    \"bartman/git-wip\",\n    opts = {\n        gpg_sign = false,    -- true enables GPG signing of commits\n        untracked = true,    -- true to include untracked files\n        ignored = false,     -- true to include files ignored by .gitignore\n        background = false,  -- true for async execution if supported (Neovim 0.10+), false for sync\n        filetypes = { \"*\" }, -- list of vim file types to call git-wip on\n    },\n},\n```\n\n**(2)** With [Vundle](https://github.com/gmarik/Vundle.vim) (Vim only):\n\n```vim\nBundle 'bartman/git-wip', {'rtp': 'vim/'}\n```\n\n**(3)** Copy the plugin directly:\n\nNot really recommended (or supported), but if you want to you could copy it to your Neovim plugin directory...\n\n```sh\n$ cp lua/git-wip/init.vim ~/.config/nvim/plugin/lua/git-wip.lua\n```\nand then in your `~/.config/nvim/init.lua` you will need to:\n```lua\nrequire(\"git-wip\").setup({})\n```\n\nMeanwhile if you use Vim, you can do this:\n```\n$ cp vim/plugin/git-wip.vim ~/.vim/plugin/\n```\nand Vim will pick it up from there.\n\n**(4)** Or add an autocommand to your `.vimrc`:\n\nThis bypasses a plugin, just uses the executable.\n\n```vim\naugroup git-wip\n  autocmd!\n  autocmd BufWritePost * :silent !cd \"`dirname \"%\"`\" && git wip save \"WIP from vim\" --editor -- \"`basename \"%\"`\"\naugroup END\n```\n\nThe `--editor` flag makes `git-wip` silent when there are no changes.\n\n### emacs\n\nAdd to your `.emacs`:\n\n```lisp\n(load \"/{path_to_git-wip}/emacs/git-wip.el\")\n```\n\nOr copy the contents of `emacs/git-wip.el` directly into your `.emacs`.\n\n### Sublime Text\n\nA Sublime Text plugin is provided in the `sublime/` directory.\n\n---\n\n## Recovery\n\nFind the commit you want to recover:\n\n```sh\n$ git wip status -l           # show current WIP stack\n$ git reflog show wip/master  # show full history including reset points\n$ git log -g -p wip/master    # inspect with diffs\n```\n\nCheck out the files from a WIP commit (HEAD stays where it is):\n\n```sh\n$ git checkout wip/master -- .   # restore entire tree\n$ git checkout <sha> -- path/to/file  # restore a single file\n```\n\nThe changes land in the index and working tree.  Review with:\n\n```sh\n$ git diff --cached\n```\n\nAdjust with `git reset` / `git add -p` as needed, then commit.\n\n# Appendix\n\n## related projects\n\n- [wip.rs](https://github.com/dlight/wip.rs) is a Rust executable that watches for changes in your work, and invokes `git-wip` as needed.\n  (by [dlight](https://github.com/dlight)).\n\n## star history\n\n<a href=\"https://www.star-history.com/?repos=bartman%2Fgit-wip&type=date&legend=top-left\">\n <picture>\n   <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/image?repos=bartman/git-wip&type=date&theme=dark&legend=top-left\" />\n   <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/image?repos=bartman/git-wip&type=date&legend=top-left\" />\n   <img alt=\"Star History Chart\" src=\"https://api.star-history.com/image?repos=bartman/git-wip&type=date&legend=top-left\" />\n </picture>\n</a>\n"
  },
  {
    "path": "cmake/GitVersion.cmake",
    "content": "# GitVersion.cmake - Integration for GitVersion.sh\n#\n# Usage:\n#   include(GitVersion)\n#   gitversion_generate(PREFIX GIT_WIP_ OUTPUT ${CMAKE_BINARY_DIR}/git_wip_version.h)\n\nfunction(gitversion_generate)\n    set(options)\n    set(oneValueArgs PREFIX OUTPUT)\n    set(multiValueArgs)\n\n    cmake_parse_arguments(GV \"${options}\" \"${oneValueArgs}\" \"${multiValueArgs}\" ${ARGN})\n\n    if(NOT GV_PREFIX)\n        message(FATAL_ERROR \"gitversion_generate: PREFIX is required\")\n    endif()\n    if(NOT GV_OUTPUT)\n        message(FATAL_ERROR \"gitversion_generate: OUTPUT is required\")\n    endif()\n\n    # Run GitVersion.sh to generate the version header\n    execute_process(\n        COMMAND ${CMAKE_SOURCE_DIR}/cmake/GitVersion.sh ${GV_PREFIX} ${GV_OUTPUT}\n        WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}\n        RESULT_VARIABLE GV_RESULT\n    )\n\n    if(NOT GV_RESULT EQUAL 0)\n        message(WARNING \"gitversion_generate: Failed to run GitVersion.sh\")\n    endif()\n\n    # Add a custom command to regenerate the version header\n    add_custom_command(\n        OUTPUT ${GV_OUTPUT}\n        COMMAND ${CMAKE_SOURCE_DIR}/cmake/GitVersion.sh ${GV_PREFIX} ${GV_OUTPUT}\n        WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}\n        DEPENDS ${CMAKE_SOURCE_DIR}/cmake/GitVersion.sh\n        COMMENT \"Generating version header: ${GV_OUTPUT}\"\n        VERBATIM\n    )\n\n    # Add a custom target that depends on the version header\n    add_custom_target(gitversion DEPENDS ${GV_OUTPUT})\nendfunction()\n"
  },
  {
    "path": "cmake/GitVersion.sh",
    "content": "#!/usr/bin/env bash\n# GitVersion.sh - Generate version header from git describe\n# Usage: GitVersion.sh PREFIX OUTPUT\n\nset -e\n\nPREFIX=\"$1\"\nOUTPUT=\"$2\"\n\nif [ -z \"$PREFIX\" ] || [ -z \"$OUTPUT\" ]; then\n    echo \"Usage: $0 PREFIX OUTPUT\" >&2\n    exit 1\nfi\n\n# Get git describe output\nDESCRIBE=\"$(git describe --tags --dirty=-dirty 2>/dev/null || echo \"unknown\")\"\n\n# Generate temporary output file\nOUTPUT_TMP=\"${OUTPUT}.tmp\"\n\ncat > \"$OUTPUT_TMP\" << EOF\n#pragma once\n#define ${PREFIX}VERSION \"${DESCRIBE}\"\nEOF\n\n# Only update the file if it changed\nif [ -f \"$OUTPUT\" ] && cmp -s \"$OUTPUT\" \"$OUTPUT_TMP\"; then\n    rm -f \"$OUTPUT_TMP\"\nelse\n    mv \"$OUTPUT_TMP\" \"$OUTPUT\"\nfi\n"
  },
  {
    "path": "dependencies.sh",
    "content": "#!/bin/bash\nset -e\n\nSUDO=sudo\n[ \"$(id -u)\" = 0 ] && SUDO=\n\nfunction die() {\n    echo >&2 \"ERROR: $*\"\n    exit 1\n}\n\n# ---------------------------------------------------------------------------\n# Detect package manager\n# ---------------------------------------------------------------------------\n\nif command -v nix &>/dev/null && [ -e ~/.nix-profile/etc/profile.d/nix.sh ]; then\n    # shellcheck disable=SC1091\n    source ~/.nix-profile/etc/profile.d/nix.sh\nfi\n\nif command -v nix-shell &>/dev/null || command -v nix &>/dev/null; then\n    if [ -f shell.nix ] || [ -f default.nix ]; then\n        die \"Nix detected. Run 'nix develop' to enter a dev shell.\"\n    fi\nfi\n\npkg_mgr=\"\"\nif command -v apt &>/dev/null; then\n    pkg_mgr=apt\nelif command -v dnf &>/dev/null; then\n    pkg_mgr=dnf\nelif command -v pacman &>/dev/null; then\n    pkg_mgr=pacman\nelif command -v nix &>/dev/null; then\n    die \"Nix detected but no shell.nix found. Run 'nix develop' to start a dev shell.\"\nelse\n    die \"Unsupported system: no apt, dnf, pacman, or nix found. dependencies.sh does not support this OS.\"\nfi\n\necho \"Detected package manager: $pkg_mgr\"\n\n# sync the package database once at the start (if needed)\ncase \"$pkg_mgr\" in\n    apt)\n        $SUDO apt update\n        ;;\n    pacman)\n        $SUDO pacman -Sy --noconfirm\n        ;;\nesac\n\n# ---------------------------------------------------------------------------\n# Package manager helpers\n# ---------------------------------------------------------------------------\n\nfunction must_have_one_of() {\n    local pkg_names=(\"$@\")\n    for n in \"${pkg_names[@]}\" ; do\n        case \"$pkg_mgr\" in\n            apt)\n                # apt-cache show exits non-zero when the package is unknown;\n                # apt policy always exits 0 so it cannot be used for existence checks.\n                if apt-cache show \"$n\" >/dev/null 2>&1 ; then\n                    echo \"$n\"\n                    return\n                fi\n                ;;\n            dnf)\n                if dnf list available \"$n\" >/dev/null 2>&1 ; then\n                    echo \"$n\"\n                    return\n                fi\n                ;;\n            pacman)\n                # Check if package exists (installed or available in repos)\n                if pacman -Si \"$n\" >/dev/null 2>&1 ; then\n                    echo \"$n\"\n                    return\n                fi\n                ;;\n        esac\n    done\n    die \"$pkg_mgr cannot find any of these packages: ${pkg_names[*]}\"\n}\n\nfunction want_one_of() {\n    local pkg_names=(\"$@\")\n    for n in \"${pkg_names[@]}\" ; do\n        case \"$pkg_mgr\" in\n            apt)\n                # apt-cache show exits non-zero when the package is unknown;\n                # apt policy always exits 0 so it cannot be used for existence checks.\n                if apt-cache show \"$n\" >/dev/null 2>&1 ; then\n                    echo \"$n\"\n                    return\n                fi\n                ;;\n            dnf)\n                if dnf list available \"$n\" >/dev/null 2>&1 ; then\n                    echo \"$n\"\n                    return\n                fi\n                ;;\n            pacman)\n                # Check if package exists (installed or available in repos)\n                if pacman -Si \"$n\" >/dev/null 2>&1 ; then\n                    echo \"$n\"\n                    return\n                fi\n                ;;\n        esac\n    done\n}\n\n# ---------------------------------------------------------------------------\n# Argument parsing\n# ---------------------------------------------------------------------------\n\ncompiler=\"\"   # empty → auto-select via must_have_one_of\ncoverage=0    # --coverage → install lcov, curl, gpg\nstatic=0      # --static  → install static libs needed for STATIC=1 builds\n\nfor arg in \"$@\" ; do\n    case \"$arg\" in\n        --compiler=gcc)   compiler=gcc   ;;\n        --compiler=gnu)   compiler=gnu   ;;\n        --compiler=g++)   compiler=g++   ;;\n        --compiler=clang) compiler=clang ;;\n        --compiler=*)\n            die \"unknown --compiler value '${arg#--compiler=}' (expected gcc or clang)\" ;;\n        --coverage)\n            coverage=1 ;;\n        --static)\n            static=1 ;;\n        -h|--help)\n            cat <<'EOF'\nUsage: dependencies.sh [--compiler=<gcc|clang>] [--coverage] [--static] [-h|--help]\n\nInstall build dependencies for git-wip.\n\nOptions:\n  --compiler=gcc    Install gcc and g++\n  --compiler=clang  Install clang\n  (no flag)         Install whichever of clang/gcc is available (auto-select)\n\n  --coverage        Also install coverage tools (lcov, curl, gpg)\n\n  --static          Also install static libraries required for `make STATIC=1`\n                    (libllhttp-dev and any other missing static .a files)\n\n  -h, --help        Show this help and exit\nEOF\n            exit 0\n            ;;\n        *)\n            die \"unknown argument '$arg'\" ;;\n    esac\ndone\n\n# ---------------------------------------------------------------------------\n# Compiler packages\n# ---------------------------------------------------------------------------\n\ncompiler_packages=()\n\ncase \"$compiler\" in\n    gcc|gnu|g++)\n        # On Arch, gcc includes both C and C++ compilers (no separate g++ package)\n        if [ \"$pkg_mgr\" = \"pacman\" ]; then\n            compiler_packages+=( gcc )\n        else\n            compiler_packages+=( gcc g++ )\n        fi\n        ;;\n    clang)\n        compiler_packages+=( \"$(must_have_one_of clang)\" )\n        ;;\n    \"\")\n        # No preference — pick whatever is available, prefer clang for the\n        # C compiler slot and gcc for the C++ slot (matches the old behaviour).\n        if [ \"$pkg_mgr\" = \"pacman\" ]; then\n            # On Arch, gcc package includes both C and C++ compilers\n            compiler_packages+=(\n                \"$(must_have_one_of clang gcc)\"\n            )\n        else\n            compiler_packages+=(\n                \"$(must_have_one_of clang gcc)\"\n                \"$(must_have_one_of clang g++)\"\n            )\n        fi\n        ;;\nesac\n\n# ---------------------------------------------------------------------------\n# Full package list\n# ---------------------------------------------------------------------------\n\npackages=(\n    cmake\n    git\n    make\n    ninja-build\n    pkg-config\n    python3\n)\n\n# Optional packages (only install if available)\nwant_one_of clangd && packages+=($(want_one_of clangd))\n#want_one_of valgrind && packages+=($(want_one_of valgrind))\n\n# Compiler packages\npackages+=(\"${compiler_packages[@]}\")\n\n# Build tools (different package names between apt, dnf, and pacman)\ncase \"$pkg_mgr\" in\n    apt)\n        packages+=(\n            googletest\n            libgmock-dev\n            libgtest-dev\n            libgit2-dev\n        )\n        ;;\n    dnf)\n        packages+=(\n            gtest-devel\n            gmock-devel\n            libgit2-devel\n        )\n        ;;\n    pacman)\n        # Arch uses different package names\n        packages+=(\n            libgit2\n        )\n        # Replace base packages with Arch equivalents\n        packages=( \"${packages[@]/ninja-build/ninja}\" )\n        packages=( \"${packages[@]/pkg-config/pkgconf}\" )\n        packages=( \"${packages[@]/python3/python}\" )\n        # For clang, ensure C++ standard library is available\n        if [ \"$compiler\" = \"clang\" ] || [ \"$compiler\" = \"\" ]; then\n            packages+=( gcc-libs )\n        fi\n        ;;\nesac\n\n# Coverage tools (only when --coverage is requested)\nif [ \"$coverage\" = 1 ]; then\n    case \"$pkg_mgr\" in\n        apt)\n            packages+=( lcov curl gpg )\n            ;;\n        dnf)\n            packages+=( lcov curl gnupg2 )\n            ;;\n        pacman)\n            packages+=( lcov curl gnupg )\n            ;;\n    esac\nfi\n\n# Static-build extra libs (only when --static is requested)\n# Most static .a files come from the -dev packages already installed above.\n# The extras needed on Debian/Ubuntu:\n#   libgpg-error-dev → libgpg-error.a (libssh2 transitive dep)\n#   libzstd-dev      → libzstd.a     (libssh2 transitive dep)\n#   libkrb5-dev      → provides libgssapi_krb5.so stubs for the dynamic link\n#   libllhttp-dev    → libllhttp.a   (libgit2 HTTP parser)\n#                      Present on Debian stable; Ubuntu 24.04 (noble) embeds\n#                      llhttp statically inside libgit2.a so the package does\n#                      not exist there — detected and skipped automatically.\n# Fedora and Arch do not ship libgit2.a / libssh2.a / libssl.a, so the static\n# build is not supported on those distros; nothing extra to install.\nif [ \"$static\" = 1 ]; then\n    case \"$pkg_mgr\" in\n        apt)\n            packages+=( libgpg-error-dev libzstd-dev libkrb5-dev )\n            # libllhttp-dev is optional — present on Debian stable, absent on\n            # Ubuntu 24.04 (noble embeds llhttp statically inside libgit2.a)\n            llhttp_pkg=$(want_one_of libllhttp-dev)\n            [ -n \"$llhttp_pkg\" ] && packages+=( \"$llhttp_pkg\" )\n            ;;\n        dnf)\n            # Fedora does not ship libgit2.a / libssh2.a / libssl.a, so a\n            # fully static build is not supported there.  Nothing to install.\n            ;;\n        pacman)\n            # Arch does not ship libgit2.a either; nothing to install.\n            ;;\n    esac\nfi\n\nset -e -x\n\ncase \"$pkg_mgr\" in\n    apt)\n        $SUDO apt update\n        $SUDO apt install -y \"${packages[@]}\"\n        ;;\n    dnf)\n        $SUDO dnf install -y \"${packages[@]}\"\n        ;;\n    pacman)\n        $SUDO pacman -Sy --noconfirm \"${packages[@]}\"\n        ;;\nesac\n"
  },
  {
    "path": "dev.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\nPROJECT=git-wip\nTARGET=\n\nHOSTNAME=\"$(hostname)\"\nUSERNAME=\"$(id -u -n)\"\nUSER_UID=\"$(id -u)\"\nUSER_GID=\"$(id -g)\"\n\n# terminal colors\nBLACK='\\033[0;30m' RED='\\033[0;31m' GREEN='\\033[0;32m' YELLOW='\\033[0;33m' BLUE='\\033[0;34m' MAGENTA='\\033[0;35m' CYAN='\\033[0;36m' WHITE='\\033[0;37m'\nBRIGHT_BLACK='\\033[1;30m' BRIGHT_RED='\\033[1;31m' BRIGHT_GREEN='\\033[1;32m' BRIGHT_YELLOW='\\033[1;33m' BRIGHT_BLUE='\\033[1;34m' BRIGHT_MAGENTA='\\033[1;35m' BRIGHT_CYAN='\\033[1;36m' BRIGHT_WHITE='\\033[1;37m'\nRESET='\\033[0m'\n\nfunction die {\n    echo >&2 \"ERROR: $*\"\n    exit 1\n}\n\nfunction available_targets {\n    ls Dockerfile-* 2>/dev/null | sed -n 's/^Dockerfile-\\([A-Za-z0-9]\\+\\)$/\\1/p'\n}\n\ndeclare TARGET= DOCKERFILE= NAME=\n\nfunction set_target {\n    TARGET=\"$1\"\n    [ -z \"$TARGET\" ] && die \"Target not specified\"\n    DOCKERFILE=\"Dockerfile-$TARGET\"\n    [ -f \"$DOCKERFILE\" ] || die \"$DOCKERFILE: no such file\"\n    [ -r \"$DOCKERFILE\" ] || die \"$DOCKERFILE: cannot be read\"\n    NAME=\"$PROJECT-builder-$USERNAME-$TARGET\"\n}\n\ndeclare -a BUILD_ARGS=()\n\nfunction set_build_args {\n    local value\n\n    # transfer host user.email to docker\n    value=\"$(git config user.email)\"\n    [ -z \"$value\" ] && value=\"$USERNAME@$HOSTNAME\"\n    [ -n \"$value\" ] && BUILD_ARGS+=( \"--build-arg=GIT_EMAIL=$value\" )\n\n    # transfer host user.name to docker\n    value=\"$(git config user.name)\"\n    [ -z \"$value\" ] && value=\"$USERNAME\"\n    [ -n \"$value\" ] && BUILD_ARGS+=( \"--build-arg=GIT_NAME=$value\" )\n\n    # Add UID, GID, and USERNAME as build arguments to match host user\n    BUILD_ARGS+=( \"--build-arg=UID=$USER_UID\" )\n    BUILD_ARGS+=( \"--build-arg=GID=$USER_GID\" )\n    BUILD_ARGS+=( \"--build-arg=USERNAME=$USERNAME\" )\n}\n\nfunction show_status {\n    local images=( $( docker images | awk \"\\$1 ~ /\\<${NAME}\\>/ { print \\$3 }\" ) )\n    if [ \"${#images[@]}\" -gt 0 ] ; then\n        echo -e \"${NAME}: image is ${BRIGHT_GREEN}built${RESET}:    ${BRIGHT_BLUE}${images[*]}${RESET}\"\n    else\n        echo -e \"${NAME}: image is ${BRIGHT_RED}not built${RESET}.\"\n    fi\n\n    # Check if container is running\n    local running=( $( docker ps | awk \"\\$2 ~ /\\<${NAME}\\>/ { print \\$1 }\" ) )\n    if [ \"${#running[@]}\" -gt 0 ] ; then\n        echo -e \"${NAME}: cntnr is ${BRIGHT_GREEN}running${RESET}:  ${BRIGHT_BLUE}${running[*]}${RESET}\"\n    fi\n\n    local stopped=( $( docker ps -a | awk \"\\$2 ~ /\\<${NAME}\\>/ && /Exited / { print \\$1 }\" ) )\n    if [ \"${#stopped[@]}\" -gt 0 ] ; then\n        echo -e \"${NAME}: cntnr is ${BRIGHT_YELLOW}stopped${RESET}:  ${BRIGHT_BLUE}${stopped[*]}${RESET}\"\n    fi\n\n    if [ \"${#running[@]}\" -eq 0 ] && [ \"${#stopped[@]}\" -eq 0 ] ; then\n        echo -e \"${NAME}: cntnr does ${BRIGHT_RED}not exist${RESET}.\"\n    fi\n}\n\n# Function to display help\nshow_help() {\n    cat <<END\n${0##*/} <target> <command>[,<command>,...] [...]\n\n    Available commands:\n\n        build          - create a dev image\n        remove         - remove a dev image\n        start          - start the container\n        stop           - stop the container\n        status         - check if built/running\n        connect        - get a shell in the container\n        run     <cmd>  - run a command in the container\n\n    Available targets:\n\nEND\n    for t in $(available_targets); do\n        echo \"        $t\"\n    done\n    echo\n}\n\ntarget=\"$1\" ; shift\ncase \"$target\" in\n    help|--help|-h)\n        show_help\n        exit 0\n        ;;\n    status|--status|-s)\n        for t in $(available_targets) ; do\n            set_target \"$t\"\n            show_status\n            echo\n        done\n        exit 0\n        ;;\nesac\n\nset_target \"$target\"\ncmd=\"$1\" ; shift\n\ncase \"$cmd\" in\n    help|--help|-h)\n        show_help\n        exit 0\n        ;;\n    *,*)\n        re=\"^[a-z,]+$\"\n        [[ \"$cmd\" =~ $re ]] || die \"bad\"\n        for x in ${cmd//,/ } ; do\n            echo -e >&2 \"# ${BRIGHT_YELLOW}$0 $x $*${RESET}\"\n            if ! $0 \"$target\" \"$x\" \"$@\" ; then\n                die \"failed: $0 $x $*\"\n            fi\n        done\n        exit 0\n        ;;\n    build)\n        set_build_args\n        ( set -x\n        docker build \"${@}\" \"${BUILD_ARGS[@]}\" -t \"${NAME}\" -f \"Dockerfile-$TARGET\" .\n        )\n        exit $?\n        ;;\n    rm|remove)\n        ( set -x\n        docker image rm -f \"${NAME}\"\n        )\n        exit $?\n        ;;\n    up|start)\n        ( set -x\n        docker run -d --init -v \"$(pwd):/home/$PROJECT\" --user \"$USER_UID:$USER_GID\" --name \"${NAME}\" \"${NAME}\"\n        )\n        exit $?\n        ;;\n    down|stop)\n        container_ids=( $(docker ps -a --filter \"name=${NAME}\" --format='{{.ID}}') )\n        if [ \"${#container_ids[@]}\" = 0 ]; then\n            echo \"Container ${NAME} is not running.\"\n        else\n            for container_id in \"${container_ids[@]}\" ; do\n                ( set -x\n                docker stop \"$container_id\"\n                )\n            done\n            for container_id in \"${container_ids[@]}\" ; do\n                ( set -x\n                docker rm \"$container_id\"\n                )\n            done\n        fi\n        exit 0\n        ;;\n    status)\n        show_status\n        exit $?\n        ;;\n    connect|enter)\n        container_id=\"$(docker ps -a --filter \"name=${NAME}\" --format='{{.ID}}')\"\n        if [ -z \"$container_id\" ]; then\n            echo \"Container ${NAME} is not running.\"\n        else\n            ( set -x\n            docker exec -it \"$container_id\" bash\n            )\n        fi\n        ;;\n    run|exec)\n        container_id=\"$(docker ps -a --filter \"name=${NAME}\" --format='{{.ID}}')\"\n        if [ -z \"$container_id\" ]; then\n            echo \"Container ${NAME} is not running.\"\n            exit 1\n        elif [ $# -eq 0 ]; then\n            echo \"No command provided to run in the container.\"\n            exit 1\n        else\n            ( set -x\n            #docker exec -w /home/${PROJECT} \"$container_id\" \"$@\"\n            docker exec -i -w /home/${PROJECT} \"$container_id\" /bin/bash -l -c \"$*\"\n            )\n        fi\n        ;;\n    *)\n        die \"${0##*/} [ build | remove | start | stop | status | connect ] <target>\"\n        ;;\nesac\n"
  },
  {
    "path": "doc/git-wip.txt",
    "content": "*git-wip.txt*           Git WIP (Work In Progress) for Neovim\n\n==============================================================================\nTable of Contents                                      *git-wip-toc*\n\n    1. Introduction ................ |git-wip-intro|\n    2. Requirements ................ |git-wip-requirements|\n    3. Installation ................ |git-wip-installation|\n    4. Configuration ............... |git-wip-configuration|\n    5. Commands .................... |git-wip-commands|\n    6. Lua API ..................... |git-wip-lua-api|\n    7. Vim Compatibility ........... |git-wip-vim|\n    8. Examples .................... |git-wip-examples|\n\n==============================================================================\nIntroduction                                           *git-wip-intro*\n\n`git-wip` automatically creates Work-In-Progress snapshots using the\n`git-wip` tool every time you save a buffer in Neovim.\n\nIt works together with the C++ rewrite of `git-wip` to maintain a stack of\ntemporary commits on `wip/<branch>` refs.\n\n==============================================================================\nRequirements                                        *git-wip-requirements*\n\n- Neovim >= 0.8\n- The `git-wip` binary must be in your $PATH\n- The current working directory (or its parent) must be a git repository\n\n==============================================================================\nInstallation                                        *git-wip-installation*\n\n*lazy.nvim* (recommended):\n\n```lua\n{\n  \"bartman/git-wip\",\n  opts = {\n    -- see |git-wip-configuration|\n    untracked = true,\n    ignored = false,\n  },\n}\n```\n\nLazy will automatically call `require(\"git-wip\").setup(opts)`.\n\n==============================================================================\nConfiguration                                       *git-wip-configuration*\n\nAll options are passed in the opts table: >\n\n    gpg_sign  (boolean|nil)\n    untracked (boolean|nil)\n    ignored   (boolean|nil)\n    filetypes (string[])\n\n*gpg_sign* (boolean|nil) ~\n    Override GPG signing behavior (currently not implemented in git-wip).\n    true    → git-wip save --gpg-sign\n    false   → git-wip save --no-gpg-sign\n    nil     → use git-wip default\n\n*untracked* (boolean|nil) ~\n    Capture untracked files.\n    true    → git-wip save --untracked\n    false   → git-wip save --no-untracked\n    nil     → use git-wip default\n\n*ignored* (boolean|nil) ~\n    Capture ignored files.\n    true    → git-wip save --ignored\n    false   → git-wip save --no-ignored\n    nil     → use git-wip default\n\n*filetypes* (string[]) ~\n    List of filetypes where the plugin should run.\n    Use `\"*\"` for all files.\n    Default: `{\"*\"}`\n\n================================================================================\nCommands                                               *git-wip-commands*\n\n:Wip                                                           *:Wip*\n    Save a WIP snapshot for the current buffer.\n    The file's directory and filename are extracted from the current\n    buffer's path and passed to git-wip.\n\n    This command is useful when you want to save a snapshot without\n    writing the buffer, or when working with files outside of git.\n\n:WipAll                                                        *:WipAll*\n    Save a WIP snapshot for all changes in the current working directory.\n    The filename is omitted, so git-wip captures all changes (similar to\n    `git-wip save` without specifying files).\n\n================================================================================\nLua API                                             *git-wip-lua-api*\n\nsetup({opts})                                        *git-wip.setup()*\n    Configure the plugin and register the BufWritePost autocmd.\n    Automatically called by lazy.nvim.\n\nGitWipBufWritePost()                        *git-wip.GitWipBufWritePost()*\n    Manually trigger a snapshot (the same function used by the autocmd).\n\n==============================================================================\nVim Compatibility                                   *git-wip-vim*\n\nThis plugin also ships `vim/plugin/git-wip.vim` for classic Vim users.\nThat file is ignored in Neovim.\n\n==============================================================================\nExamples                                            *git-wip-examples*\n\n```lua\nrequire(\"git-wip\").setup({\n  gpg_sign = false,\n  untracked = true,\n  ignored = false,\n  filetypes = { \"lua\", \"c\", \"cpp\", \"python\" },\n})\n```\n\nOn every `:w` you will see a notification like: >\n    [git-wip] saved main.cpp in 0.037 sec\n\n==============================================================================\nvim:tw=78:ts=8:noet:ft=help:norl:\n"
  },
  {
    "path": "emacs/git-wip-mode.el",
    "content": ";;; git-wip-mode.el --- Use git-wip to record every buffer save\n\n;; Copyright (C) 2013  Jerome Baum\n\n;; Author: Jerome Baum <jerome@jeromebaum.com>\n;; Version: 0.1\n;; Keywords: vc\n\n;; This program is free software; you can redistribute it and/or modify\n;; it under the terms of the GNU General Public License as published by\n;; the Free Software Foundation, either version 3 of the License, or\n;; (at your option) any later version.\n\n;; This program is distributed in the hope that it will be useful,\n;; but WITHOUT ANY WARRANTY; without even the implied warranty of\n;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n;; GNU General Public License for more details.\n\n;; You should have received a copy of the GNU General Public License\n;; along with this program.  If not, see <http://www.gnu.org/licenses/>.\n\n;;; Commentary:\n\n;;; Code:\n\n(eval-when-compile\n  (require 'cl))\n\n(require 'vc)\n\n(defvar git-wip-buffer-name \" *git-wip*\"\n  \"Name of the buffer to which git-wip's output will be echoed\")\n\n(defvar git-wip-path\n  (or\n   ;; Internal copy of git-wip; preferred because it will be\n   ;; version-matched\n   (expand-file-name\n    \"../git-wip\"\n    (file-name-directory\n     (or load-file-name\n         (locate-library \"git-wip-mode\"))))\n   ;; Look in $PATH and git exec-path\n   (let ((exec-path\n          (append\n           exec-path\n           (parse-colon-path\n            (replace-regexp-in-string\n             \"[ \\t\\n\\r]+\\\\'\" \"\"\n             (shell-command-to-string \"git --exec-path\"))))))\n     (executable-find \"git-wip\"))))\n\n(defun git-wip-after-save ()\n  (when (and (string= (vc-backend (buffer-file-name)) \"Git\")\n             git-wip-path)\n    (start-process \"git-wip\" git-wip-buffer-name\n                   git-wip-path \"save\" (concat \"WIP from emacs: \"\n                                               (file-name-nondirectory\n                                                buffer-file-name))\n                   \"--editor\" \"--\"\n                   (file-name-nondirectory buffer-file-name))\n    (message (concat \"Wrote and git-wip'd \" (buffer-file-name)))))\n\n;;;###autoload\n(define-minor-mode git-wip-mode\n  \"Toggle git-wip mode.\nWith no argument, this command toggles the mode.\nNon-null prefix argument turns on the mode.\nNull prefix argument turns off the mode.\n\nWhen git-wip mode is enabled, git-wip will be called every time\nyou save a buffer.\"\n  ;; The initial value.\n  nil\n  ;; The indicator for the mode line.\n  \" WIP\"\n  :group 'git-wip\n\n  ;; (de-)register our hook\n  (if git-wip-mode\n      (add-hook 'after-save-hook 'git-wip-after-save nil t)\n    (remove-hook 'after-save-hook 'git-wip-after-save t)))\n\n(defun git-wip-mode-if-git ()\n  (when (string= (vc-backend (buffer-file-name)) \"Git\")\n    (git-wip-mode t)))\n\n(add-hook 'find-file-hook 'git-wip-mode-if-git)\n\n(provide 'git-wip-mode)\n;;; git-wip-mode.el ends here\n"
  },
  {
    "path": "emacs/git-wip.el",
    "content": "(defun git-wip-wrapper () \n  (interactive)\n  (let ((file-arg (shell-quote-argument (buffer-file-name))))\n    (shell-command (concat \"git-wip save \\\"WIP from emacs: \" (buffer-file-name) \"\\\" --editor -- \" file-arg))\n    (message (concat \"Wrote and git-wip'd \" (buffer-file-name)))))\n\n(defun git-wip-if-git ()\n  (interactive)\n  (when (string= (vc-backend (buffer-file-name)) \"Git\")\n    (git-wip-wrapper)))\n\n(add-hook 'after-save-hook 'git-wip-if-git)\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  description = \"git-wip — Work In Progress branch manager\";\n\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    flake-utils.url = \"github:numtide/flake-utils\";\n  };\n\n  outputs = { self, nixpkgs, flake-utils }:\n    flake-utils.lib.eachDefaultSystem (system:\n      let\n        pkgs = nixpkgs.legacyPackages.${system};\n      in\n      {\n        devShells.default = pkgs.mkShell {\n          name = \"git-wip-dev\";\n\n          # Build tools and dependencies\n          # Nix name          ↔  apt name\n          # cmake             ↔  cmake\n          # ninja             ↔  ninja-build\n          # pkg-config        ↔  pkg-config\n          # gnumake           ↔  make\n          # gcc / stdenv      ↔  gcc / g++\n          # clang-tools       ↔  clangd\n          # clang             ↔  clang\n          # libgit2           ↔  libgit2-dev\n          # gtest             ↔  googletest / libgmock-dev / libgtest-dev\n          # git               ↔  git\n          packages = with pkgs; [\n            # build system\n            cmake\n            ninja\n            pkg-config\n            gnumake\n\n            # compilers\n            gcc\n            clang\n            clang-tools   # provides clangd\n\n            # runtime library (required at link time)\n            libgit2\n\n            # test framework\n            gtest\n\n            # version control (needed by cmake FetchContent and tests)\n            git\n\n            # python is used by test/runner.py\n            python3\n          ];\n\n          # Ensure pkg-config can find libgit2\n          PKG_CONFIG_PATH = \"${pkgs.libgit2}/lib/pkgconfig\";\n\n          shellHook = ''\n            echo \"git-wip dev shell\"\n            echo \"  compiler:  $(c++ --version | head -1)\"\n            echo \"  cmake:     $(cmake --version | head -1)\"\n            echo \"  libgit2:   $(pkg-config --modversion libgit2)\"\n            echo \"\"\n            echo \"  build:     make\"\n            echo \"  test:      make test\"\n          '';\n        };\n      }\n    );\n}\n"
  },
  {
    "path": "lua/git-wip/init.lua",
    "content": "-- this is a Neovim plugin that launches git-wip save on every buffer write\nlocal vim = vim\nlocal M = {}\n\n-- Detect Neovim version for API compatibility\n-- vim.system is available in Neovim 0.10+\nlocal has_vim_system = vim.system ~= nil\nlocal has_loop_spawn = vim.loop.spawn ~= nil\nlocal has_loop_hrtime = vim.loop and vim.loop.hrtime ~= nil\n\n-- Configuration\nM.defaults = {\n  git_wip_path = \"git-wip\",  -- path to git-wip binary (can be absolute)\n  gpg_sign     = nil,        -- true for --gpg-sign, false for --no-gpg-sign\n  untracked    = nil,        -- true for --untracked, false for --no-untracked\n  ignored      = nil,        -- true for --ignored, false for --no-ignored\n  background   = nil,        -- true for async execution if supported, false for sync\n  filetypes    = { \"*\" },\n}\n\n---@type table\nM.config = M.defaults\n\n---Wrapper for vim.loop.hrtime(), returns 0 if not available\nlocal function get_hrtime()\n  if has_loop_hrtime then\n    return vim.loop.hrtime()\n  else\n    return 0\n  end\nend\n\n---Helper for tri-state flags\nlocal function add_tri_flag(cmd, value, positive, negative)\n  if value == true then\n    table.insert(cmd, positive)\n  elseif value == false then\n    table.insert(cmd, negative)\n  end\n  -- nil = do nothing (git-wip default)\nend\n\n---Helper: Build command array\nlocal function build_command(display_name, filename)\n  local cmd = { M.config.git_wip_path, \"save\", string.format(\"WIP from neovim for %s\", display_name) }\n  add_tri_flag(cmd, M.config.gpg_sign, \"--gpg-sign\", \"--no-gpg-sign\")\n  add_tri_flag(cmd, M.config.untracked, \"--untracked\", \"--no-untracked\")\n  add_tri_flag(cmd, M.config.ignored, \"--ignored\", \"--no-ignored\")\n  table.insert(cmd, \"--editor\")\n  if filename ~= nil then\n    table.insert(cmd, \"--\")\n    table.insert(cmd, filename)\n  end\n  return cmd\nend\n\n---Helper: Notify result\nlocal function notify_result(display_name, code, elapsed)\n  if code == 0 then\n    local msg = \"[git-wip] saved \" .. display_name\n    if elapsed and elapsed > 0 then\n      msg = msg .. string.format(\" in %.3f sec\", elapsed)\n    end\n    vim.notify(msg, vim.log.levels.INFO)\n  else\n    local msg = \"[git-wip] failed for \" .. display_name .. \" (exit \" .. code .. \")\"\n    if M.config.background and not has_loop_spawn then\n      msg = msg .. \" (async not supported, ran sync)\"\n    end\n    vim.notify(msg, vim.log.levels.WARN)\n  end\nend\n\n---Helper: Run sync\nlocal function run_sync(cmd, dir, display_name)\n  local start = get_hrtime()\n  local code = 0\n  if has_vim_system then\n    local job = vim.system(cmd, { cwd = dir, text = true })\n    local result = job:wait()\n    code = result.code\n  else\n    local shell_cmd = \"cd \" .. vim.fn.shellescape(dir) .. \" && \" .. cmd[1]\n    for i = 2, #cmd do\n      shell_cmd = shell_cmd .. \" \" .. vim.fn.shellescape(cmd[i])\n    end\n    vim.fn.system(shell_cmd)\n    code = vim.v.shell_error\n  end\n  local elapsed = (get_hrtime() - start) / 1e9\n  notify_result(display_name, code, elapsed)\nend\n\n---Helper: Run async\nlocal function run_async(cmd, dir, display_name)\n  local unpack = table.unpack or unpack -- unpack is deprecated\n  local start = get_hrtime()\n  local handle\n  handle = vim.loop.spawn(cmd[1], {\n    args = {unpack(cmd, 2)},\n    cwd = dir,\n    stdio = {nil, nil, nil},\n  }, function(code, signal)\n    local elapsed = has_loop_hrtime and (get_hrtime() - start) / 1e9 or 0\n    notify_result(display_name, code, elapsed)\n    handle:close()\n  end)\n  if not handle then\n    vim.notify(\"Failed to spawn git-wip process\", vim.log.levels.ERROR)\n  end\nend\n\n---Setup function (automatically called by Lazy.nvim)\n---@param opts? table\nfunction M.setup(opts)\n  -- Merge user config\n  M.config = vim.tbl_deep_extend(\"force\", M.defaults, opts or {})\n\n  -- Register the BufWritePost autocmd once\n  vim.api.nvim_create_autocmd(\"BufWritePost\", {\n    group = vim.api.nvim_create_augroup(\"GitWip\", { clear = true }),\n    pattern = \"*\",\n    callback = M.GitWipBufWritePost,\n    desc = \"git-wip: run after buffer write\",\n  })\n\n  -- Register commands\n  vim.api.nvim_create_user_command(\"Wip\", function()\n    local fullpath = vim.api.nvim_buf_get_name(0)\n    if fullpath == \"\" then\n      vim.notify(\"[git-wip] no file name for current buffer\", vim.log.levels.ERROR)\n      return\n    end\n    local dir = vim.fn.fnamemodify(fullpath, \":h\")\n    local filename = vim.fn.fnamemodify(fullpath, \":t\")\n    M.RunGitWip(dir, filename)\n  end, {\n    desc = \"Save WIP snapshot for the current buffer\",\n  })\n\n  vim.api.nvim_create_user_command(\"WipAll\", function()\n    local dir = vim.fn.getcwd()\n    M.RunGitWip(dir, nil)\n  end, {\n    desc = \"Save WIP snapshot for all changes in the current directory\",\n  })\nend\n\nfunction M.RunGitWip(dir, filename)\n  local display_name = filename or '*'\n  local cmd = build_command(display_name, filename)\n  if M.config.background and has_loop_spawn then\n    run_async(cmd, dir, display_name)\n  else\n    run_sync(cmd, dir, display_name)\n  end\nend\n\nfunction M.GitWipBufWritePost()\n  local fullpath = vim.api.nvim_buf_get_name(0)\n  if fullpath == \"\" then\n    return\n  end\n\n  -- Respect config.filetypes\n  local ft = vim.bo.filetype\n  local enabled = vim.tbl_contains(M.config.filetypes, \"*\")\n    or vim.tbl_contains(M.config.filetypes, ft)\n  if not enabled then\n    return\n  end\n\n  local dir = vim.fn.fnamemodify(fullpath, \":h\")      -- directory part\n  local filename = vim.fn.fnamemodify(fullpath, \":t\") -- just the filename\n\n  M.RunGitWip(dir, filename)\nend\n\nreturn M\n"
  },
  {
    "path": "src/CMakeLists.txt",
    "content": "# Generate version header\ninclude(${CMAKE_SOURCE_DIR}/cmake/GitVersion.cmake)\ngitversion_generate(PREFIX GIT_WIP_ OUTPUT ${CMAKE_BINARY_DIR}/git_wip_version.h)\n\nadd_executable(git-wip\n    color.cpp\n    main.cpp\n    cmd_delete.cpp\n    cmd_list.cpp\n    cmd_log.cpp\n    cmd_save.cpp\n    cmd_status.cpp\n)\n\n# Ensure the executable is rebuilt when the version header changes\nadd_dependencies(git-wip gitversion)\n\ninstall(TARGETS git-wip\n    RUNTIME DESTINATION bin\n)\n\ntarget_include_directories(git-wip PRIVATE\n    ${CMAKE_BINARY_DIR}\n    ${LIBGIT2_INCLUDE_DIRS}\n)\n\nif(WIP_STATIC)\n    # Link libgit2 and all its transitive deps as static archives.\n    # Strategy:\n    #   1. -Wl,-Bstatic wraps the static block.\n    #   2. --start-group / --end-group around all static libs resolves any\n    #      circular or out-of-order symbol dependencies (e.g. libcrypto → libz).\n    #   3. -Wl,-Bdynamic switches back to dynamic for the remainder\n    #      (glibc, gssapi_krb5 and its kerberos chain have no static .a here).\n    #   4. GSSAPI / krb5 are added explicitly as dynamic-only after the switch.\n    #\n    # spdlog is always a static archive (built by FetchContent) and does NOT\n    # need to be inside -Bstatic because CMake links it by full path anyway.\n    target_link_directories(git-wip PRIVATE\n        ${LIBGIT2_STATIC_DIRS}\n    )\n    set(_static_lib_args\n        \"-Wl,-Bstatic\"\n        \"-Wl,--start-group\"\n    )\n    foreach(_lib IN LISTS LIBGIT2_STATIC_LIBS)\n        list(APPEND _static_lib_args \"-l${_lib}\")\n    endforeach()\n    list(APPEND _static_lib_args\n        \"-Wl,--end-group\"\n        \"-Wl,-Bdynamic\"\n        # GSSAPI / Kerberos — only available as shared libs on most distros\n        \"-lgssapi_krb5\"\n        \"-lkrb5\"\n        \"-lk5crypto\"\n        \"-lkrb5support\"\n        \"-lcom_err\"\n    )\n    target_link_libraries(git-wip PRIVATE\n        spdlog::spdlog\n        ${_static_lib_args}\n    )\nelse()\n    target_link_libraries(git-wip PRIVATE\n        spdlog::spdlog\n        ${LIBGIT2_LIBRARIES}\n    )\n    target_link_directories(git-wip PRIVATE\n        ${LIBGIT2_LIBRARY_DIRS}\n    )\nendif()\n\nif(WIP_HAVE_STD_PRINT)\n    target_compile_definitions(git-wip PRIVATE WIP_HAVE_STD_PRINT)\nelse()\n    target_link_libraries(git-wip PRIVATE fmt::fmt)\nendif()\n"
  },
  {
    "path": "src/cmd_delete.cpp",
    "content": "#include \"cmd_delete.hpp\"\n#include \"color.hpp\"\n#include \"git_guards.hpp\"\n#include \"git_helpers.hpp\"\n#include \"string_helpers.hpp\"\n\n#include \"spdlog/spdlog.h\"\n\n#include \"print_compat.hpp\"\n#include <iostream>\n#include <optional>\n#include <string>\n\nnamespace {\n\nint delete_ref(git_repository *repo, const std::string &wip_ref) {\n    if (!resolve_oid(repo, wip_ref)) {\n        spdlog::error(\"'{}' has no WIP commits\", strip_prefix(wip_ref, \"refs/wip/\"));\n        return 1;\n    }\n\n    if (git_reference_remove(repo, wip_ref.c_str()) < 0) {\n        spdlog::error(\"cannot delete ref '{}': {}\", wip_ref, git_error_str());\n        return 1;\n    }\n\n    std::println(\"deleted {}\", color_wip_branch(strip_prefix(wip_ref, \"refs/\")));\n    return 0;\n}\n\n} // namespace\n\nint DeleteCmd::run(int argc, char *argv[]) {\n    bool cleanup_mode = false;\n    bool yes_mode = false;\n    std::optional<std::string> ref_arg;\n\n    for (int i = 1; i < argc; ++i) {\n        std::string a(argv[i]);\n        if (a == \"--cleanup\") {\n            cleanup_mode = true;\n        } else if (a == \"--yes\") {\n            yes_mode = true;\n        } else if (a == \"--help\" || a == \"-h\") {\n            std::println(\"Usage: git-wip delete [--yes] [<ref>]\");\n            std::println(\"       git-wip delete --cleanup\\n\");\n            //                -                     #\n            std::println(\"    --yes                 # skip confirmation when deleting current branch wip ref\");\n            std::println(\"    --cleanup             # delete orphaned refs/wip/* entries\\n\");\n            return 0;\n        } else if (!a.empty() && a[0] == '-') {\n            spdlog::error(\"git-wip delete: unknown option '{}'\", a);\n            return 1;\n        } else if (ref_arg.has_value()) {\n            spdlog::error(\"git-wip delete: only one ref argument is allowed\");\n            return 1;\n        } else {\n            ref_arg = a;\n        }\n    }\n\n    if (cleanup_mode && ref_arg.has_value()) {\n        spdlog::error(\"git-wip delete: --cleanup cannot be used with <ref>\");\n        return 1;\n    }\n\n    GitLibGuard git_lib_guard;\n    GitRepoGuard repo_guard;\n    if (git_repository_open_ext(repo_guard.ptr(), \".\", 0, nullptr) < 0) {\n        spdlog::error(\"not a git repository: {}\", git_error_str());\n        return 1;\n    }\n    git_repository *repo = repo_guard.get();\n\n    if (cleanup_mode) {\n        auto wip_refs = find_refs(repo, \"refs/wip/\");\n        std::size_t removed = 0;\n\n        for (const auto &wip_ref : wip_refs) {\n            auto bn = resolve_branch_names(repo, wip_ref);\n            if (!bn) continue;\n\n            if (resolve_oid(repo, bn->work_ref))\n                continue; // matching branch exists\n\n            if (git_reference_remove(repo, wip_ref.c_str()) < 0) {\n                spdlog::error(\"cannot delete ref '{}': {}\", wip_ref, git_error_str());\n                return 1;\n            }\n            ++removed;\n            std::println(\"deleted {}\", color_wip_branch(strip_prefix(wip_ref, \"refs/\")));\n        }\n\n        std::println(\"deleted {} orphaned wip ref{}\",\n                     removed,\n                     removed == 1 ? \"\" : \"s\");\n        return 0;\n    }\n\n    std::optional<BranchNames> bn;\n    if (ref_arg.has_value())\n        bn = resolve_branch_names(repo, ref_arg);\n    else\n        bn = resolve_branch_names(repo);\n\n    if (!bn) {\n        spdlog::error(\"not on a local branch\");\n        return 1;\n    }\n\n    if (!ref_arg.has_value() && !yes_mode) {\n        std::print(\"About to delete {} [Y/n] \", color_wip_branch(strip_prefix(bn->wip_ref, \"refs/\")));\n        std::cout.flush();\n\n        std::string input;\n        if (!std::getline(std::cin, input))\n            return 1;\n\n        if (!(input.empty() || input == \"y\" || input == \"Y\"))\n            return 1;\n    }\n\n    return delete_ref(repo, bn->wip_ref);\n}\n"
  },
  {
    "path": "src/cmd_delete.hpp",
    "content": "#pragma once\n\n#include \"command.hpp\"\n#include <string>\n\nclass DeleteCmd : public Command {\npublic:\n    std::string name() const override {\n        return \"delete\";\n    }\n\n    std::string desc() const override {\n        return \"delete wip refs\";\n    }\n\n    int run(int argc, char *argv[]) override;\n};\n"
  },
  {
    "path": "src/cmd_list.cpp",
    "content": "#include \"cmd_list.hpp\"\n\n#include \"color.hpp\"\n#include \"git_guards.hpp\"\n#include \"git_helpers.hpp\"\n#include \"wip_helpers.hpp\"\n\n#include \"spdlog/spdlog.h\"\n\n#include \"print_compat.hpp\"\n\n#include <string>\n\nint ListCmd::run(int argc, char *argv[]) {\n    bool verbose = false;\n\n    for (int i = 1; i < argc; ++i) {\n        std::string a(argv[i]);\n        if (a == \"-v\" || a == \"--verbose\") {\n            verbose = true;\n        } else if (a == \"--help\" || a == \"-h\") {\n            std::println(\"Usage: git-wip list [-v|--verbose]\\n\");\n            //                -                     #\n            std::println(\"    -v, --verbose         # show ahead counts and orphaned refs\\n\");\n            return 0;\n        } else {\n            spdlog::error(\"git-wip list: unknown option '{}'\", a);\n            return 1;\n        }\n    }\n\n    GitLibGuard git_lib_guard;\n\n    GitRepoGuard repo_guard;\n    if (git_repository_open_ext(repo_guard.ptr(), \".\", 0, nullptr) < 0) {\n        spdlog::error(\"not a git repository: {}\", git_error_str());\n        return 1;\n    }\n    git_repository *repo = repo_guard.get();\n\n    auto wip_refs = find_refs(repo, \"refs/wip/\");\n    spdlog::debug(\"list: found {} refs under refs/wip/\", wip_refs.size());\n\n    for (const auto &wip_ref : wip_refs) {\n        const std::string wip_name = strip_prefix(wip_ref, \"refs/\");\n        const std::string wip_name_colored = color_wip_branch(wip_name);\n\n        if (!verbose) {\n            std::println(\"{}\", wip_name_colored);\n            continue;\n        }\n\n        const std::string branch_name = strip_prefix(wip_ref, \"refs/wip/\");\n        auto bn = resolve_branch_names(repo, branch_name);\n        if (!bn) {\n            std::println(\"{} is orphaned\", wip_name_colored);\n            continue;\n        }\n\n        auto work_last = resolve_oid(repo, bn->work_ref);\n        auto wip_last = resolve_oid(repo, bn->wip_ref);\n\n        // The wip ref came from find_refs(), so wip_last should always resolve.\n        // If either lookup fails (or histories are unrelated), report orphaned.\n        if (!work_last || !wip_last) {\n            std::println(\"{} is orphaned\", wip_name_colored);\n            continue;\n        }\n\n        auto wip_commits = collect_wip_commits(repo, *wip_last, *work_last);\n        if (!wip_commits) {\n            std::println(\"{} is orphaned\", wip_name_colored);\n            continue;\n        }\n\n        std::println(\"{} has {} commit{} ahead of {}\",\n                     wip_name_colored,\n                     wip_commits->size(),\n                     wip_commits->size() == 1 ? \"\" : \"s\",\n                     color_branch(bn->work_branch));\n    }\n\n    return 0;\n}\n"
  },
  {
    "path": "src/cmd_list.hpp",
    "content": "#pragma once\n\n#include \"command.hpp\"\n\n#include <string>\n\nclass ListCmd : public Command {\npublic:\n    std::string name() const override {\n        return \"list\";\n    }\n\n    std::string desc() const override {\n        return \"list wip branches\";\n    }\n\n    int run(int argc, char *argv[]) override;\n};\n"
  },
  {
    "path": "src/cmd_log.cpp",
    "content": "#include \"cmd_log.hpp\"\n#include \"git_guards.hpp\"\n#include \"git_helpers.hpp\"\n\n#include \"spdlog/spdlog.h\"\n\n#include <cstdlib>\n#include \"print_compat.hpp\"\n#include <string>\n#include <vector>\n\nint LogCmd::run(int argc, char *argv[]) {\n    // -----------------------------------------------------------------------\n    // 1. Parse arguments\n    // -----------------------------------------------------------------------\n    bool pretty     = false;\n    bool stat       = false;\n    bool reflog_mode = false;\n    std::vector<std::string> files;\n\n    std::vector<std::string> args;\n    for (int i = 1; i < argc; ++i)\n        args.emplace_back(argv[i]);\n\n    bool past_dashdash = false;\n    for (const auto &a : args) {\n        if (past_dashdash) {\n            files.push_back(a);\n        } else if (a == \"--\") {\n            past_dashdash = true;\n        } else if (a == \"--pretty\" || a == \"-p\") {\n            pretty = true;\n        } else if (a == \"--stat\" || a == \"-s\") {\n            stat = true;\n        } else if (a == \"--reflog\" || a == \"-r\") {\n            reflog_mode = true;\n        } else if (a == \"--help\" || a == \"-h\") {\n            std::println(\"Usage: git-wip log [--pretty|-p] [--stat|-s] [--reflog|-r] [-- <file>...]\\n\");\n            //                -                     #\n            std::println(\"    -p, --pretty          # use pretty oneline log (default full log)\");\n            std::println(\"    -s, --stat            # show file changes in log\");\n            std::println(\"    -r, --reflog          # invoke reflog (shows historical entries)\");\n            std::println(\"    <file>...             # filter on changes to specific file(s)\\n\");\n            return 0;\n        } else if (!a.empty() && a[0] == '-') {\n            spdlog::error(\"git-wip log: unknown option '{}'\", a);\n            return 1;\n        } else {\n            files.push_back(a);\n        }\n    }\n\n    spdlog::debug(\"log: pretty={} stat={} reflog={} files={}\", pretty, stat, reflog_mode, files.size());\n\n    // -----------------------------------------------------------------------\n    // 2. Open repository\n    // -----------------------------------------------------------------------\n    GitLibGuard git_lib_guard;\n\n    GitRepoGuard repo_guard;\n    if (git_repository_open_ext(repo_guard.ptr(), \".\", 0, nullptr) < 0) {\n        spdlog::error(\"not a git repository: {}\", git_error_str());\n        return 1;\n    }\n    git_repository *repo = repo_guard.get();\n\n    // -----------------------------------------------------------------------\n    // 3. Resolve branch names\n    // -----------------------------------------------------------------------\n    auto bn = resolve_branch_names(repo);\n    if (!bn) {\n        spdlog::error(\"not on a local branch\");\n        return 1;\n    }\n\n    spdlog::debug(\"log: work_branch='{}' wip_ref='{}'\", bn->work_branch, bn->wip_ref);\n\n    // -----------------------------------------------------------------------\n    // 4. Resolve work_last and wip_last OIDs\n    // -----------------------------------------------------------------------\n    auto work_last = resolve_oid(repo, bn->work_ref);\n    if (!work_last) {\n        spdlog::error(\"'{}' branch has no commits.\", bn->work_branch);\n        return 1;\n    }\n\n    auto wip_last = resolve_oid(repo, bn->wip_ref);\n    if (!wip_last) {\n        spdlog::error(\"'{}' has no WIP commits.\", bn->work_branch);\n        return 1;\n    }\n\n    spdlog::debug(\"log: work_last={} wip_last={}\",\n                  oid_to_hex(&*work_last), oid_to_hex(&*wip_last));\n\n    // -----------------------------------------------------------------------\n    // 5. Build and exec git log / git reflog\n    // -----------------------------------------------------------------------\n    const std::string pretty_fmt =\n        \" --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr)%Creset'\"\n        \" --abbrev-commit --date=relative\";\n\n    if (reflog_mode) {\n        std::string cmd = \"git reflog\";\n        if (stat)   cmd += \" --stat\";\n        if (pretty) cmd += pretty_fmt;\n        cmd += \" \" + bn->wip_ref;\n        spdlog::debug(\"log: running: {}\", cmd);\n        return std::system(cmd.c_str());\n    }\n\n    // Compute merge-base to determine the stop point.\n    git_oid base_oid{};\n    if (git_merge_base(&base_oid, repo, &*wip_last, &*work_last) < 0) {\n        spdlog::error(\"cannot find merge base: {}\", git_error_str());\n        return 1;\n    }\n\n    spdlog::debug(\"log: base={}\", oid_to_hex(&base_oid));\n\n    // stop = base~1 if base has parents, else base itself\n    std::string stop_arg;\n    {\n        GitCommitGuard base_commit;\n        std::string base_hex = oid_to_hex(&base_oid);\n        if (git_commit_lookup(base_commit.ptr(), repo, &base_oid) == 0 &&\n            git_commit_parentcount(base_commit.get()) > 0)\n            stop_arg = base_hex + \"~1\";\n        else\n            stop_arg = base_hex;\n    }\n\n    spdlog::debug(\"log: stop={}\", stop_arg);\n\n    std::string cmd = \"git log\";\n    if (pretty) cmd += \" --graph\" + pretty_fmt;\n    if (stat)   cmd += \" --stat\";\n    for (const auto &f : files) cmd += \" -- \" + f;\n    cmd += \" \" + oid_to_hex(&*wip_last);\n    cmd += \" \" + oid_to_hex(&*work_last);\n    cmd += \" ^\" + stop_arg;\n\n    spdlog::debug(\"log: running: {}\", cmd);\n    return std::system(cmd.c_str());\n}\n"
  },
  {
    "path": "src/cmd_log.hpp",
    "content": "#pragma once\n\n#include \"command.hpp\"\n#include <string>\n\nclass LogCmd : public Command {\npublic:\n    std::string name() const override {\n        return \"log\";\n    }\n\n    std::string desc() const override {\n        return \"look at WIP history\";\n    }\n\n    int run(int argc, char *argv[]) override;\n};\n"
  },
  {
    "path": "src/cmd_save.cpp",
    "content": "#include \"cmd_save.hpp\"\n#include \"git_guards.hpp\"\n#include \"git_helpers.hpp\"\n#include \"string_helpers.hpp\"\n#include \"wip_helpers.hpp\"\n\n#include \"spdlog/spdlog.h\"\n\n#include <cstdlib>\n#include <filesystem>\n#include <format>\n#include \"print_compat.hpp\"\n#include <string>\n#include <vector>\n\nint SaveCmd::run(int argc, char *argv[]) {\n    // -----------------------------------------------------------------------\n    // 1. Parse arguments\n    // -----------------------------------------------------------------------\n    bool editor_mode   = false;\n    bool add_untracked = false;\n    bool add_ignored   = false;\n    bool gpg_sign      = false; // TODO: not implemented yet\n    std::string message = \"WIP\";\n    std::vector<std::string> files;\n\n    std::vector<std::string> args;\n    for (int i = 1; i < argc; ++i)\n        args.emplace_back(argv[i]);\n\n    bool got_message   = false;\n    bool past_dashdash = false;\n    for (const auto &a : args) {\n        if (past_dashdash) {\n            files.push_back(a);\n        } else if (a == \"--\") {\n            past_dashdash = true;\n        } else if (a == \"--editor\" || a == \"-e\") {\n            editor_mode = true;\n        } else if (a == \"--untracked\" || a == \"-u\") {\n            add_untracked = true;\n        } else if (a == \"--no-untracked\" || a == \"-U\") {\n            add_untracked = false;\n        } else if (a == \"--ignored\" || a == \"-i\") {\n            add_ignored = true;\n        } else if (a == \"--no-ignored\" || a == \"-I\") {\n            add_ignored = false;\n        } else if (a == \"--gpg-sign\") {\n            gpg_sign = true;\n        } else if (a == \"--no-gpg-sign\") {\n            gpg_sign = false;\n        } else if (a == \"--help\" || a == \"-h\") {\n            std::println(\"Usage: git-wip save [<message>] [--editor|-e] [--[no-]untracked|-u|-U] [--[no-]ignored|-i|-I] [--[no-]gpg-sign] [-- <file>...]\\n\");\n            //                -                     #\n            std::println(\"    <message>             # use this message (defaults to \\\"WIP\\\")\");\n            std::println(\"    -e, --editor          # queit when there are no changes (called from editor)\");\n            std::println(\"    -u, --untracked       # enable capture of changes to untracked files\");\n            std::println(\"    -U, --no-untracked    # disable capture of changes to untracked files\");\n            std::println(\"    -i, --ignored         # enable capture of changes to ignored files\");\n            std::println(\"    -I, --no-ignored      # disable capture of changes to ignored files\");\n            std::println(\"    --gpg-sign            # enable signing of commits\");\n            std::println(\"    --no-gpg-sign         # disable signing of commits\");\n            std::println(\"    <file>...             # filter on changes to specific file(s)\\n\");\n            return 0;\n        } else if (!a.empty() && a[0] == '-') {\n            spdlog::error(\"git-wip save: unknown option '{}'\", a);\n            return 1;\n        } else if (!got_message) {\n            message     = a;\n            got_message = true;\n        } else {\n            files.push_back(a);\n        }\n    }\n\n    spdlog::debug(\"save: message='{}' editor={} untracked={} ignored={} gpg_sign={} files={}\",\n                  message, editor_mode, add_untracked, add_ignored, gpg_sign, files.size());\n\n    if (gpg_sign)\n        spdlog::warn(\"git-wip sign --gpg-sign is not implemented yet\");\n\n    // -----------------------------------------------------------------------\n    // 2. Open repository\n    // -----------------------------------------------------------------------\n    GitLibGuard git_lib_guard;\n\n    GitRepoGuard repo_guard;\n    if (git_repository_open_ext(repo_guard.ptr(), \".\", 0, nullptr) < 0) {\n        spdlog::error(\"not a git repository: {}\", git_error_str());\n        return 1;\n    }\n    git_repository *repo = repo_guard.get();\n\n    // -----------------------------------------------------------------------\n    // 2b. Normalise file paths to be relative to the workdir root.\n    //\n    //     git_index_add_all() resolves pathspecs against the workdir root,\n    //     but the paths supplied on the command line are relative to the\n    //     current working directory (which may be a subdirectory).  Convert\n    //     each path: cwd/file → absolute → workdir-relative.\n    // -----------------------------------------------------------------------\n    if (!files.empty()) {\n        const char *workdir_cstr = git_repository_workdir(repo);\n        if (workdir_cstr) {\n            std::filesystem::path workdir = std::filesystem::canonical(workdir_cstr);\n            std::filesystem::path cwd     = std::filesystem::current_path();\n            for (auto &f : files) {\n                std::filesystem::path abs = std::filesystem::weakly_canonical(cwd / f);\n                // Make relative to workdir; weakly_canonical handles non-existent files too\n                std::error_code ec;\n                auto rel = std::filesystem::relative(abs, workdir, ec);\n                if (!ec && !rel.empty())\n                    f = rel.string();\n                spdlog::debug(\"save: file '{}' → repo-relative '{}'\",\n                              (cwd / f).string(), f);\n            }\n        }\n    }\n\n    if (git_repository_is_bare(repo)) {\n        spdlog::error(\"cannot use in a bare repository\");\n        return 1;\n    }\n\n    // -----------------------------------------------------------------------\n    // 3. Resolve branch names\n    // -----------------------------------------------------------------------\n    auto bn = resolve_branch_names(repo);\n    if (!bn) {\n        if (editor_mode) { return 0; }\n        spdlog::error(\"git-wip requires a local branch\");\n        return 1;\n    }\n\n    spdlog::debug(\"save: work_branch='{}' wip_ref='{}'\", bn->work_branch, bn->wip_ref);\n\n    // -----------------------------------------------------------------------\n    // 4. Ensure reflog directory exists for the wip branch\n    // -----------------------------------------------------------------------\n    ensure_reflog_dir(repo, bn->wip_ref);\n\n    // -----------------------------------------------------------------------\n    // 5. Resolve work_last\n    // -----------------------------------------------------------------------\n    auto work_last = resolve_oid(repo, bn->work_ref);\n    if (!work_last) {\n        if (editor_mode) { return 0; }\n        spdlog::error(\"'{}' branch has no commits.\", bn->work_branch);\n        return 1;\n    }\n\n    spdlog::debug(\"save: work_last={}\", oid_to_hex(&*work_last));\n\n    // -----------------------------------------------------------------------\n    // 6. Determine wip_parent\n    // -----------------------------------------------------------------------\n    auto wip_last = resolve_oid(repo, bn->wip_ref); // nullopt if no wip branch yet\n\n    if (wip_last)\n        spdlog::debug(\"save: wip_last={}\", oid_to_hex(&*wip_last));\n\n    auto wip_parent = wip_parent_oid(repo, *work_last, wip_last);\n    if (!wip_parent) {\n        spdlog::error(\"'{}' and '{}' are unrelated.\",\n                      bn->work_branch, bn->wip_ref);\n        return 1;\n    }\n\n    spdlog::debug(\"save: wip_parent={}\", oid_to_hex(&*wip_parent));\n\n    // -----------------------------------------------------------------------\n    // 7. Build new tree (in-memory; never writes to the on-disk index)\n    //\n    //    Steps:\n    //      a) Load the parent commit's tree into the repo's in-memory index\n    //      b) Stage changes from the working directory on top\n    //      c) Write the tree object to the ODB\n    //      d) Restore the real on-disk index\n    // -----------------------------------------------------------------------\n    git_oid new_tree_oid{};\n    {\n        GitCommitGuard parent_commit;\n        if (git_commit_lookup(parent_commit.ptr(), repo, &*wip_parent) < 0) {\n            spdlog::error(\"cannot look up parent commit: {}\", git_error_str());\n            return 1;\n        }\n\n        GitTreeGuard parent_tree;\n        if (git_commit_tree(parent_tree.ptr(), parent_commit.get()) < 0) {\n            spdlog::error(\"cannot get parent tree: {}\", git_error_str());\n            return 1;\n        }\n\n        GitIndexGuard idx_guard;\n        if (git_repository_index(idx_guard.ptr(), repo) < 0) {\n            spdlog::error(\"cannot get repo index: {}\", git_error_str());\n            return 1;\n        }\n        git_index *idx = idx_guard.get();\n\n        if (git_index_read_tree(idx, parent_tree.get()) < 0) {\n            spdlog::error(\"cannot read parent tree into index: {}\", git_error_str());\n            return 1;\n        }\n\n        // Stage changes\n        int stage_rc = 0;\n        if (!files.empty()) {\n            std::vector<const char *> c_files;\n            c_files.reserve(files.size());\n            for (const auto &f : files) c_files.push_back(f.c_str());\n            git_strarray ps{const_cast<char **>(c_files.data()), c_files.size()};\n            stage_rc = git_index_add_all(idx, &ps, GIT_INDEX_ADD_FORCE, nullptr, nullptr);\n        } else if (add_ignored) {\n            git_strarray dot{nullptr, 0};\n            stage_rc = git_index_add_all(idx, &dot, GIT_INDEX_ADD_FORCE, nullptr, nullptr);\n        } else if (add_untracked) {\n            git_strarray dot{nullptr, 0};\n            stage_rc = git_index_add_all(idx, &dot, GIT_INDEX_ADD_DEFAULT, nullptr, nullptr);\n        } else {\n            git_strarray dot{nullptr, 0};\n            stage_rc = git_index_update_all(idx, &dot, nullptr, nullptr);\n        }\n\n        if (stage_rc < 0) {\n            spdlog::error(\"cannot stage changes: {}\", git_error_str());\n            git_index_read(idx, 1);\n            return 1;\n        }\n\n        if (git_index_write_tree(&new_tree_oid, idx) < 0) {\n            spdlog::error(\"cannot write tree: {}\", git_error_str());\n            git_index_read(idx, 1);\n            return 1;\n        }\n\n        spdlog::debug(\"save: new_tree={}\", oid_to_hex(&new_tree_oid));\n\n        git_index_read(idx, /*force=*/1); // restore on-disk index\n    }\n\n    // -----------------------------------------------------------------------\n    // 8. Check for changes\n    // -----------------------------------------------------------------------\n    {\n        GitCommitGuard parent_commit;\n        git_commit_lookup(parent_commit.ptr(), repo, &*wip_parent);\n        git_oid parent_tree_oid = *git_commit_tree_id(parent_commit.get());\n\n        if (git_oid_equal(&new_tree_oid, &parent_tree_oid)) {\n            spdlog::debug(\"save: no changes\");\n            if (editor_mode) { return 0; }\n            std::println(\"no changes\");\n            return 1;\n        }\n    }\n\n    spdlog::debug(\"save: has changes, creating commit\");\n\n    // -----------------------------------------------------------------------\n    // 9. Create the WIP commit\n    // -----------------------------------------------------------------------\n    GitTreeGuard new_tree_obj;\n    if (git_tree_lookup(new_tree_obj.ptr(), repo, &new_tree_oid) < 0) {\n        spdlog::error(\"cannot look up new tree: {}\", git_error_str());\n        return 1;\n    }\n\n    GitCommitGuard parent_commit_obj;\n    if (git_commit_lookup(parent_commit_obj.ptr(), repo, &*wip_parent) < 0) {\n        spdlog::error(\"cannot look up parent commit: {}\", git_error_str());\n        return 1;\n    }\n\n    GitSignatureGuard sig;\n    if (git_signature_default(sig.ptr(), repo) < 0)\n        git_signature_now(sig.ptr(), \"git-wip\", \"git-wip@localhost\");\n\n    git_oid new_commit_oid{};\n    {\n        const git_commit *parents[] = {parent_commit_obj.get()};\n        if (git_commit_create(&new_commit_oid, repo, nullptr,\n                              sig.get(), sig.get(), nullptr,\n                              message.c_str(), new_tree_obj.get(),\n                              1, parents) < 0) {\n            spdlog::error(\"cannot create commit: {}\", git_error_str());\n            return 1;\n        }\n    }\n\n    spdlog::debug(\"save: new_wip={}\", oid_to_hex(&new_commit_oid));\n\n    // -----------------------------------------------------------------------\n    // 10. Update the wip ref\n    // -----------------------------------------------------------------------\n    {\n        std::string reflog_msg = \"git-wip: \" + first_line(message.c_str());\n        const git_oid *current_id = wip_last ? &*wip_last : nullptr;\n\n        GitReferenceGuard new_ref;\n        if (git_reference_create_matching(new_ref.ptr(), repo,\n                                          bn->wip_ref.c_str(),\n                                          &new_commit_oid, /*force=*/1,\n                                          current_id,\n                                          reflog_msg.c_str()) < 0) {\n            spdlog::error(\"cannot update ref '{}': {}\",\n                          bn->wip_ref, git_error_str());\n            return 1;\n        }\n    }\n\n    spdlog::debug(\"save: SUCCESS\");\n    return 0;\n}\n"
  },
  {
    "path": "src/cmd_save.hpp",
    "content": "#pragma once\n\n#include \"command.hpp\"\n#include <string>\n\nclass SaveCmd : public Command {\npublic:\n    std::string name() const override {\n        return \"save\";\n    }\n\n    std::string desc() const override {\n        return \"save current work\";\n    }\n\n    int run(int argc, char *argv[]) override;\n};\n"
  },
  {
    "path": "src/cmd_status.cpp",
    "content": "#include \"cmd_status.hpp\"\n#include \"color.hpp\"\n#include \"git_guards.hpp\"\n#include \"git_helpers.hpp\"\n#include \"string_helpers.hpp\"\n#include \"wip_helpers.hpp\"\n\n#include \"spdlog/spdlog.h\"\n\n#include <cstdlib>\n#include <iostream>\n#include \"print_compat.hpp\"\n#include <string>\n#include <vector>\n\nint StatusCmd::run(int argc, char *argv[]) {\n    // -----------------------------------------------------------------------\n    // 1. Parse arguments\n    // -----------------------------------------------------------------------\n    bool list_mode  = false;\n    bool files_mode = false;\n    std::optional<std::string> ref_arg;\n\n    for (int i = 1; i < argc; ++i) {\n        std::string a(argv[i]);\n        if (a == \"-l\" || a == \"--list\") {\n            list_mode = true;\n        } else if (a == \"-f\" || a == \"--files\") {\n            files_mode = true;\n        } else if (a == \"--help\" || a == \"-h\") {\n            std::println(\"Usage: git-wip status [-l|--list] [-f|--files] [<ref>]\\n\");\n            //                -                     #\n            std::println(\"    -l, --list            # show each wip commit (short sha, subject, age)\");\n            std::println(\"    -f, --files           # show diff --stat of wip changes\\n\");\n            return 0;\n        } else if (!a.empty() && a[0] == '-') {\n            spdlog::error(\"git-wip status: unknown option '{}'\", a);\n            return 1;\n        } else if (ref_arg.has_value()) {\n            spdlog::error(\"git-wip status: only one ref argument is allowed\");\n            return 1;\n        } else {\n            ref_arg = a;\n        }\n    }\n\n    spdlog::debug(\"status: list={} files={}\", list_mode, files_mode);\n\n    // -----------------------------------------------------------------------\n    // 2. Open repository\n    // -----------------------------------------------------------------------\n    GitLibGuard git_lib_guard;\n\n    GitRepoGuard repo_guard;\n    if (git_repository_open_ext(repo_guard.ptr(), \".\", 0, nullptr) < 0) {\n        spdlog::error(\"not a git repository: {}\", git_error_str());\n        return 1;\n    }\n    git_repository *repo = repo_guard.get();\n\n    // -----------------------------------------------------------------------\n    // 3. Resolve branch names\n    // -----------------------------------------------------------------------\n    auto bn = resolve_branch_names(repo, ref_arg);\n    if (!bn) {\n        spdlog::error(\"not on a local branch\");\n        return 1;\n    }\n\n    spdlog::debug(\"status: work_branch='{}' wip_ref='{}'\", bn->work_branch, bn->wip_ref);\n\n    // -----------------------------------------------------------------------\n    // 4. Resolve work_last and wip_last OIDs\n    // -----------------------------------------------------------------------\n    auto work_last = resolve_oid(repo, bn->work_ref);\n    if (!work_last) {\n        spdlog::error(\"branch '{}' has no commits\", bn->work_branch);\n        return 1;\n    }\n\n    auto wip_last = resolve_oid(repo, bn->wip_ref);\n    if (!wip_last) {\n        std::println(\"branch {} has no wip commits\", color_branch(bn->work_branch));\n        return 0;\n    }\n\n    spdlog::debug(\"status: work_last={} wip_last={}\",\n                  oid_to_hex(&*work_last), oid_to_hex(&*wip_last));\n\n    // -----------------------------------------------------------------------\n    // 5. Collect the WIP-only commits (newest first)\n    // -----------------------------------------------------------------------\n    auto wip_commits = collect_wip_commits(repo, *wip_last, *work_last);\n    if (!wip_commits) {\n        spdlog::error(\"cannot enumerate wip commits: {}\", git_error_str());\n        return 1;\n    }\n\n    spdlog::debug(\"status: {} wip commit(s)\", wip_commits->size());\n\n    // -----------------------------------------------------------------------\n    // 6. Summary line\n    // -----------------------------------------------------------------------\n    std::println(\"branch {} has {} wip commit{} on {}\",\n                 color_branch(bn->work_branch),\n                 wip_commits->size(),\n                 wip_commits->size() == 1 ? \"\" : \"s\",\n                 color_wip_branch(bn->wip_ref));\n    std::cout.flush();\n\n    // -----------------------------------------------------------------------\n    // 7. Optional detail modes\n    // -----------------------------------------------------------------------\n    if (list_mode) {\n        for (const auto &oid : *wip_commits) {\n            GitCommitGuard commit;\n            if (git_commit_lookup(commit.ptr(), repo, &oid) < 0) continue;\n\n            const git_signature *author = git_commit_author(commit.get());\n            std::println(\"{} - {} ({})\",\n                         color_commit_hash(oid_to_short_hex(&oid)),\n                         first_line(git_commit_message(commit.get())),\n                         relative_time(author->when.time));\n            std::cout.flush();\n\n            if (files_mode) {\n                // per-commit diff --stat against its parent\n                std::string hex = oid_to_hex(&oid);\n                std::system((\"git diff --stat \" + hex + \"^ \" + hex).c_str());\n            }\n        }\n    } else if (files_mode) {\n        // diff --stat from work branch HEAD to latest wip tip\n        std::system((\"git diff --stat \" +\n                     oid_to_hex(&*work_last) + \" \" +\n                     oid_to_hex(&*wip_last)).c_str());\n    }\n\n    return 0;\n}\n"
  },
  {
    "path": "src/cmd_status.hpp",
    "content": "#pragma once\n\n#include \"command.hpp\"\n#include <string>\n\nclass StatusCmd : public Command {\npublic:\n    std::string name() const override {\n        return \"status\";\n    }\n\n    std::string desc() const override {\n        return \"inspect changes\";\n    }\n\n    int run(int argc, char *argv[]) override;\n};\n"
  },
  {
    "path": "src/color.cpp",
    "content": "#include \"color.hpp\"\n\n#include <algorithm>\n#include <cctype>\n#include <cstdio>\n#include <cstdlib>\n#include <format>\n\n#include <unistd.h>\n\nbool g_wip_color = true;\n\nnamespace {\n\nstd::string lower_copy(std::string s) {\n    std::ranges::transform(s, s.begin(), [](unsigned char c) {\n        return static_cast<char>(std::tolower(c));\n    });\n    return s;\n}\n\nbool stdout_is_tty() {\n    return isatty(fileno(stdout)) == 1;\n}\n\n} // namespace\n\nvoid color_init() {\n    // safe fallback\n    g_wip_color = false;\n\n    const char *env = std::getenv(\"WIP_COLOR\");\n    if (env == nullptr) {\n        g_wip_color = stdout_is_tty();\n        return;\n    }\n\n    std::string mode = lower_copy(env);\n    if (mode == \"1\" || mode == \"on\" || mode == \"always\") {\n        g_wip_color = true;\n        return;\n    }\n\n    if (mode == \"0\" || mode == \"off\" || mode == \"never\") {\n        g_wip_color = false;\n        return;\n    }\n\n    if (mode.empty() || mode == \"auto\") {\n        g_wip_color = stdout_is_tty();\n        return;\n    }\n}\n\nstd::string Color::red() {\n    return g_wip_color ? \"\\x1b[31m\" : \"\";\n}\n\nstd::string Color::green() {\n    return g_wip_color ? \"\\x1b[32m\" : \"\";\n}\n\nstd::string Color::yellow() {\n    return g_wip_color ? \"\\x1b[33m\" : \"\";\n}\n\nstd::string Color::reset() {\n    return g_wip_color ? \"\\x1b[0m\" : \"\";\n}\n\nstd::string Color::rgb(int r, int g, int b) {\n    r = std::clamp(r, 0, 255);\n    g = std::clamp(g, 0, 255);\n    b = std::clamp(b, 0, 255);\n    return g_wip_color ? std::format(\"\\x1b[38;2;{};{};{}m\", r, g, b) : \"\";\n}\n\nstd::string color_branch(std::string_view branch_name) {\n    return Color::green() + std::string(branch_name) + Color::reset();\n}\n\nstd::string color_wip_branch(std::string_view wip_branch_name) {\n    return Color::red() + std::string(wip_branch_name) + Color::reset();\n}\n\nstd::string color_commit_hash(std::string_view commit_hash) {\n    return Color::yellow() + std::string(commit_hash) + Color::reset();\n}\n"
  },
  {
    "path": "src/color.hpp",
    "content": "#pragma once\n\n#include <string>\n#include <string_view>\n\nextern bool g_wip_color;\n\nvoid color_init();\n\nclass Color {\npublic:\n    static std::string red();\n    static std::string green();\n    static std::string yellow();\n    static std::string reset();\n    static std::string rgb(int r, int g, int b);\n};\n\nstd::string color_branch(std::string_view branch_name);\nstd::string color_wip_branch(std::string_view wip_branch_name);\nstd::string color_commit_hash(std::string_view commit_hash);\n"
  },
  {
    "path": "src/command.hpp",
    "content": "#pragma once\n\n#include <string>\n#include <vector>\n\nclass Command {\npublic:\n    // Pure virtual methods to be implemented by derived classes\n    virtual std::string name() const = 0;\n    virtual std::string desc() const = 0;\n    virtual int run(int argc, char *argv[]) = 0;\n\n    // Virtual destructor to ensure proper cleanup of derived classes\n    virtual ~Command() = default;\n};\n"
  },
  {
    "path": "src/git_guards.hpp",
    "content": "#pragma once\n\n#include <git2.h>\n#include <string>\n\n// ---------------------------------------------------------------------------\n// RAII wrappers for libgit2 objects\n//\n// Each guard owns the pointed-to object and frees it on destruction.\n// Use ptr() to obtain the address to pass to libgit2 \"out\" parameters,\n// and get() to retrieve the wrapped pointer for subsequent API calls.\n// ---------------------------------------------------------------------------\n\nstruct GitLibGuard {\n    GitLibGuard() { git_libgit2_init(); }\n    ~GitLibGuard() { git_libgit2_shutdown(); }\n\n    GitLibGuard(const GitLibGuard &) = delete;\n    GitLibGuard &operator=(const GitLibGuard &) = delete;\n};\n\nstruct GitRepoGuard {\n    git_repository *m_repo = nullptr;\n    ~GitRepoGuard() { if (m_repo) git_repository_free(m_repo); }\n    git_repository       *get()       { return m_repo; }\n    git_repository const *get() const { return m_repo; }\n    git_repository      **ptr()       { return &m_repo; }\n};\n\nstruct GitIndexGuard {\n    git_index *m_idx = nullptr;\n    ~GitIndexGuard() { if (m_idx) git_index_free(m_idx); }\n    git_index       *get()       { return m_idx; }\n    git_index const *get() const { return m_idx; }\n    git_index      **ptr()       { return &m_idx; }\n};\n\nstruct GitTreeGuard {\n    git_tree *m_tree = nullptr;\n    ~GitTreeGuard() { if (m_tree) git_tree_free(m_tree); }\n    git_tree       *get()       { return m_tree; }\n    git_tree const *get() const { return m_tree; }\n    git_tree      **ptr()       { return &m_tree; }\n};\n\nstruct GitCommitGuard {\n    git_commit *m_commit = nullptr;\n    ~GitCommitGuard() { if (m_commit) git_commit_free(m_commit); }\n    git_commit       *get()       { return m_commit; }\n    git_commit const *get() const { return m_commit; }\n    git_commit      **ptr()       { return &m_commit; }\n};\n\nstruct GitReferenceGuard {\n    git_reference *m_ref = nullptr;\n    ~GitReferenceGuard() { if (m_ref) git_reference_free(m_ref); }\n    git_reference       *get()       { return m_ref; }\n    git_reference const *get() const { return m_ref; }\n    git_reference      **ptr()       { return &m_ref; }\n};\n\nstruct GitSignatureGuard {\n    git_signature *m_sig = nullptr;\n    ~GitSignatureGuard() { if (m_sig) git_signature_free(m_sig); }\n    git_signature       *get()       { return m_sig; }\n    git_signature const *get() const { return m_sig; }\n    git_signature      **ptr()       { return &m_sig; }\n};\n\nstruct GitRevwalkGuard {\n    git_revwalk *m_walk = nullptr;\n    ~GitRevwalkGuard() { if (m_walk) git_revwalk_free(m_walk); }\n    git_revwalk       *get()       { return m_walk; }\n    git_revwalk const *get() const { return m_walk; }\n    git_revwalk      **ptr()       { return &m_walk; }\n};\n\n// ---------------------------------------------------------------------------\n// Convenience helper: return the last libgit2 error message as a std::string.\n// ---------------------------------------------------------------------------\ninline std::string git_error_str() {\n    const git_error *e = git_error_last();\n    return e ? e->message : \"(unknown error)\";\n}\n"
  },
  {
    "path": "src/git_helpers.hpp",
    "content": "#pragma once\n\n// git_helpers.hpp — thin wrappers around libgit2 operations that are repeated\n// across multiple commands.  All functions return by value and report errors\n// via std::expected-style: an empty optional/string signals failure, and the\n// caller can retrieve the last libgit2 error with git_error_str().\n\n#include \"git_guards.hpp\"\n#include \"string_helpers.hpp\"\n\n#include <filesystem>\n#include <fstream>\n#include <algorithm>\n#include <optional>\n#include <string>\n#include <string_view>\n#include <vector>\n\n// ---------------------------------------------------------------------------\n// oid_to_hex  — convert a git_oid to its full lowercase hex string\n// ---------------------------------------------------------------------------\ninline std::string oid_to_hex(const git_oid *oid) {\n    char buf[GIT_OID_MAX_HEXSIZE + 1];\n    git_oid_tostr(buf, sizeof(buf), oid);\n    return buf;\n}\n\n// ---------------------------------------------------------------------------\n// oid_to_short_hex  — first 7 characters of the hex OID\n// ---------------------------------------------------------------------------\ninline std::string oid_to_short_hex(const git_oid *oid) {\n    char buf[GIT_OID_MAX_HEXSIZE + 1];\n    git_oid_tostr(buf, sizeof(buf), oid);\n    buf[7] = '\\0';\n    return buf;\n}\n\n// ---------------------------------------------------------------------------\n// BranchNames\n//\n// Holds the three names every command needs after resolving the current branch:\n//   work_branch  — short name, e.g. \"master\"\n//   work_ref     — full ref,   e.g. \"refs/heads/master\"\n//   wip_ref      — full ref,   e.g. \"refs/wip/master\"\n// ---------------------------------------------------------------------------\nstruct BranchNames {\n    std::string work_branch; // short, e.g. \"master\"\n    std::string work_ref;    // e.g. \"refs/heads/master\"\n    std::string wip_ref;     // e.g. \"refs/wip/master\"\n};\n\n// ---------------------------------------------------------------------------\n// resolve_branch_names\n//\n// Derive BranchNames from `repo`.\n//\n// If `branch_name` is provided, build names for that branch directly.\n// Otherwise read HEAD from `repo` and derive the current branch names.\n//\n// Returns std::nullopt if HEAD is unborn or detached (not on a local branch)\n// when no explicit branch name is given.\n// ---------------------------------------------------------------------------\ninline std::optional<BranchNames> resolve_branch_names(\n    git_repository *repo,\n    const std::optional<std::string> &branch_name = std::nullopt) {\n    BranchNames bn;\n\n    if (branch_name.has_value() && branch_name != \"HEAD\") {\n        bn.work_branch = *branch_name;\n        if (!strip_prefix_inplace(bn.work_branch, \"refs/heads/\") &&\n            !strip_prefix_inplace(bn.work_branch, \"refs/wip/\") &&\n            !strip_prefix_inplace(bn.work_branch, \"wip/\")) {\n            // No prefix matched: treat argument as bare branch name.\n        }\n        bn.work_ref = \"refs/heads/\" + bn.work_branch;\n    } else {\n        GitReferenceGuard head_ref;\n        if (git_repository_head(head_ref.ptr(), repo) < 0)\n            return std::nullopt;\n        if (!git_reference_is_branch(head_ref.get()))\n            return std::nullopt;\n\n        bn.work_ref = git_reference_name(head_ref.get()); // e.g. \"refs/heads/master\"\n        bn.work_branch = strip_prefix(bn.work_ref, \"refs/heads/\");\n    }\n\n    bn.wip_ref     = \"refs/wip/\" + bn.work_branch;\n    return bn;\n}\n\n// ---------------------------------------------------------------------------\n// find_refs\n//\n// Enumerate references whose names begin with `prefix`.\n// Example: prefix=\"refs/wip/\" behaves like `git for-each-ref refs/wip/`.\n//\n// Returns an empty vector when no refs match OR if iteration fails.\n// ---------------------------------------------------------------------------\ninline std::vector<std::string> find_refs(\n    git_repository *repo,\n    const std::string_view prefix) {\n    std::vector<std::string> refs;\n\n    git_reference_iterator *iter = nullptr;\n    if (git_reference_iterator_new(&iter, repo) < 0)\n        return refs;\n\n    git_reference *ref = nullptr;\n    while (git_reference_next(&ref, iter) == 0) {\n        const char *name = git_reference_name(ref);\n        if (name != nullptr && std::string_view(name).starts_with(prefix))\n            refs.emplace_back(name);\n        git_reference_free(ref);\n        ref = nullptr;\n    }\n\n    git_reference_iterator_free(iter);\n    std::ranges::sort(refs);\n    return refs;\n}\n\n// ---------------------------------------------------------------------------\n// resolve_oid\n//\n// Look up the OID for a named ref.  Returns std::nullopt on failure.\n// ---------------------------------------------------------------------------\ninline std::optional<git_oid> resolve_oid(git_repository *repo,\n                                          const std::string &ref_name) {\n    git_oid oid{};\n    if (git_reference_name_to_id(&oid, repo, ref_name.c_str()) < 0)\n        return std::nullopt;\n    return oid;\n}\n\n// ---------------------------------------------------------------------------\n// ensure_reflog_dir\n//\n// Create the directory tree and empty file needed for libgit2 to maintain a\n// reflog for `wip_ref` (e.g. \"refs/wip/master\").\n// Silently ignores errors — a missing reflog is not fatal.\n// ---------------------------------------------------------------------------\ninline void ensure_reflog_dir(git_repository *repo, const std::string &wip_ref) {\n    const char *git_dir = git_repository_path(repo); // ends with \"/\"\n    std::filesystem::path reflog_path =\n        std::filesystem::path(git_dir) / \"logs\" / wip_ref;\n    std::error_code ec;\n    std::filesystem::create_directories(reflog_path.parent_path(), ec);\n    if (!ec && !std::filesystem::exists(reflog_path, ec))\n        std::ofstream{reflog_path, std::ios::app}; // touch\n}\n"
  },
  {
    "path": "src/main.cpp",
    "content": "#include \"command.hpp\"\n#include \"color.hpp\"\n#include \"cmd_delete.hpp\"\n#include \"cmd_list.hpp\"\n#include \"cmd_log.hpp\"\n#include \"cmd_save.hpp\"\n#include \"cmd_status.hpp\"\n\n#include <cstdlib>\n#include <iostream>\n#include \"git_wip_version.h\"\n#include \"print_compat.hpp\"\n#include <format>\n#include <map>\n#include <memory>\n#include <vector>\n#include \"spdlog/spdlog.h\"\n\nbool g_wip_debug = false;\n\nvoid print_main_help(const std::vector<std::unique_ptr<Command>>& commands, std::ostream &os = std::cout) {\n    std::println(os, \"Manage Work In Progress\\n\");\n    std::println(os, \"git-wip <command> [ --help | --version | command options ]\\n\");\n    for (const auto& cmd : commands) {\n        std::println(\"    git-wip {:20} # {}\", cmd->name(), cmd->desc());\n    }\n    std::println(os, \"\\nUse git-wip <command> --help to see command options.\\n\");\n}\n\nint main(int argc, char *argv[]) {\n    color_init();\n\n    // Check WIP_DEBUG environment variable for debug logging\n    const char* wip_debug = std::getenv(\"WIP_DEBUG\");\n    if (wip_debug != nullptr && wip_debug[0] != '\\0' && wip_debug[0] != '0') {\n        spdlog::set_level(spdlog::level::debug);\n        spdlog::debug(\"Debug logging enabled via WIP_DEBUG environment variable\");\n        g_wip_debug = true;\n    }\n\n    std::vector<std::unique_ptr<Command>> commands;\n    commands.push_back(std::make_unique<SaveCmd>());\n    commands.push_back(std::make_unique<StatusCmd>());\n    commands.push_back(std::make_unique<ListCmd>());\n    commands.push_back(std::make_unique<LogCmd>());\n    commands.push_back(std::make_unique<DeleteCmd>());\n\n    std::map<std::string, Command*> command_map;\n    for (const auto& cmd : commands) {\n        command_map[cmd->name()] = cmd.get();\n    }\n\n    // No arguments: default to \"save WIP\" (matches old shell script behaviour)\n    if (argc < 2) {\n        spdlog::debug(\"no arguments, defaulting to 'save WIP'\");\n        auto it = command_map.find(\"save\");\n        if (it != command_map.end()) {\n            // Build a synthetic argv: [\"save\", \"WIP\"]\n            static const char *default_argv[] = {\"save\", \"WIP\", nullptr};\n            return it->second->run(2, const_cast<char **>(default_argv));\n        }\n        spdlog::error(\"internal error, 'save' not implemented\");\n        return 1;\n    }\n\n    std::string command_name = argv[1];\n\n    if (command_name == \"help\" || command_name == \"--help\" || command_name == \"-h\") {\n        print_main_help(commands);\n        return 0;\n    }\n\n    if (command_name == \"version\" || command_name == \"--version\" || command_name == \"-v\") {\n        std::cout << GIT_WIP_VERSION << std::endl;\n        return 0;\n    }\n\n    // If the first argument looks like a file (not a known command and not an\n    // option), treat the whole invocation as \"save WIP [files...]\" — matching\n    // the old script behaviour where bare file paths fall through to save.\n    auto it = command_map.find(command_name);\n    if (it != command_map.end()) {\n        Command* cmd = it->second;\n        // Pass remaining arguments to the command, skipping argv[0] (program name),\n        // so that argv[1] (command name) becomes argv[0] inside the command parser\n        return cmd->run(argc - 1, argv + 1);\n    } else {\n        spdlog::error(\"Unknown command '{}'\", command_name);\n        print_main_help(commands, std::cerr);\n        return 1;\n    }\n\n    return 0;\n}\n"
  },
  {
    "path": "src/print_compat.hpp",
    "content": "#pragma once\n\n// print_compat.hpp — portable std::print / std::println\n//\n// C++23's <print> (P2093) is not available on all compilers we support:\n//   - GCC 14+   ✓\n//   - GCC 13    ✗  (Ubuntu 24.04)\n//   - GCC 12    ✗  (Debian stable)\n//   - Clang 17+ ✓  (with libc++)\n//\n// When WIP_HAVE_STD_PRINT is defined by CMake (via check_cxx_source_compiles),\n// we use the real <print>.  Otherwise we shim std::print / std::println on top\n// of {fmt}, which is fetched by FetchContent and has an identical API.\n\n#ifdef WIP_HAVE_STD_PRINT\n\n#include <print>\n\n#else // fallback: map std::print / std::println → fmt::print / fmt::println\n\n#include <fmt/core.h>\n#include <fmt/ostream.h>\n\n#include <ostream>\n#include <utility>\n\nnamespace std {\n\ntemplate <typename... Args>\nvoid print(fmt::format_string<Args...> fmt_str, Args &&...args) {\n    fmt::print(fmt_str, std::forward<Args>(args)...);\n}\n\ntemplate <typename... Args>\nvoid print(std::ostream &os, fmt::format_string<Args...> fmt_str, Args &&...args) {\n    fmt::print(os, fmt_str, std::forward<Args>(args)...);\n}\n\ntemplate <typename... Args>\nvoid println(fmt::format_string<Args...> fmt_str, Args &&...args) {\n    fmt::println(fmt_str, std::forward<Args>(args)...);\n}\n\ntemplate <typename... Args>\nvoid println(std::ostream &os, fmt::format_string<Args...> fmt_str, Args &&...args) {\n    fmt::println(os, fmt_str, std::forward<Args>(args)...);\n}\n\n} // namespace std\n\n#endif // WIP_HAVE_STD_PRINT\n"
  },
  {
    "path": "src/string_helpers.hpp",
    "content": "#pragma once\n\n// string_helpers.hpp — pure string/time utility functions with no git dependency.\n// All functions are inline so this header is self-contained and unit-testable\n// without linking against libgit2.\n\n#include <chrono>\n#include <format>\n#include <string>\n#include <string_view>\n\n// ---------------------------------------------------------------------------\n// strip_prefix\n//\n// If `s` starts with `prefix`, return the remainder.  Otherwise return `s`\n// unchanged.\n// ---------------------------------------------------------------------------\ninline std::string strip_prefix(std::string_view s, std::string_view prefix) {\n    if (s.substr(0, prefix.size()) == prefix)\n        return std::string(s.substr(prefix.size()));\n    return std::string(s);\n}\n\n// ---------------------------------------------------------------------------\n// strip_prefix_inplace\n//\n// If `s` starts with `prefix`, remove it in-place and return true.\n// Otherwise leave `s` unchanged and return false.\n// ---------------------------------------------------------------------------\ninline bool strip_prefix_inplace(std::string &s, std::string_view prefix) {\n    if (!std::string_view(s).starts_with(prefix))\n        return false;\n    s.erase(0, prefix.size());\n    return true;\n}\n\n// ---------------------------------------------------------------------------\n// first_line\n//\n// Return the text up to (but not including) the first newline.\n// Returns an empty string if `msg` is null.\n// ---------------------------------------------------------------------------\ninline std::string first_line(const char *msg) {\n    if (!msg) return {};\n    std::string_view sv(msg);\n    auto pos = sv.find('\\n');\n    return std::string(pos == std::string_view::npos ? sv : sv.substr(0, pos));\n}\n\n// ---------------------------------------------------------------------------\n// relative_time\n//\n// Format a point-in-time (seconds since epoch) as a human-readable relative\n// string, e.g. \"5 minutes ago\".  Mirrors git's approximate relative-date\n// output.\n// ---------------------------------------------------------------------------\ninline std::string relative_time(std::int64_t epoch_seconds) {\n    using namespace std::chrono;\n    auto commit_tp = system_clock::from_time_t(static_cast<time_t>(epoch_seconds));\n    auto now       = system_clock::now();\n    auto secs      = duration_cast<seconds>(now - commit_tp).count();\n    if (secs < 0) secs = 0;\n\n    if (secs < 90)\n        return std::format(\"{} second{} ago\", secs, secs == 1 ? \"\" : \"s\");\n\n    auto mins = secs / 60;\n    if (mins < 90)\n        return std::format(\"{} minute{} ago\", mins, mins == 1 ? \"\" : \"s\");\n\n    auto hours = mins / 60;\n    if (hours < 36)\n        return std::format(\"{} hour{} ago\", hours, hours == 1 ? \"\" : \"s\");\n\n    auto days = hours / 24;\n    if (days < 14)\n        return std::format(\"{} day{} ago\", days, days == 1 ? \"\" : \"s\");\n\n    auto weeks = days / 7;\n    if (weeks < 8)\n        return std::format(\"{} week{} ago\", weeks, weeks == 1 ? \"\" : \"s\");\n\n    auto months = days / 30;\n    if (months < 24)\n        return std::format(\"{} month{} ago\", months, months == 1 ? \"\" : \"s\");\n\n    auto years = days / 365;\n    return std::format(\"{} year{} ago\", years, years == 1 ? \"\" : \"s\");\n}\n"
  },
  {
    "path": "src/wip_helpers.hpp",
    "content": "#pragma once\n\n// wip_helpers.hpp — higher-level helpers that encode the core git-wip\n// branching logic shared between the save, log, and status commands.\n\n#include \"git_guards.hpp\"\n#include \"git_helpers.hpp\"\n\n#include <optional>\n#include <vector>\n\n// ---------------------------------------------------------------------------\n// wip_parent_oid\n//\n// Determine the commit that a new WIP commit should be parented on, given:\n//   work_last  — current HEAD of the work branch\n//   wip_last   — current tip of refs/wip/<branch> (nullopt if none)\n//\n// Rules (matching the original shell script):\n//   • No wip branch yet  → parent = work_last\n//   • merge_base(wip_last, work_last) == work_last\n//     (work branch hasn't advanced)  → parent = wip_last  (stack on top)\n//   • Otherwise (work branch has new commits) → parent = work_last  (reset)\n//\n// Returns std::nullopt if the two branches are completely unrelated (no\n// common ancestor), which is an error the caller should report.\n// ---------------------------------------------------------------------------\ninline std::optional<git_oid> wip_parent_oid(\n    git_repository         *repo,\n    const git_oid          &work_last,\n    const std::optional<git_oid> &wip_last)\n{\n    if (!wip_last.has_value())\n        return work_last;   // first save — root from work branch HEAD\n\n    git_oid base{};\n    if (git_merge_base(&base, repo, &*wip_last, &work_last) < 0)\n        return std::nullopt; // unrelated histories\n\n    // If work_last IS the merge-base, the work branch hasn't moved since\n    // the last wip save — keep stacking.\n    if (git_oid_equal(&base, &work_last))\n        return *wip_last;\n\n    // Work branch has advanced — reset the wip stack.\n    return work_last;\n}\n\n// ---------------------------------------------------------------------------\n// collect_wip_commits\n//\n// Walk from `wip_last` backwards, stopping at (but not including) `work_last`,\n// and return the OIDs in topological order (newest first).\n//\n// Returns an empty vector when the work branch has advanced past the wip\n// branch (merge_base != work_last), mirroring the status-command logic:\n// there are 0 \"current\" wip commits in that situation.\n//\n// Returns std::nullopt on a libgit2 error.\n// ---------------------------------------------------------------------------\ninline std::optional<std::vector<git_oid>> collect_wip_commits(\n    git_repository *repo,\n    const git_oid  &wip_last,\n    const git_oid  &work_last)\n{\n    // Compute merge-base to decide whether the wip stack is still \"live\".\n    git_oid base{};\n    if (git_merge_base(&base, repo, &wip_last, &work_last) < 0)\n        return std::nullopt;\n\n    // Work branch has advanced past the wip branch → 0 current commits.\n    if (!git_oid_equal(&base, &work_last))\n        return std::vector<git_oid>{};\n\n    GitRevwalkGuard walk;\n    if (git_revwalk_new(walk.ptr(), repo) < 0)\n        return std::nullopt;\n\n    git_revwalk_sorting(walk.get(), GIT_SORT_TOPOLOGICAL);\n    git_revwalk_push(walk.get(), &wip_last);\n    git_revwalk_hide(walk.get(), &work_last);\n\n    std::vector<git_oid> result;\n    git_oid oid{};\n    while (git_revwalk_next(&oid, walk.get()) == 0)\n        result.push_back(oid);\n\n    return result;\n}\n"
  },
  {
    "path": "sublime/gitwip.py",
    "content": "import sublime_plugin\nfrom subprocess import Popen, PIPE, STDOUT\nimport os\nimport sublime\nimport copy\n\nclass GitWipAutoCommand(sublime_plugin.EventListener):\n\n    def on_post_save_async(self, view):\n        dirname, fname = os.path.split(view.file_name())\n\n        p = Popen([\"git\", \"wip\", \"save\",\n                   \"WIP from ST3: saving %s\" % fname,\n                   \"--editor\", \"--\", fname],\n                  cwd=dirname, universal_newlines=True,\n                  bufsize=8096, stdout=PIPE, stderr=STDOUT)\n\n        def finish_callback():\n            rcode = p.poll()\n\n            if rcode is None: # not terminated yet\n                sublime.set_timeout_async(finish_callback, 20)\n                return\n\n            if rcode != 0:\n                print (\"git command returned code {}\".format(rcode))\n\n            for line in p.stdout:\n                print(line)\n\n        finish_callback()\n\n"
  },
  {
    "path": "test/cli/CMakeLists.txt",
    "content": "# test/cli/CMakeLists.txt\n#\n# Registers each cli integration test as a ctest entry.\n#\n# TEST_TREE is placed inside the cmake binary directory so ctest manages it.\n# Artifacts are NOT removed after the test runs (useful for debugging).\n# Before each test its own subdirectory is wiped and re-created by the script.\n\nset(LEGACY_TEST_TREE \"${CMAKE_CURRENT_BINARY_DIR}/test-artifacts\")\nset(GIT_WIP_BIN \"$<TARGET_FILE:git-wip>\")\n\nforeach(TEST_NAME IN ITEMS test_legacy test_spaces test_status test_status2 test_status_ref test_save_file test_save_subdir test_list test_delete test_help)\n    add_test(\n        NAME    \"cli/${TEST_NAME}\"\n        COMMAND \"${CMAKE_CURRENT_SOURCE_DIR}/${TEST_NAME}.sh\"\n    )\n    set_tests_properties(\"cli/${TEST_NAME}\" PROPERTIES\n        ENVIRONMENT \"GIT_WIP=${GIT_WIP_BIN};TEST_TREE=${LEGACY_TEST_TREE}\"\n        TIMEOUT 60\n    )\nendforeach()\n"
  },
  {
    "path": "test/cli/lib.sh",
    "content": "# lib.sh -- shared helpers for git-wip legacy integration tests\n#\n# Source this file from each test script; do NOT execute directly.\n#\n# Required environment variables (set by ctest via CMakeLists.txt):\n#   GIT_WIP      path to the git-wip binary under test\n#   TEST_TREE    base directory for test artifacts (one subdir per test)\n#\n# TEST_NAME is derived from the sourcing script's filename (basename without .sh).\n\nset -e\n\ndie()  { echo >&2 \"ERROR: $*\"   ; exit 1 ; }\nwarn() { echo >&2 \"WARNING: $*\" ; }\nnote() { echo >&2 \"# $*\"        ; }\n\n# ------------------------------------------------------------------------\n# Validate environment\n\n[ -z \"${GIT_WIP}\"   ] && die \"GIT_WIP is not set\"\n[ -x \"${GIT_WIP}\"   ] || die \"GIT_WIP=${GIT_WIP} is not executable\"\n[ -z \"${TEST_TREE}\" ] && die \"TEST_TREE is not set\"\n\n# Derive test name from the calling script's filename\nTEST_NAME=\"$(basename \"$0\" .sh)\"\n\n# ------------------------------------------------------------------------\n# Per-test paths\n\nREPO=\"$TEST_TREE/$TEST_NAME/repo\"\nCMD=\"$TEST_TREE/$TEST_NAME/cmd\"\nOUT=\"$TEST_TREE/$TEST_NAME/out\"\nRC=\"$TEST_TREE/$TEST_NAME/rc\"\n\n# Clean before running so each run starts fresh; leave artifacts after for debugging\nrm -rf \"$TEST_TREE/$TEST_NAME\"\nmkdir -p \"$TEST_TREE/$TEST_NAME\"\n\nnote \"Running $TEST_NAME (artifacts in $TEST_TREE/$TEST_NAME)\"\n\n# ------------------------------------------------------------------------\n# Test helpers\n\n# Current working directory for _RUN/_RUN_IN; starts at $REPO.\n_RUN_CWD=\"\"\n\n_RUN() {\n    note \"$@\"\n    [ \"$(pwd)\" = \"$REPO\" ] || die \"expected cwd=$REPO, got $(pwd)\"\n\n    set +e\n    printf '%s' \"$*\" >\"$CMD\"\n    eval \"$@\" >\"$OUT\" 2>&1\n    printf '%s' \"$?\" >\"$RC\"\n    set -e\n}\n\nRUN() {\n    _RUN \"$@\"\n    local rc\n    rc=\"$(cat \"$RC\")\"\n    [ \"$rc\" = 0 ] || handle_error\n}\n\n# CD <subdir> — change the working directory for subsequent RUN_IN calls.\n# Use CD \"\" or CD_ROOT to return to $REPO.\nCD() {\n    local target\n    if [ -z \"$1\" ]; then\n        target=\"$REPO\"\n    else\n        target=\"$REPO/$1\"\n    fi\n    cd \"$target\" || die \"CD: cannot cd to $target\"\n    _RUN_CWD=\"$target\"\n    note \"CD → $(pwd)\"\n}\n\nCD_ROOT() { CD \"\"; }\n\n# _RUN_IN — like _RUN but allows cwd to be a subdirectory of $REPO.\n_RUN_IN() {\n    note \"$@\"\n    local expected=\"${_RUN_CWD:-$REPO}\"\n    [ \"$(pwd)\" = \"$expected\" ] || die \"expected cwd=$expected, got $(pwd)\"\n\n    set +e\n    printf '%s' \"$*\" >\"$CMD\"\n    eval \"$@\" >\"$OUT\" 2>&1\n    printf '%s' \"$?\" >\"$RC\"\n    set -e\n}\n\n# RUN_IN — like RUN but for subdirectory context (set via CD).\nRUN_IN() {\n    _RUN_IN \"$@\"\n    local rc\n    rc=\"$(cat \"$RC\")\"\n    [ \"$rc\" = 0 ] || handle_error\n}\n\nEXP_none() {\n    local out\n    out=\"$(head -n1 \"$OUT\")\"\n    if [ -n \"$out\" ]; then\n        warn \"expected no output, got: $out\"\n        handle_error\n    fi\n}\n\nEXP_text() {\n    local exp=\"$1\"\n    local out\n    out=\"$(head -n1 \"$OUT\")\"\n    if [ \"$out\" != \"$exp\" ]; then\n        warn \"exp: $exp\"\n        warn \"out: $out\"\n        handle_error\n    fi\n}\n\nEXP_grep() {\n    if ! grep -q \"$@\" <\"$OUT\"; then\n        warn \"grep $* — not matched\"\n        handle_error\n    fi\n}\n\ncreate_test_repo() {\n    rm -rf \"$REPO\"\n    mkdir -p \"$REPO\"\n    cd \"$REPO\"\n    RUN git init\n    # Force branch name to \"master\" regardless of init.defaultBranch config\n    RUN git checkout -b master\n}\n\nhandle_error() {\n    set +e\n    warn \"CMD='$(cat \"$CMD\")' RC=$(cat \"$RC\")\"\n    cat >&2 \"$OUT\"\n    exit 1\n}\n"
  },
  {
    "path": "test/cli/profile.sh",
    "content": "#!/usr/bin/env bash\n# profile.sh -- performance profiling test for git-wip save\n# Run manually: GIT_WIP=/path/to/git-wip TEST_TREE=/tmp/test-tree ./profile.sh\n#\n# This test builds a large git repository and measures time for different\n# git-wip save scenarios to identify performance bottlenecks.\n\nif [ -z \"$GIT_WIP\" ] && [ -z \"$TEST_TREE\" ] ; then\n    echo \"# GIT_WIP and TEST_TREE not specified, using defaults\"\n    GIT_WIP=$(pwd)/build/src/git-wip\n    TEST_TREE=/tmp/wip-profile\nfi\n\nsource \"$(dirname \"$0\")/lib.sh\"\n\n# -------------------------------------------------------------------------\n# Helper: time a command and print its duration\n\ntime_cmd() {\n    local label=\"$1\"\n    shift\n\n    note \"=== $label ===\"\n    local start end duration\n    start=$(date +%s%3N)\n    eval \"$@\"\n    end=$(date +%s%3N)\n    duration=$((end - start))\n    printf '%d ms\\n' \"$duration\"\n    note \"\"\n}\n\n# -------------------------------------------------------------------------\n# Create the test repository with nested directories\n\ncreate_test_repo\nRUN git config user.email \"test@example.com\"\nRUN git config user.name \"Test User\"\n\nnote \"Creating nested directory structure (2560 files)...\"\ncd \"$REPO\"\n\n# Create directories {a,b,c,d}/{a,b,c,d}/{a,b,c,d}/{a,b,c,d}\nfor d1 in a b c d; do\n    for d2 in a b c d; do\n        for d3 in a b c d; do\n            for d4 in a b c d; do\n                dir=\"$REPO/$d1/$d2/$d3/$d4\"\n                mkdir -p \"$dir\"\n                # Create 10 files in each directory\n                for f in 0 1 2 3 4 5 6 7 8 9; do\n                    echo \"content\" > \"$dir/$f\"\n                done\n                # Commit each directory separately\n                git add -A \"$dir\"/ >/dev/null\n                git commit -m \"\\\"$dir\\\"\" >/dev/null\n            done\n        done\n    done\ndone\n\nnote \"Initial commit structure ready:\"\nRUN git log --oneline\nRUN git rev-list --count HEAD\nRUN git ls-files\nlines=\"$(wc -l <\"$OUT\")\"\nif ! [ \"$lines\" = 2560 ] ; then\n    die \"expecting 2560 files but found $files, check $OUT\"\nfi\n\n# -------------------------------------------------------------------------\n# Scenario 1: wip save on all files (all changed)\n\nnote \"=== SCENARIO 1: wip save on all files (all changed) ===\"\ncd \"$REPO\"\nfor f in $(find . -path ./.git -prune -o -type f -print); do\n    echo \"test1\" > \"$f\"\ndone\nsync\n\ntime_cmd \"git-wip save (all files changed)\" \"$GIT_WIP\" save \"\\\"WIP-1\\\"\"\n\n# -------------------------------------------------------------------------\n# Scenario 2: wip save on one file, all changed (but only save one path)\n\nnote \"=== SCENARIO 2: wip save on one path, all files changed ===\"\ngit reset --hard\nfor f in $(find . -path ./.git -prune -o -type f -print); do\n    echo \"test2\" > \"$f\"\ndone\nsync\n\ntime_cmd \"git-wip save -- a/b/c/d/0 (one path)\" \"$GIT_WIP\" save \"\\\"WIP-2\\\"\" -- a/b/c/d/0\n\n# -------------------------------------------------------------------------\n# Scenario 3: wip save on one file, one changed\n\nnote \"=== SCENARIO 3: wip save on one file, one changed ===\"\ngit reset --hard\necho \"test3\" > a/b/c/d/0\nsync\n\ntime_cmd \"git-wip save -- a/b/c/d/0 (single file)\" \"$GIT_WIP\" save \"\\\"WIP-3\\\"\" -- a/b/c/d/0\n\n# -------------------------------------------------------------------------\n# Summary\n\nnote \"=== PROFILING COMPLETE ===\"\nnote \"Repository: $REPO\"\nnote \"Files: 2560 in 256 directories\"\nnote \"\"\nnote \"Run 'du -sh $REPO/.git' to see git object database size\"\n\necho \"OK: $TEST_NAME\"\n"
  },
  {
    "path": "test/cli/test_delete.sh",
    "content": "#!/usr/bin/env bash\nsource \"$(dirname \"$0\")/lib.sh\"\n\ncreate_test_repo\nRUN git config user.email \"test@example.com\"\nRUN git config user.name \"Test User\"\n\nRUN \"echo v1 >file.txt\"\nRUN git add file.txt\nRUN git commit -m initial\n\n# master wip\nRUN \"echo m2 >file.txt\"\nRUN \"$GIT_WIP\" save \"\\\"master one\\\"\"\n\n# foo wip\nRUN git checkout -b foo\nRUN \"echo f1 >foo.txt\"\nRUN git add foo.txt\nRUN git commit -m \"\\\"foo base\\\"\"\nRUN \"echo f2 >>foo.txt\"\nRUN \"$GIT_WIP\" save \"\\\"foo one\\\"\"\n\n# orphaned wip ref\nRUN git update-ref refs/wip/orphan HEAD\n\n# cleanup removes only orphaned refs\nRUN \"$GIT_WIP\" delete --cleanup\nEXP_grep \"deleted wip/orphan\"\nEXP_grep \"deleted 1 orphaned wip ref\"\n\nRUN git for-each-ref\nEXP_grep \"refs/wip/master$\"\nEXP_grep \"refs/wip/foo$\"\nEXP_grep -v \"refs/wip/orphan$\"\n\n# delete a single wip ref by explicit ref name\nRUN \"$GIT_WIP\" delete refs/wip/foo\nEXP_grep \"deleted wip/foo\"\nRUN git for-each-ref\nEXP_grep -v \"refs/wip/foo$\"\nEXP_grep \"refs/wip/master$\"\n\n# delete current branch wip with confirmation: 'n' aborts\nRUN git checkout -f master\n_RUN \"printf 'n\\n' | \\\"$GIT_WIP\\\" delete\"\nEXP_grep \"About to delete wip/master \\[Y/n\\]\"\nRUN git for-each-ref\nEXP_grep \"refs/wip/master$\"\n\n# empty response confirms delete\nRUN \"printf '\\n' | \\\"$GIT_WIP\\\" delete\"\nEXP_grep \"deleted wip/master\"\nRUN git for-each-ref\nEXP_grep -v \"refs/wip/master$\"\n\n# recreate and delete with --yes (no prompt)\nRUN \"echo m3 >file.txt\"\nRUN \"$GIT_WIP\" save \"\\\"master two\\\"\"\nRUN \"$GIT_WIP\" delete --yes\nEXP_grep \"deleted wip/master\"\nEXP_grep -v \"About to delete\"\nRUN git for-each-ref\nEXP_grep -v \"refs/wip/master$\"\n\necho \"OK: $TEST_NAME\"\n"
  },
  {
    "path": "test/cli/test_help.sh",
    "content": "#!/usr/bin/env bash\n\nsource \"$(dirname \"$0\")/lib.sh\"\n\ncreate_test_repo\n\n# Test main help commands\n\n_RUN \"$GIT_WIP\" help 2>/dev/null\n\nEXP_grep \".\"\n\n_RUN \"$GIT_WIP\" --help 2>/dev/null\n\nEXP_grep \".\"\n\n_RUN \"$GIT_WIP\" -h 2>/dev/null\n\nEXP_grep \".\"\n\n_RUN \"$GIT_WIP\" --version 2>/dev/null\n\nEXP_grep \".\"\n\n# Test per-command help\n\nfor cmd in save status log delete ; do\n\n  _RUN \"$GIT_WIP\" $cmd --help 2>/dev/null\n\n  EXP_grep \".\"\n\n  _RUN \"$GIT_WIP\" $cmd -h 2>/dev/null\n\n  EXP_grep \".\"\n\ndone\n\necho \"OK: $TEST_NAME\""
  },
  {
    "path": "test/cli/test_legacy.sh",
    "content": "#!/usr/bin/env bash\nsource \"$(dirname \"$0\")/lib.sh\"\n\n# these tests are here to make sure we behave the same way as the legacy git-wip shell implementation\n# do not add anymore tests here.  Create new tests in test/cli/test_*.sh instead.\n\ncreate_test_repo\nRUN \"echo 1 >README\"\nRUN git add README\nRUN git commit -m README\n\n# run wip w/o changes\n_RUN \"$GIT_WIP\" save\nEXP_text \"no changes\"\n\nRUN \"$GIT_WIP\" save --editor\nEXP_none\n\nRUN \"$GIT_WIP\" save -e\nEXP_none\n\n# expecting a master branch\nRUN git branch\nEXP_grep \"^\\* master$\"\nEXP_grep -v \"wip\"\n\n# not expecting a wip ref at this time\nRUN git for-each-ref\nEXP_grep -v \"commit.refs/wip/master$\"\n\n# make changes, store wip\nRUN \"echo 2 >README\"\nRUN \"$GIT_WIP\" save --editor\nEXP_none\n\n# expecting a wip ref\nRUN git for-each-ref\nEXP_grep \"commit.refs/wip/master$\"\n\n# expecting a log entry\nRUN \"$GIT_WIP\" log\nEXP_grep \"^commit \"\nEXP_grep \"^\\s\\+WIP$\"\n\n# there should be no wip branch\nRUN git branch\nEXP_grep -v \"wip\"\n\n# make changes, store wip\nRUN \"echo 3 >README\"\nRUN \"$GIT_WIP\" save \"\\\"message2\\\"\"\nEXP_none\n\n# expecting both log entries\nRUN \"$GIT_WIP\" log\nEXP_grep \"^commit \"\nEXP_grep \"^\\s\\+WIP$\"\nEXP_grep \"^\\s\\+message2$\"\n\n# make a commit\nRUN git add -u README\nRUN git commit -m README.2\n\n# make changes, store wip\nRUN \"echo 4 >UNTRACKED\"\nRUN \"echo 4 >README\"\nRUN \"$GIT_WIP\" save \"\\\"message3\\\"\"\nEXP_none\n\n# expecting message3, not message2 or original WIP\nRUN \"$GIT_WIP\" log\nEXP_grep \"^commit \"\nEXP_grep -v \"^\\s\\+WIP$\"\nEXP_grep -v \"^\\s\\+message2$\"\nEXP_grep \"^\\s\\+message3$\"\n\n# expecting file changes to README, not UNTRACKED\nRUN \"$GIT_WIP\" log --stat\nEXP_grep \"^commit \"\nEXP_grep \"^ README | 2\"\nEXP_grep -v \"UNTRACKED\"\n\n# need to be able to extract latest data from git wip branch\nRUN git show HEAD:README\nEXP_grep '^3$'\nEXP_grep -v '^4$'\n\nRUN git show wip/master:README\nEXP_grep -v '^3$'\nEXP_grep '^4$'\n\necho \"OK: $TEST_NAME\"\n"
  },
  {
    "path": "test/cli/test_list.sh",
    "content": "#!/usr/bin/env bash\nsource \"$(dirname \"$0\")/lib.sh\"\n\ncreate_test_repo\nRUN git config user.email \"test@example.com\"\nRUN git config user.name \"Test User\"\n\nRUN \"echo v1 >file.txt\"\nRUN git add file.txt\nRUN git commit -m initial\n\n# one wip commit on master\nRUN \"echo master-wip >file.txt\"\nRUN \"$GIT_WIP\" save \"\\\"master snapshot\\\"\"\n\n# one wip commit on foo\nRUN git checkout -b foo\nRUN \"echo foo-wip >file.txt\"\nRUN \"$GIT_WIP\" save \"\\\"foo snapshot\\\"\"\n\n# add an orphaned wip ref (no refs/heads/baz branch)\nRUN git update-ref refs/wip/baz HEAD\n\n# list all wip refs (short form)\nRUN \"$GIT_WIP\" list\nEXP_grep \"^wip/master$\"\nEXP_grep \"^wip/foo$\"\nEXP_grep \"^wip/baz$\"\n\n# verbose output includes ahead counts and orphaned refs\nRUN \"$GIT_WIP\" list -v\nEXP_grep \"^wip/master has 1 commit ahead of master$\"\nEXP_grep \"^wip/foo has 1 commit ahead of foo$\"\nEXP_grep \"^wip/baz is orphaned$\"\n\necho \"OK: $TEST_NAME\"\n"
  },
  {
    "path": "test/cli/test_save_file.sh",
    "content": "#!/usr/bin/env bash\nsource \"$(dirname \"$0\")/lib.sh\"\n\ncreate_test_repo\nRUN git config user.email \"test@example.com\"\nRUN git config user.name \"Test User\"\n\nRUN \"echo base >file_a\"\nRUN \"echo base >file_b\"\nRUN \"echo base >file_c\"\nRUN git add file_a file_b file_c\nRUN git commit -m initial\n\n# -------------------------------------------------------------------------\n# modify two files, save only one — only the specified file appears in WIP\n\nRUN \"echo mod_a >file_a\"\nRUN \"echo mod_b >file_b\"\n\nRUN \"$GIT_WIP\" save \"\\\"save-a-only\\\"\" -- file_a\nRUN git show wip/master --stat\n\n# file_a changed, file_b must not appear\nEXP_grep \"file_a\"\nEXP_grep -v \"file_b\"\nEXP_grep -v \"file_c\"\n\n# verify content in wip tree\nRUN git show wip/master:file_a\nEXP_grep \"^mod_a$\"\n\nRUN git show wip/master:file_b\nEXP_grep \"^base$\"   # unchanged in wip tree\n\n# -------------------------------------------------------------------------\n# save the other modified file — stacks on previous WIP commit\n\nRUN \"$GIT_WIP\" save \"\\\"save-b-only\\\"\" -- file_b\nRUN git show wip/master --stat\n\nEXP_grep \"file_b\"\nEXP_grep -v \"file_a\"   # file_a was already at mod_a in the parent; no delta\nEXP_grep -v \"file_c\"\n\nRUN git show wip/master:file_a\nEXP_grep \"^mod_a$\"   # persisted from previous wip commit\n\nRUN git show wip/master:file_b\nEXP_grep \"^mod_b$\"\n\n# -------------------------------------------------------------------------\n# save multiple files at once with --\n\nRUN \"echo mod2_a >file_a\"\nRUN \"echo mod2_c >file_c\"\n\nRUN \"$GIT_WIP\" save \"\\\"save-a-and-c\\\"\" -- file_a file_c\nRUN git show wip/master --stat\n\nEXP_grep \"file_a\"\nEXP_grep \"file_c\"\nEXP_grep -v \"file_b\"\n\nRUN git show wip/master:file_a\nEXP_grep \"^mod2_a$\"\n\nRUN git show wip/master:file_c\nEXP_grep \"^mod2_c$\"\n\n# -------------------------------------------------------------------------\n# untracked file saved explicitly with --\n\nRUN \"echo untracked >file_d\"\n\nRUN \"$GIT_WIP\" save \"\\\"save-untracked-file\\\"\" -- file_d\nRUN git show wip/master --stat\n\nEXP_grep \"file_d\"\nEXP_grep -v \"file_a\"\nEXP_grep -v \"file_b\"\nEXP_grep -v \"file_c\"\n\nRUN git show wip/master:file_d\nEXP_grep \"^untracked$\"\n\n# -------------------------------------------------------------------------\n# saving an unchanged (already-at-baseline) file reports \"no changes\"\n\n_RUN \"$GIT_WIP\" save \"\\\"save-c-nochange\\\"\" -- file_c\nEXP_text \"no changes\"\n\n# -------------------------------------------------------------------------\n# --editor mode with unchanged file is silent and exits 0\n\nRUN \"$GIT_WIP\" save --editor \"\\\"save-c-editor\\\"\" -- file_c\nEXP_none\n\n# -------------------------------------------------------------------------\n# --editor mode with a changed file succeeds silently\n\nRUN \"echo editor_change >file_c\"\nRUN \"$GIT_WIP\" save --editor \"\\\"save-c-via-editor\\\"\" -- file_c\nEXP_none\n\nRUN git show wip/master:file_c\nEXP_grep \"^editor_change$\"\n\n# -------------------------------------------------------------------------\n# after a real commit, a new -- file save starts a fresh WIP stack\n\nRUN git add file_a file_b file_c file_d\nRUN git commit -m \"\\\"commit all changes\\\"\"\n\nRUN \"echo post_commit >file_a\"\nRUN \"$GIT_WIP\" save \"\\\"post-commit-a\\\"\" -- file_a\n\n# new wip commit is rooted at the new work HEAD — only file_a in delta\nRUN git show wip/master --stat\nEXP_grep \"file_a\"\nEXP_grep -v \"file_b\"\nEXP_grep -v \"file_c\"\nEXP_grep -v \"file_d\"\n\n# status shows exactly 1 wip commit\nRUN \"$GIT_WIP\" status -l\nEXP_grep \"branch master has 1 wip commit on refs/wip/master\"\nEXP_grep \"post-commit-a\"\nEXP_grep -v \"save-a-only\"\n\necho \"OK: $TEST_NAME\"\n"
  },
  {
    "path": "test/cli/test_save_subdir.sh",
    "content": "#!/usr/bin/env bash\nsource \"$(dirname \"$0\")/lib.sh\"\n\ncreate_test_repo\nRUN git config user.email \"test@example.com\"\nRUN git config user.name \"Test User\"\n\n# -------------------------------------------------------------------------\n# Set up: a/b/file committed at baseline\nRUN mkdir -p a/b\nRUN \"echo base >a/b/file\"\nRUN git add a/b/file\nRUN git commit -m \"\\\"initial\\\"\"\n\n# -------------------------------------------------------------------------\n# Save from repo root using full relative path a/b/file\n\nRUN \"echo 'from root' >a/b/file\"\nRUN_IN \"$GIT_WIP\" save \"\\\"from root\\\"\" -- a/b/file\n\nRUN_IN \"$GIT_WIP\" status -l -f\nEXP_grep \"1 wip commit\"\nEXP_grep \"from root\"\nEXP_grep \"a/b/file\"\n\nRUN git show wip/master:a/b/file\nEXP_grep \"^from root$\"\n\n# -------------------------------------------------------------------------\n# Save from subdirectory a/ using path b/file\n\nCD a\nRUN_IN \"echo 'from a' >b/file\"\nRUN_IN \"$GIT_WIP\" save \"\\\"from a\\\"\" -- b/file\n\nCD_ROOT\nRUN_IN \"$GIT_WIP\" status -l -f\nEXP_grep \"2 wip commits\"\nEXP_grep \"from a\"\nEXP_grep \"from root\"\nEXP_grep \"a/b/file\"\n\nRUN git show wip/master:a/b/file\nEXP_grep \"^from a$\"\n\n# -------------------------------------------------------------------------\n# Save from subdirectory a/b/ using bare filename file\n\nCD a/b\nRUN_IN \"echo 'from b' >file\"\nRUN_IN \"$GIT_WIP\" save \"\\\"from b\\\"\" -- file\n\nCD_ROOT\nRUN_IN \"$GIT_WIP\" status -l -f\nEXP_grep \"3 wip commits\"\nEXP_grep \"from b\"\nEXP_grep \"from a\"\nEXP_grep \"from root\"\nEXP_grep \"a/b/file\"\n\nRUN git show wip/master:a/b/file\nEXP_grep \"^from b$\"\n\n# -------------------------------------------------------------------------\n# Verify --editor mode from a subdirectory (no changes → silent exit 0)\n\nCD a/b\nRUN_IN \"$GIT_WIP\" save --editor \"\\\"no-change-editor\\\"\" -- file\nEXP_none\n\n# -------------------------------------------------------------------------\n# Verify that saving from subdir does NOT corrupt the real index\n\nCD_ROOT\nRUN git status --short\n# a/b/file should show as modified (M) in the working tree, not staged\nEXP_grep \" M a/b/file\"\n\necho \"OK: $TEST_NAME\"\n"
  },
  {
    "path": "test/cli/test_spaces.sh",
    "content": "#!/usr/bin/env bash\nsource \"$(dirname \"$0\")/lib.sh\"\n\ncreate_test_repo\nRUN \"echo 1 >\\\"s p a c e s\\\"\"\nRUN git add \"\\\"s p a c e s\\\"\"\nRUN git commit -m \"\\\"s p a c e s\\\"\"\n\n# make changes, store wip\nRUN \"echo 2 >\\\"s p a c e s\\\"\"\nRUN \"$GIT_WIP\" save \"\\\"message with spaces\\\"\"\nEXP_none\n\n# expecting a wip ref\nRUN git for-each-ref\nEXP_grep \"commit.refs/wip/master$\"\n\n# expecting a log entry\nRUN \"$GIT_WIP\" log\nEXP_grep \"^commit \"\nEXP_grep \"^\\s\\+message with spaces$\"\n\necho \"OK: $TEST_NAME\"\n"
  },
  {
    "path": "test/cli/test_status.sh",
    "content": "#!/usr/bin/env bash\nsource \"$(dirname \"$0\")/lib.sh\"\n\ncreate_test_repo\nRUN git config user.email \"test@example.com\"\nRUN git config user.name \"Test User\"\n\nRUN \"echo v1 >file.txt\"\nRUN git add file.txt\nRUN git commit -m initial\n\n# -------------------------------------------------------------------------\n# no wip branch yet — status should report zero wip commits (exit 0)\n\nRUN \"$GIT_WIP\" status\nEXP_grep \"no wip commits\"\n\n# -------------------------------------------------------------------------\n# create 3 wip commits with distinct file changes\n\nRUN \"echo v2 >file.txt\"\nRUN \"$GIT_WIP\" save \"\\\"WIP one\\\"\"\n\nRUN \"echo v3 >file.txt\"\nRUN \"$GIT_WIP\" save \"\\\"WIP two\\\"\"\n\nRUN \"echo v4 >file.txt\"\nRUN \"$GIT_WIP\" save \"\\\"WIP three\\\"\"\n\n# -------------------------------------------------------------------------\n# status (no flags) — summary line only\n\nRUN \"$GIT_WIP\" status\nEXP_grep \"branch master has 3 wip commits on refs/wip/master\"\n\n# -------------------------------------------------------------------------\n# status -l — summary + one line per commit (newest first)\n\nRUN \"$GIT_WIP\" status -l\nEXP_grep \"branch master has 3 wip commits on refs/wip/master\"\n# each commit line: <sha7> - <subject> (<age>)\nEXP_grep \" - WIP three (\"\nEXP_grep \" - WIP two (\"\nEXP_grep \" - WIP one (\"\n# order: newest first — line number of \"WIP three\" must be less than \"WIP one\"\nthree_line=$(grep -n \"WIP three\" \"$OUT\" | cut -d: -f1)\none_line=$(grep -n \"WIP one\" \"$OUT\" | cut -d: -f1)\n[ \"$three_line\" -lt \"$one_line\" ] || { warn \"WIP three not before WIP one in output\"; handle_error; }\n\n# -------------------------------------------------------------------------\n# status --list is synonymous with -l\n\nRUN \"$GIT_WIP\" status --list\nEXP_grep \"branch master has 3 wip commits on refs/wip/master\"\nEXP_grep \" - WIP three (\"\n\n# -------------------------------------------------------------------------\n# status -f — summary + diff --stat from HEAD to latest wip\n\nRUN \"$GIT_WIP\" status -f\nEXP_grep \"branch master has 3 wip commits on refs/wip/master\"\n# git diff --stat output includes the filename and change counts\nEXP_grep \"file.txt\"\nEXP_grep \"changed\"\n\n# -------------------------------------------------------------------------\n# status --files is synonymous with -f\n\nRUN \"$GIT_WIP\" status --files\nEXP_grep \"branch master has 3 wip commits on refs/wip/master\"\nEXP_grep \"file.txt\"\n\n# -------------------------------------------------------------------------\n# status -l -f — per-commit diff --stat interleaved with list lines\n\nRUN \"$GIT_WIP\" status -l -f\nEXP_grep \"branch master has 3 wip commits on refs/wip/master\"\nEXP_grep \" - WIP three (\"\nEXP_grep \" - WIP two (\"\nEXP_grep \" - WIP one (\"\n# each commit's diff --stat should show file.txt changed\nEXP_grep \"file.txt\"\nEXP_grep \"changed\"\n# summary comes before any commit lines\nsummary_line=$(grep -n \"3 wip commits\" \"$OUT\" | cut -d: -f1)\nfirst_commit_line=$(grep -n \"WIP three\" \"$OUT\" | cut -d: -f1)\n[ \"$summary_line\" -lt \"$first_commit_line\" ] || { warn \"summary not before commit lines\"; handle_error; }\n\necho \"OK: $TEST_NAME\"\n"
  },
  {
    "path": "test/cli/test_status2.sh",
    "content": "#!/usr/bin/env bash\nsource \"$(dirname \"$0\")/lib.sh\"\n\ncreate_test_repo\nRUN git config user.email \"test@example.com\"\nRUN git config user.name \"Test User\"\n\nRUN touch file\nRUN git add file\nRUN git commit -m initial\n\n# save one wip commit\nRUN \"echo one >file\"\nRUN \"$GIT_WIP\" save \"\\\"one\\\"\"\n\n# should see \"one\"\nRUN \"$GIT_WIP\" status -l\nEXP_grep \"branch master has 1 wip commit on refs/wip/master\"\nEXP_grep \" - one (\"\n\n# advance the work branch past the wip\nRUN git add file\nRUN git commit -m one\n\n# wip branch is now behind (or at) the work branch — no wip entries visible\nRUN \"$GIT_WIP\" status -l\nEXP_grep \"branch master has 0 wip commits on refs/wip/master\"\n\n# save two more wip commits on the new work branch HEAD\nRUN \"echo two >file\"\nRUN \"$GIT_WIP\" save \"\\\"two\\\"\"\n\n# should see only \"two\"\nRUN \"$GIT_WIP\" status -l\nEXP_grep \"branch master has 1 wip commit on refs/wip/master\"\nEXP_grep \" - two (\"\nEXP_grep -v \" - one (\"\n\nRUN \"echo three >file\"\nRUN \"$GIT_WIP\" save \"\\\"three\\\"\"\n\n# should see \"two\" and \"three\", not \"one\"\nRUN \"$GIT_WIP\" status -l\nEXP_grep \"branch master has 2 wip commits on refs/wip/master\"\nEXP_grep \" - two (\"\nEXP_grep \" - three (\"\nEXP_grep -v \" - one (\"\n\necho \"OK: $TEST_NAME\"\n"
  },
  {
    "path": "test/cli/test_status_ref.sh",
    "content": "#!/usr/bin/env bash\nsource \"$(dirname \"$0\")/lib.sh\"\n\ncreate_test_repo\nRUN git config user.email \"test@example.com\"\nRUN git config user.name \"Test User\"\n\nRUN \"echo v1 >file.txt\"\nRUN git add file.txt\nRUN git commit -m initial\n\n# master: two wip commits\nRUN \"echo m2 >file.txt\"\nRUN \"$GIT_WIP\" save \"\\\"master one\\\"\"\nRUN \"echo m3 >file.txt\"\nRUN \"$GIT_WIP\" save \"\\\"master two\\\"\"\n\n# foo: one wip commit\nRUN git checkout -b foo\nRUN \"echo f1 >foo.txt\"\nRUN git add foo.txt\nRUN git commit -m \"\\\"foo base\\\"\"\nRUN \"echo f2 >>foo.txt\"\nRUN \"$GIT_WIP\" save \"\\\"foo one\\\"\"\n\n# Status on master from foo branch, across all accepted ref syntaxes\nRUN \"$GIT_WIP\" status master\nEXP_grep \"branch master has 2 wip commits on refs/wip/master\"\n\nRUN \"$GIT_WIP\" status wip/master\nEXP_grep \"branch master has 2 wip commits on refs/wip/master\"\n\nRUN \"$GIT_WIP\" status refs/heads/master\nEXP_grep \"branch master has 2 wip commits on refs/wip/master\"\n\nRUN \"$GIT_WIP\" status refs/wip/master\nEXP_grep \"branch master has 2 wip commits on refs/wip/master\"\n\n# Status on foo across all accepted ref syntaxes\nRUN \"$GIT_WIP\" status foo\nEXP_grep \"branch foo has 1 wip commit on refs/wip/foo\"\n\nRUN \"$GIT_WIP\" status wip/foo\nEXP_grep \"branch foo has 1 wip commit on refs/wip/foo\"\n\nRUN \"$GIT_WIP\" status refs/heads/foo\nEXP_grep \"branch foo has 1 wip commit on refs/wip/foo\"\n\nRUN \"$GIT_WIP\" status refs/wip/foo\nEXP_grep \"branch foo has 1 wip commit on refs/wip/foo\"\n\necho \"OK: $TEST_NAME\"\n"
  },
  {
    "path": "test/nvim/CMakeLists.txt",
    "content": "# test/nvim/CMakeLists.txt\n#\n# Registers each Neovim integration test as a ctest entry.\n#\n# TEST_TREE is placed inside the cmake binary directory so ctest manages it.\n# Artifacts are NOT removed after the test runs (useful for debugging).\n# Before each test its own subdirectory is wiped and re-created by the script.\n#\n# Tests are skipped if nvim is not found in PATH.\n\nset(NVIM_TEST_TREE \"${CMAKE_CURRENT_BINARY_DIR}/test-artifacts\")\nset(GIT_WIP_BIN \"$<TARGET_FILE:git-wip>\")\n\n# Find nvim\nfind_program(NVIM_EXECUTABLE nvim)\n\nif(NVIM_EXECUTABLE)\n    message(STATUS \"Found nvim: ${NVIM_EXECUTABLE}\")\n\n    foreach(TEST_NAME IN ITEMS test_nvim_single test_nvim_buffers test_nvim_windows test_nvim_background)\n        add_test(\n            NAME    \"nvim/${TEST_NAME}\"\n            COMMAND \"${CMAKE_CURRENT_SOURCE_DIR}/${TEST_NAME}.sh\"\n        )\n        set_tests_properties(\"nvim/${TEST_NAME}\" PROPERTIES\n            ENVIRONMENT \"GIT_WIP=${GIT_WIP_BIN};NVIM=${NVIM_EXECUTABLE};TEST_TREE=${NVIM_TEST_TREE}\"\n            TIMEOUT 120\n        )\n    endforeach()\nelse()\n    message(STATUS \"nvim not found - Neovim tests will be skipped\")\nendif()\n"
  },
  {
    "path": "test/nvim/lib.sh",
    "content": "# lib.sh -- shared helpers for git-wip Neovim integration tests\n#\n# Source this file from each test script; do NOT execute directly.\n#\n# Required environment variables (set by ctest via CMakeLists.txt):\n#   GIT_WIP     path to the git-wip binary under test\n#   NVIM        path to the nvim binary\n#   TEST_TREE   base directory for test artifacts (one subdir per test)\n#\n# TEST_NAME is derived from the sourcing script's filename (basename without .sh).\n\nset -e\n\ndie()  { echo >&2 \"ERROR: $*\"   ; exit 1 ; }\nwarn() { echo >&2 \"WARNING: $*\" ; }\nnote() { echo >&2 \"# $*\"        ; }\n\n# ------------------------------------------------------------------------\n# Validate environment\n\n[ -z \"${GIT_WIP}\"   ] && die \"GIT_WIP is not set\"\n[ -x \"${GIT_WIP}\"   ] || die \"GIT_WIP=${GIT_WIP} is not executable\"\n[ -z \"${NVIM}\"      ] && die \"NVIM is not set\"\n\n# Check if nvim is available, skip if not\nif ! command -v \"${NVIM}\" &>/dev/null; then\n    note \"nvim not found at ${NVIM} - skipping test\"\n    exit 0\nfi\n\n# Get the repo root (parent of test/nvim/)\nREPO_ROOT=\"$(cd \"$(dirname \"$0\")/../..\" && pwd)\"\n\n# Derive test name from the calling script's filename\nTEST_NAME=\"$(basename \"$0\" .sh)\"\n\n# ------------------------------------------------------------------------\n# Per-test paths\n\nREPO=\"$TEST_TREE/$TEST_NAME/repo\"\nNVIM_CMD=\"$TEST_TREE/$TEST_NAME/nvim_cmd\"\nNVIM_OUT=\"$TEST_TREE/$TEST_NAME/nvim_out\"\nNVIM_RC=\"$TEST_TREE/$TEST_NAME/nvim_rc\"\nNVIM_LOG=\"$TEST_TREE/$TEST_NAME/nvim_log\"\n\n# Clean before running so each run starts fresh; leave artifacts after for debugging\nrm -rf \"$TEST_TREE/$TEST_NAME\"\nmkdir -p \"$TEST_TREE/$TEST_NAME\"\n\nnote \"Running $TEST_NAME (artifacts in $TEST_TREE/$TEST_NAME)\"\n\n# ------------------------------------------------------------------------\n# Git helpers (from cli lib.sh)\n\ncreate_test_repo() {\n    rm -rf \"$REPO\"\n    mkdir -p \"$REPO\"\n    cd \"$REPO\"\n    git init\n    git config user.email \"test@example.com\"\n    git config user.name \"Test User\"\n    # Force branch name to \"master\" regardless of init.defaultBranch config\n    git checkout -b master\n}\n\n# Git helper functions (simplified from cli/lib.sh)\n_run() {\n    note \"$@\"\n    [ \"$(pwd)\" = \"$REPO\" ] || die \"expected cwd=$REPO, got $(pwd)\"\n\n    set +e\n    eval \"$@\"\n    local rc=$?\n    set -e\n    return $rc\n}\n\nrun() {\n    _run \"$@\" || die \"command failed: $*\"\n}\n\n# ------------------------------------------------------------------------\n# Neovim helpers\n\n# _run_nvim -- Run nvim with sandboxed config, capturing output and exit code\n# Usage: _run_nvim [ex-command]...\n# Each argument is executed as a separate -c command in nvim\n# Always appends quit! at the end to ensure headless nvim exits\n_run_nvim() {\n    # Build the -c arguments from positional parameters\n    local nvim_args=()\n    for cmd in \"$@\"; do\n        nvim_args+=(-c \"$cmd\")\n    done\n\n    note \"nvim -c ${*//$'\\n'/ }\"\n\n    # Create sandboxed init.lua that loads our plugin\n    cat >\"$REPO/init.lua\" <<INITLUA\n-- Sandboxed Neovim config for git-wip testing\n-- Load the plugin from the repo root\nlocal repo_root = os.getenv(\"REPO_ROOT\") or \"${REPO_ROOT}\"\n\n-- Add the lua/?.lua path so require(\"git-wip\") finds lua/git-wip/init.lua\npackage.path = repo_root .. \"/lua/?.lua;\" .. repo_root .. \"/lua/?/init.lua;\" .. package.path\n\nlocal git_wip_path = os.getenv(\"GIT_WIP\") or \"git-wip\"\nrequire(\"git-wip\").setup({\n    git_wip_path = git_wip_path,\n    untracked = false,\n    ignored = false,\n    background = os.getenv(\"GIT_WIP_TEST_BACKGROUND\") == \"true\",\n    filetypes = { \"*\" },\n})\nINITLUA\n\n    set +e\n    printf '%s' \"nvim ${nvim_args[*]} -c quit!\" >\"$NVIM_CMD\"\n\n    # Run nvim with a watchdog timeout (10 seconds max)\n    # This prevents tests from hanging indefinitely\n    GIT_WIP_TEST_BACKGROUND=\"${GIT_WIP_TEST_BACKGROUND:-false}\" \\\n    GIT_WIP=\"$GIT_WIP\" \\\n    REPO_ROOT=\"$REPO_ROOT\" \\\n    timeout 10 \"$NVIM\" --headless -u \"$REPO/init.lua\" \"${nvim_args[@]}\" -c \"quit!\" >\"$NVIM_OUT\" 2>&1\n    local rc=$?\n    \n    # timeout returns 124 if killed, 125 if timeout command itself failed\n    if [ $rc -eq 124 ]; then\n        echo \"TIMEOUT: nvim was killed after 10 seconds\" >>\"$NVIM_OUT\"\n    fi\n    printf '%s' \"$rc\" >\"$NVIM_RC\"\n    set -e\n}\n\n# run_nvim -- Run nvim and fail if it exits non-zero\n# Note: git-wip runs asynchronously via vim.system, so we add a delay\n# to ensure the command completes before checking results.\nrun_nvim() {\n    _run_nvim \"$@\"\n    local rc\n    rc=\"$(cat \"$NVIM_RC\")\"\n    if [ \"$rc\" != 0 ]; then\n        handle_error\n    fi\n    # Wait for async git-wip to complete\n    sleep 0.5\n}\n\nhandle_error() {\n    set +e\n    warn \"CMD='$(cat \"$NVIM_CMD\")' RC=$(cat \"$NVIM_RC\")\"\n    cat >&2 \"$NVIM_OUT\"\n    exit 1\n}\n"
  },
  {
    "path": "test/nvim/test_nvim_background.sh",
    "content": "#!/usr/bin/env bash\n# test_nvim_background.sh -- Test git-wip with background=true (async execution)\nsource \"$(dirname \"$0\")/lib.sh\"\n\n# -------------------------------------------------------------------------\n# Setup: create a test repo with one file\n\ncreate_test_repo\n\n# Create initial file and commit\necho \"initial content\" > file.txt\ngit add file.txt\ngit commit -m \"initial commit\"\n\nnote \"Repo created at $REPO\"\n\n# Set background=true for async execution\nexport GIT_WIP_TEST_BACKGROUND=true\n\n# -------------------------------------------------------------------------\n# Test 1: Single save (one edit + :w)\n\nnote \"Test 1: Single save with background=true\"\n\n# Make a change and save using nvim\nrun_nvim \"edit file.txt\" \"normal osecond line\" \"write\"\n\n# Verify WIP commit was created\nrun git for-each-ref | grep -q \"refs/wip/master\"\nnote \"WIP ref exists after single save\"\n\n# Verify the change is in the WIP tree\nrun git show wip/master:file.txt | grep -q \"second line\"\nnote \"Change captured in WIP tree\"\n\n# Verify exactly 1 WIP commit\n\"$GIT_WIP\" status | grep -q \"branch master has 1 wip commit\"\nnote \"Exactly 1 WIP commit\"\n\n# -------------------------------------------------------------------------\n# Test 2: Two saves (two edits)\n\nnote \"Test 2: Two saves with background=true\"\n\n# Make another change and save\nrun_nvim \"edit file.txt\" \"normal othird line\" \"write\"\n\n# Verify exactly 2 WIP commits\n\"$GIT_WIP\" status | grep -q \"branch master has 2 wip commit\"\nnote \"Exactly 2 WIP commits\"\n\n# Verify latest WIP has third line\nrun git show wip/master:file.txt | grep -q \"third line\"\nnote \"Third line in WIP tree\"\n\n# Verify first WIP still has second line but not third\nrun git show wip/master~1:file.txt | grep -q \"second line\"\nnote \"First WIP still has second line\"\n\necho \"OK: $TEST_NAME\""
  },
  {
    "path": "test/nvim/test_nvim_buffers.sh",
    "content": "#!/usr/bin/env bash\n# test_nvim_buffers.sh -- Test git-wip with multiple buffers\nsource \"$(dirname \"$0\")/lib.sh\"\n\n# -------------------------------------------------------------------------\n# Setup: create a test repo with multiple files\n\ncreate_test_repo\n\n# Create initial files and commit\necho \"initial content A\" > file_a.txt\necho \"initial content B\" > file_b.txt\ngit add file_a.txt file_b.txt\ngit commit -m \"initial commit\"\n\nnote \"Repo created at $REPO\"\n\n# -------------------------------------------------------------------------\n# Test 1: Edit both files and save each\n\nnote \"Test 1: Edit and save both files\"\n\n# Edit first file\nrun_nvim \"edit file_a.txt\" \"normal oline 2 a\" \"write\"\n\n# Verify file_a was saved to WIP\nrun git show wip/master:file_a.txt | grep -q \"line 2 a\"\nnote \"file_a changes captured in WIP\"\n\n# Edit second file (new WIP commit)\nrun_nvim \"edit file_b.txt\" \"normal oline 2 b\" \"write\"\n\n# Verify file_b was saved to WIP\nrun git show wip/master:file_b.txt | grep -q \"line 2 b\"\nnote \"file_b changes captured in WIP\"\n\n# Verify we have 2 WIP commits (one for each save)\n\"$GIT_WIP\" status | grep -q \"branch master has 2 wip commit\"\nnote \"2 WIP commits for 2 saves\"\n\n# -------------------------------------------------------------------------\n# Test 2: Use :bufnext to switch between buffers\n\nnote \"Test 2: Switch between buffers with :bnext\"\n\n# Edit file_a\nrun_nvim \"edit file_a.txt\" \"normal oline 3 a\" \"write\"\n\n# Switch to next buffer\nrun_nvim \"bnext\" \"edit file_b.txt\" \"normal oline 3 b\" \"write\"\n\n# Verify we now have 4 WIP commits\n\"$GIT_WIP\" status | grep -q \"branch master has 4 wip commit\"\nnote \"4 WIP commits after buffer switching\"\n\n# Verify latest changes\nrun git show wip/master:file_a.txt | grep -q \"line 3 a\"\nrun git show wip/master:file_b.txt | grep -q \"line 3 b\"\nnote \"Latest changes in WIP tree\"\n\n# -------------------------------------------------------------------------\n# Test 3: Edit both files in sequence, verify both captured\n\nnote \"Test 3: Edit both files in one session\"\n\n# Edit both files and save each\nrun_nvim \"edit file_a.txt\" \"normal oline 4 a\" \"write\"\n\nrun_nvim \"edit file_b.txt\" \"normal oline 4 b\" \"write\"\n\n# Verify we have 6 WIP commits now\n\"$GIT_WIP\" status | grep -q \"branch master has 6 wip commit\"\nnote \"6 WIP commits total\"\n\n# -------------------------------------------------------------------------\n# Test 4: Untracked file\n\nnote \"Test 4: Save untracked file\"\n\n# Create an untracked file\necho \"untracked content\" > file_c.txt\n\n# The plugin by default doesn't capture untracked files, so we need to verify\n# the file remains untracked in WIP tree\nrun_nvim \"edit file_c.txt\" \"normal ountracked line 2\" \"write\"\n\n# File should be in WIP tree now (it's untracked but was explicitly saved)\nrun git show wip/master:file_c.txt | grep -q \"untracked line 2\"\nnote \"Untracked file captured in WIP tree\"\n\necho \"OK: $TEST_NAME\"\n"
  },
  {
    "path": "test/nvim/test_nvim_single.sh",
    "content": "#!/usr/bin/env bash\n# test_nvim_single.sh -- Test git-wip with single file saves\nsource \"$(dirname \"$0\")/lib.sh\"\n\n# -------------------------------------------------------------------------\n# Setup: create a test repo with one file\n\ncreate_test_repo\n\n# Create initial file and commit\necho \"initial content\" > file.txt\ngit add file.txt\ngit commit -m \"initial commit\"\n\nnote \"Repo created at $REPO\"\n\n# -------------------------------------------------------------------------\n# Test 1: Single save (one edit + :w)\n\nnote \"Test 1: Single save\"\n\n# Make a change and save using nvim\nrun_nvim \"edit file.txt\" \"normal osecond line\" \"write\"\n\n# Verify WIP commit was created\nrun git for-each-ref | grep -q \"refs/wip/master\"\nnote \"WIP ref exists after single save\"\n\n# Verify the change is in the WIP tree\nrun git show wip/master:file.txt | grep -q \"second line\"\nnote \"Change captured in WIP tree\"\n\n# Verify exactly 1 WIP commit\n\"$GIT_WIP\" status | grep -q \"branch master has 1 wip commit\"\nnote \"Exactly 1 WIP commit\"\n\n# -------------------------------------------------------------------------\n# Test 2: Two saves (two edits)\n\nnote \"Test 2: Two saves\"\n\n# Make another change and save\nrun_nvim \"edit file.txt\" \"normal othird line\" \"write\"\n\n# Verify exactly 2 WIP commits\n\"$GIT_WIP\" status | grep -q \"branch master has 2 wip commit\"\nnote \"Exactly 2 WIP commits\"\n\n# Verify latest WIP has third line\nrun git show wip/master:file.txt | grep -q \"third line\"\nnote \"Third line in WIP tree\"\n\n# Verify first WIP still has second line but not third\nrun git show wip/master~1:file.txt | grep -q \"second line\"\nnote \"First WIP still has second line\"\n\n# -------------------------------------------------------------------------\n# Test 3: Three saves\n\nnote \"Test 3: Three saves\"\n\n# Make another change and save\nrun_nvim \"edit file.txt\" \"normal ofourth line\" \"write\"\n\n# Verify exactly 3 WIP commits\n\"$GIT_WIP\" status | grep -q \"branch master has 3 wip commit\"\nnote \"Exactly 3 WIP commits\"\n\n# Verify log shows 3 commits (count lines starting with *)\ncount=$(\"$GIT_WIP\" log --pretty | grep -c \"^[*\\>]\") || count=0\nif [ \"$count\" -eq 3 ]; then\n    note \"Log shows 3 WIP commits\"\nelse\n    die \"Expected 3 WIP commits in log, got $count\"\nfi\n\n# -------------------------------------------------------------------------\n# Test 4: Save with no changes (should be silent in --editor mode)\n\nnote \"Test 4: Save with no changes\"\n\n# Save the same content again\n_run_nvim \"edit file.txt\" \"write\"\n\n# Should still be 3 WIP commits (no new one created)\n\"$GIT_WIP\" status | grep -q \"branch master has 3 wip commit\"\nnote \"No new WIP commit for unchanged file\"\n\necho \"OK: $TEST_NAME\"\n"
  },
  {
    "path": "test/nvim/test_nvim_windows.sh",
    "content": "#!/usr/bin/env bash\n# test_nvim_windows.sh -- Test git-wip with multiple windows\n# Tests window-related operations in Neovim\nsource \"$(dirname \"$0\")/lib.sh\"\n\n# -------------------------------------------------------------------------\n# Setup: create a test repo with multiple files\n\ncreate_test_repo\n\n# Create initial files and commit\necho \"initial content A\" > file_a.txt\necho \"initial content B\" > file_b.txt\ngit add file_a.txt file_b.txt\ngit commit -m \"initial commit\"\n\nnote \"Repo created at $REPO\"\n\n# -------------------------------------------------------------------------\n# Test 1: Edit file in one window (single window)\n\nnote \"Test 1: Single window edit\"\n\nrun_nvim \"edit file_a.txt\" \"normal oline 2 a\" \"write\"\n\n# Verify WIP commit was created\nrun git for-each-ref | grep -q \"refs/wip/master\"\nnote \"WIP ref exists\"\n\n# Verify the change is in the WIP tree\nrun git show wip/master:file_a.txt | grep -q \"line 2 a\"\nnote \"Change captured in WIP tree\"\n\n# -------------------------------------------------------------------------\n# Test 2: Multiple saves (simulates multiple window edits over time)\n\nnote \"Test 2: Multiple saves\"\n\nrun_nvim \"edit file_a.txt\" \"normal oline 3 a\" \"write\"\nrun_nvim \"edit file_b.txt\" \"normal oline 2 b\" \"write\"\n\n# Verify we have 3 WIP commits now\n\"$GIT_WIP\" status | grep -q \"branch master has 3 wip commit\"\nnote \"3 WIP commits\"\n\n# -------------------------------------------------------------------------\n# Test 3: Buffer in window, then switch buffers\n\nnote \"Test 3: Buffer switching\"\n\nrun_nvim \"edit file_a.txt\" \"badd file_b.txt\" \"bnext\" \"normal oline 3 b\" \"write\"\n\n# Verify we have 4 WIP commits now\n\"$GIT_WIP\" status | grep -q \"branch master has 4 wip commit\"\nnote \"4 WIP commits after buffer switch\"\n\n# -------------------------------------------------------------------------\n# Test 4: Edit multiple files in sequence (simulates working in multiple windows)\n\nnote \"Test 4: Multiple file edits\"\n\nrun_nvim \"edit file_a.txt\" \"normal oline 4 a\" \"write\"\nrun_nvim \"edit file_b.txt\" \"normal oline 4 b\" \"write\"\n\n# Verify we have 6 WIP commits now\n\"$GIT_WIP\" status | grep -q \"branch master has 6 wip commit\"\nnote \"6 WIP commits\"\n\n# Verify both files have changes\nrun git show wip/master:file_a.txt | grep -q \"line 4 a\"\nrun git show wip/master:file_b.txt | grep -q \"line 4 b\"\nnote \"Both files have changes in WIP tree\"\n\necho \"OK: $TEST_NAME\"\n"
  },
  {
    "path": "test/unit/CMakeLists.txt",
    "content": "find_package(GTest REQUIRED)\n\n# Directory where test repos are created (inside the cmake binary tree)\nset(UNIT_TEST_TMPDIR \"${CMAKE_CURRENT_BINARY_DIR}/repos\")\n\n# ---------------------------------------------------------------------------\n# Helper macro: add a unit test executable and register it with ctest.\n#\n# Usage: add_unit_test(NAME <name> SOURCES <file.cpp> [NEEDS_GIT])\n#   NEEDS_GIT — also links libgit2 and gets the LIBGIT2 include dirs\n# ---------------------------------------------------------------------------\nmacro(add_unit_test)\n    cmake_parse_arguments(UT \"\" \"NAME\" \"SOURCES;LINKS\" ${ARGN})\n\n    add_executable(${UT_NAME} ${UT_SOURCES})\n\n    target_include_directories(${UT_NAME} PRIVATE\n        ${CMAKE_SOURCE_DIR}/src\n        ${CMAKE_CURRENT_SOURCE_DIR}   # for test_repo_fixture.hpp\n        ${LIBGIT2_INCLUDE_DIRS}\n    )\n\n    target_link_libraries(${UT_NAME} PRIVATE\n        GTest::gtest\n        GTest::gtest_main\n        ${LIBGIT2_LIBRARIES}\n        ${UT_LINKS}\n    )\n\n    target_link_directories(${UT_NAME} PRIVATE\n        ${LIBGIT2_LIBRARY_DIRS}\n    )\n\n    add_test(NAME \"unit/${UT_NAME}\" COMMAND ${UT_NAME})\n\n    set_tests_properties(\"unit/${UT_NAME}\" PROPERTIES\n        ENVIRONMENT \"TEST_TMPDIR=${UNIT_TEST_TMPDIR}/${UT_NAME}\"\n    )\nendmacro()\n\n# ---------------------------------------------------------------------------\n# test_string_helpers — pure functions, no libgit2 needed at runtime but\n# we include the libgit2 headers via LIBGIT2_INCLUDE_DIRS anyway since\n# string_helpers.hpp indirectly pulls in git_guards.hpp → git2.h.\n# ---------------------------------------------------------------------------\nadd_unit_test(NAME test_string_helpers SOURCES test_string_helpers.cpp)\n\n# ---------------------------------------------------------------------------\n# test_git_helpers — exercises git_helpers.hpp; needs libgit2\n# ---------------------------------------------------------------------------\nadd_unit_test(NAME test_git_helpers SOURCES test_git_helpers.cpp)\n\n# ---------------------------------------------------------------------------\n# test_wip_helpers — exercises wip_helpers.hpp; needs libgit2\n# ---------------------------------------------------------------------------\nadd_unit_test(NAME test_wip_helpers SOURCES test_wip_helpers.cpp)\n"
  },
  {
    "path": "test/unit/test_git_helpers.cpp",
    "content": "#include \"git_helpers.hpp\"\n#include \"test_repo_fixture.hpp\"\n\n#include <gtest/gtest.h>\n\n#include <filesystem>\n#include <set>\n#include <string>\n\n// ---------------------------------------------------------------------------\n// oid_to_hex / oid_to_short_hex\n// ---------------------------------------------------------------------------\n\nTEST(OidToHex, FullLengthIs40) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"hello\");\n    git_oid oid = repo.commit(\"init\");\n\n    std::string hex = oid_to_hex(&oid);\n    EXPECT_EQ(hex.size(), 40u);\n    // only hex digits\n    EXPECT_TRUE(hex.find_first_not_of(\"0123456789abcdef\") == std::string::npos);\n}\n\nTEST(OidToHex, ShortLengthIs7) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"hi\");\n    git_oid oid = repo.commit(\"init\");\n\n    std::string s = oid_to_short_hex(&oid);\n    EXPECT_EQ(s.size(), 7u);\n    EXPECT_TRUE(s.find_first_not_of(\"0123456789abcdef\") == std::string::npos);\n}\n\nTEST(OidToHex, ShortIsPrefixOfFull) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"data\");\n    git_oid oid = repo.commit(\"init\");\n\n    std::string full  = oid_to_hex(&oid);\n    std::string short_ = oid_to_short_hex(&oid);\n    EXPECT_EQ(full.substr(0, 7), short_);\n}\n\n// ---------------------------------------------------------------------------\n// resolve_branch_names\n// ---------------------------------------------------------------------------\n\nTEST(ResolveBranchNames, FreshRepoUnbornHead) {\n    // No commits yet → HEAD is unborn → should return nullopt\n    TestRepo repo;\n    // Don't commit anything — HEAD is unborn\n    auto bn = resolve_branch_names(repo.repo());\n    EXPECT_FALSE(bn.has_value());\n}\n\nTEST(ResolveBranchNames, AfterFirstCommit) {\n    TestRepo repo;\n    repo.write_file(\"README\", \"hi\");\n    repo.commit(\"initial\");\n\n    auto bn = resolve_branch_names(repo.repo());\n    ASSERT_TRUE(bn.has_value());\n    EXPECT_EQ(bn->work_branch, \"master\");\n    EXPECT_EQ(bn->work_ref,    \"refs/heads/master\");\n    EXPECT_EQ(bn->wip_ref,     \"refs/wip/master\");\n}\n\nTEST(ResolveBranchNames, DetachedHead) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"x\");\n    git_oid oid = repo.commit(\"initial\");\n\n    // Detach HEAD by pointing it directly at the commit OID\n    git_repository_set_head_detached(repo.repo(), &oid);\n\n    auto bn = resolve_branch_names(repo.repo());\n    EXPECT_FALSE(bn.has_value());\n}\n\nTEST(ResolveBranchNames, ExplicitBranchName) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"x\");\n    repo.commit(\"initial\");\n\n    auto bn = resolve_branch_names(repo.repo(), std::string{\"feature/foo\"});\n    ASSERT_TRUE(bn.has_value());\n    EXPECT_EQ(bn->work_branch, \"feature/foo\");\n    EXPECT_EQ(bn->work_ref, \"refs/heads/feature/foo\");\n    EXPECT_EQ(bn->wip_ref, \"refs/wip/feature/foo\");\n}\n\nTEST(ResolveBranchNames, ExplicitWipShortName) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"x\");\n    repo.commit(\"initial\");\n\n    auto bn = resolve_branch_names(repo.repo(), std::string{\"wip/feature/foo\"});\n    ASSERT_TRUE(bn.has_value());\n    EXPECT_EQ(bn->work_branch, \"feature/foo\");\n    EXPECT_EQ(bn->work_ref, \"refs/heads/feature/foo\");\n    EXPECT_EQ(bn->wip_ref, \"refs/wip/feature/foo\");\n}\n\nTEST(ResolveBranchNames, ExplicitHeadsRef) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"x\");\n    repo.commit(\"initial\");\n\n    auto bn = resolve_branch_names(repo.repo(), std::string{\"refs/heads/feature/foo\"});\n    ASSERT_TRUE(bn.has_value());\n    EXPECT_EQ(bn->work_branch, \"feature/foo\");\n    EXPECT_EQ(bn->work_ref, \"refs/heads/feature/foo\");\n    EXPECT_EQ(bn->wip_ref, \"refs/wip/feature/foo\");\n}\n\nTEST(ResolveBranchNames, ExplicitWipRef) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"x\");\n    repo.commit(\"initial\");\n\n    auto bn = resolve_branch_names(repo.repo(), std::string{\"refs/wip/feature/foo\"});\n    ASSERT_TRUE(bn.has_value());\n    EXPECT_EQ(bn->work_branch, \"feature/foo\");\n    EXPECT_EQ(bn->work_ref, \"refs/heads/feature/foo\");\n    EXPECT_EQ(bn->wip_ref, \"refs/wip/feature/foo\");\n}\n\nTEST(ResolveBranchNames, StripsOnlyOnePrefix) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"x\");\n    repo.commit(\"initial\");\n\n    auto bn = resolve_branch_names(repo.repo(), std::string{\"refs/heads/refs/wip/wip/foo\"});\n    ASSERT_TRUE(bn.has_value());\n    EXPECT_EQ(bn->work_branch, \"refs/wip/wip/foo\");\n    EXPECT_EQ(bn->work_ref, \"refs/heads/refs/wip/wip/foo\");\n    EXPECT_EQ(bn->wip_ref, \"refs/wip/refs/wip/wip/foo\");\n}\n\n// ---------------------------------------------------------------------------\n// resolve_oid\n// ---------------------------------------------------------------------------\n\nTEST(ResolveOid, ExistingRef) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"data\");\n    git_oid expected = repo.commit(\"initial\");\n\n    auto got = resolve_oid(repo.repo(), \"refs/heads/master\");\n    ASSERT_TRUE(got.has_value());\n    EXPECT_TRUE(git_oid_equal(&*got, &expected));\n}\n\nTEST(ResolveOid, MissingRefReturnsNullopt) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"data\");\n    repo.commit(\"initial\");\n\n    auto got = resolve_oid(repo.repo(), \"refs/wip/master\");\n    EXPECT_FALSE(got.has_value());\n}\n\nTEST(ResolveOid, WipRefAfterCreation) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"data\");\n    git_oid commit_oid = repo.commit(\"initial\");\n\n    repo.create_ref(\"refs/wip/master\", commit_oid);\n\n    auto got = resolve_oid(repo.repo(), \"refs/wip/master\");\n    ASSERT_TRUE(got.has_value());\n    EXPECT_TRUE(git_oid_equal(&*got, &commit_oid));\n}\n\n// ---------------------------------------------------------------------------\n// ensure_reflog_dir\n// ---------------------------------------------------------------------------\n\nTEST(EnsureReflogDir, CreatesDirectoryAndFile) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"x\");\n    repo.commit(\"initial\");\n\n    ensure_reflog_dir(repo.repo(), \"refs/wip/master\");\n\n    const char *git_dir = git_repository_path(repo.repo());\n    std::filesystem::path reflog_file =\n        std::filesystem::path(git_dir) / \"logs\" / \"refs\" / \"wip\" / \"master\";\n\n    EXPECT_TRUE(std::filesystem::exists(reflog_file))\n        << \"expected reflog file: \" << reflog_file;\n}\n\nTEST(EnsureReflogDir, IdempotentSecondCall) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"x\");\n    repo.commit(\"initial\");\n\n    // Calling twice should not throw or fail\n    EXPECT_NO_THROW(ensure_reflog_dir(repo.repo(), \"refs/wip/master\"));\n    EXPECT_NO_THROW(ensure_reflog_dir(repo.repo(), \"refs/wip/master\"));\n\n    const char *git_dir = git_repository_path(repo.repo());\n    std::filesystem::path reflog_file =\n        std::filesystem::path(git_dir) / \"logs\" / \"refs\" / \"wip\" / \"master\";\n    EXPECT_TRUE(std::filesystem::exists(reflog_file));\n}\n\nTEST(EnsureReflogDir, NestedBranchName) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"x\");\n    repo.commit(\"initial\");\n\n    ensure_reflog_dir(repo.repo(), \"refs/wip/team/feature\");\n\n    const char *git_dir = git_repository_path(repo.repo());\n    std::filesystem::path reflog_file =\n        std::filesystem::path(git_dir) / \"logs\" / \"refs\" / \"wip\" / \"team\" / \"feature\";\n    EXPECT_TRUE(std::filesystem::exists(reflog_file));\n}\n\n// ---------------------------------------------------------------------------\n// find_refs\n// ---------------------------------------------------------------------------\n\nTEST(FindRefs, FindsWipRefsByPrefix) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"x\");\n    git_oid oid = repo.commit(\"initial\");\n\n    repo.create_ref(\"refs/wip/master\", oid);\n    repo.create_ref(\"refs/wip/foo\", oid);\n    repo.create_ref(\"refs/wip/team/feature\", oid);\n\n    auto refs = find_refs(repo.repo(), \"refs/wip/\");\n\n    EXPECT_EQ(refs.size(), 3u);\n    EXPECT_EQ(refs[0], \"refs/wip/foo\");\n    EXPECT_EQ(refs[1], \"refs/wip/master\");\n    EXPECT_EQ(refs[2], \"refs/wip/team/feature\");\n}\n\nTEST(FindRefs, FindsHeadsRefsByPrefix) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"x\");\n    git_oid oid = repo.commit(\"initial\");\n\n    repo.create_ref(\"refs/heads/foo\", oid);\n    repo.create_ref(\"refs/heads/bar\", oid);\n\n    auto refs = find_refs(repo.repo(), \"refs/heads\");\n    std::set<std::string> got(refs.begin(), refs.end());\n\n    EXPECT_TRUE(got.contains(\"refs/heads/master\"));\n    EXPECT_TRUE(got.contains(\"refs/heads/foo\"));\n    EXPECT_TRUE(got.contains(\"refs/heads/bar\"));\n}\n"
  },
  {
    "path": "test/unit/test_repo_fixture.hpp",
    "content": "#pragma once\n\n// test_repo_fixture.hpp — helpers for creating throwaway git repositories\n// inside the cmake binary directory during unit tests.\n//\n// Usage:\n//   TestRepo repo(\"my_test_name\");   // creates build/test/unit/repos/my_test_name/\n//   repo.write_file(\"README\", \"hello\");\n//   git_oid oid = repo.commit(\"initial\");\n//\n// The TEST_TMPDIR environment variable (set by CMakeLists.txt via\n// set_tests_properties ENVIRONMENT) points to CMAKE_CURRENT_BINARY_DIR.\n// Each TestRepo creates a uniquely-named subdirectory under it.\n\n#include \"git_guards.hpp\"\n\n#include <git2.h>\n#include <gtest/gtest.h>\n\n#include <cstdlib>\n#include <filesystem>\n#include <fstream>\n#include <stdexcept>\n#include <string>\n\n// ---------------------------------------------------------------------------\n// TestRepo\n//\n// RAII wrapper that initialises a bare-minimum git repository in a temp dir,\n// provides helpers for writing files and making commits, and cleans up on\n// destruction.\n//\n// Usage (preferred — name derived automatically from gtest):\n//   TestRepo repo;\n//\n// The repo directory is  $TEST_TMPDIR/<SuiteName>/<TestName>  which is\n// guaranteed unique because gtest enforces unique suite+test name pairs.\n// ---------------------------------------------------------------------------\nclass TestRepo {\npublic:\n    // Default constructor: derive the directory name from the currently-running\n    // gtest case (\"SuiteName/TestName\").  Must be called from inside a TEST()\n    // body so that current_test_info() is non-null.\n    TestRepo() {\n        const auto *info = ::testing::UnitTest::GetInstance()->current_test_info();\n        if (!info) {\n            ADD_FAILURE() << \"TestRepo() default constructor called outside a TEST body\";\n            return;\n        }\n        // suite_name/test_name — both are guaranteed unique by gtest\n        std::string name = std::string(info->test_suite_name()) +\n                           \"/\" + info->name();\n        init(name);\n    }\n\n    // Explicit constructor: use a caller-supplied name (for special cases).\n    explicit TestRepo(const std::string &name) { init(name); }\n\nprivate:\n    void init(const std::string &name) {\n        const char *base = std::getenv(\"TEST_TMPDIR\");\n        if (!base) {\n            ADD_FAILURE() << \"TEST_TMPDIR env var not set — cannot create test repo\";\n            return;\n        }\n\n        m_path = std::filesystem::path(base) / name;\n        std::filesystem::remove_all(m_path);\n        std::filesystem::create_directories(m_path);\n\n        // git init\n        if (git_repository_init(m_repo.ptr(), m_path.string().c_str(), /*bare=*/0) < 0)\n            throw std::runtime_error(\"git_repository_init failed: \" + last_error());\n\n        // Set identity so commits don't fail\n        git_config *cfg = nullptr;\n        git_repository_config(&cfg, m_repo.get());\n        git_config_set_string(cfg, \"user.name\",  \"Test User\");\n        git_config_set_string(cfg, \"user.email\", \"test@example.com\");\n        git_config_free(cfg);\n\n        // Always start on a branch called \"master\"\n        // (override init.defaultBranch if needed)\n        // For a fresh repo there are no commits yet, so we just set HEAD\n        // symbolically.\n        git_repository_set_head(m_repo.get(), \"refs/heads/master\");\n    } // end init()\n\npublic:\n    ~TestRepo() {\n        // repo/libgit2 cleanup handled by RAII guards\n    }\n\n    git_repository *repo() { return m_repo.get(); }\n\n    const std::filesystem::path &path() const { return m_path; }\n\n    // Write (or overwrite) a file in the working tree.\n    void write_file(const std::string &rel_path, const std::string &content) {\n        auto full = m_path / rel_path;\n        std::filesystem::create_directories(full.parent_path());\n        std::ofstream f(full);\n        f << content;\n    }\n\n    // Stage all files in the working tree and create a commit.\n    // Returns the OID of the new commit.\n    git_oid commit(const std::string &message) {\n        // Stage everything\n        GitIndexGuard idx;\n        if (git_repository_index(idx.ptr(), m_repo.get()) < 0)\n            throw std::runtime_error(\"index: \" + last_error());\n        git_strarray dot{nullptr, 0};\n        git_index_add_all(idx.get(), &dot, GIT_INDEX_ADD_DEFAULT, nullptr, nullptr);\n        git_index_update_all(idx.get(), &dot, nullptr, nullptr);\n        git_index_write(idx.get());\n\n        // Write tree\n        git_oid tree_oid{};\n        if (git_index_write_tree(&tree_oid, idx.get()) < 0)\n            throw std::runtime_error(\"write_tree: \" + last_error());\n        GitTreeGuard tree;\n        git_tree_lookup(tree.ptr(), m_repo.get(), &tree_oid);\n\n        // Signature\n        GitSignatureGuard sig;\n        git_signature_now(sig.ptr(), \"Test User\", \"test@example.com\");\n\n        // Parent (nullptr if this is the first commit)\n        git_oid commit_oid{};\n        GitReferenceGuard head_ref;\n        const git_commit *parents[1] = {nullptr};\n        int n_parents = 0;\n        GitCommitGuard parent_commit;\n        if (git_repository_head(head_ref.ptr(), m_repo.get()) == 0) {\n            git_oid parent_oid{};\n            git_reference_name_to_id(&parent_oid, m_repo.get(),\n                                     git_reference_name(head_ref.get()));\n            git_commit_lookup(parent_commit.ptr(), m_repo.get(), &parent_oid);\n            parents[0]  = parent_commit.get();\n            n_parents   = 1;\n        }\n\n        if (git_commit_create(&commit_oid, m_repo.get(),\n                              \"HEAD\",\n                              sig.get(), sig.get(),\n                              nullptr, message.c_str(),\n                              tree.get(),\n                              n_parents, parents) < 0)\n            throw std::runtime_error(\"commit_create: \" + last_error());\n\n        return commit_oid;\n    }\n\n    // Create a direct reference (e.g. a wip branch).\n    void create_ref(const std::string &ref_name, const git_oid &oid) {\n        GitReferenceGuard ref;\n        git_reference_create(ref.ptr(), m_repo.get(),\n                             ref_name.c_str(), &oid,\n                             /*force=*/1, \"test\");\n    }\n\n    // Advance HEAD to point to `oid` and update the branch ref.\n    // Used to simulate making a real commit after WIP commits.\n    void advance_head(const git_oid &oid) {\n        git_repository_set_head(m_repo.get(), \"refs/heads/master\");\n        GitReferenceGuard ref;\n        git_reference_create(ref.ptr(), m_repo.get(),\n                             \"refs/heads/master\", &oid,\n                             /*force=*/1, \"advance_head\");\n    }\n\nprivate:\n    static std::string last_error() {\n        const git_error *e = git_error_last();\n        return e ? e->message : \"(unknown)\";\n    }\n\n    GitLibGuard           m_git_lib_guard;\n    GitRepoGuard          m_repo;\n    std::filesystem::path m_path;\n};\n"
  },
  {
    "path": "test/unit/test_string_helpers.cpp",
    "content": "#include \"string_helpers.hpp\"\n\n#include <gtest/gtest.h>\n\n#include <chrono>\n#include <ctime>\n\n// ---------------------------------------------------------------------------\n// strip_prefix\n// ---------------------------------------------------------------------------\n\nTEST(StripPrefix, RemovesPresentPrefix) {\n    EXPECT_EQ(strip_prefix(\"refs/heads/master\", \"refs/heads/\"), \"master\");\n}\n\nTEST(StripPrefix, LeavesStringUnchangedWhenPrefixAbsent) {\n    EXPECT_EQ(strip_prefix(\"refs/wip/master\", \"refs/heads/\"), \"refs/wip/master\");\n}\n\nTEST(StripPrefix, EmptyPrefix) {\n    EXPECT_EQ(strip_prefix(\"hello\", \"\"), \"hello\");\n}\n\nTEST(StripPrefix, PrefixEqualsString) {\n    EXPECT_EQ(strip_prefix(\"refs/heads/\", \"refs/heads/\"), \"\");\n}\n\nTEST(StripPrefix, EmptyString) {\n    EXPECT_EQ(strip_prefix(\"\", \"refs/heads/\"), \"\");\n}\n\nTEST(StripPrefix, WipRefUnchanged) {\n    EXPECT_EQ(strip_prefix(\"refs/wip/feature\", \"refs/heads/\"), \"refs/wip/feature\");\n}\n\n// ---------------------------------------------------------------------------\n// strip_prefix_inplace\n// ---------------------------------------------------------------------------\n\nTEST(StripPrefixInplace, RemovesPresentPrefixAndReturnsTrue) {\n    std::string s = \"refs/heads/master\";\n    EXPECT_TRUE(strip_prefix_inplace(s, \"refs/heads/\"));\n    EXPECT_EQ(s, \"master\");\n}\n\nTEST(StripPrefixInplace, LeavesStringUnchangedWhenPrefixAbsent) {\n    std::string s = \"refs/wip/master\";\n    EXPECT_FALSE(strip_prefix_inplace(s, \"refs/heads/\"));\n    EXPECT_EQ(s, \"refs/wip/master\");\n}\n\n// ---------------------------------------------------------------------------\n// first_line\n// ---------------------------------------------------------------------------\n\nTEST(FirstLine, NullReturnsEmpty) {\n    EXPECT_EQ(first_line(nullptr), \"\");\n}\n\nTEST(FirstLine, SingleLine) {\n    EXPECT_EQ(first_line(\"hello world\"), \"hello world\");\n}\n\nTEST(FirstLine, MultiLine) {\n    EXPECT_EQ(first_line(\"subject\\n\\nbody\"), \"subject\");\n}\n\nTEST(FirstLine, EmptyString) {\n    EXPECT_EQ(first_line(\"\"), \"\");\n}\n\nTEST(FirstLine, LeadingNewline) {\n    EXPECT_EQ(first_line(\"\\nsecond\"), \"\");\n}\n\nTEST(FirstLine, TrailingNewline) {\n    EXPECT_EQ(first_line(\"subject\\n\"), \"subject\");\n}\n\n// ---------------------------------------------------------------------------\n// relative_time\n//\n// These tests produce a fixed epoch offset from \"now\" and check that the\n// output falls into the right bucket.  We cannot check the exact number\n// because the function reads the real wall clock, but the rounding logic\n// is deterministic once we control the input epoch.\n// ---------------------------------------------------------------------------\n\nnamespace {\n// Returns an epoch seconds value that is `delta` seconds in the past.\nstd::int64_t seconds_ago(std::int64_t delta) {\n    using namespace std::chrono;\n    auto now = system_clock::now();\n    return static_cast<std::int64_t>(\n        system_clock::to_time_t(now - seconds{delta}));\n}\n} // namespace\n\nTEST(RelativeTime, JustNow) {\n    auto s = relative_time(seconds_ago(5));\n    EXPECT_NE(s.find(\"second\"), std::string::npos) << \"got: \" << s;\n}\n\nTEST(RelativeTime, OneMinuteAgo) {\n    auto s = relative_time(seconds_ago(91)); // > 90 s → minutes bucket\n    EXPECT_NE(s.find(\"minute\"), std::string::npos) << \"got: \" << s;\n}\n\nTEST(RelativeTime, OneHourAgo) {\n    auto s = relative_time(seconds_ago(91 * 60)); // > 90 min → hours bucket\n    EXPECT_NE(s.find(\"hour\"), std::string::npos) << \"got: \" << s;\n}\n\nTEST(RelativeTime, OneDayAgo) {\n    auto s = relative_time(seconds_ago(37 * 3600)); // > 36 h → days bucket\n    EXPECT_NE(s.find(\"day\"), std::string::npos) << \"got: \" << s;\n}\n\nTEST(RelativeTime, TwoWeeksAgo) {\n    auto s = relative_time(seconds_ago(15 * 24 * 3600)); // > 14 days → weeks\n    EXPECT_NE(s.find(\"week\"), std::string::npos) << \"got: \" << s;\n}\n\nTEST(RelativeTime, ThreeMonthsAgo) {\n    auto s = relative_time(seconds_ago(90L * 24 * 3600)); // > 8 weeks → months\n    EXPECT_NE(s.find(\"month\"), std::string::npos) << \"got: \" << s;\n}\n\nTEST(RelativeTime, TwoYearsAgo) {\n    auto s = relative_time(seconds_ago(730L * 24 * 3600)); // > 24 months → years\n    EXPECT_NE(s.find(\"year\"), std::string::npos) << \"got: \" << s;\n}\n\nTEST(RelativeTime, FutureTimestampClampsToZero) {\n    // A future timestamp should not produce negative output.\n    auto s = relative_time(seconds_ago(-3600)); // 1 hour in the future\n    EXPECT_NE(s.find(\"second\"), std::string::npos) << \"got: \" << s;\n}\n\nTEST(RelativeTime, SingularSecond) {\n    // Exactly 1 second ago → \"1 second ago\" (singular)\n    auto s = relative_time(seconds_ago(1));\n    EXPECT_EQ(s, \"1 second ago\");\n}\n\nTEST(RelativeTime, SingularMinute) {\n    auto s = relative_time(seconds_ago(91)); // first minute bucket entry\n    EXPECT_NE(s.find(\"minute\"), std::string::npos) << \"got: \" << s;\n}\n"
  },
  {
    "path": "test/unit/test_wip_helpers.cpp",
    "content": "#include \"wip_helpers.hpp\"\n#include \"test_repo_fixture.hpp\"\n\n#include <gtest/gtest.h>\n\n// ---------------------------------------------------------------------------\n// wip_parent_oid\n// ---------------------------------------------------------------------------\n\n// No wip branch at all → parent should be work_last\nTEST(WipParentOid, NoWipBranch) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"v1\");\n    git_oid work_last = repo.commit(\"initial\");\n\n    auto parent = wip_parent_oid(repo.repo(), work_last, std::nullopt);\n    ASSERT_TRUE(parent.has_value());\n    EXPECT_TRUE(git_oid_equal(&*parent, &work_last));\n}\n\n// wip exists and work branch hasn't moved → parent = wip_last (stack)\nTEST(WipParentOid, WorkBranchUnchanged) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"v1\");\n    git_oid work_last = repo.commit(\"initial\");\n\n    // Simulate a wip commit on top of work_last\n    repo.write_file(\"f\", \"v2\");\n    git_oid wip_last = repo.commit(\"WIP\");\n    // Reset the work branch back to work_last (wip is ahead)\n    repo.create_ref(\"refs/wip/master\", wip_last);\n    repo.advance_head(work_last);\n\n    auto parent = wip_parent_oid(repo.repo(), work_last, wip_last);\n    ASSERT_TRUE(parent.has_value());\n    EXPECT_TRUE(git_oid_equal(&*parent, &wip_last));\n}\n\n// wip exists but work branch has advanced → parent = new work_last (reset)\nTEST(WipParentOid, WorkBranchAdvanced) {\n    TestRepo repo;\n\n    // Build:  A (work) → W (wip)\n    //              ↘ B (new work commit)\n    repo.write_file(\"f\", \"v1\");\n    git_oid commit_a = repo.commit(\"commit A\");\n\n    repo.write_file(\"f\", \"wip\");\n    git_oid wip_last = repo.commit(\"WIP\");\n    repo.create_ref(\"refs/wip/master\", wip_last);\n\n    // Advance work branch: go back to A, make a new commit B\n    repo.advance_head(commit_a);\n    repo.write_file(\"f\", \"v2\");\n    git_oid commit_b = repo.commit(\"commit B\");\n\n    auto parent = wip_parent_oid(repo.repo(), commit_b, wip_last);\n    ASSERT_TRUE(parent.has_value());\n    // Should reset to new work_last (commit_b), not stack on wip_last\n    EXPECT_TRUE(git_oid_equal(&*parent, &commit_b));\n}\n\n// wip and work have no common ancestor → nullopt\nTEST(WipParentOid, UnrelatedHistories) {\n    // Create two independent repos and steal a commit OID from one to use\n    // as a fake wip_last in the other — they'll have no common ancestor.\n    TestRepo repo_a(\"wip_parent_unrelated_a\");\n    repo_a.write_file(\"f\", \"a\");\n    git_oid oid_a = repo_a.commit(\"repo a root\");\n\n    TestRepo repo_b(\"wip_parent_unrelated_b\");\n    repo_b.write_file(\"f\", \"b\");\n    git_oid oid_b = repo_b.commit(\"repo b root\");\n\n    // Use repo_a's repo but present oid_b as the wip_last.\n    // git_merge_base will fail because oid_b doesn't exist in repo_a.\n    auto parent = wip_parent_oid(repo_a.repo(), oid_a, oid_b);\n    EXPECT_FALSE(parent.has_value());\n}\n\n// ---------------------------------------------------------------------------\n// collect_wip_commits\n// ---------------------------------------------------------------------------\n\n// No wip commits on top of work → empty vector\nTEST(CollectWipCommits, SingleCommitIsWorkHead) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"v1\");\n    git_oid work_last = repo.commit(\"initial\");\n\n    // wip_last == work_last: nothing stacked on top\n    auto commits = collect_wip_commits(repo.repo(), work_last, work_last);\n    ASSERT_TRUE(commits.has_value());\n    EXPECT_EQ(commits->size(), 0u);\n}\n\n// One wip commit stacked on work_last\nTEST(CollectWipCommits, OneWipCommit) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"v1\");\n    git_oid work_last = repo.commit(\"initial\");\n\n    repo.write_file(\"f\", \"v2\");\n    git_oid wip1 = repo.commit(\"WIP 1\");\n    // Reset work branch to work_last\n    repo.advance_head(work_last);\n\n    auto commits = collect_wip_commits(repo.repo(), wip1, work_last);\n    ASSERT_TRUE(commits.has_value());\n    ASSERT_EQ(commits->size(), 1u);\n    EXPECT_TRUE(git_oid_equal(&(*commits)[0], &wip1));\n}\n\n// Three wip commits stacked, newest first\nTEST(CollectWipCommits, ThreeWipCommits) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"v1\");\n    git_oid work_last = repo.commit(\"initial\");\n\n    repo.write_file(\"f\", \"v2\"); git_oid w1 = repo.commit(\"WIP 1\");\n    repo.write_file(\"f\", \"v3\"); git_oid w2 = repo.commit(\"WIP 2\");\n    repo.write_file(\"f\", \"v4\"); git_oid w3 = repo.commit(\"WIP 3\");\n    repo.advance_head(work_last);\n\n    auto commits = collect_wip_commits(repo.repo(), w3, work_last);\n    ASSERT_TRUE(commits.has_value());\n    ASSERT_EQ(commits->size(), 3u);\n    // Topological order: newest (w3) first\n    EXPECT_TRUE(git_oid_equal(&(*commits)[0], &w3));\n    EXPECT_TRUE(git_oid_equal(&(*commits)[1], &w2));\n    EXPECT_TRUE(git_oid_equal(&(*commits)[2], &w1));\n}\n\n// Work branch has advanced past wip → 0 visible commits\nTEST(CollectWipCommits, WorkBranchAdvanced) {\n    TestRepo repo;\n\n    repo.write_file(\"f\", \"v1\");\n    git_oid commit_a = repo.commit(\"commit A\");\n\n    repo.write_file(\"f\", \"wip\");\n    git_oid wip_last = repo.commit(\"WIP\");\n\n    // Now advance work branch past A (new commit B)\n    repo.advance_head(commit_a);\n    repo.write_file(\"f\", \"v2\");\n    git_oid commit_b = repo.commit(\"commit B\");\n\n    // wip_last's parent is A; work_last is now B (child of A)\n    // merge_base(wip_last, commit_b) = A ≠ commit_b → 0 commits\n    auto commits = collect_wip_commits(repo.repo(), wip_last, commit_b);\n    ASSERT_TRUE(commits.has_value());\n    EXPECT_EQ(commits->size(), 0u);\n}\n\n// wip_last and work_last are the same commit (no wip yet) → 0 commits\nTEST(CollectWipCommits, WipSameAsWork) {\n    TestRepo repo;\n    repo.write_file(\"f\", \"v1\");\n    git_oid oid = repo.commit(\"initial\");\n\n    auto commits = collect_wip_commits(repo.repo(), oid, oid);\n    ASSERT_TRUE(commits.has_value());\n    EXPECT_EQ(commits->size(), 0u);\n}\n"
  },
  {
    "path": "vim/plugin/git-wip.vim",
    "content": "\" ---------------------------------------------------------------------------\n\" this is a Vim plugin that launches git-wip save on every buffer write.\n\nif has('nvim')\n        \" note that for Neovim, a lua script is used instead.\n        finish\nendif\n\nif !exists('g:git_wip_verbose')\n        let g:git_wip_verbose = 0\nendif\nif !exists('g:git_wip_disable_signing')\n        let g:git_wip_disable_signing = 0\nendif\n\nlet g:git_wip_status = 0  \" 0 = unchecked, 1 = good, 2 = failed\n\nfunction! GitWipSave()\n        if expand(\"%\") == \".git/COMMIT_EDITMSG\"\n            return\n        endif\n        if g:git_wip_status == 2\n            augroup git-wip\n                    autocmd!\n            augroup END\n            return\n        endif\n        if g:git_wip_status == 0\n            silent! !git wip -h >/dev/null 2>&1\n            if v:shell_error\n                let g:git_wip_status = 2\n                return\n            else\n                let g:git_wip_status = 1\n            endif\n        endif\n        let wip_opts = '--editor'\n        if g:git_wip_disable_signing\n            let wip_opts .= ' --no-gpg-sign'\n        endif\n        let out = system('git rev-parse 2>&1')\n        if v:shell_error\n            return\n        endif\n        let dir = expand(\"%:p:h\")\n        let show_cdup = system('cd \"' . dir . '\" && git rev-parse --show-cdup 2>/dev/null')\n        if v:shell_error\n            \" We're not editing a file anywhere near a .git repository, so abort\n            return\n        endif\n        let show_cdup_len = len( show_cdup )\n        if show_cdup_len == 0\n            \" We're editing a file in the .git directory\n            \" (.git/EDIT_COMMITMSG, .git/config, etc.), so abort\n            return\n        endif\n        let file = expand(\"%:t\")\n        let out = system('cd \"' . dir . '\" && git wip save \"WIP from vim (' . file . ')\" ' . wip_opts . ' -- \"' . file . '\" 2>&1')\n        let err = v:shell_error\n        if err\n                redraw\n                echohl Error\n                echo \"git-wip: \" . out\n                echohl None\n        elseif g:git_wip_verbose\n                redraw\n                echo \"git-wip: \" . out\n        endif\nendf\n\naugroup git-wip\n        autocmd!\n        autocmd BufWritePost * :call GitWipSave()\naugroup END\n"
  }
]