From d990d950387bc80f11bd79a32e8931cb22a22367 Mon Sep 17 00:00:00 2001 From: Bart Trojanowski Date: Fri, 3 Apr 2026 18:11:46 +0000 Subject: [PATCH 1/2] [vibe] add background execution of git-wip --- AGENTS.md | 13 +++ README.md | 11 +-- lua/git-wip/init.lua | 128 +++++++++++++++++++----------- test/nvim/CMakeLists.txt | 2 +- test/nvim/lib.sh | 2 + test/nvim/test_nvim_background.sh | 60 ++++++++++++++ 6 files changed, 164 insertions(+), 52 deletions(-) create mode 100755 test/nvim/test_nvim_background.sh diff --git a/AGENTS.md b/AGENTS.md index 683530c..c69de22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,19 @@ - `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 diff --git a/README.md b/README.md index 788a0ba..a0f9078 100644 --- a/README.md +++ b/README.md @@ -263,10 +263,11 @@ $ git wip -h { "bartman/git-wip", opts = { - gpg_sign = false, - untracked = true, - ignored = false, - filetypes = { "*" }, + 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 }, }, ``` @@ -349,7 +350,7 @@ $ git diff --cached Adjust with `git reset` / `git add -p` as needed, then commit. -# Appendix +# Appendix ## related projects diff --git a/lua/git-wip/init.lua b/lua/git-wip/init.lua index 7c69f75..cf47b1d 100644 --- a/lua/git-wip/init.lua +++ b/lua/git-wip/init.lua @@ -5,6 +5,7 @@ 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_hrtime = vim.loop and vim.loop.hrtime ~= nil -- Configuration M.defaults = { @@ -12,12 +13,22 @@ M.defaults = { 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 @@ -28,6 +39,72 @@ local function add_tri_flag(cmd, value, positive, negative) -- 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_vim_system 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 start = get_hrtime() + vim.system(cmd, { + cwd = dir, + text = true, + on_exit = function(result) + local elapsed = (get_hrtime() - start) / 1e9 + vim.schedule(function() + notify_result(display_name, result.code, elapsed) + end) + end + }) +end + ---Setup function (automatically called by Lazy.nvim) ---@param opts? table function M.setup(opts) @@ -65,53 +142,12 @@ function M.setup(opts) end function M.RunGitWip(dir, filename) - local display_name = '*' - if filename ~= nil then - display_name = filename - end - - -- Build the command using config options - local cmd = { M.config.git_wip_path, "save", string.format("WIP from neovim for %s", display_name) } - - -- Tri-state flags - 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) -- the actual file (full path) - end - - -- Run git-wip from the correct directory - if has_vim_system then - -- Neovim 0.10+: use async vim.system with job:wait() - local start = vim.loop.hrtime() - local job = vim.system(cmd, { cwd = dir, text = true }) - local result = job:wait() - local elapsed = (vim.loop.hrtime() - start) / 1e9 - - vim.schedule(function() - if result.code == 0 then - vim.notify(string.format("[git-wip] saved %s in %.3f sec", display_name, elapsed), vim.log.levels.INFO) - else - vim.notify(string.format("[git-wip] failed for %s (exit %d)", display_name, result.code), vim.log.levels.WARN) - end - end) + local display_name = filename or '*' + local cmd = build_command(display_name, filename) + if M.config.background and has_vim_system then + run_async(cmd, dir, display_name) else - -- Neovim < 0.10: use vim.fn.system() with shell cd - 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) - - if vim.v.shell_error == 0 then - vim.notify(string.format("[git-wip] saved %s", display_name), vim.log.levels.INFO) - else - vim.notify(string.format("[git-wip] failed for %s", display_name), vim.log.levels.WARN) - end + run_sync(cmd, dir, display_name) end end diff --git a/test/nvim/CMakeLists.txt b/test/nvim/CMakeLists.txt index 48a3051..7076549 100644 --- a/test/nvim/CMakeLists.txt +++ b/test/nvim/CMakeLists.txt @@ -17,7 +17,7 @@ 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) + 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" diff --git a/test/nvim/lib.sh b/test/nvim/lib.sh index 75c30d6..a07c3fc 100755 --- a/test/nvim/lib.sh +++ b/test/nvim/lib.sh @@ -109,6 +109,7 @@ require("git-wip").setup({ git_wip_path = git_wip_path, untracked = false, ignored = false, + background = os.getenv("GIT_WIP_TEST_BACKGROUND") == "true", filetypes = { "*" }, }) INITLUA @@ -118,6 +119,7 @@ INITLUA # 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 diff --git a/test/nvim/test_nvim_background.sh b/test/nvim/test_nvim_background.sh new file mode 100755 index 0000000..e99afce --- /dev/null +++ b/test/nvim/test_nvim_background.sh @@ -0,0 +1,60 @@ +#!/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" \ No newline at end of file From bb1bcec1aec6c4af37808ee06d962c0c7db88389 Mon Sep 17 00:00:00 2001 From: Bart Trojanowski Date: Fri, 3 Apr 2026 15:00:18 -0400 Subject: [PATCH 2/2] async exec fixes --- lua/git-wip/init.lua | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/lua/git-wip/init.lua b/lua/git-wip/init.lua index cf47b1d..12d0323 100644 --- a/lua/git-wip/init.lua +++ b/lua/git-wip/init.lua @@ -5,6 +5,7 @@ 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 @@ -63,7 +64,7 @@ local function notify_result(display_name, code, elapsed) 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_vim_system then + 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) @@ -92,17 +93,21 @@ 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() - vim.system(cmd, { + local handle + handle = vim.loop.spawn(cmd[1], { + args = {unpack(cmd, 2)}, cwd = dir, - text = true, - on_exit = function(result) - local elapsed = (get_hrtime() - start) / 1e9 - vim.schedule(function() - notify_result(display_name, result.code, elapsed) - end) - end - }) + 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) @@ -144,7 +149,7 @@ end function M.RunGitWip(dir, filename) local display_name = filename or '*' local cmd = build_command(display_name, filename) - if M.config.background and has_vim_system then + if M.config.background and has_loop_spawn then run_async(cmd, dir, display_name) else run_sync(cmd, dir, display_name)