Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions doc/agents/herdr.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ 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
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 `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.

| Command | Reads |
| -------------------------------------- | --------------------------------------------------------- |
Expand Down
3 changes: 2 additions & 1 deletion doc/changeset.nvim.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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. An error inside changeset while
it measures the base is reported, then treated as finding no base.

==============================================================================
5. Commands *changeset-commands*
Expand Down
28 changes: 14 additions & 14 deletions lua/changeset/diff.lua
Original file line number Diff line number Diff line change
@@ -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 = {}
Expand Down Expand Up @@ -300,11 +300,11 @@ end
---Run every command concurrently and hand all results to `on_done` on the main loop.
---@param commands table<string, string[]>
---@param cwd string
---@param on_done fun(results: table<string, vim.SystemCompleted>)
---@param on_done fun(results: table<string, changeset.process.Completed>)
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)
Process.run(argv, { cwd = cwd }, function(result)
results[name] = result
pending = pending - 1
if pending == 0 then
Expand All @@ -314,20 +314,20 @@ local function run_all(commands, cwd, on_done)
end
end

---@param results table<string, vim.SystemCompleted>
---@param results table<string, changeset.process.Completed>
---@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.process.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.
Expand Down Expand Up @@ -372,12 +372,12 @@ 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, 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"
Expand Down Expand Up @@ -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
Expand All @@ -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)
Process.run({ "git", "cat-file", "blob", object }, { cwd = cwd }, function(result)
callback(result.code == 0 and result.stdout or nil)
end)
end
Expand Down
5 changes: 3 additions & 2 deletions lua/changeset/fork_point.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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?)
Expand Down
53 changes: 18 additions & 35 deletions lua/changeset/git.lua
Original file line number Diff line number Diff line change
@@ -1,22 +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 they and herdr share.
local M = {}
---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")

---`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.
---@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) }
vim.schedule(function()
on_exit(result)
end)
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
Expand Down Expand Up @@ -94,31 +79,33 @@ local async_threads = setmetatable({}, { __mode = "k" })

---In a coroutine `async` runs, `args`' result, waited for without blocking Neovim; nil elsewhere.
---@param args string[]
---@return vim.SystemCompleted?
---@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[]): vim.SystemCompleted]])(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.
---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 `process.run` needs.
---@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, tostring(value)), vim.log.levels.ERROR)
return on_done(nil)
end
if coroutine.status(co) == "dead" then
return on_done(value)
end
M.system(value, { text = true }, step)
process.run(value, {}, step)
end
step()
end
Expand All @@ -137,7 +124,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
Expand Down Expand Up @@ -287,16 +274,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
)
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"
cb(open and pr.baseRefName or nil, open and pr.number or nil, not answered)
end)
end

return M
7 changes: 4 additions & 3 deletions lua/changeset/herdr.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down Expand Up @@ -186,13 +187,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)
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 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
Expand Down
74 changes: 74 additions & 0 deletions lua/changeset/process.lua
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading