From 4837266671b71bc20f7299a9b2dd3e82090049e9 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Mon, 12 Oct 2026 18:51:39 -0400 Subject: [PATCH 01/12] fix(git): call back with nil when Git.async's fn raises A raise inside the coroutine re-raised from a scheduled callback, so on_done never ran and every waiter on a fork-point measure stayed pending. step now reports the error with vim.notify and calls on_done(nil), which every consumer already reads as no fork point. --- doc/changeset.nvim.txt | 3 ++- lua/changeset/git.lua | 7 ++++--- tests/git_spec.lua | 18 ++++++++++++------ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/doc/changeset.nvim.txt b/doc/changeset.nvim.txt index 9f34c0c..6bbad72 100644 --- a/doc/changeset.nvim.txt +++ b/doc/changeset.nvim.txt @@ -173,7 +173,8 @@ and changeset warns "no merge base with the default branch". Reopened on a tree kept from before, the sidebar shows it at once, then warns and closes once it measures the base again and finds none. A commit or rebase that leaves none keeps the tree as it was. Changeset measures the base without holding up -the editor, and the sidebar opens once it has. +the editor, and the sidebar opens once it has. A measure that fails with an +error is reported, then treated as finding no base. ============================================================================== 5. Commands *changeset-commands* diff --git a/lua/changeset/git.lua b/lua/changeset/git.lua index 5e95d88..8a22ef7 100644 --- a/lua/changeset/git.lua +++ b/lua/changeset/git.lua @@ -103,17 +103,18 @@ local function await(args) end ---Run `fn` with every git command `lines` and `is_ancestor` start inside it spawned without blocking Neovim, then ----call `on_done` on the main loop with what `fn` returns. +---call `on_done` on the main loop with what `fn` returns, or with nil after reporting the error `fn` raised. ---@generic T ---@param fn fun(): T ----@param on_done fun(result: T) +---@param on_done fun(result: T?) function M.async(fn, on_done) local co = coroutine.create(fn) async_threads[co] = true local function step(...) local ok, value = coroutine.resume(co, ...) if not ok then - error(debug.traceback(co, value)) + vim.notify("Changeset: " .. debug.traceback(co, value), vim.log.levels.ERROR) + return on_done(nil) end if coroutine.status(co) == "dead" then return on_done(value) diff --git a/tests/git_spec.lua b/tests/git_spec.lua index 7b22f72..dc97bc7 100644 --- a/tests/git_spec.lua +++ b/tests/git_spec.lua @@ -1,5 +1,6 @@ local Git = require("changeset.git") local Fixture = require("support.git") +local Notify = require("support.notify") local present = require("support.present") describe("changeset.git", function() @@ -284,14 +285,19 @@ describe("changeset.git", function() ) end) - it("raises an error raised inside fn, with its traceback", function() - local ok, err = pcall(Git.async, function() + it("reports an error fn raises after a git command, and calls back with nil", function() + local notes, restore = Notify.capture() + local result = run(function() + Git.lines({ "git", "branch", "--show-current" }, tmp) error("measure failed") - end, function() end) + end) + restore() - assert.is_false(ok) - ---@cast err string - assert.matches("measure failed.*stack traceback", err) + assert.is_nil(result) + assert.matches( + "^Changeset: .*measure failed.*stack traceback", + present(Notify.messages(notes, vim.log.levels.ERROR)[1]) + ) end) end) From ec73ada01d6ab89c5dd918f26e244cbe251bc9e4 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Mon, 12 Oct 2026 18:56:07 -0400 Subject: [PATCH 02/12] perf(git): spawn Git.async's commands through jobstart Each vim.system call blocks the main loop for about 2 ms idle and 8 ms under load; jobstart blocks for under a millisecond. Git.async now spawns through a local spawn() on jobstart, with stdout buffered and stdin closed. Git.system stays on vim.system for the diff, herdr and pr_target. The specs that hold or record async git now stub vim.fn.jobstart. --- lua/changeset/git.lua | 34 +++++++++++++++++++++++++++++++--- tests/fork_point_spec.lua | 31 ++++++++++++++----------------- tests/git_spec.lua | 22 ++++++++++++++++++++++ tests/support/spawns.lua | 4 ++-- tests/tree_lifecycle_spec.lua | 26 ++++++++++++++++++-------- 5 files changed, 87 insertions(+), 30 deletions(-) diff --git a/lua/changeset/git.lua b/lua/changeset/git.lua index 8a22ef7..5b07903 100644 --- a/lua/changeset/git.lua +++ b/lua/changeset/git.lua @@ -92,13 +92,41 @@ end ---@type table local async_threads = setmetatable({}, { __mode = "k" }) +---What a command `async` spawned exited with and printed. +---@class changeset.git.Spawned +---@field code integer +---@field stdout string + +---Start `argv` through `jobstart`, whose spawn blocks Neovim a fraction as long as `vim.system`'s. A failed spawn +---reaches `on_exit` as a failed result instead of raising. +---@param argv string[] +---@param on_exit fun(result: changeset.git.Spawned) +local function spawn(argv, on_exit) + local stdout = {} ---@type string[] + local ok, job = pcall(vim.fn.jobstart, argv, { + stdin = "null", + stdout_buffered = true, + on_stdout = function(_, data) + stdout = data + end, + on_exit = function(_, code) + on_exit({ code = code, stdout = table.concat(stdout, "\n") }) + end, + }) + if not ok or job <= 0 then + vim.schedule(function() + on_exit({ code = -1, stdout = "" }) + end) + end +end + ---In a coroutine `async` runs, `args`' result, waited for without blocking Neovim; nil elsewhere. ---@param args string[] ----@return vim.SystemCompleted? +---@return changeset.git.Spawned? local function await(args) local co = coroutine.running() if co and async_threads[co] then - return (coroutine.yield --[[@as fun(args: string[]): vim.SystemCompleted]])(args) + return (coroutine.yield --[[@as fun(args: string[]): changeset.git.Spawned]])(args) end end @@ -119,7 +147,7 @@ function M.async(fn, on_done) if coroutine.status(co) == "dead" then return on_done(value) end - M.system(value, { text = true }, step) + spawn(value, step) end step() end diff --git a/tests/fork_point_spec.lua b/tests/fork_point_spec.lua index 8427ddd..c25e7fa 100644 --- a/tests/fork_point_spec.lua +++ b/tests/fork_point_spec.lua @@ -329,13 +329,10 @@ describe("fork_point", function() measures = measures + 1 return real_get_async(...) end - local real_system, held = vim.system, {} - vim.system = function(argv, opts, on_exit) - if argv[1] == "gh" then - return real_system(argv, opts, on_exit) - end - held[#held + 1] = { argv, opts, on_exit } - return {} + local real_jobstart, held = vim.fn.jobstart, {} + vim.fn.jobstart = function(argv, opts) + held[#held + 1] = { argv, opts } + return 1 end local done = false fork_point.get_async(root, "feature", function() @@ -345,9 +342,9 @@ describe("fork_point", function() local answered = vim.wait(5000, function() return asks == 2 and #held > 1 end, 10) - vim.system = real_system + vim.fn.jobstart = real_jobstart for _, process in ipairs(held) do - real_system(unpack(process)) + real_jobstart(unpack(process)) end assert.is_true(answered) @@ -371,13 +368,13 @@ describe("fork_point", function() end) end get(root, "feature") - local real_system, held = vim.system, {} - vim.system = function(argv, opts, on_exit) - if argv[1] == "gh" or answered then - return real_system(argv, opts, on_exit) + local real_jobstart, held = vim.fn.jobstart, {} + vim.fn.jobstart = function(argv, opts) + if answered then + return real_jobstart(argv, opts) end - held[#held + 1] = { argv, opts, on_exit } - return {} + held[#held + 1] = { argv, opts } + return 1 end local point ---@type changeset.ForkPoint? fork_point.get_async(root, "feature", function(landed) @@ -386,9 +383,9 @@ describe("fork_point", function() local done = vim.wait(5000, function() return answered end, 10) - vim.system = real_system + vim.fn.jobstart = real_jobstart for _, process in ipairs(held) do - real_system(unpack(process)) + real_jobstart(unpack(process)) end assert.is_true(done) diff --git a/tests/git_spec.lua b/tests/git_spec.lua index dc97bc7..0c8d71a 100644 --- a/tests/git_spec.lua +++ b/tests/git_spec.lua @@ -249,6 +249,28 @@ describe("changeset.git", function() Fixture.init_repo("main", tmp) end) + it("spawns its git commands without vim.system, whose spawn blocks Neovim longer", function() + local real = vim.system + vim.system = function() + error("spawned through vim.system") + end + local lines = run(function() + return Git.lines({ "git", "branch", "--show-current" }, tmp) + end) + vim.system = real + + assert.same({ "main" }, lines) + end) + + it("reads a command that fails to spawn as no lines", function() + assert.same( + {}, + run(function() + return Git.lines({ "changeset-no-such-program" }) + end) + ) + end) + it("reads a failed command's lines as none", function() assert.same( {}, diff --git a/tests/support/spawns.lua b/tests/support/spawns.lua index 9039c5c..16f1a2f 100644 --- a/tests/support/spawns.lua +++ b/tests/support/spawns.lua @@ -24,12 +24,12 @@ local function spying(targets, fn) return argvs end ----Runs `fn`, keeping the argv of every process started through `vim.system` or `vim.fn.systemlist`. +---Runs `fn`, keeping the argv of every process started through `vim.system`, `vim.fn.jobstart` or `vim.fn.systemlist`. ---A wait belongs inside `fn`: `check-attr` starts from a scheduled callback, after `build()` returns. ---@param fn fun() ---@return string[][] function M.recording(fn) - return spying({ { vim, "system" }, { vim.fn, "systemlist" } }, fn) + return spying({ { vim, "system" }, { vim.fn, "jobstart" }, { vim.fn, "systemlist" } }, fn) end ---Runs `fn`, keeping the argv of every process started through `vim.fn.systemlist` or `vim.fn.system`, which block diff --git a/tests/tree_lifecycle_spec.lua b/tests/tree_lifecycle_spec.lua index 4bb7631..62fe6d3 100644 --- a/tests/tree_lifecycle_spec.lua +++ b/tests/tree_lifecycle_spec.lua @@ -334,17 +334,27 @@ describe("changeset tree", function() describe("reopened on a kept tree", function() local real_system ---@type function + local real_jobstart ---@type function local real_systemlist ---@type function local held ---@type table[] local waited - ---Hold every `vim.system` process unstarted, and count the git processes Neovim waits on. + ---Put back every spawner `hold` replaced. + local function unhold() + vim.system, vim.fn.jobstart, vim.fn.systemlist = real_system, real_jobstart, real_systemlist + end + + ---Hold every `vim.system` and `jobstart` process unstarted, and count the git processes Neovim waits on. local function hold() held, waited = {}, 0 vim.system = function(argv, opts, on_exit) - held[#held + 1] = { argv, opts, on_exit } + held[#held + 1] = { real_system, argv, opts, on_exit } return {} end + vim.fn.jobstart = function(argv, opts) + held[#held + 1] = { real_jobstart, argv, opts } + return 1 + end vim.fn.systemlist = function(...) waited = waited + 1 return real_systemlist(...) @@ -353,21 +363,21 @@ describe("changeset tree", function() ---Start every held process, now. local function release() - vim.system, vim.fn.systemlist = real_system, real_systemlist + unhold() for _, process in ipairs(held) do - real_system(unpack(process)) + process[1](unpack(process, 2)) end held = {} end before_each(function() - real_system, real_systemlist = vim.system, vim.fn.systemlist + real_system, real_jobstart, real_systemlist = vim.system, vim.fn.jobstart, vim.fn.systemlist Build.build() assert.is_true(wait_for_file("mod.lua")) end) after_each(function() - vim.system, vim.fn.systemlist = real_system, real_systemlist + unhold() end) it("draws the kept tree before waiting on git", function() @@ -428,7 +438,7 @@ describe("changeset tree", function() it("builds for the branch HEAD moved to before the re-measure landed", function() hold() changeset.open() - vim.system, vim.fn.systemlist = real_system, real_systemlist + unhold() Fixture.git({ "checkout", "-q", "-b", "feature2" }, tmp) release() @@ -441,7 +451,7 @@ describe("changeset tree", function() it("keeps the tree a build for another branch made before the re-measure landed", function() hold() changeset.open() - vim.system, vim.fn.systemlist = real_system, real_systemlist + unhold() Fixture.git({ "checkout", "-q", "-b", "feature2" }, tmp) Build.build() local tree = present(build.current()) From 06c9648fe0d13388c9b9e47b7486b44ed22c9830 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Mon, 12 Oct 2026 19:06:15 -0400 Subject: [PATCH 03/12] docs(git): record Git.async's main-loop precondition - Git.async must start on the main loop: in a fast event jobstart can't spawn, and the command reads as failed - spawn calls on_exit on the main loop; Spawned's code is -1 when the command could not start - fork_point.get_async hands nil when measuring raised an error - the vimdoc says only an error inside changeset is reported, not a failing git command --- doc/changeset.nvim.txt | 4 ++-- lua/changeset/fork_point.lua | 5 +++-- lua/changeset/git.lua | 11 ++++++----- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/doc/changeset.nvim.txt b/doc/changeset.nvim.txt index 6bbad72..e02457a 100644 --- a/doc/changeset.nvim.txt +++ b/doc/changeset.nvim.txt @@ -173,8 +173,8 @@ and changeset warns "no merge base with the default branch". Reopened on a tree kept from before, the sidebar shows it at once, then warns and closes once it measures the base again and finds none. A commit or rebase that leaves none keeps the tree as it was. Changeset measures the base without holding up -the editor, and the sidebar opens once it has. A measure that fails with an -error is reported, then treated as finding no base. +the editor, and the sidebar opens once it has. An error inside changeset while +it measures the base is reported, then treated as finding no base. ============================================================================== 5. Commands *changeset-commands* diff --git a/lua/changeset/fork_point.lua b/lua/changeset/fork_point.lua index fb370eb..af4d2ef 100644 --- a/lua/changeset/fork_point.lua +++ b/lua/changeset/fork_point.lua @@ -242,8 +242,9 @@ local function measure_async(root, branch, settle, on_done) end ---The fork point of `branch` at `root`, measured without blocking Neovim and handed to `on_done` on the main loop, nil ----when HEAD shares none with the branch it was created from or the default branch. Asks gh about its PR unless gh has ----named a target or is being asked. Subscribers hear the point after `on_done` does. +---when HEAD shares none with the branch it was created from or the default branch, or when measuring it raised an +---error, which is reported. Asks gh about its PR unless gh has named a target or is being asked. +---Subscribers hear the point after `on_done` does. ---@param root string ---@param branch string ---@param on_done fun(point: changeset.ForkPoint?) diff --git a/lua/changeset/git.lua b/lua/changeset/git.lua index 5b07903..e9e3ed3 100644 --- a/lua/changeset/git.lua +++ b/lua/changeset/git.lua @@ -1,5 +1,5 @@ ---The git and gh queries that pick the branch changeset diffs against, HEAD read off git's own files, and the process ----runner they and herdr share. +---runner gh's query, the diff and herdr share. local M = {} ---`vim.system`, calling `on_exit` on the main loop. A failed spawn — a repository removed under a pending @@ -94,11 +94,11 @@ local async_threads = setmetatable({}, { __mode = "k" }) ---What a command `async` spawned exited with and printed. ---@class changeset.git.Spawned ----@field code integer +---@field code integer -1 when it could not start. ---@field stdout string ----Start `argv` through `jobstart`, whose spawn blocks Neovim a fraction as long as `vim.system`'s. A failed spawn ----reaches `on_exit` as a failed result instead of raising. +---Start `argv` through `jobstart`, whose spawn blocks Neovim a fraction as long as `vim.system`'s, calling `on_exit` +---on the main loop. A failed spawn reaches `on_exit` as a failed result instead of raising. ---@param argv string[] ---@param on_exit fun(result: changeset.git.Spawned) local function spawn(argv, on_exit) @@ -131,7 +131,8 @@ local function await(args) end ---Run `fn` with every git command `lines` and `is_ancestor` start inside it spawned without blocking Neovim, then ----call `on_done` on the main loop with what `fn` returns, or with nil after reporting the error `fn` raised. +---call `on_done` on the main loop with what `fn` returns, or with nil after reporting the error `fn` raised. Start it +---on the main loop: in a fast event `jobstart` can't spawn, and the command reads as failed. ---@generic T ---@param fn fun(): T ---@param on_done fun(result: T?) From 1a04505827bb23ef6518b28c8b516acdf7b81a89 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Mon, 12 Oct 2026 19:10:47 -0400 Subject: [PATCH 04/12] docs(fork_point): reflow get_async's docstring --- lua/changeset/fork_point.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/changeset/fork_point.lua b/lua/changeset/fork_point.lua index af4d2ef..d748bbd 100644 --- a/lua/changeset/fork_point.lua +++ b/lua/changeset/fork_point.lua @@ -243,8 +243,8 @@ end ---The fork point of `branch` at `root`, measured without blocking Neovim and handed to `on_done` on the main loop, nil ---when HEAD shares none with the branch it was created from or the default branch, or when measuring it raised an ----error, which is reported. Asks gh about its PR unless gh has named a target or is being asked. ----Subscribers hear the point after `on_done` does. +---error, which is reported. Asks gh about its PR unless gh has named a target or is being asked. Subscribers hear the +---point after `on_done` does. ---@param root string ---@param branch string ---@param on_done fun(point: changeset.ForkPoint?) From 33f37b4c653febd142dc2c3e16e5c8c2a60ecd3a Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Mon, 12 Oct 2026 19:11:31 -0400 Subject: [PATCH 05/12] refactor(git): name spawn's stdout list and the held calls - spawn's buffered line list is `lines`, so the result's `stdout` field is the only `stdout` - tree_lifecycle_spec's release loop walks held `call`s, which are a spawner and its arguments, not processes --- lua/changeset/git.lua | 6 +++--- tests/tree_lifecycle_spec.lua | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lua/changeset/git.lua b/lua/changeset/git.lua index e9e3ed3..561c1e1 100644 --- a/lua/changeset/git.lua +++ b/lua/changeset/git.lua @@ -102,15 +102,15 @@ local async_threads = setmetatable({}, { __mode = "k" }) ---@param argv string[] ---@param on_exit fun(result: changeset.git.Spawned) local function spawn(argv, on_exit) - local stdout = {} ---@type string[] + local lines = {} ---@type string[] local ok, job = pcall(vim.fn.jobstart, argv, { stdin = "null", stdout_buffered = true, on_stdout = function(_, data) - stdout = data + lines = data end, on_exit = function(_, code) - on_exit({ code = code, stdout = table.concat(stdout, "\n") }) + on_exit({ code = code, stdout = table.concat(lines, "\n") }) end, }) if not ok or job <= 0 then diff --git a/tests/tree_lifecycle_spec.lua b/tests/tree_lifecycle_spec.lua index 62fe6d3..0cd5b13 100644 --- a/tests/tree_lifecycle_spec.lua +++ b/tests/tree_lifecycle_spec.lua @@ -364,8 +364,8 @@ describe("changeset tree", function() ---Start every held process, now. local function release() unhold() - for _, process in ipairs(held) do - process[1](unpack(process, 2)) + for _, call in ipairs(held) do + call[1](unpack(call, 2)) end held = {} end From 0226daf9dcb65a1a2c99def6a85ffc9b0def7eb2 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Mon, 12 Oct 2026 19:51:28 -0400 Subject: [PATCH 06/12] fix(git): call back on any error value Git.async's fn raises debug.traceback hands a table error back unchanged, so the notify's concatenation raised and stranded every waiter again. The value goes through tostring first. git_spec's async specs now restore vim.system and vim.notify in an after_each, so a run that times out can't leak them, and the vim.system check counts calls instead of raising inside a pcall. --- lua/changeset/git.lua | 2 +- tests/git_spec.lua | 29 +++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/lua/changeset/git.lua b/lua/changeset/git.lua index 561c1e1..bbe8a53 100644 --- a/lua/changeset/git.lua +++ b/lua/changeset/git.lua @@ -142,7 +142,7 @@ function M.async(fn, on_done) local function step(...) local ok, value = coroutine.resume(co, ...) if not ok then - vim.notify("Changeset: " .. debug.traceback(co, value), vim.log.levels.ERROR) + vim.notify("Changeset: " .. debug.traceback(co, tostring(value)), vim.log.levels.ERROR) return on_done(nil) end if coroutine.status(co) == "dead" then diff --git a/tests/git_spec.lua b/tests/git_spec.lua index 0c8d71a..5411a3d 100644 --- a/tests/git_spec.lua +++ b/tests/git_spec.lua @@ -245,21 +245,29 @@ describe("changeset.git", function() return result end + local real_system, real_notify = vim.system, vim.notify + before_each(function() Fixture.init_repo("main", tmp) end) + -- A failing `run` raises before a spec could restore its stubs. + after_each(function() + vim.system, vim.notify = real_system, real_notify + end) + it("spawns its git commands without vim.system, whose spawn blocks Neovim longer", function() - local real = vim.system - vim.system = function() - error("spawned through vim.system") + local calls = 0 + vim.system = function(...) + calls = calls + 1 + return real_system(...) end local lines = run(function() return Git.lines({ "git", "branch", "--show-current" }, tmp) end) - vim.system = real assert.same({ "main" }, lines) + assert.equals(0, calls) end) it("reads a command that fails to spawn as no lines", function() @@ -308,12 +316,11 @@ describe("changeset.git", function() end) it("reports an error fn raises after a git command, and calls back with nil", function() - local notes, restore = Notify.capture() + local notes = Notify.capture() local result = run(function() Git.lines({ "git", "branch", "--show-current" }, tmp) error("measure failed") end) - restore() assert.is_nil(result) assert.matches( @@ -321,6 +328,16 @@ describe("changeset.git", function() present(Notify.messages(notes, vim.log.levels.ERROR)[1]) ) end) + it("reports an error value fn raises that is not a string, and calls back with nil", function() + local notes = Notify.capture() + local result = run(function() + Git.lines({ "git", "branch", "--show-current" }, tmp) + error({ code = 1 }) + end) + + assert.is_nil(result) + assert.matches("^Changeset: table: .*stack traceback", present(Notify.messages(notes, vim.log.levels.ERROR)[1])) + end) end) describe("system", function() From cbf628333dbbf41fb8d73ad8d83619a515ef0ac6 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Mon, 12 Oct 2026 20:02:03 -0400 Subject: [PATCH 07/12] perf(git): start every process through jobstart Git.system now runs on jobstart, whose spawn blocks the main loop a fraction as long as vim.system's, and Git.async's private spawn folds into it, so the diff refresh, herdr and the gh query stop paying vim.system's stall too. - output comes back byte-exact: jobstart's line lists carry NUL as a newline inside an item, so it is turned back before joining; CRLF reads as LF, as vim.system's text mode did - stdin goes in through chansend; a timeout stops the job and reports 124; a failed start reports -1 with the reason - the result type drops signal and makes stdout/stderr strings, so the callers' `or ""` guards go - the specs hold Git.system calls through one spawns.holding helper, as closures, instead of three jobstart stubs returning a fake job id --- doc/agents/herdr.md | 2 +- lua/changeset/diff.lua | 24 +++--- lua/changeset/git.lua | 133 ++++++++++++++++++++-------------- lua/changeset/herdr.lua | 6 +- tests/diff_spec.lua | 14 ++-- tests/fork_point_spec.lua | 31 +++----- tests/git_spec.lua | 74 ++++++++++++++----- tests/herdr_spec.lua | 6 +- tests/support/spawns.lua | 38 ++++++++++ tests/tree_lifecycle_spec.lua | 31 +++----- 10 files changed, 222 insertions(+), 137 deletions(-) diff --git a/doc/agents/herdr.md b/doc/agents/herdr.md index ff214bd..104885e 100644 --- a/doc/agents/herdr.md +++ b/doc/agents/herdr.md @@ -7,7 +7,7 @@ JSON it reads, and what each one can surprise you with. ## Calls -Each call runs `herdr` from PATH through `vim.system` with a 5-second timeout. A spawn that +Each call runs `herdr` from PATH through `Git.system`, on `jobstart`, with a 5-second timeout. A spawn that fails, the binary gone since the check or an argv over the OS limit, counts as a refused call. diff --git a/lua/changeset/diff.lua b/lua/changeset/diff.lua index a4b97e3..ecd9640 100644 --- a/lua/changeset/diff.lua +++ b/lua/changeset/diff.lua @@ -300,11 +300,11 @@ end ---Run every command concurrently and hand all results to `on_done` on the main loop. ---@param commands table ---@param cwd string ----@param on_done fun(results: table) +---@param on_done fun(results: table) local function run_all(commands, cwd, on_done) local results, pending = {}, vim.tbl_count(commands) for name, argv in pairs(commands) do - Git.system(argv, { cwd = cwd, text = true }, function(result) + Git.system(argv, { cwd = cwd }, function(result) results[name] = result pending = pending - 1 if pending == 0 then @@ -314,20 +314,20 @@ local function run_all(commands, cwd, on_done) end end ----@param results table +---@param results table ---@return string? stderr Of a failed command, if any. local function first_failure(results) for _, result in pairs(results) do if result.code ~= 0 then - return vim.trim(result.stderr or "") + return vim.trim(result.stderr) end end end ----@param result vim.SystemCompleted +---@param result changeset.git.Completed ---@return string[] local function stdout_lines(result) - return vim.split(result.stdout or "", "\n", { trimempty = true }) + return vim.split(result.stdout, "\n", { trimempty = true }) end ---Line count and content digest per readable file; directories (untracked nested repos) and dangling symlinks are skipped. @@ -374,10 +374,10 @@ local function generated_paths(files, cwd, on_done) end, files) Git.system( { "git", "check-attr", "-z", "--stdin", "linguist-generated" }, - { cwd = cwd, text = true, stdin = table.concat(paths, "\0") }, + { cwd = cwd, stdin = table.concat(paths, "\0") }, function(result) -- A failed read only costs files their Generated section. - local marked = result.code == 0 and M._parse_check_attr(result.stdout or "") or {} + local marked = result.code == 0 and M._parse_check_attr(result.stdout) or {} for _, file in ipairs(files) do if file.status ~= "deleted" @@ -406,10 +406,10 @@ function M.collect(base, cwd, callback) end local hunks, digests = M._parse_hunks(stdout_lines(results.hunks)) local untracked, untracked_digests = - count_lines(vim.split(results.untracked.stdout or "", "\0", { trimempty = true }), cwd) + count_lines(vim.split(results.untracked.stdout, "\0", { trimempty = true }), cwd) local files = M._assemble({ - numstat = M._parse_numstat(results.numstat.stdout or ""), - statuses = M._parse_name_status(results.name_status.stdout or ""), + numstat = M._parse_numstat(results.numstat.stdout), + statuses = M._parse_name_status(results.name_status.stdout), hunks = hunks, untracked = untracked, -- A path is both deleted and untracked after `git rm --cached`; the untracked digest wins so both rows share @@ -431,7 +431,7 @@ end ---@param cwd string ---@param callback fun(text: string?) function M.blob(object, cwd, callback) - Git.system({ "git", "cat-file", "blob", object }, { cwd = cwd, text = true }, function(result) + Git.system({ "git", "cat-file", "blob", object }, { cwd = cwd }, function(result) callback(result.code == 0 and result.stdout or nil) end) end diff --git a/lua/changeset/git.lua b/lua/changeset/git.lua index bbe8a53..4515b55 100644 --- a/lua/changeset/git.lua +++ b/lua/changeset/git.lua @@ -1,20 +1,79 @@ ---The git and gh queries that pick the branch changeset diffs against, HEAD read off git's own files, and the process ----runner gh's query, the diff and herdr share. +---runner that starts every process changeset runs, async git included. local M = {} ----`vim.system`, calling `on_exit` on the main loop. A failed spawn — a repository removed under a pending ----refresh, a binary gone since its executable check, an argv over the OS's limit — reaches `on_exit` as a failed ----result instead of raising. +---What `system` hands `argv` and how long it lets it run. +---@class changeset.git.SystemOpts +---@field cwd string? +---@field stdin string? +---@field timeout integer? Milliseconds. + +---What a command `system` started exited with and printed. +---@class changeset.git.Completed +---@field code integer -1 when it could not start; 124 when it ran out of time. +---@field stdout string +---@field stderr string + +---A buffered `jobstart` stream's bytes: its list carries each NUL as "\n" inside an item. CRLF reads as LF. +---@param data string[] +---@return string +local function joined(data) + return ( + table + .concat( + vim.tbl_map(function(item) + return (item:gsub("\n", "\0")) + end, data), + "\n" + ) + :gsub("\r\n", "\n") + ) +end + +---Start `argv` through `jobstart`, whose spawn blocks Neovim a fraction as long as `vim.system`'s, calling +---`on_result` on the main loop. Start it on the main loop: in a fast event `jobstart` can't spawn, and the command +---reads as failed. A failed spawn — a repository removed under a pending refresh, a binary gone since its executable +---check, an argv over the OS's limit — reaches `on_result` as a failed result instead of raising. ---@param argv string[] ----@param opts vim.SystemOpts ----@param on_exit fun(result: vim.SystemCompleted) -function M.system(argv, opts, on_exit) - local ok, err = pcall(vim.system, argv, opts, vim.schedule_wrap(on_exit)) - if not ok then - local result = { code = -1, signal = 0, stdout = "", stderr = tostring(err) } +---@param opts changeset.git.SystemOpts +---@param on_result fun(result: changeset.git.Completed) +function M.system(argv, opts, on_result) + local stdout, stderr = {}, {} ---@type string[], string[] + local exited, timed_out = false, false + local ok, job = pcall(vim.fn.jobstart, argv, { + cwd = opts.cwd, + stdin = opts.stdin and "pipe" or "null", + stdout_buffered = true, + stderr_buffered = true, + on_stdout = function(_, data) + stdout = data + end, + on_stderr = function(_, data) + stderr = data + end, + on_exit = function(_, code) + exited = true + on_result({ code = timed_out and 124 or code, stdout = joined(stdout), stderr = joined(stderr) }) + end, + }) + if not ok or job <= 0 then + local reason = ok and ("could not start " .. argv[1]) or tostring(job) vim.schedule(function() - on_exit(result) + on_result({ code = -1, stdout = "", stderr = reason }) end) + return + end + if opts.stdin then + vim.fn.chansend(job, opts.stdin) + vim.fn.chanclose(job, "stdin") + end + if opts.timeout then + vim.defer_fn(function() + if not exited then + timed_out = true + vim.fn.jobstop(job) + end + end, opts.timeout) end end @@ -92,41 +151,13 @@ end ---@type table local async_threads = setmetatable({}, { __mode = "k" }) ----What a command `async` spawned exited with and printed. ----@class changeset.git.Spawned ----@field code integer -1 when it could not start. ----@field stdout string - ----Start `argv` through `jobstart`, whose spawn blocks Neovim a fraction as long as `vim.system`'s, calling `on_exit` ----on the main loop. A failed spawn reaches `on_exit` as a failed result instead of raising. ----@param argv string[] ----@param on_exit fun(result: changeset.git.Spawned) -local function spawn(argv, on_exit) - local lines = {} ---@type string[] - local ok, job = pcall(vim.fn.jobstart, argv, { - stdin = "null", - stdout_buffered = true, - on_stdout = function(_, data) - lines = data - end, - on_exit = function(_, code) - on_exit({ code = code, stdout = table.concat(lines, "\n") }) - end, - }) - if not ok or job <= 0 then - vim.schedule(function() - on_exit({ code = -1, stdout = "" }) - end) - end -end - ---In a coroutine `async` runs, `args`' result, waited for without blocking Neovim; nil elsewhere. ---@param args string[] ----@return changeset.git.Spawned? +---@return changeset.git.Completed? local function await(args) local co = coroutine.running() if co and async_threads[co] then - return (coroutine.yield --[[@as fun(args: string[]): changeset.git.Spawned]])(args) + return (coroutine.yield --[[@as fun(args: string[]): changeset.git.Completed]])(args) end end @@ -148,7 +179,7 @@ function M.async(fn, on_done) if coroutine.status(co) == "dead" then return on_done(value) end - spawn(value, step) + M.system(value, {}, step) end step() end @@ -167,7 +198,7 @@ function M.lines(args, cwd) if result.code ~= 0 then return {} end - local out = vim.split(result.stdout or "", "\n", { plain = true }) + local out = vim.split(result.stdout, "\n", { plain = true }) if out[#out] == "" then out[#out] = nil end @@ -317,16 +348,12 @@ function M.pr_target(cwd, cb) cb(nil, nil, true) end) end - M.system( - { "gh", "pr", "view", "--json", "baseRefName,number,state" }, - { cwd = cwd, text = true, timeout = 5000 }, - function(res) - local ok, pr = pcall(vim.json.decode, res.stdout or "") - local answered = res.code == 0 and ok and type(pr) == "table" - local open = answered and pr.state == "OPEN" - cb(open and pr.baseRefName or nil, open and pr.number or nil, not answered) - end - ) + M.system({ "gh", "pr", "view", "--json", "baseRefName,number,state" }, { cwd = cwd, timeout = 5000 }, function(res) + local ok, pr = pcall(vim.json.decode, res.stdout) + local answered = res.code == 0 and ok and type(pr) == "table" + local open = answered and pr.state == "OPEN" + cb(open and pr.baseRefName or nil, open and pr.number or nil, not answered) + end) end return M diff --git a/lua/changeset/herdr.lua b/lua/changeset/herdr.lua index dfe49e3..5096df2 100644 --- a/lua/changeset/herdr.lua +++ b/lua/changeset/herdr.lua @@ -186,13 +186,13 @@ end ---@param args string[] ---@param cb fun(result: table?, code: string?) local function herdr(args, cb) - git.system(vim.list_extend({ "herdr" }, args), { text = true, timeout = TIMEOUT }, function(res) + git.system(vim.list_extend({ "herdr" }, args), { timeout = TIMEOUT }, function(res) if res.code ~= 0 then - local ok, env = pcall(vim.json.decode, res.stderr or "") + local ok, env = pcall(vim.json.decode, res.stderr) cb(nil, ok and type(env) == "table" and type(env.error) == "table" and env.error.code or "") return end - local ok, out = pcall(vim.json.decode, res.stdout or "", { luanil = { object = true, array = true } }) + local ok, out = pcall(vim.json.decode, res.stdout, { luanil = { object = true, array = true } }) cb(ok and type(out) == "table" and out.result or {}, nil) end) end diff --git a/tests/diff_spec.lua b/tests/diff_spec.lua index 09c5726..6889727 100644 --- a/tests/diff_spec.lua +++ b/tests/diff_spec.lua @@ -797,21 +797,23 @@ describe("changeset.diff.collect", function() it("calls back when the repository vanishes before the generated-file read", function() local base = seed_edited_file(tmp) - local system = vim.system + local jobstart = vim.fn.jobstart local check_attr_ran = false - vim.system = function(argv, ...) - if vim.tbl_contains(argv, "check-attr") then + vim.fn.jobstart = function(argv, ...) + if + vim.tbl_contains(argv --[[@as string[] ]], "check-attr") + then check_attr_ran = true vim.fn.delete(tmp, "rf") end - return system(argv, ...) + return jobstart(argv, ...) end local files ---@type changeset.File[]? local err local ok, raised = pcall(function() files, err = await_collect(base, tmp) end) - vim.system = system + vim.fn.jobstart = jobstart ---@cast raised string? assert.is_true(ok, raised) assert.is_true(check_attr_ran) @@ -822,7 +824,7 @@ describe("changeset.diff.collect", function() end) it("reports a missing repository as an error", function() - assert.matches("ENOENT", present((select(2, await_collect("HEAD", tmp .. "/gone"))))) + assert.matches("expected valid directory", present((select(2, await_collect("HEAD", tmp .. "/gone"))))) end) end) diff --git a/tests/fork_point_spec.lua b/tests/fork_point_spec.lua index c25e7fa..167ad06 100644 --- a/tests/fork_point_spec.lua +++ b/tests/fork_point_spec.lua @@ -329,23 +329,18 @@ describe("fork_point", function() measures = measures + 1 return real_get_async(...) end - local real_jobstart, held = vim.fn.jobstart, {} - vim.fn.jobstart = function(argv, opts) - held[#held + 1] = { argv, opts } - return 1 - end + local held = spawns.holding(function(argv) + return spawns.runs(argv, "gh") + end) local done = false fork_point.get_async(root, "feature", function() done = true end) fork_point.recheck(root, "feature") local answered = vim.wait(5000, function() - return asks == 2 and #held > 1 + return asks == 2 and #held.calls > 1 end, 10) - vim.fn.jobstart = real_jobstart - for _, process in ipairs(held) do - real_jobstart(unpack(process)) - end + held.release() assert.is_true(answered) assert.is_true(vim.wait(5000, function() @@ -368,14 +363,9 @@ describe("fork_point", function() end) end get(root, "feature") - local real_jobstart, held = vim.fn.jobstart, {} - vim.fn.jobstart = function(argv, opts) - if answered then - return real_jobstart(argv, opts) - end - held[#held + 1] = { argv, opts } - return 1 - end + local held = spawns.holding(function(argv) + return answered or spawns.runs(argv, "gh") + end) local point ---@type changeset.ForkPoint? fork_point.get_async(root, "feature", function(landed) point = landed @@ -383,10 +373,7 @@ describe("fork_point", function() local done = vim.wait(5000, function() return answered end, 10) - vim.fn.jobstart = real_jobstart - for _, process in ipairs(held) do - real_jobstart(unpack(process)) - end + held.release() assert.is_true(done) assert.is_true(vim.wait(5000, function() diff --git a/tests/git_spec.lua b/tests/git_spec.lua index 5411a3d..5415dba 100644 --- a/tests/git_spec.lua +++ b/tests/git_spec.lua @@ -267,7 +267,7 @@ describe("changeset.git", function() end) assert.same({ "main" }, lines) - assert.equals(0, calls) + assert.equal(0, calls) end) it("reads a command that fails to spawn as no lines", function() @@ -328,6 +328,7 @@ describe("changeset.git", function() present(Notify.messages(notes, vim.log.levels.ERROR)[1]) ) end) + it("reports an error value fn raises that is not a string, and calls back with nil", function() local notes = Notify.capture() local result = run(function() @@ -341,17 +342,32 @@ describe("changeset.git", function() end) describe("system", function() - it("hands a failed spawn to on_exit on the main loop as a failed result", function() - local real = vim.system - vim.system = function() + ---Run `argv` through `Git.system`, waiting for its result. + ---@param argv string[] + ---@param opts changeset.git.SystemOpts + ---@return changeset.git.Completed + local function run(argv, opts) + local got ---@type changeset.git.Completed? + Git.system(argv, opts, function(result) + got = result + end) + assert.is_true(vim.wait(5000, function() + return got ~= nil + end, 10)) + return present(got) + end + + it("hands a failed spawn to its callback on the main loop as a failed result", function() + local real = vim.fn.jobstart + vim.fn.jobstart = function() error("E2BIG") end - local got ---@type vim.SystemCompleted? + local got ---@type changeset.git.Completed? local fast Git.system({ "git", "status" }, {}, function(result) got, fast = result, vim.in_fast_event() end) - vim.system = real + vim.fn.jobstart = real assert.is_nil(got) vim.wait(1000, function() return got ~= nil @@ -362,10 +378,10 @@ describe("changeset.git", function() assert.is_false(fast) end) - it("calls on_exit on the main loop with the process's result", function() - local got ---@type vim.SystemCompleted? + it("calls back on the main loop with the process's result", function() + local got ---@type changeset.git.Completed? local fast - Git.system({ "git", "--version" }, { text = true }, function(result) + Git.system({ "git", "--version" }, {}, function(result) got, fast = result, vim.in_fast_event() end) vim.wait(5000, function() @@ -376,13 +392,37 @@ describe("changeset.git", function() assert.matches("^git version", present(result.stdout)) assert.is_false(fast) end) + + it("keeps NUL bytes in stdout", function() + assert.equal("a\0b\n", run({ "printf", "a\\0b\\n" }, {}).stdout) + end) + + it("reads CRLF output as LF", function() + assert.equal("a\nb\n", run({ "printf", "a\\r\\nb\\r\\n" }, {}).stdout) + end) + + it("writes stdin to the process with its NUL bytes", function() + assert.equal("x\0y\nz", run({ "cat" }, { stdin = "x\0y\nz" }).stdout) + end) + + it("captures a failing command's stderr", function() + local result = run({ "sh", "-c", "echo oops >&2; exit 3" }, {}) + assert.equal(3, result.code) + assert.equal("oops\n", result.stderr) + end) + + it("stops a command that outlives its timeout and reports code 124", function() + local start = vim.uv.now() + assert.equal(124, run({ "sleep", "5" }, { timeout = 50 }).code) + assert.is_true(vim.uv.now() - start < 2000) + end) end) describe("pr_target", function() it("answers no PR when gh can't be started", function() require("support.gh") - local real = vim.system - vim.system = function() + local real = vim.fn.jobstart + vim.fn.jobstart = function() error("E2BIG") end local answered = false @@ -390,7 +430,7 @@ describe("changeset.git", function() local ok = pcall(Git.pr_target, tmp, function(t) answered, target = true, t end) - vim.system = real + vim.fn.jobstart = real assert.is_true(ok) vim.wait(1000, function() return answered @@ -486,23 +526,23 @@ describe("changeset.git", function() end) describe("detached", function() - local real_systemlist, real_system, spawned + local real_systemlist, real_jobstart, spawned before_each(function() spawned = 0 - real_systemlist, real_system = vim.fn.systemlist, vim.system + real_systemlist, real_jobstart = vim.fn.systemlist, vim.fn.jobstart vim.fn.systemlist = function(...) spawned = spawned + 1 return real_systemlist(...) end - vim.system = function(...) + vim.fn.jobstart = function(...) spawned = spawned + 1 - return real_system(...) + return real_jobstart(...) end end) after_each(function() - vim.fn.systemlist, vim.system = real_systemlist, real_system + vim.fn.systemlist, vim.fn.jobstart = real_systemlist, real_jobstart end) it("is false on a branch", function() diff --git a/tests/herdr_spec.lua b/tests/herdr_spec.lua index a65e18e..76dacdb 100644 --- a/tests/herdr_spec.lua +++ b/tests/herdr_spec.lua @@ -294,12 +294,12 @@ describe("changeset.herdr", function() end) it("refuses when herdr can't be started", function() - local system = vim.system - vim.system = function() + local jobstart = vim.fn.jobstart + vim.fn.jobstart = function() error("E2BIG") end local err, _, called = send("hi") - vim.system = system + vim.fn.jobstart = jobstart assert.is_true(called) assert.truthy(err) end) diff --git a/tests/support/spawns.lua b/tests/support/spawns.lua index 16f1a2f..c66f634 100644 --- a/tests/support/spawns.lua +++ b/tests/support/spawns.lua @@ -40,6 +40,44 @@ function M.blocking(fn) return spying({ { vim.fn, "system" }, { vim.fn, "systemlist" } }, fn) end +---The calls `holding` keeps unstarted, and what can be done with them. +---@class changeset.test.Held +---@field calls fun()[] Each starts its held call. +---@field stop fun() Stops holding, starting nothing; safe to call twice. +---@field release fun() Stops holding, then starts every held call in the order it was held. + +---Holds every call to `Git.system` unstarted from now on, letting through those `pass` accepts. +---@param pass (fun(argv: string[]): boolean)? +---@return changeset.test.Held +function M.holding(pass) + local Git = require("changeset.git") + local real = Git.system + local held ---@type changeset.test.Held + held = { + calls = {}, + stop = function() + Git.system = real + end, + release = function() + held.stop() + local calls = held.calls + held.calls = {} + for _, call in ipairs(calls) do + call() + end + end, + } + Git.system = function(argv, opts, on_result) + if pass and pass(argv) then + return real(argv, opts, on_result) + end + table.insert(held.calls, function() + real(argv, opts, on_result) + end) + end + return held +end + ---Whether `argv` starts `program` with `subcommand`, as `git -C merge-base …` starts git's merge-base. ---@param argv string[] ---@param program string diff --git a/tests/tree_lifecycle_spec.lua b/tests/tree_lifecycle_spec.lua index 0cd5b13..59b436f 100644 --- a/tests/tree_lifecycle_spec.lua +++ b/tests/tree_lifecycle_spec.lua @@ -8,6 +8,7 @@ local Notify = require("support.notify") local Sidebar = require("support.sidebar") local gh = require("support.gh") local present = require("support.present") +local spawns = require("support.spawns") ---@param tree changeset.Tree? ---@return string[] @@ -333,28 +334,21 @@ describe("changeset tree", function() end) describe("reopened on a kept tree", function() - local real_system ---@type function - local real_jobstart ---@type function local real_systemlist ---@type function - local held ---@type table[] + local held ---@type changeset.test.Held? local waited ---Put back every spawner `hold` replaced. local function unhold() - vim.system, vim.fn.jobstart, vim.fn.systemlist = real_system, real_jobstart, real_systemlist + vim.fn.systemlist = real_systemlist + if held then + held.stop() + end end - ---Hold every `vim.system` and `jobstart` process unstarted, and count the git processes Neovim waits on. + ---Hold every `Git.system` process unstarted, and count the git processes Neovim waits on. local function hold() - held, waited = {}, 0 - vim.system = function(argv, opts, on_exit) - held[#held + 1] = { real_system, argv, opts, on_exit } - return {} - end - vim.fn.jobstart = function(argv, opts) - held[#held + 1] = { real_jobstart, argv, opts } - return 1 - end + held, waited = spawns.holding(), 0 vim.fn.systemlist = function(...) waited = waited + 1 return real_systemlist(...) @@ -363,15 +357,12 @@ describe("changeset tree", function() ---Start every held process, now. local function release() - unhold() - for _, call in ipairs(held) do - call[1](unpack(call, 2)) - end - held = {} + vim.fn.systemlist = real_systemlist + present(held).release() end before_each(function() - real_system, real_jobstart, real_systemlist = vim.system, vim.fn.jobstart, vim.fn.systemlist + real_systemlist = vim.fn.systemlist Build.build() assert.is_true(wait_for_file("mod.lua")) end) From 3adfba88e2aed9acec4c90b30a252eaf01349e5a Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Mon, 12 Oct 2026 20:18:36 -0400 Subject: [PATCH 08/12] fix(git): read a timeout off jobstop, and join output cheaply - a command exiting just as its timeout fires reported 124; the timer now marks a timeout only when jobstop finds the job still running - joined rewrites only the lines holding a NUL and only CRLF output, so a megabyte of diff no longer costs more than vim.system did - docs: the runner's summary leaves out the blocking sync fallback, stdin/stderr say what they carry, and CRLF says who needs it - specs: the vim.system guard moves to describe("system"), stubs sit at Git.system, notify is captured per block, and the held calls' class is support.spawns.Held --- doc/agents/herdr.md | 6 ++-- lua/changeset/git.lua | 36 +++++++++----------- tests/diff_spec.lua | 16 ++++----- tests/git_spec.lua | 62 ++++++++++++++++++++++++----------- tests/support/spawns.lua | 9 ++--- tests/tree_lifecycle_spec.lua | 6 ++-- 6 files changed, 77 insertions(+), 58 deletions(-) diff --git a/doc/agents/herdr.md b/doc/agents/herdr.md index 104885e..a4f461a 100644 --- a/doc/agents/herdr.md +++ b/doc/agents/herdr.md @@ -7,9 +7,9 @@ JSON it reads, and what each one can surprise you with. ## Calls -Each call runs `herdr` from PATH through `Git.system`, on `jobstart`, with a 5-second timeout. A spawn that -fails, the binary gone since the check or an argv over the OS limit, counts as a refused -call. +Each call runs `herdr` from PATH through `Git.system` with a 5-second timeout. A spawn +that fails, the binary gone since the check or an argv over the OS limit, counts as a +refused call. | Command | Reads | | -------------------------------------- | --------------------------------------------------------- | diff --git a/lua/changeset/git.lua b/lua/changeset/git.lua index 4515b55..1106cf7 100644 --- a/lua/changeset/git.lua +++ b/lua/changeset/git.lua @@ -1,33 +1,31 @@ ---The git and gh queries that pick the branch changeset diffs against, HEAD read off git's own files, and the process ----runner that starts every process changeset runs, async git included. +---runner that starts every process changeset doesn't wait on, async git's included. local M = {} ---What `system` hands `argv` and how long it lets it run. ---@class changeset.git.SystemOpts ---@field cwd string? ----@field stdin string? +---@field stdin string? Written to the process, then closed; without it, the process's stdin is null. ---@field timeout integer? Milliseconds. ---What a command `system` started exited with and printed. ---@class changeset.git.Completed ---@field code integer -1 when it could not start; 124 when it ran out of time. ---@field stdout string ----@field stderr string +---@field stderr string What it printed to stderr; the reason when it could not start. ----A buffered `jobstart` stream's bytes: its list carries each NUL as "\n" inside an item. CRLF reads as LF. +---A buffered `jobstart` stream's bytes, taking over `data`: its list carries each NUL as "\n" inside an item. CRLF +---reads as LF, so a CRLF file's base text shows and parses without a "\r" on every line. ---@param data string[] ---@return string local function joined(data) - return ( - table - .concat( - vim.tbl_map(function(item) - return (item:gsub("\n", "\0")) - end, data), - "\n" - ) - :gsub("\r\n", "\n") - ) + for i, item in ipairs(data) do + if item:find("\n", 1, true) then + data[i] = item:gsub("\n", "\0") + end + end + local text = table.concat(data, "\n") + return text:find("\r\n", 1, true) and (text:gsub("\r\n", "\n")) or text end ---Start `argv` through `jobstart`, whose spawn blocks Neovim a fraction as long as `vim.system`'s, calling @@ -39,7 +37,7 @@ end ---@param on_result fun(result: changeset.git.Completed) function M.system(argv, opts, on_result) local stdout, stderr = {}, {} ---@type string[], string[] - local exited, timed_out = false, false + local timed_out = false local ok, job = pcall(vim.fn.jobstart, argv, { cwd = opts.cwd, stdin = opts.stdin and "pipe" or "null", @@ -52,7 +50,6 @@ function M.system(argv, opts, on_result) stderr = data end, on_exit = function(_, code) - exited = true on_result({ code = timed_out and 124 or code, stdout = joined(stdout), stderr = joined(stderr) }) end, }) @@ -69,10 +66,7 @@ function M.system(argv, opts, on_result) end if opts.timeout then vim.defer_fn(function() - if not exited then - timed_out = true - vim.fn.jobstop(job) - end + timed_out = vim.fn.jobstop(job) == 1 end, opts.timeout) end end @@ -163,7 +157,7 @@ end ---Run `fn` with every git command `lines` and `is_ancestor` start inside it spawned without blocking Neovim, then ---call `on_done` on the main loop with what `fn` returns, or with nil after reporting the error `fn` raised. Start it ----on the main loop: in a fast event `jobstart` can't spawn, and the command reads as failed. +---on the main loop, as `system` needs. ---@generic T ---@param fn fun(): T ---@param on_done fun(result: T?) diff --git a/tests/diff_spec.lua b/tests/diff_spec.lua index 6889727..1c7e1df 100644 --- a/tests/diff_spec.lua +++ b/tests/diff_spec.lua @@ -1,6 +1,8 @@ local diff = require("changeset.diff") local Fixture = require("support.git") local present = require("support.present") +local spawns = require("support.spawns") +local Git = require("changeset.git") -- Real `git diff --numstat -M -z ` output. A rename's two paths follow an empty path field; -- binary files report `-` for both counts. @@ -797,23 +799,21 @@ describe("changeset.diff.collect", function() it("calls back when the repository vanishes before the generated-file read", function() local base = seed_edited_file(tmp) - local jobstart = vim.fn.jobstart + local real = Git.system local check_attr_ran = false - vim.fn.jobstart = function(argv, ...) - if - vim.tbl_contains(argv --[[@as string[] ]], "check-attr") - then + Git.system = function(argv, ...) + if spawns.runs(argv, "git", "check-attr") then check_attr_ran = true vim.fn.delete(tmp, "rf") end - return jobstart(argv, ...) + return real(argv, ...) end local files ---@type changeset.File[]? local err local ok, raised = pcall(function() files, err = await_collect(base, tmp) end) - vim.fn.jobstart = jobstart + Git.system = real ---@cast raised string? assert.is_true(ok, raised) assert.is_true(check_attr_ran) @@ -824,7 +824,7 @@ describe("changeset.diff.collect", function() end) it("reports a missing repository as an error", function() - assert.matches("expected valid directory", present((select(2, await_collect("HEAD", tmp .. "/gone"))))) + assert.matches(".", present((select(2, await_collect("HEAD", tmp .. "/gone"))))) end) end) diff --git a/tests/git_spec.lua b/tests/git_spec.lua index 5415dba..e35d69b 100644 --- a/tests/git_spec.lua +++ b/tests/git_spec.lua @@ -245,29 +245,16 @@ describe("changeset.git", function() return result end - local real_system, real_notify = vim.system, vim.notify + local notes ---@type support.notify.Note[] + local restore_notify ---@type fun() before_each(function() Fixture.init_repo("main", tmp) + notes, restore_notify = Notify.capture() end) - -- A failing `run` raises before a spec could restore its stubs. after_each(function() - vim.system, vim.notify = real_system, real_notify - end) - - it("spawns its git commands without vim.system, whose spawn blocks Neovim longer", function() - local calls = 0 - vim.system = function(...) - calls = calls + 1 - return real_system(...) - end - local lines = run(function() - return Git.lines({ "git", "branch", "--show-current" }, tmp) - end) - - assert.same({ "main" }, lines) - assert.equal(0, calls) + restore_notify() end) it("reads a command that fails to spawn as no lines", function() @@ -316,7 +303,6 @@ describe("changeset.git", function() end) it("reports an error fn raises after a git command, and calls back with nil", function() - local notes = Notify.capture() local result = run(function() Git.lines({ "git", "branch", "--show-current" }, tmp) error("measure failed") @@ -330,7 +316,6 @@ describe("changeset.git", function() end) it("reports an error value fn raises that is not a string, and calls back with nil", function() - local notes = Notify.capture() local result = run(function() Git.lines({ "git", "branch", "--show-current" }, tmp) error({ code = 1 }) @@ -357,6 +342,24 @@ describe("changeset.git", function() return present(got) end + local real_system = vim.system + + -- A failing `run` raises before a spec could restore its stub. + after_each(function() + vim.system = real_system + end) + + it("starts its process without vim.system, whose spawn blocks Neovim longer", function() + local calls = 0 + vim.system = function(...) + calls = calls + 1 + return real_system(...) + end + + assert.equal(0, run({ "git", "--version" }, {}).code) + assert.equal(0, calls) + end) + it("hands a failed spawn to its callback on the main loop as a failed result", function() local real = vim.fn.jobstart vim.fn.jobstart = function() @@ -416,6 +419,27 @@ describe("changeset.git", function() assert.equal(124, run({ "sleep", "5" }, { timeout = 50 }).code) assert.is_true(vim.uv.now() - start < 2000) end) + + it("reports the exit code of a command that exits as its timeout fires", function() + local real_jobstart, real_defer = vim.fn.jobstart, vim.defer_fn + local timeout ---@type fun()? + vim.defer_fn = function(fn) + timeout = fn + return present(vim.uv.new_timer()) + end + vim.fn.jobstart = function(argv, opts) + local on_exit = present(opts).on_exit + present(opts).on_exit = function(...) + present(timeout)() + return on_exit(...) + end + return real_jobstart(argv, opts) + end + local ok, result = pcall(run, { "true" }, { timeout = 5000 }) + vim.fn.jobstart, vim.defer_fn = real_jobstart, real_defer + assert.is_true(ok, tostring(result)) + assert.equal(0, result.code) + end) end) describe("pr_target", function() diff --git a/tests/support/spawns.lua b/tests/support/spawns.lua index c66f634..422d736 100644 --- a/tests/support/spawns.lua +++ b/tests/support/spawns.lua @@ -1,4 +1,5 @@ ----Records the processes code starts, to tell what it spawned and whether that blocked Neovim. +---Records or holds the processes code starts, to tell what it spawned, whether that blocked Neovim, and what happens +---before it runs. local M = {} @@ -41,18 +42,18 @@ function M.blocking(fn) end ---The calls `holding` keeps unstarted, and what can be done with them. ----@class changeset.test.Held +---@class support.spawns.Held ---@field calls fun()[] Each starts its held call. ---@field stop fun() Stops holding, starting nothing; safe to call twice. ---@field release fun() Stops holding, then starts every held call in the order it was held. ---Holds every call to `Git.system` unstarted from now on, letting through those `pass` accepts. ---@param pass (fun(argv: string[]): boolean)? ----@return changeset.test.Held +---@return support.spawns.Held function M.holding(pass) local Git = require("changeset.git") local real = Git.system - local held ---@type changeset.test.Held + local held ---@type support.spawns.Held held = { calls = {}, stop = function() diff --git a/tests/tree_lifecycle_spec.lua b/tests/tree_lifecycle_spec.lua index 59b436f..bee2781 100644 --- a/tests/tree_lifecycle_spec.lua +++ b/tests/tree_lifecycle_spec.lua @@ -335,7 +335,7 @@ describe("changeset tree", function() describe("reopened on a kept tree", function() local real_systemlist ---@type function - local held ---@type changeset.test.Held? + local held ---@type support.spawns.Held? local waited ---Put back every spawner `hold` replaced. @@ -357,12 +357,12 @@ describe("changeset tree", function() ---Start every held process, now. local function release() - vim.fn.systemlist = real_systemlist + unhold() present(held).release() end before_each(function() - real_systemlist = vim.fn.systemlist + real_systemlist, held = vim.fn.systemlist, nil Build.build() assert.is_true(wait_for_file("mod.lua")) end) From 062f328356e7e6c94f4c47fb6d8da9ad32f0de28 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Mon, 12 Oct 2026 20:41:33 -0400 Subject: [PATCH 09/12] test(build): hold gh until HEAD's fork point moves The spec leaned on the fake gh's 1 s sleep to keep its ask in flight while HEAD moved. Under full-suite load the moves outran it, gh answered no PR first, and the second build rightly asked again: about a third of loaded runs failed, on main as on this branch. gh's call is now held until the second build has run. --- tests/build_spec.lua | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/build_spec.lua b/tests/build_spec.lua index 55e9991..59c1e81 100644 --- a/tests/build_spec.lua +++ b/tests/build_spec.lua @@ -229,22 +229,28 @@ describe("changeset.build", function() end) describe("while gh is slow to answer", function() - before_each(function() - vim.env.FAKE_GH_DELAY = "1" - end) + local held ---@type support.spawns.Held? after_each(function() - vim.env.FAKE_GH_DELAY = nil + if held then + held.stop() + held = nil + end end) it("asks gh once when HEAD's fork point moves before it answers no PR", function() local argvs = recording(function() + -- Held until HEAD's fork point has moved, however long moving it takes under load. + held = spawns.holding(function(argv) + return not runs(argv, "gh") + end) build_and_collect() Fixture.git({ "checkout", "-q", "trunk" }, tmp) Fixture.git({ "commit", "-q", "--allow-empty", "-m", "trunk work" }, tmp) Fixture.git({ "checkout", "-q", "feature" }, tmp) Fixture.git({ "rebase", "-q", "trunk" }, tmp) build_and_collect() + present(held).release() -- Long enough for the first answer to land and anything it starts to ask gh again. vim.wait(3000, function() return false From 799db4ddf7342bedd284f0b4ed69c408599d5087 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Mon, 12 Oct 2026 21:31:53 -0400 Subject: [PATCH 10/12] refactor(process): move the process runner out of git.lua - Git.system runs gh, herdr and any argv, with no git in it; it becomes Process.run in lua/changeset/process.lua, with its Opts and Completed types - git.lua is back to git and gh queries; Git.async, pr_target, diff and herdr call Process.run through the module table, so spawns.holding still holds every start - its specs move from git_spec to process_spec --- doc/agents/herdr.md | 2 +- lua/changeset/diff.lua | 14 ++-- lua/changeset/git.lua | 84 +++--------------------- lua/changeset/herdr.lua | 3 +- lua/changeset/process.lua | 74 +++++++++++++++++++++ tests/diff_spec.lua | 8 +-- tests/git_spec.lua | 116 -------------------------------- tests/process_spec.lua | 120 ++++++++++++++++++++++++++++++++++ tests/support/spawns.lua | 10 +-- tests/tree_lifecycle_spec.lua | 2 +- 10 files changed, 222 insertions(+), 211 deletions(-) create mode 100644 lua/changeset/process.lua create mode 100644 tests/process_spec.lua diff --git a/doc/agents/herdr.md b/doc/agents/herdr.md index a4f461a..c247bd6 100644 --- a/doc/agents/herdr.md +++ b/doc/agents/herdr.md @@ -7,7 +7,7 @@ JSON it reads, and what each one can surprise you with. ## Calls -Each call runs `herdr` from PATH through `Git.system` with a 5-second timeout. A spawn +Each call runs `herdr` from PATH through `Process.run` with a 5-second timeout. A spawn that fails, the binary gone since the check or an argv over the OS limit, counts as a refused call. diff --git a/lua/changeset/diff.lua b/lua/changeset/diff.lua index ecd9640..b9edf20 100644 --- a/lua/changeset/diff.lua +++ b/lua/changeset/diff.lua @@ -1,6 +1,6 @@ ---Runs the branch's git diff and parses it into files and hunks, and reads a file's text at the base. -local Git = require("changeset.git") +local Process = require("changeset.process") local sections = require("changeset.sections") local M = {} @@ -300,11 +300,11 @@ end ---Run every command concurrently and hand all results to `on_done` on the main loop. ---@param commands table ---@param cwd string ----@param on_done fun(results: table) +---@param on_done fun(results: table) local function run_all(commands, cwd, on_done) local results, pending = {}, vim.tbl_count(commands) for name, argv in pairs(commands) do - Git.system(argv, { cwd = cwd }, function(result) + Process.run(argv, { cwd = cwd }, function(result) results[name] = result pending = pending - 1 if pending == 0 then @@ -314,7 +314,7 @@ local function run_all(commands, cwd, on_done) end end ----@param results table +---@param results table ---@return string? stderr Of a failed command, if any. local function first_failure(results) for _, result in pairs(results) do @@ -324,7 +324,7 @@ local function first_failure(results) end end ----@param result changeset.git.Completed +---@param result changeset.process.Completed ---@return string[] local function stdout_lines(result) return vim.split(result.stdout, "\n", { trimempty = true }) @@ -372,7 +372,7 @@ local function generated_paths(files, cwd, on_done) local paths = vim.tbl_map(function(file) return file.path end, files) - Git.system( + Process.run( { "git", "check-attr", "-z", "--stdin", "linguist-generated" }, { cwd = cwd, stdin = table.concat(paths, "\0") }, function(result) @@ -431,7 +431,7 @@ end ---@param cwd string ---@param callback fun(text: string?) function M.blob(object, cwd, callback) - Git.system({ "git", "cat-file", "blob", object }, { cwd = cwd }, function(result) + Process.run({ "git", "cat-file", "blob", object }, { cwd = cwd }, function(result) callback(result.code == 0 and result.stdout or nil) end) end diff --git a/lua/changeset/git.lua b/lua/changeset/git.lua index 1106cf7..9dfafaa 100644 --- a/lua/changeset/git.lua +++ b/lua/changeset/git.lua @@ -1,75 +1,7 @@ ----The git and gh queries that pick the branch changeset diffs against, HEAD read off git's own files, and the process ----runner that starts every process changeset doesn't wait on, async git's included. -local M = {} - ----What `system` hands `argv` and how long it lets it run. ----@class changeset.git.SystemOpts ----@field cwd string? ----@field stdin string? Written to the process, then closed; without it, the process's stdin is null. ----@field timeout integer? Milliseconds. - ----What a command `system` started exited with and printed. ----@class changeset.git.Completed ----@field code integer -1 when it could not start; 124 when it ran out of time. ----@field stdout string ----@field stderr string What it printed to stderr; the reason when it could not start. - ----A buffered `jobstart` stream's bytes, taking over `data`: its list carries each NUL as "\n" inside an item. CRLF ----reads as LF, so a CRLF file's base text shows and parses without a "\r" on every line. ----@param data string[] ----@return string -local function joined(data) - for i, item in ipairs(data) do - if item:find("\n", 1, true) then - data[i] = item:gsub("\n", "\0") - end - end - local text = table.concat(data, "\n") - return text:find("\r\n", 1, true) and (text:gsub("\r\n", "\n")) or text -end +---The git and gh queries that pick the branch changeset diffs against, and HEAD read off git's own files. +local process = require("changeset.process") ----Start `argv` through `jobstart`, whose spawn blocks Neovim a fraction as long as `vim.system`'s, calling ----`on_result` on the main loop. Start it on the main loop: in a fast event `jobstart` can't spawn, and the command ----reads as failed. A failed spawn — a repository removed under a pending refresh, a binary gone since its executable ----check, an argv over the OS's limit — reaches `on_result` as a failed result instead of raising. ----@param argv string[] ----@param opts changeset.git.SystemOpts ----@param on_result fun(result: changeset.git.Completed) -function M.system(argv, opts, on_result) - local stdout, stderr = {}, {} ---@type string[], string[] - local timed_out = false - local ok, job = pcall(vim.fn.jobstart, argv, { - cwd = opts.cwd, - stdin = opts.stdin and "pipe" or "null", - stdout_buffered = true, - stderr_buffered = true, - on_stdout = function(_, data) - stdout = data - end, - on_stderr = function(_, data) - stderr = data - end, - on_exit = function(_, code) - on_result({ code = timed_out and 124 or code, stdout = joined(stdout), stderr = joined(stderr) }) - end, - }) - if not ok or job <= 0 then - local reason = ok and ("could not start " .. argv[1]) or tostring(job) - vim.schedule(function() - on_result({ code = -1, stdout = "", stderr = reason }) - end) - return - end - if opts.stdin then - vim.fn.chansend(job, opts.stdin) - vim.fn.chanclose(job, "stdin") - end - if opts.timeout then - vim.defer_fn(function() - timed_out = vim.fn.jobstop(job) == 1 - end, opts.timeout) - end -end +local M = {} ---The first line of the file at `path`; nil when it can't be read, as a directory can't. ---@param path string @@ -147,17 +79,17 @@ local async_threads = setmetatable({}, { __mode = "k" }) ---In a coroutine `async` runs, `args`' result, waited for without blocking Neovim; nil elsewhere. ---@param args string[] ----@return changeset.git.Completed? +---@return changeset.process.Completed? local function await(args) local co = coroutine.running() if co and async_threads[co] then - return (coroutine.yield --[[@as fun(args: string[]): changeset.git.Completed]])(args) + return (coroutine.yield --[[@as fun(args: string[]): changeset.process.Completed]])(args) end end ---Run `fn` with every git command `lines` and `is_ancestor` start inside it spawned without blocking Neovim, then ---call `on_done` on the main loop with what `fn` returns, or with nil after reporting the error `fn` raised. Start it ----on the main loop, as `system` needs. +---on the main loop, as `process.run` needs. ---@generic T ---@param fn fun(): T ---@param on_done fun(result: T?) @@ -173,7 +105,7 @@ function M.async(fn, on_done) if coroutine.status(co) == "dead" then return on_done(value) end - M.system(value, {}, step) + process.run(value, {}, step) end step() end @@ -342,7 +274,7 @@ function M.pr_target(cwd, cb) cb(nil, nil, true) end) end - M.system({ "gh", "pr", "view", "--json", "baseRefName,number,state" }, { cwd = cwd, timeout = 5000 }, function(res) + process.run({ "gh", "pr", "view", "--json", "baseRefName,number,state" }, { cwd = cwd, timeout = 5000 }, function(res) local ok, pr = pcall(vim.json.decode, res.stdout) local answered = res.code == 0 and ok and type(pr) == "table" local open = answered and pr.state == "OPEN" diff --git a/lua/changeset/herdr.lua b/lua/changeset/herdr.lua index 5096df2..f19b98b 100644 --- a/lua/changeset/herdr.lua +++ b/lua/changeset/herdr.lua @@ -3,6 +3,7 @@ local comment_store = require("changeset.comment_store") local dialog = require("changeset.dialog") local git = require("changeset.git") local highlights = require("changeset.highlights") +local process = require("changeset.process") local M = {} @@ -186,7 +187,7 @@ end ---@param args string[] ---@param cb fun(result: table?, code: string?) local function herdr(args, cb) - git.system(vim.list_extend({ "herdr" }, args), { timeout = TIMEOUT }, function(res) + process.run(vim.list_extend({ "herdr" }, args), { timeout = TIMEOUT }, function(res) if res.code ~= 0 then local ok, env = pcall(vim.json.decode, res.stderr) cb(nil, ok and type(env) == "table" and type(env.error) == "table" and env.error.code or "") diff --git a/lua/changeset/process.lua b/lua/changeset/process.lua new file mode 100644 index 0000000..c989dcf --- /dev/null +++ b/lua/changeset/process.lua @@ -0,0 +1,74 @@ +---Starts the processes changeset doesn't wait on, through `jobstart`, handing each one's exit code and output back on +---the main loop. +local M = {} + +---What `run` hands `argv` and how long it lets it run. +---@class changeset.process.Opts +---@field cwd string? +---@field stdin string? Written to the process, then closed; without it, the process's stdin is null. +---@field timeout integer? Milliseconds. + +---What a command `run` started exited with and printed. +---@class changeset.process.Completed +---@field code integer -1 when it could not start; 124 when it ran out of time. +---@field stdout string +---@field stderr string What it printed to stderr; the reason when it could not start. + +---A buffered `jobstart` stream's bytes, taking over `data`: its list carries each NUL as "\n" inside an item. CRLF +---reads as LF, so a CRLF file's base text shows and parses without a "\r" on every line. +---@param data string[] +---@return string +local function joined(data) + for i, item in ipairs(data) do + if item:find("\n", 1, true) then + data[i] = item:gsub("\n", "\0") + end + end + local text = table.concat(data, "\n") + return text:find("\r\n", 1, true) and (text:gsub("\r\n", "\n")) or text +end + +---Start `argv` through `jobstart`, whose spawn blocks Neovim a fraction as long as `vim.system`'s, calling +---`on_result` on the main loop. Start it on the main loop: in a fast event `jobstart` can't spawn, and the command +---reads as failed. A failed spawn — a repository removed under a pending refresh, a binary gone since its executable +---check, an argv over the OS's limit — reaches `on_result` as a failed result instead of raising. +---@param argv string[] +---@param opts changeset.process.Opts +---@param on_result fun(result: changeset.process.Completed) +function M.run(argv, opts, on_result) + local stdout, stderr = {}, {} ---@type string[], string[] + local timed_out = false + local ok, job = pcall(vim.fn.jobstart, argv, { + cwd = opts.cwd, + stdin = opts.stdin and "pipe" or "null", + stdout_buffered = true, + stderr_buffered = true, + on_stdout = function(_, data) + stdout = data + end, + on_stderr = function(_, data) + stderr = data + end, + on_exit = function(_, code) + on_result({ code = timed_out and 124 or code, stdout = joined(stdout), stderr = joined(stderr) }) + end, + }) + if not ok or job <= 0 then + local reason = ok and ("could not start " .. argv[1]) or tostring(job) + vim.schedule(function() + on_result({ code = -1, stdout = "", stderr = reason }) + end) + return + end + if opts.stdin then + vim.fn.chansend(job, opts.stdin) + vim.fn.chanclose(job, "stdin") + end + if opts.timeout then + vim.defer_fn(function() + timed_out = vim.fn.jobstop(job) == 1 + end, opts.timeout) + end +end + +return M diff --git a/tests/diff_spec.lua b/tests/diff_spec.lua index 1c7e1df..7165835 100644 --- a/tests/diff_spec.lua +++ b/tests/diff_spec.lua @@ -2,7 +2,7 @@ local diff = require("changeset.diff") local Fixture = require("support.git") local present = require("support.present") local spawns = require("support.spawns") -local Git = require("changeset.git") +local Process = require("changeset.process") -- Real `git diff --numstat -M -z ` output. A rename's two paths follow an empty path field; -- binary files report `-` for both counts. @@ -799,9 +799,9 @@ describe("changeset.diff.collect", function() it("calls back when the repository vanishes before the generated-file read", function() local base = seed_edited_file(tmp) - local real = Git.system + local real = Process.run local check_attr_ran = false - Git.system = function(argv, ...) + Process.run = function(argv, ...) if spawns.runs(argv, "git", "check-attr") then check_attr_ran = true vim.fn.delete(tmp, "rf") @@ -813,7 +813,7 @@ describe("changeset.diff.collect", function() local ok, raised = pcall(function() files, err = await_collect(base, tmp) end) - Git.system = real + Process.run = real ---@cast raised string? assert.is_true(ok, raised) assert.is_true(check_attr_ran) diff --git a/tests/git_spec.lua b/tests/git_spec.lua index e35d69b..569bf1b 100644 --- a/tests/git_spec.lua +++ b/tests/git_spec.lua @@ -326,122 +326,6 @@ describe("changeset.git", function() end) end) - describe("system", function() - ---Run `argv` through `Git.system`, waiting for its result. - ---@param argv string[] - ---@param opts changeset.git.SystemOpts - ---@return changeset.git.Completed - local function run(argv, opts) - local got ---@type changeset.git.Completed? - Git.system(argv, opts, function(result) - got = result - end) - assert.is_true(vim.wait(5000, function() - return got ~= nil - end, 10)) - return present(got) - end - - local real_system = vim.system - - -- A failing `run` raises before a spec could restore its stub. - after_each(function() - vim.system = real_system - end) - - it("starts its process without vim.system, whose spawn blocks Neovim longer", function() - local calls = 0 - vim.system = function(...) - calls = calls + 1 - return real_system(...) - end - - assert.equal(0, run({ "git", "--version" }, {}).code) - assert.equal(0, calls) - end) - - it("hands a failed spawn to its callback on the main loop as a failed result", function() - local real = vim.fn.jobstart - vim.fn.jobstart = function() - error("E2BIG") - end - local got ---@type changeset.git.Completed? - local fast - Git.system({ "git", "status" }, {}, function(result) - got, fast = result, vim.in_fast_event() - end) - vim.fn.jobstart = real - assert.is_nil(got) - vim.wait(1000, function() - return got ~= nil - end) - local result = present(got) - assert.equal(-1, result.code) - assert.matches("E2BIG", present(result.stderr)) - assert.is_false(fast) - end) - - it("calls back on the main loop with the process's result", function() - local got ---@type changeset.git.Completed? - local fast - Git.system({ "git", "--version" }, {}, function(result) - got, fast = result, vim.in_fast_event() - end) - vim.wait(5000, function() - return got ~= nil - end) - local result = present(got) - assert.equal(0, result.code) - assert.matches("^git version", present(result.stdout)) - assert.is_false(fast) - end) - - it("keeps NUL bytes in stdout", function() - assert.equal("a\0b\n", run({ "printf", "a\\0b\\n" }, {}).stdout) - end) - - it("reads CRLF output as LF", function() - assert.equal("a\nb\n", run({ "printf", "a\\r\\nb\\r\\n" }, {}).stdout) - end) - - it("writes stdin to the process with its NUL bytes", function() - assert.equal("x\0y\nz", run({ "cat" }, { stdin = "x\0y\nz" }).stdout) - end) - - it("captures a failing command's stderr", function() - local result = run({ "sh", "-c", "echo oops >&2; exit 3" }, {}) - assert.equal(3, result.code) - assert.equal("oops\n", result.stderr) - end) - - it("stops a command that outlives its timeout and reports code 124", function() - local start = vim.uv.now() - assert.equal(124, run({ "sleep", "5" }, { timeout = 50 }).code) - assert.is_true(vim.uv.now() - start < 2000) - end) - - it("reports the exit code of a command that exits as its timeout fires", function() - local real_jobstart, real_defer = vim.fn.jobstart, vim.defer_fn - local timeout ---@type fun()? - vim.defer_fn = function(fn) - timeout = fn - return present(vim.uv.new_timer()) - end - vim.fn.jobstart = function(argv, opts) - local on_exit = present(opts).on_exit - present(opts).on_exit = function(...) - present(timeout)() - return on_exit(...) - end - return real_jobstart(argv, opts) - end - local ok, result = pcall(run, { "true" }, { timeout = 5000 }) - vim.fn.jobstart, vim.defer_fn = real_jobstart, real_defer - assert.is_true(ok, tostring(result)) - assert.equal(0, result.code) - end) - end) - describe("pr_target", function() it("answers no PR when gh can't be started", function() require("support.gh") diff --git a/tests/process_spec.lua b/tests/process_spec.lua new file mode 100644 index 0000000..0fdfaf5 --- /dev/null +++ b/tests/process_spec.lua @@ -0,0 +1,120 @@ +local Process = require("changeset.process") +local present = require("support.present") + +describe("changeset.process", function() + describe("run", function() + ---Run `argv` through `Process.run`, waiting for its result. + ---@param argv string[] + ---@param opts changeset.process.Opts + ---@return changeset.process.Completed + local function run(argv, opts) + local got ---@type changeset.process.Completed? + Process.run(argv, opts, function(result) + got = result + end) + assert.is_true(vim.wait(5000, function() + return got ~= nil + end, 10)) + return present(got) + end + + local real_system = vim.system + + -- A failing `run` raises before a spec could restore its stub. + after_each(function() + vim.system = real_system + end) + + it("starts its process without vim.system, whose spawn blocks Neovim longer", function() + local calls = 0 + vim.system = function(...) + calls = calls + 1 + return real_system(...) + end + + assert.equal(0, run({ "git", "--version" }, {}).code) + assert.equal(0, calls) + end) + + it("hands a failed spawn to its callback on the main loop as a failed result", function() + local real = vim.fn.jobstart + vim.fn.jobstart = function() + error("E2BIG") + end + local got ---@type changeset.process.Completed? + local fast + Process.run({ "git", "status" }, {}, function(result) + got, fast = result, vim.in_fast_event() + end) + vim.fn.jobstart = real + assert.is_nil(got) + vim.wait(1000, function() + return got ~= nil + end) + local result = present(got) + assert.equal(-1, result.code) + assert.matches("E2BIG", present(result.stderr)) + assert.is_false(fast) + end) + + it("calls back on the main loop with the process's result", function() + local got ---@type changeset.process.Completed? + local fast + Process.run({ "git", "--version" }, {}, function(result) + got, fast = result, vim.in_fast_event() + end) + vim.wait(5000, function() + return got ~= nil + end) + local result = present(got) + assert.equal(0, result.code) + assert.matches("^git version", present(result.stdout)) + assert.is_false(fast) + end) + + it("keeps NUL bytes in stdout", function() + assert.equal("a\0b\n", run({ "printf", "a\\0b\\n" }, {}).stdout) + end) + + it("reads CRLF output as LF", function() + assert.equal("a\nb\n", run({ "printf", "a\\r\\nb\\r\\n" }, {}).stdout) + end) + + it("writes stdin to the process with its NUL bytes", function() + assert.equal("x\0y\nz", run({ "cat" }, { stdin = "x\0y\nz" }).stdout) + end) + + it("captures a failing command's stderr", function() + local result = run({ "sh", "-c", "echo oops >&2; exit 3" }, {}) + assert.equal(3, result.code) + assert.equal("oops\n", result.stderr) + end) + + it("stops a command that outlives its timeout and reports code 124", function() + local start = vim.uv.now() + assert.equal(124, run({ "sleep", "5" }, { timeout = 50 }).code) + assert.is_true(vim.uv.now() - start < 2000) + end) + + it("reports the exit code of a command that exits as its timeout fires", function() + local real_jobstart, real_defer = vim.fn.jobstart, vim.defer_fn + local timeout ---@type fun()? + vim.defer_fn = function(fn) + timeout = fn + return present(vim.uv.new_timer()) + end + vim.fn.jobstart = function(argv, opts) + local on_exit = present(opts).on_exit + present(opts).on_exit = function(...) + present(timeout)() + return on_exit(...) + end + return real_jobstart(argv, opts) + end + local ok, result = pcall(run, { "true" }, { timeout = 5000 }) + vim.fn.jobstart, vim.defer_fn = real_jobstart, real_defer + assert.is_true(ok, tostring(result)) + assert.equal(0, result.code) + end) + end) +end) diff --git a/tests/support/spawns.lua b/tests/support/spawns.lua index 422d736..883e4e9 100644 --- a/tests/support/spawns.lua +++ b/tests/support/spawns.lua @@ -47,17 +47,17 @@ end ---@field stop fun() Stops holding, starting nothing; safe to call twice. ---@field release fun() Stops holding, then starts every held call in the order it was held. ----Holds every call to `Git.system` unstarted from now on, letting through those `pass` accepts. +---Holds every call to `Process.run` unstarted from now on, letting through those `pass` accepts. ---@param pass (fun(argv: string[]): boolean)? ---@return support.spawns.Held function M.holding(pass) - local Git = require("changeset.git") - local real = Git.system + local Process = require("changeset.process") + local real = Process.run local held ---@type support.spawns.Held held = { calls = {}, stop = function() - Git.system = real + Process.run = real end, release = function() held.stop() @@ -68,7 +68,7 @@ function M.holding(pass) end end, } - Git.system = function(argv, opts, on_result) + Process.run = function(argv, opts, on_result) if pass and pass(argv) then return real(argv, opts, on_result) end diff --git a/tests/tree_lifecycle_spec.lua b/tests/tree_lifecycle_spec.lua index bee2781..c4d60a9 100644 --- a/tests/tree_lifecycle_spec.lua +++ b/tests/tree_lifecycle_spec.lua @@ -346,7 +346,7 @@ describe("changeset tree", function() end end - ---Hold every `Git.system` process unstarted, and count the git processes Neovim waits on. + ---Hold every `Process.run` process unstarted, and count the git processes Neovim waits on. local function hold() held, waited = spawns.holding(), 0 vim.fn.systemlist = function(...) From dabfe8870de81761059c7d2584c5e2b40b224fb4 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Mon, 12 Oct 2026 21:33:39 -0400 Subject: [PATCH 11/12] test(spawns): share one way to make every start fail - spawns.failing(fn, ...) runs fn with jobstart raising E2BIG and restores it even when fn raises, as gh.without does for PATH - process_spec, git_spec's pr_target and herdr_spec use it instead of hand-rolled stubs that leaked jobstart on a raise --- tests/git_spec.lua | 8 +------- tests/herdr_spec.lua | 7 +------ tests/process_spec.lua | 7 +------ tests/support/spawns.lua | 23 +++++++++++++++++++++-- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/tests/git_spec.lua b/tests/git_spec.lua index 569bf1b..96b1724 100644 --- a/tests/git_spec.lua +++ b/tests/git_spec.lua @@ -329,17 +329,11 @@ describe("changeset.git", function() describe("pr_target", function() it("answers no PR when gh can't be started", function() require("support.gh") - local real = vim.fn.jobstart - vim.fn.jobstart = function() - error("E2BIG") - end local answered = false local target = "unset" ---@type string? - local ok = pcall(Git.pr_target, tmp, function(t) + require("support.spawns").failing(Git.pr_target, tmp, function(t) answered, target = true, t end) - vim.fn.jobstart = real - assert.is_true(ok) vim.wait(1000, function() return answered end) diff --git a/tests/herdr_spec.lua b/tests/herdr_spec.lua index 76dacdb..e5c3b10 100644 --- a/tests/herdr_spec.lua +++ b/tests/herdr_spec.lua @@ -294,12 +294,7 @@ describe("changeset.herdr", function() end) it("refuses when herdr can't be started", function() - local jobstart = vim.fn.jobstart - vim.fn.jobstart = function() - error("E2BIG") - end - local err, _, called = send("hi") - vim.fn.jobstart = jobstart + local err, _, called = require("support.spawns").failing(send, "hi") assert.is_true(called) assert.truthy(err) end) diff --git a/tests/process_spec.lua b/tests/process_spec.lua index 0fdfaf5..0dbf58b 100644 --- a/tests/process_spec.lua +++ b/tests/process_spec.lua @@ -37,16 +37,11 @@ describe("changeset.process", function() end) it("hands a failed spawn to its callback on the main loop as a failed result", function() - local real = vim.fn.jobstart - vim.fn.jobstart = function() - error("E2BIG") - end local got ---@type changeset.process.Completed? local fast - Process.run({ "git", "status" }, {}, function(result) + require("support.spawns").failing(Process.run, { "git", "status" }, {}, function(result) got, fast = result, vim.in_fast_event() end) - vim.fn.jobstart = real assert.is_nil(got) vim.wait(1000, function() return got ~= nil diff --git a/tests/support/spawns.lua b/tests/support/spawns.lua index 883e4e9..7e6d5b0 100644 --- a/tests/support/spawns.lua +++ b/tests/support/spawns.lua @@ -1,5 +1,5 @@ ----Records or holds the processes code starts, to tell what it spawned, whether that blocked Neovim, and what happens ----before it runs. +---Records, holds or fails the processes code starts, to tell what it spawned, whether that blocked Neovim, what +---happens before it runs, and what happens when it can't start. local M = {} @@ -79,6 +79,25 @@ function M.holding(pass) return held end +---Run `fn(...)` with every process failing to start, as an argv over the OS's limit does. `vim.fn.jobstart` comes +---back even when `fn` errors. +---@generic T +---@param fn fun(...): T +---@return T +---@return any ... Any further values `fn` returns. +function M.failing(fn, ...) + local real = vim.fn.jobstart + vim.fn.jobstart = function() + error("E2BIG") + end + local res = vim.F.pack_len(pcall(fn, ...)) + vim.fn.jobstart = real + if not res[1] then + error(res[2], 0) + end + return unpack(res, 2, res.n) +end + ---Whether `argv` starts `program` with `subcommand`, as `git -C merge-base …` starts git's merge-base. ---@param argv string[] ---@param program string From 328aa3d91736738e7108add74a3ae620e23ac6e0 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Mon, 12 Oct 2026 22:02:19 -0400 Subject: [PATCH 12/12] test: hold gh's call instead of sleeping on it - fork_point, review, review_switch and build specs that set FAKE_GH_DELAY so gh answered "after" something now hold gh through spawns.holding and release it where the answer belongs; two whose ordering was already fixed just drop the delay - the fake gh no longer sleeps, and FAKE_GH_DELAY is gone - build's "measures against the PR's target at once when switching back" passed without measuring since builds went async; it now waits for each build and fails when the target cache breaks - review's "drops a PR lookup that a branch switch superseded" now advances main so a stale answer would move its buffers, and fails when follow stops dropping it --- tests/build_spec.lua | 42 +++++++++++++++++++++++++++++------- tests/fork_point_spec.lua | 14 ++++++++---- tests/review_spec.lua | 35 +++++++++++++++++++++--------- tests/review_switch_spec.lua | 13 +++++++++-- tests/support/gh.lua | 9 ++++---- 5 files changed, 84 insertions(+), 29 deletions(-) diff --git a/tests/build_spec.lua b/tests/build_spec.lua index 59c1e81..c3aef78 100644 --- a/tests/build_spec.lua +++ b/tests/build_spec.lua @@ -607,11 +607,17 @@ describe("changeset.build", function() end) describe("once gh names a PR onto the branch already compared against", function() + local held ---@type support.spawns.Held? + before_each(function() vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "trunk", number = 7 }) end) after_each(function() + if held then + held.stop() + held = nil + end vim.env.FAKE_GH_PR = nil end) @@ -626,18 +632,21 @@ describe("changeset.build", function() end) it("hears the PR while the current buffer is outside the tree's repository", function() - vim.env.FAKE_GH_DELAY = "1" local elsewhere = vim.fn.tempname() vim.fn.mkdir(elsewhere, "p") + -- Held until the current buffer has left the tree's repository. + held = spawns.holding(function(argv) + return not runs(argv, "gh") + end) build_and_collect() vim.fn.chdir(elsewhere) vim.cmd("enew") vim.bo.buftype = "nofile" + present(held).release() local heard = vim.wait(5000, function() return vim.list_contains(events, "pr") end, 25) - vim.env.FAKE_GH_DELAY = nil vim.fn.delete(elsewhere, "rf") assert.is_true(heard) assert.equal(7, present(build.current()).pr) @@ -694,6 +703,7 @@ describe("changeset.build on a branch measured against its PR's target", functio local previous_dir ---@type string local source ---@type support.symbols.Source local parent_base ---@type string + local held ---@type support.spawns.Held? ---@param name string ---@return string @@ -730,8 +740,11 @@ describe("changeset.build on a branch measured against its PR's target", functio end) after_each(function() + if held then + held.stop() + held = nil + end vim.env.FAKE_GH_PR = nil - vim.env.FAKE_GH_DELAY = nil source.restore() vim.cmd("silent! %bwipeout!") vim.fn.chdir(previous_dir) @@ -741,11 +754,16 @@ describe("changeset.build on a branch measured against its PR's target", functio it("keeps the tree on the PR's target across a commit", function() build_on_pr() local kept = present(build.current()) - vim.env.FAKE_GH_DELAY = "0.5" - commit_file("more.txt") + -- Held, so the re-measure lands while gh is still being asked again. + held = spawns.holding(function(argv) + return not runs(argv, "gh") + end) + local head = commit_file("more.txt") build.update() - vim.wait(400) + assert.is_true(vim.wait(5000, function() + return (build.current() or {}).head == head + end, 10)) assert.equal(kept, build.current()) assert.equal(parent_base, kept.base) @@ -769,14 +787,22 @@ describe("changeset.build on a branch measured against its PR's target", functio it("measures against the PR's target at once when switching back to the branch", function() build_on_pr() + -- Held, so the switch back can measure only from the target gh named before. + held = spawns.holding(function(argv) + return not runs(argv, "gh") + end) Fixture.git({ "switch", "-q", "parent" }, root) build.update() - vim.env.FAKE_GH_DELAY = "0.5" + assert.is_true(vim.wait(5000, function() + return (build.current() or {}).branch == "parent" + end, 10)) Fixture.git({ "switch", "-q", "feature" }, root) build.update() + assert.is_true(vim.wait(5000, function() + return (build.current() or {}).branch == "feature" + end, 10)) - assert.equal("feature", present(build.current()).branch) assert.equal(parent_base, present(build.current()).base) end) diff --git a/tests/fork_point_spec.lua b/tests/fork_point_spec.lua index 167ad06..8be68ab 100644 --- a/tests/fork_point_spec.lua +++ b/tests/fork_point_spec.lua @@ -75,6 +75,7 @@ end describe("fork_point", function() local real_pr_target, asks local roots = {} + local held_gh ---@type support.spawns.Held? before_each(function() asks = 0 @@ -86,9 +87,12 @@ describe("fork_point", function() end) after_each(function() + if held_gh then + held_gh.stop() + held_gh = nil + end Git.pr_target = real_pr_target vim.env.FAKE_GH_PR = nil - vim.env.FAKE_GH_DELAY = nil for _, root in ipairs(roots) do vim.fn.delete(root, "rf") end @@ -310,7 +314,6 @@ describe("fork_point", function() local root = repo() get(root, "feature") assert.is_true(await_heard(root, 1)) - vim.env.FAKE_GH_DELAY = "0.3" fork_point.recheck(root, "feature") fork_point.recheck(root, "feature") @@ -352,7 +355,6 @@ describe("fork_point", function() it("measures again in the background when gh names a target while it measures", function() vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "parent", number = 7 }) - vim.env.FAKE_GH_DELAY = "0.5" local root, _, parent_base = repo() local answered = false local pr_target = Git.pr_target @@ -434,13 +436,17 @@ describe("fork_point", function() end) it("asks gh once while an answer is in flight, and again after an answer of no PR", function() - vim.env.FAKE_GH_DELAY = "0.2" local root = repo() + -- Held until the second measure has landed, however long measuring takes under load. + held_gh = spawns.holding(function(argv) + return not spawns.runs(argv, "gh") + end) get(root, "feature") get(root, "feature") assert.equal(1, asks) + present(held_gh).release() assert.is_true(await_heard(root, 1)) get(root, "feature") assert.equal(2, asks) diff --git a/tests/review_spec.lua b/tests/review_spec.lua index 9a1ec3d..b198311 100644 --- a/tests/review_spec.lua +++ b/tests/review_spec.lua @@ -3,6 +3,7 @@ local support = require("support.git") local gutter = require("support.gutter") local Notify = require("support.notify") local present = require("support.present") +local spawns = require("support.spawns") local await, edit, revision = gutter.await, gutter.edit, gutter.revision @@ -13,6 +14,7 @@ describe("the gutter's base", function() local change_base ---@type { msg: string, level: integer? }[] local notices + local held ---@type support.spawns.Held? ---A repo with `a.txt` changed on `parent`, then again on `child` cut from it. ---@param child string @@ -33,9 +35,12 @@ describe("the gutter's base", function() end) after_each(function() + if held then + held.stop() + held = nil + end gutter.teardown(dir, cwd) vim.env.FAKE_GH_PR = nil - vim.env.FAKE_GH_DELAY = nil restore_notify() require("gitsigns").change_base = change_base end) @@ -170,23 +175,33 @@ describe("the gutter's base", function() it("drops a PR lookup that a branch switch superseded", function() stack("stacked-switched") - vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "parent" }) - vim.env.FAKE_GH_DELAY = "1" + -- main moves on past where parent forked, so main's base differs from its fork point with parent. + support.git({ "switch", "-q", "main" }, dir) + gutter.advance(dir) + support.git({ "switch", "-q", "stacked-switched" }, dir) vim.fn.chdir(dir) + held = spawns.holding(function(argv) + return not spawns.runs(argv, "gh") + end) local bufs = edit({ "a.txt" }) assert.is_true(await(bufs, gutter.merge_base(dir), 5000)) - local parent_tip = support.git({ "rev-parse", "parent" }, dir) + -- Only the child's lookup stays held; main's runs once the switch asks it, and finds no PR. + present(held).stop() + assert.equal(1, #present(held).calls) -- The child's lookup is still in flight when the switch happens. Its answer names the parent, and must - -- not move main's buffers onto it. - vim.env.FAKE_GH_PR = nil + -- not move main's buffers off main's base. support.git({ "switch", "-q", "main" }, dir) - assert.is_true(vim.wait(10000, function() - return vim.b[bufs[1]].gitsigns_head == "main" - end, 20)) + local main_base = gutter.merge_base(dir) + -- main's own lookup has started by the time its base lands, so only the child's reads the PR set below. + assert.is_true(await(bufs, main_base, 10000)) + -- gh reads $FAKE_GH_PR as it starts, which a held lookup does only on release. + vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "parent" }) + present(held).release() + vim.env.FAKE_GH_PR = nil assert.is_false(vim.wait(1500, function() - return revision(present(bufs[1])) == parent_tip + return revision(present(bufs[1])) ~= main_base end, 20)) end) end) diff --git a/tests/review_switch_spec.lua b/tests/review_switch_spec.lua index bcbc6a9..91bacd6 100644 --- a/tests/review_switch_spec.lua +++ b/tests/review_switch_spec.lua @@ -33,6 +33,7 @@ end describe("the gutter's base", function() local dir ---@type string local cwd ---@type string + local held_gh ---@type support.spawns.Held? before_each(function() cwd = vim.fn.getcwd() @@ -40,8 +41,12 @@ describe("the gutter's base", function() end) after_each(function() + if held_gh then + held_gh.stop() + held_gh = nil + end gutter.teardown(dir, cwd) - vim.env.FAKE_GH_PR, vim.env.FAKE_GH_DELAY, vim.env.FAKE_GH_LOG = nil, nil, nil + vim.env.FAKE_GH_PR, vim.env.FAKE_GH_LOG = nil, nil end) it("moves attached buffers to the new merge base after an external branch switch", function() @@ -273,8 +278,11 @@ describe("the gutter's base", function() vim.fn.writefile({ "one" }, dir .. "/a.txt") local pushed = support.commit("pushed", dir) support.git({ "update-ref", "refs/remotes/origin/main", pushed }, dir) - vim.env.FAKE_GH_DELAY = "2" vim.fn.chdir(dir) + -- Held until HEAD has moved the base, however long that takes under load. + held_gh = spawns.holding(function(argv) + return not spawns.runs(argv, "gh") + end) local bufs = edit({ "a.txt" }) assert.is_true(await(bufs, pushed, 5000)) @@ -282,6 +290,7 @@ describe("the gutter's base", function() local pulled = support.commit("pulled", dir) support.git({ "update-ref", "refs/remotes/origin/main", pulled }, dir) assert.is_true(await(bufs, pulled, 10000)) + present(held_gh).release() assert.is_false(vim.wait(3000, function() return revision(present(bufs[1])) ~= pulled diff --git a/tests/support/gh.lua b/tests/support/gh.lua index 906baed..670381d 100644 --- a/tests/support/gh.lua +++ b/tests/support/gh.lua @@ -1,13 +1,12 @@ ----A fake `gh` on PATH, so no spec asks GitHub about its fixture branch: it prints ----$FAKE_GH_PR after $FAKE_GH_DELAY seconds, and fails as if there were no PR when ----that is empty. Each call appends a line to $FAKE_GH_LOG when that is set. Requiring it is what installs it; PATH is never restored, since ----each spec runs in its own nvim. `without` hides it, for a case where gh is not installed. +---A fake `gh` on PATH, so no spec asks GitHub about its fixture branch: it prints $FAKE_GH_PR, and fails as if there +---were no PR when that is empty. Each call appends a line to $FAKE_GH_LOG when that is set. Requiring it is what +---installs it; PATH is never restored, since each spec runs in its own nvim. `without` hides it, for a case where gh is +---not installed. local bin = vim.fn.tempname() vim.fn.mkdir(bin, "p") vim.fn.writefile({ "#!/bin/sh", '[ -z "$FAKE_GH_LOG" ] || echo "$*" >> "$FAKE_GH_LOG"', - 'sleep "${FAKE_GH_DELAY:-0}"', '[ -n "$FAKE_GH_PR" ] || exit 1', 'printf "%s" "$FAKE_GH_PR"', }, bin .. "/gh")