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