diff --git a/doc/agents/design.md b/doc/agents/design.md index 2828de6..a84abcd 100644 --- a/doc/agents/design.md +++ b/doc/agents/design.md @@ -1232,10 +1232,14 @@ Every fork point measured without blocking reaches both the gutter and the tree, it as its own re-measure would, so the two measure once between them where they can and never disagree after a push. A measure is taken again when HEAD moves while it runs, so none answers for a HEAD left behind, nor lands after a newer one with an older fork point; but -three times at most, since a blocking measure would freeze Neovim for as long as another -process kept moving HEAD, and HEAD's next move measures again through the watcher. While +three times at most, so a measure doesn't spawn git for as long as another process keeps +moving HEAD, and HEAD's next move measures again through the watcher. While gh is still being asked, its answer carries the newest measure rather than the one it was asked with. +A switch of repository or branch holds every gutter move until its measure lands, since +until then the base in hand is the one of the branch left behind. A switch replaced by +another repository's before its measure lands still moves its own repository's buffers +onto that measure's base. A buffer moves onto a new base only once gitsigns holds its text. gitsigns stores a read's text even after a move has replaced its base, so a buffer moved mid-read would name the base @@ -1369,14 +1373,16 @@ condition, apart from the reach and from a view closed by hand. The tree is built the first time something asks for it — `:Changeset`, `:Changeset refresh`, the picker (`:Changeset pick`, or `require("changeset.pick").pick()`), `:Changeset diff` turning the unified diff on, or a restored session refilling the sidebar -— for the current buffer's repository: the fork point -is measured at once, -the diff and symbols in the background. Reading symbols loads each changed file the cache +— for the current buffer's repository: the fork point is measured, and the diff and symbols +read, in the background, so none holds the editor up. Reading symbols loads each changed file the cache can't answer, Generated ones aside, so those buffers and their language servers arrive with that first -ask, and a session that never asks starts none. The picker waits a moment for the diff, -which it has no way to fill in behind. The sidebar opens at once: a tree still waiting on -its first diff opens blank rather than claiming nothing changed. Closing the sidebar lets -go of the window only, and opening it again, or the picker asking again, draws the tree it +ask, and a session that never asks starts none. The picker waits a moment for the fork +point and the diff, which it has no way to fill in behind. The sidebar opens once its fork +point lands, in the tabpage it was asked from; the no-base warning comes before any +window. A tree still waiting on its first diff opens blank rather than claiming nothing +changed. Keys pressed while the fork point is measured act on the editor as it is, so a +second toggle cancels the open it would have closed. Closing the sidebar lets go of the +window only, and opening it again, or the picker asking again, draws the tree it kept while HEAD stays on its branch, and measures its fork point and refreshes its diff in the background; a fork point that moved replaces the tree once measured. The tree is rebuilt for a different repository, fork point or branch, and a build that finds no fork point keeps the tree it had. The tree knows nothing of the diff --git a/doc/changeset.nvim.txt b/doc/changeset.nvim.txt index 6c869ee..9f34c0c 100644 --- a/doc/changeset.nvim.txt +++ b/doc/changeset.nvim.txt @@ -171,8 +171,9 @@ does, measures the base again and asks `gh` about the PR again. When none of them shares history with your branch, the sidebar doesn't open, 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. +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. ============================================================================== 5. Commands *changeset-commands* diff --git a/lua/changeset/build.lua b/lua/changeset/build.lua index 519f845..956b869 100644 --- a/lua/changeset/build.lua +++ b/lua/changeset/build.lua @@ -16,6 +16,10 @@ local REFRESH_DEBOUNCE_MS = 250 -- One write per burst of answers rather than one per file. local SAVE_DEBOUNCE_MS = 1000 +-- Measures a build makes before landing on the last, so asks joining it or HEAD moving without end can't keep it, and +-- the open waiting on it, from landing. +local MEASURE_TRIES = 3 + local M = {} ---The tree as the pipeline keeps it. Only this module writes these fields. @@ -38,6 +42,10 @@ local M = {} ---diff `failed` to read. ---@alias changeset.TreeEvent "diff"|"symbols"|"pr"|"failed" +---What a build ended in: the tree `built` or kept, `no_base` to measure from, or `replaced` by an ask for another +---repository. +---@alias changeset.BuildOutcome "built"|"no_base"|"replaced" + ---@type changeset.Tree? local tree @@ -274,14 +282,72 @@ end ---@type fun(root: string, branch: string, commit: string?, point: changeset.ForkPoint?): boolean local place ----Build the tree for the repository at `root`, unless it is already built there. +---A build in flight. +---@class changeset.build.Request +---@field root string +---@field branch string +---@field waiters fun(outcome: changeset.BuildOutcome)[] +---@field again boolean? An ask joined it after it started, so it measures once more on landing. +---@field tries integer Measures made so far. + +---@type changeset.build.Request? The build in flight; an ask for another repository replaces it. +local building + +---Whether `request` is still the build in flight. +---@param request changeset.build.Request +---@return boolean +local function in_flight(request) + return building == request +end + +---Build the tree for the repository at `root`, keeping the tree when its fork point is the one it was built on, +---measuring that point without blocking, then call `on_done` once on the main loop with the outcome. An ask for `root` +---while one is in flight waits on it, and has it measure again once it lands, since a ref can have moved after it +---started. A landing that finds HEAD on another branch measures again for that branch. +---@param root string +---@param on_done fun(outcome: changeset.BuildOutcome) +local function build_at(root, on_done) + if building and building.root == root then + table.insert(building.waiters, on_done) + building.again = true + return + end + local replaced = building + ---@type changeset.build.Request + local request = { root = root, branch = Git.head(root) or "HEAD", waiters = { on_done }, tries = 0 } + building = request + if replaced then + for _, waiter in ipairs(replaced.waiters) do + waiter("replaced") + end + end + local function measure() + request.tries = request.tries + 1 + fork_point.get_async(root, request.branch, function(point) + if not in_flight(request) then + return + end + local branch, commit = Git.head(root) + if request.tries < MEASURE_TRIES and (request.again or (branch or "HEAD") ~= request.branch) then + request.branch, request.again = branch or "HEAD", nil + return measure() + end + building = nil + local outcome = place(root, request.branch, commit, point) and "built" or "no_base" + for _, waiter in ipairs(request.waiters) do + waiter(outcome) + end + end) + end + measure() +end + +---Whether a background rebuild for `root` should wait: a build for another repository is in flight, and replaces the +---tree anyway. ---@param root string ----@return boolean ready false when the repository has no merge base with its default ----branch, which includes a `root` outside any repository. -local function build_at(root) - local branch, commit = Git.head(root) - branch = branch or "HEAD" - return place(root, branch, commit, fork_point.get(root, branch)) +---@return boolean +local function yields(root) + return building ~= nil and building.root ~= root end ---Keep the tree when `point` is the one it was built on, else build one there. @@ -335,10 +401,10 @@ end --- ---The buffer's repository, not Neovim's directory: with the two different, a base ---measured in the wrong one leaves every later `git diff` on a bad object. ----@return boolean ready false when the repository has no merge base with its default ----branch, which includes a buffer outside any repository. -function M.build() - return build_at(Paths.root(0)) +---@param on_done fun(outcome: changeset.BuildOutcome) As `build_at` calls it; `no_base` includes a buffer outside any +---repository. +function M.build(on_done) + build_at(Paths.root(0), on_done) end ---The tree, while it is for the current buffer's repository and HEAD is still on its branch. @@ -371,17 +437,21 @@ function M.remeasure(on_no_base, on_kept) return end remeasuring = nil + ---@param outcome changeset.BuildOutcome + local function finish(outcome) + if outcome == "no_base" and on_no_base then + on_no_base() + elseif outcome == "built" and tree == kept and on_kept then + on_kept() + end + end local branch, commit = Git.head(kept.root) - local ready if (branch or "HEAD") ~= kept.branch then - ready = build_at(kept.root) + if not yields(kept.root) then + build_at(kept.root, finish) + end else - ready = place(kept.root, kept.branch, commit, point) - end - if not ready and on_no_base then - on_no_base() - elseif ready and tree == kept and on_kept then - on_kept() + finish(place(kept.root, kept.branch, commit, point) and "built" or "no_base") end end) end @@ -402,10 +472,15 @@ function M.update() -- A refresh before the re-measure lands would diff a rebased branch against its old fork point. return M.remeasure(nil, M.refresh) end - local kept = tree - if not build_at(tree.root) or tree == kept then - M.refresh() + if yields(tree.root) then + return end + local kept = tree + build_at(tree.root, function(outcome) + if outcome == "no_base" or (outcome == "built" and tree == kept) then + M.refresh() + end + end) end -- Fires: gh answering a fork_point lookup, or a fork point measured without blocking, for any repository and branch. @@ -416,7 +491,7 @@ fork_point.subscribe(function(root, branch, point) if tree.base == point.base and tree.ref == point.ref and tree.pr == point.pr then return end - -- Placed as a re-measure places its point, since a point measured without blocking is as fresh as a blocking one. + -- Placed as a re-measure places its point. -- A HEAD gone to another branch is that branch's to build. local head, commit = Git.head(root) if (head or "HEAD") == branch then diff --git a/lua/changeset/fork_point.lua b/lua/changeset/fork_point.lua index 17ec53f..fb370eb 100644 --- a/lua/changeset/fork_point.lua +++ b/lua/changeset/fork_point.lua @@ -135,8 +135,8 @@ local function measure(root, branch, pr) return point end --- How many measures `settled` takes while HEAD keeps moving under them. Its blocking callers would freeze Neovim for as --- long as another process kept moving HEAD, and the watcher measures again on HEAD's next move anyway. +-- How many measures `settled` takes while HEAD keeps moving under them, so another process moving HEAD without end +-- can't keep it measuring; the watcher measures again on HEAD's next move. local SETTLE_TRIES = 3 ---`measure`, taken again until HEAD holds still across one, up to `SETTLE_TRIES` in all, so a point never answers for a @@ -177,43 +177,41 @@ end ---@param branch string ---@param point changeset.ForkPoint? ---@return changeset.ForkPoint? point ----@return boolean asking local function ask(root, branch, point) local key = root .. "\n" .. branch if not point then - return nil, false + return nil end if asking[key] then - return carry(root, branch, point), true + return carry(root, branch, point) end if targets[key] then - return point, false + return point end asking[key] = point Git.pr_target(root, function(target, number) local heard = asking[key] ---@type changeset.ForkPoint? asking[key] = nil - if target and number then - targets[key] = { target = target, number = number } - -- HEAD can have moved by the time gh answers; subscribers still need a point. - heard = settled(root, branch, targets[key]) or heard - end - if heard then - for fn in pairs(subscribers) do - fn(root, branch, heard) + local function tell(told) + if told then + for fn in pairs(subscribers) do + fn(root, branch, told) + end end end + if not (target and number) then + return tell(heard) + end + local pr = { target = target, number = number } + targets[key] = pr + Git.async(function() + return settled(root, branch, pr) + end, function(remeasured) + -- HEAD can have moved by the time gh answers; subscribers still need a point. + tell(remeasured or heard) + end) end) - return point, true -end - ----The fork point of `branch` at `root`. Asks gh about its PR unless gh has named a target or is being asked. ----@param root string Repository to measure in. ----@param branch string The branch checked out at `root`. ----@return changeset.ForkPoint? point nil when HEAD shares no fork point with the branch it was created from or the default branch. ----@return boolean asking Whether gh is still being asked, so subscribers will hear its answer. -function M.get(root, branch) - return ask(root, branch, settled(root, branch, targets[root .. "\n" .. branch])) + return point end ---Measure `branch`'s fork point at `root` without blocking Neovim, pass it through `settle`, then hand it to `on_done` @@ -243,15 +241,14 @@ local function measure_async(root, branch, settle, on_done) end) end ----`get`, measuring without blocking Neovim, and calling back on the main loop. Subscribers hear the point after ----`on_done` does. +---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. ---@param root string ---@param branch string ---@param on_done fun(point: changeset.ForkPoint?) function M.get_async(root, branch, on_done) - measure_async(root, branch, function(...) - return (ask(...)) - end, on_done) + measure_async(root, branch, ask, on_done) end ---`get_async` without asking gh, measured from the PR target gh last named: for measuring again as HEAD or a remote diff --git a/lua/changeset/init.lua b/lua/changeset/init.lua index 65b8a19..15f8860 100644 --- a/lua/changeset/init.lua +++ b/lua/changeset/init.lua @@ -22,7 +22,9 @@ local unified_diff = require("changeset.unified_diff") local view = require("changeset.view") local window = require("changeset.window") --- How long a picker's first ask blocks on the diff before giving up on it. +-- How long a picker's first ask blocks on the fork point, then on the diff. The measure gets its own budget because +-- under load it can run for seconds, and its own retries bound it. +local MEASURE_WAIT_MS = 10000 local DIFF_WAIT_MS = 2000 -- Capitalised: `:mksession` saves only globals named so, and only with "globals" in 'sessionoptions'. @@ -99,6 +101,8 @@ local DELETED = "This file was deleted on this branch" local NO_BASE = "no merge base with the default branch" +local READING = "reading the changes…" + -- snacks.nvim's bigfile size: past it a buffer's synchronous treesitter parse takes noticeable time, on every pass -- over the row. local PREVIEW_MAX_BYTES = 1.5 * 1024 * 1024 @@ -494,23 +498,32 @@ end ---Public API: the file rows under the sidebar's sections, less the kinds it hides, for the current buffer's repository. --- ----The first call builds and keeps the tree, loading the changed files and their language servers, and ----blocks up to `DIFF_WAIT_MS` (2 s) while the diff is read. `rows` are the file rows, uncompressed, with ----their symbols under `children`. +---The first call builds and keeps the tree, loading the changed files and their language servers. Keeping the event loop +---turning, it blocks up to 10 s while the fork point is measured, then up to 2 s while the diff is read. `rows` are +---the file rows, uncompressed, with their symbols under `children`. ---@return { rows: changeset.Row[], root: string, ref: string }? tree ---@return string? err Why there is no tree yet. function M.rows() local kept = build.kept() - if not (kept or build.build()) then - return nil, NO_BASE + local outcome = kept and "built" or nil ---@type changeset.BuildOutcome? + if not kept then + build.build(function(answer) + outcome = answer + end) end -- The first ask builds the tree too, and a picker cannot fill in behind it the way the sidebar does. + vim.wait(MEASURE_WAIT_MS, function() + return outcome ~= nil + end, 10) vim.wait(DIFF_WAIT_MS, function() local tree = build.current() - return not tree or tree.collected + return outcome ~= "built" or not tree or tree.collected end, 10) + if outcome == "no_base" then + return nil, NO_BASE + end local state = sidebar_state.current() - if not (state and state.tree.collected) then + if outcome ~= "built" or not (state and state.tree.collected) then return nil, "still reading the diff" end -- A tree whose diff landed before the sidebar loaded announced nothing the sidebar heard. @@ -548,8 +561,18 @@ function M.setup(opts) review_comments.redraw() end +---An open whose fork point is being measured. +---@class changeset.Opening +---@field afters fun()[] Called once it lands, in the tabpage it was asked from. +---@field focus { win: integer }? A toggle pressed meanwhile, from `win`; the sidebar takes focus only if `win` is still +---current. + +---@type changeset.Opening? release() drops it. +local opening + ---Drop everything the sidebar set up but its window. local function release() + opening = nil cancel_settle() waiting = nil opened_id, stepping_back = nil, false @@ -558,15 +581,19 @@ local function release() end ---Builds the tree for the current buffer's repository, unless it is already built there, warning when it can't. ----@return boolean ready -local function built() - if not build.build() then - vim.notify("Changeset: " .. NO_BASE, vim.log.levels.WARN) - return false - end - -- The cursor is still where the user was, and nothing tracked it before a tree existed. - track() - return true +---@param on_done fun(outcome: changeset.BuildOutcome)? Called as `build.build` calls back, after the warning. +local function built(on_done) + build.build(function(answer) + if answer == "no_base" then + vim.notify("Changeset: " .. NO_BASE, vim.log.levels.WARN) + elseif answer == "built" then + -- The tracker records no cursor before a tree exists. + track() + end + if on_done then + on_done(answer) + end + end) end ---Rebuild the tree now, or build the current buffer's repository's when there is none. @@ -578,25 +605,8 @@ function M.refresh() end end ----Open the sidebar on the current buffer's repository, drawing its tree. -function M.open() - -- `window.buf()`, not `window.win()`: the buffer is wiped with its window, so it is - -- live exactly while a sidebar stands on some tabpage. - if window.buf() then - M.close() - else - -- A session read over the sidebar closes its window, and this runs before the - -- `WinClosed` close that would let go of it. - release() - end - local previous = build.current() - -- A kept tree draws at once; its fork point is measured again behind it. - local kept = build.kept() - if kept then - track() - elseif not built() then - return - end +---Open the sidebar's window on the tree just built or kept. +local function show() bound_keys = config.get().keymaps local buf = vim.api.nvim_create_buf(false, true) @@ -661,8 +671,9 @@ function M.open() desc = "changeset: let go of the sidebar when its window closes another way", callback = function() vim.schedule(function() - -- A session read over the sidebar opens its own before this runs. - if not window.is_visible() then + -- A session read over the sidebar opens its own before this runs; and `close()` would drop an open still + -- measuring. + if not (window.is_visible() or opening) then M.close() end end) @@ -812,16 +823,86 @@ function M.open() unified_diff.turn_on() redraw() - -- A kept tree misses what nothing announced, such as a file edited outside Neovim while it kept focus. It refreshes - -- once re-measured, so a rebased branch is never diffed against its old fork point. +end + +---What `M.open` does, handing back the open it left measuring. +---@param after fun()? +---@return changeset.Opening? pending The open still measuring; nil when a kept tree opened at once. +local function open(after) + if opening then + table.insert(opening.afters, after) + return opening + end + -- `window.buf()`, not `window.win()`: the buffer is wiped with its window, so it is + -- live exactly while a sidebar stands on some tabpage. + if window.buf() then + M.close() + else + -- A session read over the sidebar closes its window, and this runs before the + -- `WinClosed` close that would let go of it. + release() + end + local previous = build.current() + -- A kept tree draws at once; its fork point is measured again behind it. + local kept = build.kept() if kept then + track() + show() + -- A kept tree misses what nothing announced, such as a file edited outside Neovim while it kept focus. It + -- refreshes once re-measured, so a rebased branch is never diffed against its old fork point. build.remeasure(function() vim.notify("Changeset: " .. NO_BASE, vim.log.levels.WARN) M.close() end, build.refresh) - elseif build.current() == previous then - build.refresh() + if after then + after() + end + return end + ---@type changeset.Opening + local pending = { afters = { after } } + opening = pending + local tab = vim.api.nvim_get_current_tabpage() + built(function(answer) + if opening ~= pending then + return + end + opening = nil + if not vim.api.nvim_tabpage_is_valid(tab) then + for _, fn in ipairs(pending.afters) do + fn() + end + return + end + local here = vim.api.nvim_get_current_tabpage() + vim.api.nvim_set_current_tabpage(tab) + if answer == "built" then + show() + if build.current() == previous then + build.refresh() + end + local focus = pending.focus + -- Only from where the toggle was pressed, and not mid-insert: typed text must not land in the sidebar. + if focus and vim.api.nvim_get_current_win() == focus.win and vim.api.nvim_get_mode().mode == "n" then + window.focus() + end + end + for _, fn in ipairs(pending.afters) do + fn() + end + if here ~= tab and vim.api.nvim_tabpage_is_valid(here) then + vim.api.nvim_set_current_tabpage(here) + end + end) + return pending +end + +---Open the sidebar on the current buffer's repository, drawing its tree, then call `after` once the open lands, +---whether or not a window opened; an open `release()` drops calls nothing. A kept tree opens at once. The window opens +---in the tabpage `open` was called from, and none opens if that tabpage is gone by then. +---@param after fun()? +function M.open(after) + open(after) end ---Dismiss the sidebar, the unified diff kept on only as far as `unified_diff.keep` says. The tree stays, and keeps @@ -832,6 +913,16 @@ function M.close() unified_diff.limit(config.get().unified_diff.keep) end +---@type table? A `diff()` waiting on the build to turn the unified diff on; a second `diff()` clears it. +local diff_pending + +---Whether `token` is still the `diff()` waiting on the build. +---@param token table +---@return boolean +local function diff_wanted(token) + return diff_pending == token +end + ---Turn the unified diff off everywhere, or on for every file of the tree as the sidebar opening does, building the ---current buffer's repository's tree when there is none, and say which; warn when gitsigns can't show it. function M.diff() @@ -842,18 +933,42 @@ function M.diff() if not unified_diff.available() then return vim.notify("Changeset: the unified diff needs gitsigns", vim.log.levels.WARN) end - if not (build.current() or built()) then - return + if build.current() then + unified_diff.turn_on() + return vim.notify("Changeset: unified diff on") end - unified_diff.turn_on() - vim.notify("Changeset: unified diff on") + -- A second ask before the build lands turns back off what the first would have turned on. + if diff_pending then + diff_pending = nil + return vim.notify("Changeset: unified diff off") + end + local token = {} + diff_pending = token + built(function(answer) + if not diff_wanted(token) then + return + end + diff_pending = nil + if answer == "built" then + unified_diff.turn_on() + vim.notify("Changeset: unified diff on") + end + end) end ---Fill `placeholder`, in the current tabpage, with the tree, or let go of it when there is no tree to fill it with. ---@param placeholder integer local function fill(placeholder) - M.open() - if not window.is_visible() then + -- Read now: the tracker can record over it before the open lands. + local ok, recorded = pcall(vim.json.decode, vim.g[POSITION_GLOBAL]) + M.open(function() + if window.is_visible() then + local state = assert(sidebar_state.current(), "changeset: no tree built yet") + return apply(state.position:restore(ok and recorded or nil, draw.view(), decided)) + end + if not vim.api.nvim_win_is_valid(placeholder) then + return + end local stale = vim.api.nvim_win_get_buf(placeholder) if #window.panes(0) > 1 then vim.api.nvim_win_close(placeholder, true) @@ -862,12 +977,7 @@ local function fill(placeholder) end -- A session lists it, under a name nothing else answers to. pcall(vim.api.nvim_buf_delete, stale, { force = true }) - return - end - local state = sidebar_state.current() - assert(state, "changeset: no tree built yet") - local ok, recorded = pcall(vim.json.decode, vim.g[POSITION_GLOBAL]) - apply(state.position:restore(ok and recorded or nil, draw.view(), decided)) + end) end ---Fill the window a restored session left standing where the sidebar was, and bring @@ -907,12 +1017,20 @@ end ---@param take fun(count: integer) local function walk(count, kind, take) if not window.is_visible() then - M.open() - if not window.is_visible() then - return + local win = vim.api.nvim_get_current_win() + local buf = vim.api.nvim_win_get_buf(win) + local pending = open(function() + if not window.is_visible() or vim.api.nvim_get_current_win() ~= win or vim.api.nvim_win_get_buf(win) ~= buf then + return + end + local state = assert(sidebar_state.current(), "changeset: no tree built yet") + apply(state.position:entered(draw.view())) + walk(count, kind, take) + end) + if pending then + vim.api.nvim_echo({ { READING } }, false, {}) end - local state = assert(sidebar_state.current(), "changeset: no tree built yet") - apply(state.position:entered(draw.view())) + return end local tree = build.current() if not (tree and ready(tree)) then @@ -920,7 +1038,7 @@ local function walk(count, kind, take) local buf = vim.api.nvim_win_get_buf(win) local before = waiting and waiting.win == win and waiting.buf == buf and waiting.kind == kind and waiting.count or 0 waiting = { count = before + count, kind = kind, take = take, win = win, buf = buf } - return vim.api.nvim_echo({ { "reading the changes…" } }, false, {}) + return vim.api.nvim_echo({ { READING } }, false, {}) end -- The step walks the drawn rows, and `ready()` judged the tree's; a key pressed is worth drawing now. if settling.owed then @@ -954,10 +1072,24 @@ end ---Open, focus, or dismiss the sidebar, depending on where the cursor is. function M.toggle() + if opening then + -- As if typed once the open landed: after a toggle, a press closes what it opened; after another open, it focuses + -- it. + if opening.focus then + release() + else + opening.focus = { win = vim.api.nvim_get_current_win() } + end + return + end local action = next_action({ visible = window.is_visible(), focused = window.is_focused() }) if action == "open" then - M.open() - window.focus() + local pending = open() + if pending then + pending.focus = { win = vim.api.nvim_get_current_win() } + else + window.focus() + end elseif action == "focus" then window.focus() else @@ -994,13 +1126,16 @@ vim.api.nvim_create_autocmd({ "WinEnter", "BufWinEnter" }, { }) -- Fires: a session starting to load. It lays its windows out from the focused one, and --- the sidebar's would take a file with the sidebar's window options still on it. +-- the sidebar's would take a file with the sidebar's window options still on it. Drops an open still measuring too, +-- which would land on the session's layout beside the placeholder `restore()` fills. vim.api.nvim_create_autocmd("SessionLoadPre", { group = vim.api.nvim_create_augroup("changeset.session", { clear = true }), desc = "changeset: close the sidebar before a session lays out its windows", callback = function() if window.buf() then M.close() + elseif opening then + release() end end, }) diff --git a/lua/changeset/review.lua b/lua/changeset/review.lua index 0e41f2b..24435af 100644 --- a/lua/changeset/review.lua +++ b/lua/changeset/review.lua @@ -30,12 +30,23 @@ local want ---@type string? Toplevel of the repository the base acts on. local toplevel ----@type table Bases `apply` has set. +---@type table Bases `point_at` has set. local ours = {} ---@type table Buffers with a move in flight, by target; false is the index. local moving = {} +---The `sync` whose measure is in flight; until it lands, `want` is the base of what it left, so nothing moves. +---@type table? +local syncing + +---Whether `sync` is waiting on no measure other than `token`'s. +---@param token table +---@return boolean +local function in_flight(token) + return syncing == token +end + ---@type boolean Whether `on_read` is registered; gitsigns has no way to remove a callback. local hears_reads = false @@ -50,28 +61,30 @@ local hears_reads = false ---watcher reclaims it. A buffer gitsigns holds no text for may have a read in flight, ---and gitsigns stores that read's text even after a move has replaced its base, so the ---move waits for the update that publishes the read. +---@param root string? Toplevel of the repository whose buffers may move. ---@param buf integer ---@param bcache Gitsigns.CacheEntry ---@return boolean -local function movable(buf, bcache) +local function movable(root, buf, bcache) local git_obj = bcache.git_obj - return git_obj.repo.toplevel == toplevel + return git_obj.repo.toplevel == root and not vim.api.nvim_buf_get_name(buf):match("^%a+://") and (git_obj.revision == nil or ours[git_obj.revision] == true) and bcache.compare_text ~= nil end ----Move one buffer onto `want`, unless a move there is already in flight. +---Move one buffer onto `base`, unless a move there is already in flight. ---@param buf integer +---@param base string? nil is the index. ---@param done fun(err: string?)? -local function move(buf, done) - local target = want or false +local function move(buf, base, done) + local target = base or false if moving[buf] == target then return done and done() end moving[buf] = target vim.api.nvim_buf_call(buf, function() - require("gitsigns").change_base(want, false, function(err) + require("gitsigns").change_base(base, false, function(err) if moving[buf] == target then moving[buf] = nil end @@ -92,18 +105,21 @@ end ---Move buffers that missed the base, such as ones attached while it changed. local function reconcile() + if syncing then + return + end for buf, bcache in pairs(require("gitsigns.cache").cache) do - if bcache.git_obj.revision ~= want and movable(buf, bcache) then - move(buf, report) + if bcache.git_obj.revision ~= want and movable(toplevel, buf, bcache) then + move(buf, want, report) end end end ----Point every buffer this file may move now at `base`; the rest move once gitsigns reads them. +---Point every buffer of `root` this file may move now at `base`; the rest move once gitsigns reads them. +---@param root string? ---@param base string? ---@param done fun(err: string?)? Called once every move has landed, with the first error. -local function apply(base, done) - want = base +local function point_at(root, base, done) if base then ours[base] = true end @@ -116,18 +132,26 @@ local function apply(base, done) end end for buf, bcache in pairs(require("gitsigns.cache").cache) do - if movable(buf, bcache) then + if movable(root, buf, bcache) then -- The repo watcher's refresh reads this field right after GitSignsUpdate -- and writes it back when it lands; setting it now makes that refresh land -- on the new base. bcache.git_obj.revision = base left = left + 1 - move(buf, landed) + move(buf, base, landed) end end landed() end +---Make `base` the one every buffer of the repository the base acts on should diff against, and move them onto it. +---@param base string? +---@param done fun(err: string?)? +local function apply(base, done) + want = base + point_at(toplevel, base, done) +end + ---Move onto `point`, a fork point measured for `branch` at `root`, while that is still what the base is resolved for; ---without one, onto the index. ---@param root string @@ -151,7 +175,7 @@ local function remeasure(root, branch) end ---Follow a change of repository or branch, re-resolving the base, since the fork point belongs to the branch left ----behind. +---behind. It measures without blocking; until that lands no buffer moves, since `want` still names the old base. ---@param root string ---@param branch string local function sync(root, branch) @@ -159,7 +183,18 @@ local function sync(root, branch) return end toplevel, applied, applied_head = root, branch, select(2, Git.head(root)) - follow(root, branch, (fork_point.get(root, branch))) + local token = {} + syncing = token + fork_point.get_async(root, branch, function(point) + if in_flight(token) then + syncing = nil + follow(root, branch, point) + reconcile() + elseif root ~= toplevel and point then + -- Another repository's sync replaced this one; its buffers still belong on this base. + point_at(root, point.base, report) + end + end) end ---Follow HEAD moving to `head` on the branch the base is resolved for, as a pull, a commit or a rebase moves it: the @@ -209,8 +244,8 @@ end ---unchanged, since gitsigns sends `GitSignsUpdate` only when the status changes. ---@param ctx Gitsigns.ManagerUpdate local function on_read(ctx) - if ctx.bcache.git_obj.revision ~= want and movable(ctx.bufnr, ctx.bcache) then - move(ctx.bufnr, report) + if not syncing and ctx.bcache.git_obj.revision ~= want and movable(toplevel, ctx.bufnr, ctx.bcache) then + move(ctx.bufnr, want, report) end end diff --git a/tests/base_pick_spec.lua b/tests/base_pick_spec.lua index a44a338..301e9fa 100644 --- a/tests/base_pick_spec.lua +++ b/tests/base_pick_spec.lua @@ -8,7 +8,7 @@ local Fixture = require("support.git") local present = require("support.present") local Paths = require("changeset.paths") local base = require("changeset.base") -local fork_point = require("changeset.fork_point") +local measured = require("support.fork_point") describe("the base pickers with mini.pick", function() local root ---@type string @@ -39,7 +39,7 @@ describe("the base pickers with mini.pick", function() base.pick("branch") - assert.equal("other", present((fork_point.get(Paths.root(0), "feature"))).against) + assert.equal("other", present((measured.get(Paths.root(0), "feature"))).against) end) it("draws each item under its glyph, with when it was last committed to at the right edge", function() diff --git a/tests/base_spec.lua b/tests/base_spec.lua index 27cbd0a..6fa3815 100644 --- a/tests/base_spec.lua +++ b/tests/base_spec.lua @@ -4,7 +4,7 @@ local Paths = require("changeset.paths") local base = require("changeset.base") local fixture = require("support.git") local present = require("support.present") -local fork_point = require("changeset.fork_point") +local measured = require("support.fork_point") ---Commit a file named `name` at `date`, which orders the branches whose tip it is. ---@param root string @@ -31,7 +31,7 @@ end ---What the current branch is compared against now. ---@return string local function against() - return present((fork_point.get(Paths.root(0), "feature"))).against + return present((measured.get(Paths.root(0), "feature"))).against end describe(":Changeset base", function() diff --git a/tests/build_spec.lua b/tests/build_spec.lua index 42dbb2d..55e9991 100644 --- a/tests/build_spec.lua +++ b/tests/build_spec.lua @@ -1,3 +1,4 @@ +local Build = require("support.build") local build = require("changeset.build") local resolve = require("changeset.resolve") local Changes = require("support.changes") @@ -6,6 +7,7 @@ local Notify = require("support.notify") local symbols = require("support.symbols") local gh = require("support.gh") -- a fake gh on PATH: never the real one, never the network local present = require("support.present") +local spawns = require("support.spawns") ---Every announcement the tree makes, in order; emptied before each case. ---@type changeset.TreeEvent[] @@ -14,39 +16,11 @@ build.subscribe(function(event) events[#events + 1] = event end) ----Runs `fn`, keeping the argv of every process started through `vim.system` or `vim.fn.systemlist`. ----A wait belongs inside `fn`: `check-attr` starts from a scheduled callback, after `build()` returns. ----@param fn fun() ----@return string[][] -local function recording(fn) - local argvs = {} - local real_system, real_systemlist = vim.system, vim.fn.systemlist - vim.system = function(argv, ...) - argvs[#argvs + 1] = argv - return real_system(argv, ...) - end - vim.fn.systemlist = function(argv, ...) - argvs[#argvs + 1] = argv - return real_systemlist(argv, ...) - end - local ok, err = pcall(fn) - vim.system, vim.fn.systemlist = real_system, real_systemlist - assert.is_true(ok, tostring(err)) - return argvs -end - ----Whether `argv` starts `program` with `subcommand`, as `git -C merge-base …` starts git's merge-base. ----@param argv string[] ----@param program string ----@param subcommand string? ----@return boolean -local function runs(argv, program, subcommand) - return argv[1] == program and (subcommand == nil or vim.list_contains(argv, subcommand)) -end +local recording, runs = spawns.recording, spawns.runs ---Build the tree, waiting for its diff. local function build_and_collect() - assert.is_true(build.build()) + assert.equal("built", Build.build()) assert.is_true(vim.wait(10000, function() return (build.current() or {}).collected end, 25)) @@ -116,6 +90,133 @@ describe("changeset.build", function() end, 10)) end) + it("measures the first build's fork point without a blocking git call", function() + spawns.measures_unblocked(function() + assert.equal("built", Build.build()) + end) + end) + + it("rebuilds for a switched branch without a blocking git call", function() + build_and_collect() + Fixture.git({ "checkout", "-q", "-b", "feature2" }, tmp) + + local blocked = spawns.blocking(function() + build.update() + assert.is_true(vim.wait(10000, function() + return present(build.current()).branch == "feature2" + end, 10)) + end) + + for _, argv in ipairs(blocked) do + assert.is_false(spawns.measuring(argv), table.concat(argv, " ")) + end + end) + + it("an ask for another repository answers the replaced ask with replaced", function() + local other = vim.fn.tempname() + vim.fn.mkdir(other, "p") + Fixture.feature_one_file(other) + local heard = { first = {}, second = {} } + + build.build(function(outcome) + table.insert(heard.first, { outcome }) + end) + vim.cmd.edit(other .. "/mod.lua") + build.build(function(outcome) + table.insert(heard.second, { outcome }) + end) + assert.is_true(vim.wait(10000, function() + return #heard.second > 0 + end, 10)) + -- The replaced ask's measure is still running; give a stray second answer time to arrive. + vim.wait(200) + + vim.fn.delete(other, "rf") + assert.are.same({ { "replaced" } }, heard.first) + assert.are.same({ { "built" } }, heard.second) + end) + + it("lands an ask made while a build measures on a fork point measured after the ask", function() + local first = Fixture.git({ "rev-parse", "HEAD" }, tmp) + vim.fn.writefile({ "return 2" }, tmp .. "/second.lua") + Fixture.commit("second", tmp) + local fork_point = require("changeset.fork_point") + local get_async = fork_point.get_async + local held = {} ---@type fun()[] + fork_point.get_async = function(root, branch, on_done) + get_async(root, branch, function(point) + held[#held + 1] = function() + on_done(point) + end + end) + end + local landed = 0 + build.build(function() + landed = landed + 1 + end) + assert.is_true(vim.wait(10000, function() + return #held > 0 + end, 10)) + fork_point.get_async = get_async + + Fixture.git({ "update-ref", "refs/heads/trunk", first }, tmp) + build.build(function() + landed = landed + 1 + end) + present(held[1])() + + assert.is_true(vim.wait(10000, function() + return landed == 2 + end, 10)) + assert.equal(first, present(build.current()).base) + end) + + it("lands a build that asks keep joining while it measures", function() + local fork_point = require("changeset.fork_point") + local get_async = fork_point.get_async + local measures = 0 + fork_point.get_async = function(root, branch, on_done) + measures = measures + 1 + build.build(function() end) + get_async(root, branch, on_done) + end + local landed + build.build(function(outcome) + landed = outcome + end) + + local ok = vim.wait(10000, function() + return landed ~= nil + end, 10) + fork_point.get_async = get_async + assert.is_true(ok, "the build never landed") + assert.equal("built", landed) + assert.equal(3, measures) + end) + + it("builds for the branch HEAD switched to while it measured", function() + local fork_point = require("changeset.fork_point") + local get_async, held = fork_point.get_async, {} + fork_point.get_async = function(root, branch, on_done) + held[#held + 1] = { root, branch, on_done } + end + local landed + build.build(function(outcome) + landed = outcome + end) + fork_point.get_async = get_async + Fixture.git({ "checkout", "-q", "-b", "feature2" }, tmp) + for _, args in ipairs(held) do + get_async(args[1], args[2], args[3]) + end + + assert.is_true(vim.wait(10000, function() + return landed ~= nil + end, 10)) + assert.equal("built", landed) + assert.equal("feature2", present(build.current()).branch) + end) + it("names the ref set by hand as its base when that ref forks where the base does", function() build_and_collect() Fixture.git({ "tag", "v1", "trunk" }, tmp) @@ -194,7 +295,7 @@ describe("changeset.build", function() Fixture.feature_one_file(other) vim.cmd.edit(other .. "/mod.lua") - build.build() + Build.build() local saved = require("changeset.cache").load(require("changeset.cache").path(root)) vim.fn.delete(other, "rf") @@ -477,7 +578,7 @@ describe("changeset.build", function() end it("announces the diff, then a file's symbols as they arrive", function() - build.build() + Build.build() wait_for_asks(1) assert.same({ "diff" }, events) @@ -487,7 +588,7 @@ describe("changeset.build", function() end) it("stays silent for an answer to a refresh that a newer one replaced", function() - build.build() + Build.build() wait_for_asks(1) build.refresh() wait_for_asks(2) @@ -575,7 +676,7 @@ describe("changeset.build", function() end) it("announces the failure", function() - build.build() + Build.build() assert.same({ "failed" }, events) end) @@ -597,7 +698,7 @@ describe("changeset.build on a branch measured against its PR's target", functio ---Build the tree, waiting for gh's PR to move it onto parent. local function build_on_pr() - assert.is_true(build.build()) + assert.equal("built", Build.build()) assert.is_true(vim.wait(5000, function() local tree = build.current() return tree ~= nil and tree.pr == 7 and tree.collected diff --git a/tests/fork_point_spec.lua b/tests/fork_point_spec.lua index 917f371..8427ddd 100644 --- a/tests/fork_point_spec.lua +++ b/tests/fork_point_spec.lua @@ -1,6 +1,8 @@ local gh = require("support.gh") local fixture = require("support.git") +local measured = require("support.fork_point") local present = require("support.present") +local spawns = require("support.spawns") local Git = require("changeset.git") local fork_point = require("changeset.fork_point") @@ -27,6 +29,23 @@ local function await_heard(root, count) end)) end +---`branch`'s fork point at `root`, once measured. `heard` drops the subscribers' copy, so it keeps only what follows, +---such as gh's answer. +---@param root string +---@param branch string +---@return changeset.ForkPoint? +local function get(root, branch) + local before = #heard + local point = measured.get(root, branch) + for i = before + 1, #heard do + if heard[i].root == root then + table.remove(heard, i) + break + end + end + return point +end + ---@param root string ---@param name string ---@return string @@ -86,7 +105,7 @@ describe("fork_point", function() it("measures against the default branch on a branch with no PR", function() local root, default_base = repo() - local point = present((fork_point.get(root, "feature"))) + local point = present((get(root, "feature"))) assert.equal(default_base, point.base) assert.equal("main", point.against) @@ -109,8 +128,8 @@ describe("fork_point", function() end local point ---@type changeset.ForkPoint? - fork_point.get_async(root, "feature", function(measured) - point = measured + fork_point.get_async(root, "feature", function(landed) + point = landed end) vim.wait(5000, function() return point ~= nil @@ -136,7 +155,7 @@ describe("fork_point", function() return sha, ref end - local point = fork_point.get(root, "feature") + local point = get(root, "feature") Git.merge_base = real_merge_base assert.same({ 3, default_base }, { measures, present(point).base }) @@ -145,7 +164,7 @@ describe("fork_point", function() it("measures against the branch it was created from without waiting on gh", function() local root, _, parent_base = repo("parent") - local point = present((fork_point.get(root, "feature"))) + local point = present((get(root, "feature"))) assert.equal(parent_base, point.base) assert.equal("parent", point.against) @@ -163,7 +182,7 @@ describe("fork_point", function() fixture.git({ "checkout", "-q", "-b", "feature", "parent" }, root) commit_file(root, "feature.txt") - local point = present((fork_point.get(root, "feature"))) + local point = present((get(root, "feature"))) assert.equal(fork, point.base) assert.equal("parent", point.against) @@ -176,7 +195,7 @@ describe("fork_point", function() local squashed = fixture.commit("squash parent", root) fixture.git({ "rebase", "-q", "--onto", "main", "parent", "feature" }, root) - local point = present((fork_point.get(root, "feature"))) + local point = present((get(root, "feature"))) assert.equal(squashed, point.base) assert.equal("main", point.against) @@ -186,7 +205,7 @@ describe("fork_point", function() vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "parent", number = 7 }) local root, _, parent_base = repo("parent") - fork_point.get(root, "feature") + get(root, "feature") assert.is_true(await_heard(root, 1)) local point = present(heard_in(root)[1]).point @@ -198,10 +217,10 @@ describe("fork_point", function() vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "main", number = 7 }) local root, _, parent_base = repo("parent") - fork_point.get(root, "feature") + get(root, "feature") assert.is_true(await_heard(root, 1)) local point = present(heard_in(root)[1]).point - local again = fork_point.get(root, "feature") + local again = get(root, "feature") for _, p in ipairs({ point, again }) do assert.equal(parent_base, p.base) @@ -217,7 +236,7 @@ describe("fork_point", function() fixture.git({ "merge", "-q", "--no-ff", "-m", "merge parent", "parent" }, root) fixture.git({ "checkout", "-q", "feature" }, root) - fork_point.get(root, "feature") + get(root, "feature") assert.is_true(await_heard(root, 1)) assert.equal(7, present(heard_in(root)[1]).point.pr) @@ -234,7 +253,7 @@ describe("fork_point", function() fixture.git({ "checkout", "-q", "-b", "feature", "parent" }, root) commit_file(root, "feature.txt") - fork_point.get(root, "feature") + get(root, "feature") assert.is_true(await_heard(root, 1)) assert.equal(7, present(heard_in(root)[1]).point.pr) @@ -244,7 +263,7 @@ describe("fork_point", function() vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "parent", number = 7 }) local root = repo("main") - fork_point.get(root, "feature") + get(root, "feature") assert.is_true(await_heard(root, 1)) local point = present(heard_in(root)[1]).point @@ -256,10 +275,10 @@ describe("fork_point", function() vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "parent", number = 7 }) local root, default_base, parent_base = repo() - local first, asking = fork_point.get(root, "feature") + local first = get(root, "feature") assert.is_truthy(first) assert.equal(default_base, present(first).base) - assert.is_true(asking) + assert.equal(1, asks) assert.is_true(await_heard(root, 1)) local point = present(heard_in(root)[1]).point @@ -267,15 +286,15 @@ describe("fork_point", function() assert.equal("parent", point.against) assert.equal(7, point.pr) - local again, still_asking = fork_point.get(root, "feature") + local again = get(root, "feature") assert.same(point, again) - assert.is_false(still_asking) + assert.equal(1, asks) end) it("moves to the target gh names when asked again", function() vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "parent", number = 7 }) local root, default_base = repo() - fork_point.get(root, "feature") + get(root, "feature") assert.is_true(await_heard(root, 1)) vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "main", number = 7 }) @@ -289,7 +308,7 @@ describe("fork_point", function() it("asks gh once while a recheck is in flight", function() vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "parent", number = 7 }) local root = repo() - fork_point.get(root, "feature") + get(root, "feature") assert.is_true(await_heard(root, 1)) vim.env.FAKE_GH_DELAY = "0.3" @@ -303,7 +322,7 @@ describe("fork_point", function() it("does not measure again in the background when a recheck names the same target", function() vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "parent", number = 7 }) local root = repo() - fork_point.get(root, "feature") + get(root, "feature") assert.is_true(await_heard(root, 1)) local real_get_async, measures = fork_point.get_async, 0 fork_point.get_async = function(...) @@ -339,61 +358,39 @@ describe("fork_point", function() assert.equal(1, measures) end) - ---@param root string - ---@return changeset.ForkPoint? - local function measured_async(root) - local done, point = false, nil - fork_point.get_async(root, "feature", function(measured) - done, point = true, measured - end) - assert.is_true(vim.wait(5000, function() - return done - end, 10)) - return point - end - - for _, case in ipairs({ - { "the default branch", nil, nil }, - { "the branch it was created from", "parent", nil }, - { "its PR's target", nil, "parent" }, - }) do - it("measures in the background what it measures at once, against " .. case[1], function() - local root = repo(case[2]) - if case[3] then - vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = case[3], number = 7 }) - fork_point.get(root, "feature") - assert.is_true(await_heard(root, 1)) - end - - local point = fork_point.get(root, "feature") - - assert.same(point, measured_async(root)) - end) - end - 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() - fork_point.get(root, "feature") + local answered = false + local pr_target = Git.pr_target + Git.pr_target = function(r, on_answer) + return pr_target(r, function(...) + on_answer(...) + answered = true + end) + end + get(root, "feature") local real_system, held = vim.system, {} vim.system = function(argv, opts, on_exit) - if argv[1] == "gh" then + if argv[1] == "gh" or answered then return real_system(argv, opts, on_exit) end held[#held + 1] = { argv, opts, on_exit } return {} end local point ---@type changeset.ForkPoint? - fork_point.get_async(root, "feature", function(measured) - point = measured + fork_point.get_async(root, "feature", function(landed) + point = landed end) - local answered = await_heard(root, 1) + local done = vim.wait(5000, function() + return answered + end, 10) vim.system = real_system for _, process in ipairs(held) do real_system(unpack(process)) end - assert.is_true(answered) + assert.is_true(done) assert.is_true(vim.wait(5000, function() return point ~= nil @@ -402,14 +399,29 @@ describe("fork_point", function() assert.equal(7, present(point).pr) end) + it("measures again without blocking once gh names a target", function() + vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "parent", number = 7 }) + local root, _, parent_base = repo() + + spawns.measures_unblocked(function() + fork_point.get_async(root, "feature", function() end) + assert.is_true(vim.wait(5000, function() + return vim.iter(heard_in(root)):any(function(h) + return h.point.pr == 7 + end) + end, 10)) + end) + assert.equal(parent_base, present(heard_in(root)[#heard_in(root)]).point.base) + end) + it("stays on the default base when the PR's target shares no fork point", function() vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "gone", number = 7 }) local root, default_base = repo() - fork_point.get(root, "feature") + get(root, "feature") assert.is_true(await_heard(root, 1)) local point = present(heard_in(root)[1]).point - local again = fork_point.get(root, "feature") + local again = get(root, "feature") for _, p in ipairs({ point, again }) do assert.equal(default_base, p.base) @@ -421,7 +433,7 @@ describe("fork_point", function() vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "parent", number = 7, state = "MERGED" }) local root, default_base = repo() - fork_point.get(root, "feature") + get(root, "feature") assert.is_true(await_heard(root, 1)) assert.equal(default_base, present(heard_in(root)[1]).point.base) @@ -431,7 +443,7 @@ describe("fork_point", function() it("answers the default base when gh is not installed", function() local root, default_base = repo() - gh.without(fork_point.get, root, "feature") + gh.without(get, root, "feature") assert.is_true(await_heard(root, 1)) assert.equal(default_base, present(heard_in(root)[1]).point.base) @@ -441,29 +453,29 @@ describe("fork_point", function() vim.env.FAKE_GH_DELAY = "0.2" local root = repo() - fork_point.get(root, "feature") - fork_point.get(root, "feature") + get(root, "feature") + get(root, "feature") assert.equal(1, asks) assert.is_true(await_heard(root, 1)) - fork_point.get(root, "feature") + get(root, "feature") assert.equal(2, asks) end) it("keeps separate answers for two repositories on the same branch", function() vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "parent", number = 7 }) local stacked, _, parent_base = repo() - fork_point.get(stacked, "feature") + get(stacked, "feature") assert.is_true(await_heard(stacked, 1)) vim.env.FAKE_GH_PR = nil local plain, default_base = repo() - local point, asking = fork_point.get(plain, "feature") + local point = get(plain, "feature") assert.is_truthy(point) assert.equal(default_base, present(point).base) - assert.is_true(asking) - assert.equal(parent_base, present((fork_point.get(stacked, "feature"))).base) + assert.equal(2, asks) + assert.equal(parent_base, present((get(stacked, "feature"))).base) end) it("returns nil outside a repository and asks gh nothing", function() @@ -471,7 +483,7 @@ describe("fork_point", function() vim.fn.mkdir(dir, "p") table.insert(roots, dir) - assert.is_nil((fork_point.get(dir, "feature"))) + assert.is_nil((get(dir, "feature"))) assert.equal(0, asks) end) @@ -480,7 +492,7 @@ describe("fork_point", function() local root, default_base = repo("parent") fork_point.pin(root, "feature", "main") - local point = present((fork_point.get(root, "feature"))) + local point = present((get(root, "feature"))) assert.same({ default_base, "main", "main" }, { point.base, point.against, point.ref }) end) @@ -490,7 +502,7 @@ describe("fork_point", function() fork_point.pin(root, "feature", "main") fork_point.pin(root, "feature", nil) - local point = present((fork_point.get(root, "feature"))) + local point = present((get(root, "feature"))) assert.same({ parent_base, "parent" }, { point.base, point.against }) end) @@ -499,7 +511,7 @@ describe("fork_point", function() local root, _, parent_base = repo("parent") fork_point.pin(root, "feature", "deleted") - assert.equal(parent_base, present((fork_point.get(root, "feature"))).base) + assert.equal(parent_base, present((get(root, "feature"))).base) end) it("keeps it to its own branch", function() @@ -509,7 +521,7 @@ describe("fork_point", function() fixture.git({ "checkout", "-q", "-b", "child" }, root) commit_file(root, "child.txt") - local point = present((fork_point.get(root, "child"))) + local point = present((get(root, "child"))) assert.same({ feature_tip, "feature" }, { point.base, point.against }) end) @@ -521,7 +533,7 @@ describe("fork_point", function() for _, ref in ipairs({ "v1", default_base:sub(1, 12), "main", false }) do fork_point.pin(root, "feature", ref or nil) - kinds[#kinds + 1] = present((fork_point.get(root, "feature"))).kind + kinds[#kinds + 1] = present((get(root, "feature"))).kind end assert.same({ "tag", "commit", "branch", "branch" }, kinds) @@ -539,7 +551,7 @@ describe("fork_point", function() it("names the PR whose target it is", function() vim.env.FAKE_GH_PR = gh.pr_view({ baseRefName = "main", number = 7 }) local root = repo("parent") - fork_point.get(root, "feature") + get(root, "feature") assert.is_true(await_heard(root, 1)) fork_point.pin(root, "feature", "main") diff --git a/tests/highlights_spec.lua b/tests/highlights_spec.lua index 9f304cf..440ba02 100644 --- a/tests/highlights_spec.lua +++ b/tests/highlights_spec.lua @@ -1,3 +1,4 @@ +local Build = require("support.build") local changeset = require("changeset") local build = require("changeset.build") -- The maps live in the plugin file, which the spec runner does not load. @@ -202,7 +203,7 @@ describe("changeset row highlights", function() it("keeps tracking where you are while the sidebar is closed", function() vim.cmd.edit("mod.lua") - assert.is_true(build.build()) + assert.equal("built", Build.build()) vim.cmd.edit("other.lua") diff --git a/tests/persist_spec.lua b/tests/persist_spec.lua index 7705081..cd466b4 100644 --- a/tests/persist_spec.lua +++ b/tests/persist_spec.lua @@ -1,3 +1,4 @@ +local Build = require("support.build") local changeset = require("changeset") local highlights = require("changeset.highlights") local Rows = require("changeset.rows") @@ -120,6 +121,49 @@ describe("changeset position in a session", function() assert.truthy((Sidebar.cursor_line():find("other.lua", 1, true))) end) + it("a restored session fills its placeholder in the placeholder's tabpage once the fork point lands", function() + vim.cmd.edit("mod.lua") + changeset.open() + Sidebar.settle() + Sidebar.cursor_to("other.lua") + Sidebar.flush() + local recorded = vim.g.ChangesetPosition + changeset.close() + Fixture.git({ "checkout", "-q", "-b", "scratch", "trunk" }, tmp) + vim.fn.writefile({ "return 1" }, tmp .. "/scratch.lua") + Fixture.git({ "add", "scratch.lua" }, tmp) + Fixture.commit("scratch", tmp) + assert.equal("built", Build.build()) + Fixture.git({ "checkout", "-q", "feature" }, tmp) + local session_tab = vim.api.nvim_get_current_tabpage() + + restore_session(recorded) + vim.cmd.tabnew() + assert.is_true(vim.wait(10000, function() + return window.buf() ~= nil + end, 10)) + vim.api.nvim_set_current_tabpage(session_tab) + Sidebar.settle() + Sidebar.flush() + vim.cmd("silent! tabonly!") + + assert.equal(session_tab, vim.api.nvim_win_get_tabpage(present(window.win()))) + assert.truthy((Sidebar.cursor_line():find("other.lua", 1, true))) + end) + + it("a session loaded while an open waits cancels it", function() + vim.cmd.edit("mod.lua") + + changeset.open() + vim.api.nvim_exec_autocmds("SessionLoadPre", {}) + assert.is_true(vim.wait(10000, function() + return require("changeset.build").kept() ~= nil + end, 10)) + Sidebar.flush() + + assert.is_false(window.is_visible()) + end) + it("brings the sidebar back scrolled as it was", function() vim.cmd.edit("mod.lua") changeset.open() @@ -185,6 +229,7 @@ describe("changeset position in a session", function() vim.cmd.edit("mod.lua") restore_session({ here = { path = "mod.lua", lnum = 8 } }, true) + Sidebar.await_open() assert.equal(window.win(), vim.api.nvim_get_current_win()) assert.is_true(Cursor.hidden()) @@ -257,7 +302,9 @@ describe("changeset position in a session", function() vim.fn.chdir(tmp) vim.fn.delete(outside, "rf") assert.is_true(ok, tostring(err)) - assert.equal(before, #vim.api.nvim_tabpage_list_wins(0)) + assert.is_true(vim.wait(10000, function() + return before == #vim.api.nvim_tabpage_list_wins(0) + end, 10)) end) it("lets go of a restored sidebar window it cannot fill when it is the only window, and of its buffer", function() @@ -275,7 +322,9 @@ describe("changeset position in a session", function() vim.fn.delete(outside, "rf") assert.is_true(ok, tostring(err)) - assert.is_false(vim.api.nvim_buf_is_valid(stale)) + assert.is_true(vim.wait(10000, function() + return not vim.api.nvim_buf_is_valid(stale) + end, 10)) end) it("lets go of a restored sidebar window it cannot fill when only floats stand beside it", function() @@ -301,7 +350,9 @@ describe("changeset position in a session", function() vim.fn.delete(outside, "rf") assert.is_true(ok, tostring(err)) - assert.is_false(vim.api.nvim_buf_is_valid(stale)) + assert.is_true(vim.wait(10000, function() + return not vim.api.nvim_buf_is_valid(stale) + end, 10)) end) it( @@ -318,6 +369,9 @@ describe("changeset position in a session", function() vim.api.nvim_set_current_tabpage(first) vim.api.nvim_exec_autocmds("SessionLoadPost", {}) + assert.is_true(vim.wait(10000, function() + return window.buf() ~= nil + end, 10)) local seen = { tab = vim.api.nvim_get_current_tabpage(), shown = vim.api.nvim_win_get_buf(placeholder) } local tree = window.buf() vim.cmd("silent! tabonly") diff --git a/tests/pick_spec.lua b/tests/pick_spec.lua index 9032fa8..a9baf68 100644 --- a/tests/pick_spec.lua +++ b/tests/pick_spec.lua @@ -1,3 +1,4 @@ +local Sidebar = require("support.sidebar") vim.opt.rtp:prepend(require("support.deps").path("mini.pick")) vim.opt.rtp:prepend(require("support.deps").path("mini.icons")) require("mini.pick").setup() @@ -265,6 +266,7 @@ describe("changeset.pick", function() vim.cmd("leftabove vnew") vim.api.nvim_set_current_win(from) require("changeset").open() + Sidebar.await_open() local sidebar = present(window.win()) vim.api.nvim_set_current_win(sidebar) @@ -281,6 +283,7 @@ describe("changeset.pick", function() local from = vim.api.nvim_get_current_win() vim.fn.writefile({ "return 0" }, "plain.lua") require("changeset").open() + Sidebar.await_open() local sidebar = present(window.win()) vim.api.nvim_set_current_win(sidebar) -- Entering a window runs handlers that may redraw the screen, which would show the choice in the sidebar. diff --git a/tests/refresh_spec.lua b/tests/refresh_spec.lua index fc4df86..6b863cc 100644 --- a/tests/refresh_spec.lua +++ b/tests/refresh_spec.lua @@ -35,6 +35,9 @@ describe(":Changeset refresh", function() it("says why when there is no tree and none can be built", function() vim.cmd("Changeset refresh") + assert.is_true(vim.wait(10000, function() + return #notes > 0 + end, 10)) assert.is_nil(build().current()) assert.same({ { msg = "Changeset: no merge base with the default branch", level = vim.log.levels.WARN } }, notes) @@ -45,6 +48,9 @@ describe(":Changeset refresh", function() vim.cmd.edit("mod.lua") vim.cmd("Changeset refresh") + assert.is_true(vim.wait(10000, function() + return build().current() ~= nil + end, 10)) assert.equal(Paths.root(0), present(build().current()).root) end) diff --git a/tests/restore_comment_row_spec.lua b/tests/restore_comment_row_spec.lua index a40f60d..55bce88 100644 --- a/tests/restore_comment_row_spec.lua +++ b/tests/restore_comment_row_spec.lua @@ -41,6 +41,7 @@ describe("a restored session", function() vim.g.ChangesetPosition = vim.json.encode({ row = { id = case[2], path = case[3] } }) changeset.restore() + Sidebar.await_open() assert.is_true(vim.wait(10000, function() return vim.iter(Sidebar.lines()):any(function(line) return line:find("alpha.txt:13", 1, true) ~= nil diff --git a/tests/review_switch_spec.lua b/tests/review_switch_spec.lua index b730951..bcbc6a9 100644 --- a/tests/review_switch_spec.lua +++ b/tests/review_switch_spec.lua @@ -2,6 +2,8 @@ local gh = require("support.gh") local support = require("support.git") local gutter = require("support.gutter") local present = require("support.present") +local spawns = require("support.spawns") +local fork_point = require("changeset.fork_point") local await, await_cached, show_and_await, edit, revision, settle, base_text = gutter.await, @@ -16,6 +18,18 @@ local function moves() return gutter.moves end +---A repository beside the one under test, with `x.txt` committed on main, and its base. +---@return string root +---@return string base +local function other_repo() + local other = vim.fn.resolve(vim.fn.tempname()) + vim.fn.mkdir(other, "p") + support.init_repo("main", other) + vim.fn.writefile({ "one" }, other .. "/x.txt") + support.commit("base", other) + return other, gutter.merge_base(other) +end + describe("the gutter's base", function() local dir ---@type string local cwd ---@type string @@ -78,17 +92,16 @@ describe("the gutter's base", function() end) it("keeps what is applied, asking nothing, while HEAD is detached", function() - local fork_point = require("changeset.fork_point") local files = { "a.txt" } gutter.fixture(dir, "picking", files) vim.fn.chdir(dir) local bufs = edit(files) local base = gutter.merge_base(dir) assert.is_true(await(bufs, base, 10000)) - local get, asked = fork_point.get, {} - fork_point.get = function(root, branch) + local get_async, asked = fork_point.get_async, {} + fork_point.get_async = function(root, branch, on_done) asked[#asked + 1] = branch - return get(root, branch) + return get_async(root, branch, on_done) end support.git({ "switch", "-q", "--detach" }, dir) @@ -97,7 +110,7 @@ describe("the gutter's base", function() return status ~= nil and status.head ~= "picking" end, 25) settle() - fork_point.get = get + fork_point.get_async = get_async assert.is_true(detached, "gitsigns never saw HEAD detach") assert.same({}, asked) @@ -132,12 +145,7 @@ describe("the gutter's base", function() end) it("leaves buffers from another repository alone", function() - local other = vim.fn.resolve(vim.fn.tempname()) - vim.fn.mkdir(other, "p") - support.init_repo("main", other) - vim.fn.writefile({ "one" }, other .. "/x.txt") - support.commit("base", other) - local theirs = gutter.merge_base(other) + local other, theirs = other_repo() gutter.fixture(dir, "scoped", { "a.txt" }) support.git({ "switch", "-q", "main" }, dir) gutter.advance(dir) @@ -409,4 +417,82 @@ describe("the gutter's base", function() assert.is_true(show_and_await({ b }, main_tip, 10000)) assert.are.same(want, base_text(b)) end) + + it("follows a branch it attaches to without blocking", function() + gutter.fixture(dir, "unblocked", { "a.txt" }) + gutter.advance(dir) + local base = gutter.merge_base(dir) + vim.fn.chdir(dir) + + local bufs ---@type integer[] + spawns.measures_unblocked(function() + bufs = edit({ "a.txt" }) + assert.is_true(await_cached(bufs)) + end) + assert.is_true(await(bufs, base, 5000)) + end) + + it("never moves a buffer onto the base of the branch it left", function() + gutter.fixture(dir, "left", { "a.txt", "c.txt" }) + local left_base = gutter.merge_base(dir) + support.git({ "switch", "-q", "main" }, dir) + gutter.advance(dir) + support.git({ "switch", "-q", "-c", "right" }, dir) + gutter.advance(dir) + local right_base = gutter.merge_base(dir) + support.git({ "switch", "-q", "left" }, dir) + vim.fn.chdir(dir) + local a = edit({ "a.txt" }) + assert.is_true(show_and_await(a, left_base, 5000)) + assert.is_true(settle()) + local get_async, held = fork_point.get_async, {} + fork_point.get_async = function(root, branch, on_done) + held[#held + 1] = { root, branch, on_done } + end + + support.git({ "switch", "-q", "right" }, dir) + local switched = vim.wait(5000, function() + return vim.b[a[1]].gitsigns_head == "right" + end, 20) + local before = gutter.moves_to[left_base] or 0 + local c = edit({ "c.txt" }) + local cached = await_cached(c) + settle() + fork_point.get_async = get_async + for _, args in ipairs(held) do + get_async(args[1], args[2], args[3]) + end + + assert.is_true(switched, "gitsigns never saw the switch") + assert.is_true(cached) + assert.equal(before, gutter.moves_to[left_base] or 0) + assert.is_true(show_and_await(c, right_base, 5000)) + end) + + it("lands a repository's base on its buffers once another repository's sync replaces it", function() + local other, theirs = other_repo() + gutter.fixture(dir, "replaced", { "a.txt" }) + gutter.advance(dir) + local base = gutter.merge_base(dir) + vim.fn.chdir(dir) + local get_async, held = fork_point.get_async, {} + fork_point.get_async = function(root, branch, on_done) + held[#held + 1] = { root, branch, on_done } + end + + local ours = edit({ "a.txt" }) + local ours_cached = await_cached(ours) + local their_bufs = edit({ other .. "/x.txt" }) + local theirs_cached = await_cached(their_bufs) + fork_point.get_async = get_async + for _, args in ipairs(held) do + get_async(args[1], args[2], args[3]) + end + + assert.is_true(ours_cached) + assert.is_true(theirs_cached) + assert.is_true(show_and_await(ours, base, 5000)) + assert.is_true(await(their_bufs, theirs, 5000)) + vim.fn.delete(other, "rf") + end) end) diff --git a/tests/sidebar_after_build_spec.lua b/tests/sidebar_after_build_spec.lua index 7642769..d3a8000 100644 --- a/tests/sidebar_after_build_spec.lua +++ b/tests/sidebar_after_build_spec.lua @@ -1,3 +1,4 @@ +local Build = require("support.build") local Fixture = require("support.git") local Symbols = require("support.symbols") local present = require("support.present") @@ -24,7 +25,7 @@ describe("the sidebar loaded after the tree's diff was read", function() it("hands the picker the rows of that diff", function() Fixture.feature_one_file(tmp) - build.build() + Build.build() assert.is_true(vim.wait(10000, function() return present(build.current()).collected end, 10)) diff --git a/tests/sidebar_spec.lua b/tests/sidebar_spec.lua index 57a4214..81d5adf 100644 --- a/tests/sidebar_spec.lua +++ b/tests/sidebar_spec.lua @@ -401,6 +401,7 @@ describe("changeset sidebar", function() Fixture.git({ "checkout", "-q", "trunk" }, tmp) changeset.open() + Sidebar.await_open() local buf = present(window.buf()) assert.is_truthy( vim.wait(5000, function() diff --git a/tests/sidebar_state_spec.lua b/tests/sidebar_state_spec.lua index 8541f6c..20e5b5e 100644 --- a/tests/sidebar_state_spec.lua +++ b/tests/sidebar_state_spec.lua @@ -1,3 +1,4 @@ +local Build = require("support.build") local Changes = require("support.changes") local Fixture = require("support.git") local Symbols = require("support.symbols") @@ -21,7 +22,7 @@ describe("changeset.sidebar_state", function() ---Build the tree and wait for its diff. local function built() - build.build() + Build.build() assert.is_true(vim.wait(10000, function() return present(build.current()).collected end, 10)) diff --git a/tests/stepping_spec.lua b/tests/stepping_spec.lua index c67111f..91880e4 100644 --- a/tests/stepping_spec.lua +++ b/tests/stepping_spec.lua @@ -56,24 +56,31 @@ describe("changeset.step", function() end))) end - ---@param win integer - ---@return string file, integer line - local function shown(win) - local buf = vim.api.nvim_win_get_buf(win) - return vim.fs.basename(vim.api.nvim_buf_get_name(buf)), vim.api.nvim_win_get_cursor(win)[1] - end - it("opens a closed sidebar without focus, then takes the step once the changes are read", function() local win = edit(2) changeset.step(1) + Sidebar.await_open() assert.truthy(window.is_visible()) assert.equal(win, vim.api.nvim_get_current_win()) settle() -- Taken once mod.lua's symbols are read, so its rows are its changes: L2 opens where you are, L8 is next. assert.equal(win, vim.api.nvim_get_current_win()) - assert.same({ "mod.lua", 8 }, { shown(win) }) + assert.same({ "mod.lua", 8 }, { Sidebar.shown(win) }) + end) + + it("says nothing about reading for a step from a closed sidebar on a kept tree", function() + local win = edit(2) + changeset.open() + settle() + changeset.close() + echoed = {} + + changeset.step(1) + + assert.same({}, echoed) + assert.same({ "mod.lua", 8 }, { Sidebar.shown(win) }) end) it("never steps backwards from a cold press past your file's last change", function() @@ -82,7 +89,7 @@ describe("changeset.step", function() changeset.step(1) settle() - assert.same({ "other.lua", 3 }, { shown(win) }) + assert.same({ "other.lua", 3 }, { Sidebar.shown(win) }) end) it("adds up presses made while the changes are read, a press the other way taking one off", function() @@ -93,7 +100,7 @@ describe("changeset.step", function() changeset.step(-1) settle() - assert.same({ "mod.lua", 8 }, { shown(win) }) + assert.same({ "mod.lua", 8 }, { Sidebar.shown(win) }) end) it("drops a waiting step once you have moved to another buffer", function() @@ -103,7 +110,7 @@ describe("changeset.step", function() vim.cmd.edit("other.lua") settle() - assert.same({ "other.lua", 1 }, { shown(win) }) + assert.same({ "other.lua", 1 }, { Sidebar.shown(win) }) end) it("drops a waiting step when the diff fails to read", function() @@ -131,7 +138,7 @@ describe("changeset.step", function() restore() assert.is_truthy(ok, tostring(err)) - assert.same({ "mod.lua", 2 }, { shown(win) }) + assert.same({ "mod.lua", 2 }, { Sidebar.shown(win) }) end) it("drops a waiting step when the sidebar closes first", function() @@ -143,7 +150,7 @@ describe("changeset.step", function() vim.api.nvim_set_current_win(win) settle() - assert.same({ "mod.lua", 2 }, { shown(win) }) + assert.same({ "mod.lua", 2 }, { Sidebar.shown(win) }) end) it("steps over rows that open where you already are, counting places", function() @@ -154,14 +161,14 @@ describe("changeset.step", function() vim.api.nvim_set_current_win(win) changeset.step(1) - assert.same({ "mod.lua", 8 }, { shown(win) }) + assert.same({ "mod.lua", 8 }, { Sidebar.shown(win) }) changeset.step(-1) - assert.same({ "mod.lua", 2 }, { shown(win) }) + assert.same({ "mod.lua", 2 }, { Sidebar.shown(win) }) assert.truthy((Sidebar.cursor_line():find("L2", 1, true))) changeset.step(2) assert.equal(win, vim.api.nvim_get_current_win()) - assert.same({ "other.lua", 3 }, { shown(win) }) + assert.same({ "other.lua", 3 }, { Sidebar.shown(win) }) assert.truthy((Sidebar.cursor_line():find("other.lua", 1, true))) end) @@ -187,7 +194,7 @@ describe("changeset.step", function() changeset.step(-1) - assert.same({ "mod.lua", 8 }, { shown(win) }) + assert.same({ "mod.lua", 8 }, { Sidebar.shown(win) }) end) it("goes on to the file's first change from above it", function() @@ -196,7 +203,7 @@ describe("changeset.step", function() changeset.step(1) - assert.same({ "mod.lua", 2 }, { shown(win) }) + assert.same({ "mod.lua", 2 }, { Sidebar.shown(win) }) end) it("goes back past the file from above its first change, never down", function() @@ -204,7 +211,7 @@ describe("changeset.step", function() changeset.step(-1) - assert.same({ "other.lua", 3 }, { shown(win) }) + assert.same({ "other.lua", 3 }, { Sidebar.shown(win) }) end) it("goes back up the tree from above a file's first change, past its review comment's row", function() @@ -212,7 +219,7 @@ describe("changeset.step", function() changeset.step(-1) - assert.same({ "mod.lua", 8 }, { shown(win) }) + assert.same({ "mod.lua", 8 }, { Sidebar.shown(win) }) end) it("goes on to a file's first change from above it, though a review comment's row comes first", function() @@ -221,7 +228,7 @@ describe("changeset.step", function() changeset.step(1) changeset.step(1) - assert.same({ "other.lua", 3 }, { shown(win) }) + assert.same({ "other.lua", 3 }, { Sidebar.shown(win) }) end) it("starts from your file, not the sidebar's row in another", function() @@ -229,7 +236,7 @@ describe("changeset.step", function() changeset.step(1) - assert.same({ "mod.lua", 2 }, { shown(win) }) + assert.same({ "mod.lua", 2 }, { Sidebar.shown(win) }) end) end) @@ -258,6 +265,7 @@ describe("changeset.step", function() local win = edit(2) changeset.step(1) + Sidebar.await_open() vim.cmd.tabnew() assert.is_truthy((vim.wait(5000, function() local tree = require("changeset.build").current() @@ -268,7 +276,7 @@ describe("changeset.step", function() require("changeset.build").refresh() settle() - assert.same({ "mod.lua", 5 }, { shown(win) }) + assert.same({ "mod.lua", 5 }, { Sidebar.shown(win) }) end) it("steps over section headers onto a Comments row, opening its line but not the review comment", function() @@ -281,7 +289,7 @@ describe("changeset.step", function() changeset.step(-1) - assert.same({ "other.lua", 3 }, { shown(win) }) + assert.same({ "other.lua", 3 }, { Sidebar.shown(win) }) assert.equal(wins, #vim.api.nvim_list_wins()) end) @@ -295,7 +303,7 @@ describe("changeset.step", function() changeset.step(1) - assert.same({ "other.lua", 3 }, { shown(win) }) + assert.same({ "other.lua", 3 }, { Sidebar.shown(win) }) assert.equal(before, vim.api.nvim_win_get_cursor(window.win() or 0)[1]) assert.equal(1, #echoed) end) @@ -316,7 +324,7 @@ describe("changeset.step", function() assert.is_truthy(ok, tostring(err)) assert.same({}, notes) assert.same({ "no next change" }, echoed) - assert.same({ "other.lua", 3 }, { shown(win) }) + assert.same({ "other.lua", 3 }, { Sidebar.shown(win) }) end) it("opens the row in the window the sidebar opens changes in, keeping focus on the sidebar", function() @@ -340,7 +348,7 @@ describe("changeset.step", function() vim.api.nvim_del_augroup_by_name("stepping_spec") assert.equal(sidebar, vim.api.nvim_get_current_win()) - assert.same({ "other.lua", 3 }, { shown(win) }) + assert.same({ "other.lua", 3 }, { Sidebar.shown(win) }) assert.equal("", vim.wo[win].winbar) assert.truthy(left[win]) end) @@ -354,15 +362,15 @@ describe("changeset.step", function() vim.api.nvim_set_current_win(win) vim.api.nvim_feedkeys(vim.keycode("(changeset-next)"), "x", false) - assert.same({ "mod.lua", 8 }, { shown(win) }) + assert.same({ "mod.lua", 8 }, { Sidebar.shown(win) }) vim.api.nvim_feedkeys(".", "x", false) - assert.same({ "other.lua", 3 }, { shown(win) }) + assert.same({ "other.lua", 3 }, { Sidebar.shown(win) }) vim.api.nvim_feedkeys("2" .. vim.keycode("(changeset-prev)"), "x", false) - assert.same({ "mod.lua", 2 }, { shown(win) }) + assert.same({ "mod.lua", 2 }, { Sidebar.shown(win) }) vim.api.nvim_feedkeys(".", "x", false) assert.equal(win, vim.api.nvim_get_current_win()) - assert.same({ "other.lua", 3 }, { shown(win) }) + assert.same({ "other.lua", 3 }, { Sidebar.shown(win) }) assert.truthy((Sidebar.cursor_line():find("other.lua:3", 1, true))) end) @@ -376,7 +384,7 @@ describe("changeset.step", function() -- The mod.lua row previews its first change, line 2, so the steps pass the rows there and open L8, then other.lua. assert.equal(window.win(), vim.api.nvim_get_current_win()) - assert.same({ "other.lua", 3 }, { shown(win) }) + assert.same({ "other.lua", 3 }, { Sidebar.shown(win) }) end) end) @@ -385,13 +393,14 @@ describe("changeset.step", function() local win = edit(8) vim.api.nvim_feedkeys(vim.keycode("(changeset-preview-next)"), "x", false) + Sidebar.await_open() assert.truthy(window.is_visible()) settle() -- Yours is mod.lua's Other changes, which holds line 8. assert.equal(win, vim.api.nvim_get_current_win()) assert.truthy((Sidebar.cursor_line():find("L2", 1, true))) - assert.same({ "mod.lua", 2 }, { shown(win) }) + assert.same({ "mod.lua", 2 }, { Sidebar.shown(win) }) end) end) @@ -455,13 +464,13 @@ describe("changeset.step", function() press_on("mod.lua", "H", win) changeset.step(1, "symbol") - assert.same({ "mod.lua", 7 }, { shown(win) }) + assert.same({ "mod.lua", 7 }, { Sidebar.shown(win) }) assert.truthy((Sidebar.cursor_line():find("second", 1, true))) changeset.step(1, "symbol") - assert.same({ "other.lua", 3 }, { shown(win) }) + assert.same({ "other.lua", 3 }, { Sidebar.shown(win) }) changeset.step(1, "symbol") - assert.same({ "other.lua", 3 }, { shown(win) }) + assert.same({ "other.lua", 3 }, { Sidebar.shown(win) }) assert.same({ "no next symbol" }, echoed) end) @@ -469,10 +478,10 @@ describe("changeset.step", function() local win = open_with(NESTED, 7) changeset.step(-1, "symbol") - assert.same({ "mod.lua", 2 }, { shown(win) }) + assert.same({ "mod.lua", 2 }, { Sidebar.shown(win) }) changeset.step(-1, "symbol") - assert.same({ "mod.lua", 2 }, { shown(win) }) + assert.same({ "mod.lua", 2 }, { Sidebar.shown(win) }) assert.same({ "no previous symbol" }, echoed) end) @@ -481,7 +490,7 @@ describe("changeset.step", function() changeset.step(-1, "symbol") - assert.same({ "mod.lua", 2 }, { shown(win) }) + assert.same({ "mod.lua", 2 }, { Sidebar.shown(win) }) end) it("goes on to the symbol below you though the file's Other changes sort after its symbols", function() @@ -489,7 +498,7 @@ describe("changeset.step", function() changeset.step(1, "symbol") - assert.same({ "mod.lua", 7 }, { shown(win) }) + assert.same({ "mod.lua", 7 }, { Sidebar.shown(win) }) end) it("opens the change below you though the file's Other changes sort after its symbols", function() @@ -497,7 +506,7 @@ describe("changeset.step", function() changeset.step(1) - assert.same({ "mod.lua", 7 }, { shown(win) }) + assert.same({ "mod.lua", 7 }, { Sidebar.shown(win) }) end) it("steps from file to file, into a folded section", function() @@ -505,11 +514,11 @@ describe("changeset.step", function() press_on("Implementation", "h", win) changeset.step(1, "file") - assert.same({ "other.lua", 3 }, { shown(win) }) + assert.same({ "other.lua", 3 }, { Sidebar.shown(win) }) assert.truthy((Sidebar.cursor_line():find("other.lua", 1, true))) changeset.step(-1, "file") - assert.same({ "mod.lua", 2 }, { shown(win) }) + assert.same({ "mod.lua", 2 }, { Sidebar.shown(win) }) end) it("opens a closed sidebar, then takes a symbol step once the changes are read", function() @@ -519,7 +528,7 @@ describe("changeset.step", function() changeset.step(1, "symbol") answer({ symbol("late", "Function", 0, 7, 9) }) - assert.same({ "mod.lua", 7 }, { shown(win) }) + assert.same({ "mod.lua", 7 }, { Sidebar.shown(win) }) end) end) end) diff --git a/tests/support/build.lua b/tests/support/build.lua new file mode 100644 index 0000000..48c4490 --- /dev/null +++ b/tests/support/build.lua @@ -0,0 +1,21 @@ +---Builds the tree as a spec needs it: started, then waited on until it lands. +local M = {} + +---Build the tree for the current buffer's repository and wait for the build to land. +---@return changeset.BuildOutcome +function M.build() + local landed = false + local outcome ---@type changeset.BuildOutcome? + require("changeset.build").build(function(answer) + landed, outcome = true, answer + end) + assert.is_true( + vim.wait(10000, function() + return landed + end, 10), + "the build never landed" + ) + return require("support.present")(outcome) +end + +return M diff --git a/tests/support/fork_point.lua b/tests/support/fork_point.lua new file mode 100644 index 0000000..c74d6f6 --- /dev/null +++ b/tests/support/fork_point.lua @@ -0,0 +1,24 @@ +---Fork points measured for specs, awaiting the measure that never blocks. +local fork_point = require("changeset.fork_point") + +local M = {} + +---The fork point of `branch` at `root`, once `get_async` lands it. +---@param root string +---@param branch string +---@return changeset.ForkPoint? +function M.get(root, branch) + local done, point = false, nil + fork_point.get_async(root, branch, function(measured) + done, point = true, measured + end) + assert.is_true( + vim.wait(10000, function() + return done + end, 10), + "the fork point was never measured" + ) + return point +end + +return M diff --git a/tests/support/sidebar.lua b/tests/support/sidebar.lua index 18bdd81..23405b2 100644 --- a/tests/support/sidebar.lua +++ b/tests/support/sidebar.lua @@ -14,6 +14,24 @@ function M.text() return table.concat(M.lines(), "\n") end +---The file `win` shows and its cursor line. +---@param win integer +---@return string file, integer line +function M.shown(win) + local buf = vim.api.nvim_win_get_buf(win) + return vim.fs.basename(vim.api.nvim_buf_get_name(buf)), vim.api.nvim_win_get_cursor(win)[1] +end + +---Wait for a sidebar opened before its fork point landed to stand. +function M.await_open() + assert.is_true( + vim.wait(10000, function() + return window.is_visible() + end, 10), + "the sidebar never opened" + ) +end + ---Wait for the tree to hold `other.lua`, its symbols read or not. function M.await_diff() local arrived = vim.wait(10000, function() diff --git a/tests/support/spawns.lua b/tests/support/spawns.lua new file mode 100644 index 0000000..9039c5c --- /dev/null +++ b/tests/support/spawns.lua @@ -0,0 +1,75 @@ +---Records the processes code starts, to tell what it spawned and whether that blocked Neovim. + +local M = {} + +---Runs `fn`, keeping the argv of every process started through the functions `targets` names, as `{ table, key }`. +---@param targets [table, string][] +---@param fn fun() +---@return string[][] +local function spying(targets, fn) + local argvs, real = {}, {} + for i, target in ipairs(targets) do + local tbl, key = target[1], target[2] + real[i] = tbl[key] + tbl[key] = function(argv, ...) + argvs[#argvs + 1] = argv + return real[i](argv, ...) + end + end + local ok, err = pcall(fn) + for i, target in ipairs(targets) do + target[1][target[2]] = real[i] + end + assert.is_true(ok, tostring(err)) + return argvs +end + +---Runs `fn`, keeping the argv of every process started through `vim.system` 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) +end + +---Runs `fn`, keeping the argv of every process started through `vim.fn.systemlist` or `vim.fn.system`, which block +---Neovim until it exits. A wait belongs inside `fn`. +---@param fn fun() +---@return string[][] +function M.blocking(fn) + return spying({ { vim.fn, "system" }, { vim.fn, "systemlist" } }, fn) +end + +---Whether `argv` starts `program` with `subcommand`, as `git -C merge-base …` starts git's merge-base. +---@param argv string[] +---@param program string +---@param subcommand string? +---@return boolean +function M.runs(argv, program, subcommand) + return argv[1] == program and (subcommand == nil or vim.list_contains(argv, subcommand)) +end + +---Whether `argv` is one of the git subcommands a fork point's measure runs; keep in sync with `fork_point.measure`. +---@param argv string[] +---@return boolean +function M.measuring(argv) + return argv[2] == "-C" + and vim.iter({ "merge-base", "reflog", "symbolic-ref" }):any(function(subcommand) + return M.runs(argv, "git", subcommand) + end) +end + +---Runs `fn`, asserting it measured a fork point and blocked Neovim on no part of that measure. +---@param fn fun() +function M.measures_unblocked(fn) + local blocked ---@type string[][] + local spawned = M.recording(function() + blocked = M.blocking(fn) + end) + for _, argv in ipairs(blocked) do + assert.is_false(M.measuring(argv), table.concat(argv, " ")) + end + assert.is_true(vim.iter(spawned):any(M.measuring), "nothing was measured") +end + +return M diff --git a/tests/tree_lifecycle_spec.lua b/tests/tree_lifecycle_spec.lua index b2caeb0..4bb7631 100644 --- a/tests/tree_lifecycle_spec.lua +++ b/tests/tree_lifecycle_spec.lua @@ -1,3 +1,4 @@ +local Build = require("support.build") local changeset = require("changeset") local build = require("changeset.build") local window = require("changeset.window") @@ -51,7 +52,7 @@ describe("changeset tree", function() it("builds without opening a window", function() local window_count = #vim.api.nvim_list_wins() - assert.is_true(build.build()) + assert.equal("built", Build.build()) assert.is_true(wait_for_file("mod.lua")) assert.is_nil(window.win()) @@ -92,17 +93,36 @@ describe("changeset tree", function() }) end) + it("hands over the rows on the first ask while the fork point takes seconds to measure", function() + local fork_point = require("changeset.fork_point") + local get_async = fork_point.get_async + fork_point.get_async = function(root, branch, on_done) + get_async(root, branch, function(point) + vim.defer_fn(function() + on_done(point) + end, 2500) + end) + end + + local ok, tree, err = pcall(changeset.rows) + fork_point.get_async = get_async + + assert.is_true(ok) + assert.is_nil(err) + assert.equal(1, #present(tree).rows) + end) + it("keeps the tree it already built for the same fork point", function() - build.build() + Build.build() local tree = build.current() - build.build() + Build.build() assert.equal(tree, build.current()) end) it("draws the built tree when the sidebar opens", function() - build.build() + Build.build() assert.is_true(wait_for_file("mod.lua")) changeset.open() @@ -112,6 +132,7 @@ describe("changeset tree", function() it("keeps the tree after the sidebar closes", function() changeset.open() + Sidebar.await_open() local tree = present(build.current()) changeset.close() @@ -121,6 +142,7 @@ describe("changeset tree", function() it("wipes the sidebar's buffer once its window closes", function() changeset.open() + Sidebar.await_open() local tree = present(window.buf()) vim.api.nvim_win_close(present(window.win()), true) @@ -130,31 +152,31 @@ describe("changeset tree", function() end) it("rebuilds the tree for another branch at the same fork point", function() - build.build() + Build.build() local tree = build.current() Fixture.git({ "checkout", "-q", "-b", "feature2" }, tmp) - build.build() + Build.build() assert.not_equal(tree, build.current()) assert.equal("feature2", present(build.current()).branch) end) it("rebuilds the tree when gitsigns sees HEAD land on another branch", function() - build.build() + Build.build() local tree = build.current() Fixture.git({ "checkout", "-q", "-b", "feature2" }, tmp) vim.api.nvim_exec_autocmds("User", { pattern = "GitSignsUpdate" }) - assert.is_true(vim.wait(2000, function() + assert.is_true(vim.wait(10000, function() return present(build.current()).branch == "feature2" end, 25)) assert.not_equal(tree, build.current()) end) it("refreshes the tree when gitsigns sees HEAD stay on its branch", function() - build.build() + Build.build() assert.is_true(wait_for_file("mod.lua")) local tree = build.current() @@ -171,7 +193,7 @@ describe("changeset tree", function() end) it("refreshes the tree when gitsigns sees HEAD detach", function() - build.build() + Build.build() assert.is_true(wait_for_file("mod.lua")) local tree = build.current() @@ -189,14 +211,14 @@ describe("changeset tree", function() end) it("rebuilds the tree once the fork point moves", function() - build.build() + Build.build() local tree = present(build.current()) Fixture.git({ "checkout", "-q", "trunk" }, tmp) vim.fn.writefile({ "return 1" }, "other.lua") Fixture.commit("trunk moves on", tmp) Fixture.git({ "checkout", "-q", "-b", "later" }, tmp) - build.build() + Build.build() assert.not_equal(tree, build.current()) assert.not_equal(tree.base, present(build.current()).base) @@ -214,7 +236,7 @@ describe("changeset tree", function() end it("measures the new fork point when gitsigns sees HEAD rebased onto a newer trunk", function() - build.build() + Build.build() assert.is_true(wait_for_file("mod.lua")) local base = rebase_onto_newer_trunk() @@ -227,7 +249,7 @@ describe("changeset tree", function() end) it("measures the new fork point on a refresh after a rebase onto a newer trunk", function() - build.build() + Build.build() assert.is_true(wait_for_file("mod.lua")) local base = rebase_onto_newer_trunk() @@ -241,17 +263,20 @@ describe("changeset tree", function() it("follows a branch switch when R is pressed in the sidebar", function() changeset.open() + Sidebar.await_open() assert.is_true(wait_for_file("mod.lua")) Fixture.git({ "checkout", "-q", "-b", "feature2" }, tmp) vim.api.nvim_set_current_win(window.win() --[[@as integer]]) vim.api.nvim_feedkeys("R", "x", false) - assert.equal("feature2", present(build.current()).branch) + assert.is_true(vim.wait(10000, function() + return present(build.current()).branch == "feature2" + end, 10)) end) it("follows a branch switch while the current buffer is outside the tree's repository", function() - build.build() + Build.build() assert.is_true(wait_for_file("mod.lua")) local elsewhere = vim.fn.tempname() vim.fn.mkdir(elsewhere, "p") @@ -262,7 +287,7 @@ describe("changeset tree", function() Fixture.git({ "switch", "-q", "-c", "other" }, tmp) vim.api.nvim_exec_autocmds("FocusGained", {}) - local followed = vim.wait(3000, function() + local followed = vim.wait(10000, function() return present(build.current()).branch == "other" end, 25) vim.fn.delete(elsewhere, "rf") @@ -270,7 +295,7 @@ describe("changeset tree", function() end) it("stays quiet on focus changes once its repository is deleted", function() - build.build() + Build.build() assert.is_true(wait_for_file("mod.lua")) local notes, restore = Notify.capture() local ok, err = pcall(function() @@ -289,6 +314,7 @@ describe("changeset tree", function() it("leaves the sidebar blank until the diff is read", function() changeset.open() + Sidebar.await_open() assert.equal("", Sidebar.text()) assert.is_true(vim.wait(10000, function() @@ -297,7 +323,7 @@ describe("changeset tree", function() end) it("re-reads the diff when the sidebar reopens on a kept tree", function() - build.build() + Build.build() assert.is_true(wait_for_file("mod.lua")) vim.fn.writefile({ "return 3" }, "new.lua") @@ -336,7 +362,7 @@ describe("changeset tree", function() before_each(function() real_system, real_systemlist = vim.system, vim.fn.systemlist - build.build() + Build.build() assert.is_true(wait_for_file("mod.lua")) end) @@ -417,7 +443,7 @@ describe("changeset tree", function() changeset.open() vim.system, vim.fn.systemlist = real_system, real_systemlist Fixture.git({ "checkout", "-q", "-b", "feature2" }, tmp) - build.build() + Build.build() local tree = present(build.current()) release() @@ -429,7 +455,7 @@ describe("changeset tree", function() end) it("refreshes when gitsigns reports HEAD moved, while the sidebar is closed", function() - build.build() + Build.build() assert.is_true(wait_for_file("mod.lua")) vim.fn.writefile({ "return 3" }, "new.lua") @@ -439,7 +465,7 @@ describe("changeset tree", function() end) it("refreshes once a buffer is written", function() - build.build() + Build.build() assert.is_true(wait_for_file("mod.lua")) vim.cmd.edit("new.lua") @@ -451,7 +477,7 @@ describe("changeset tree", function() for _, event in ipairs({ "FileChangedShellPost", "FocusGained" }) do it("refreshes on " .. event .. ", when a change made outside Neovim can surface", function() - build.build() + Build.build() assert.is_true(wait_for_file("mod.lua")) vim.fn.writefile({ "return 3" }, "new.lua") @@ -485,7 +511,7 @@ describe("changeset tree", function() end) it("keeps what a replaced refresh read, so the next one does not ask again", function() - build.build() + Build.build() wait_for_asks(1) build.refresh() wait_for_asks(2) @@ -501,7 +527,7 @@ describe("changeset tree", function() -- gitsigns fires a buffer's update on attach and on every hunk change while typing, -- none of which moves the diff git reads from disk. it("keeps the diff it has through a gitsigns update for one buffer", function() - build.build() + Build.build() assert.is_true(wait_for_file("mod.lua")) vim.fn.writefile({ "return 3" }, "new.lua") @@ -529,7 +555,7 @@ describe("changeset tree", function() end) it("diffs against the PR's target once gh names it", function() - build.build() + Build.build() assert.is_true(vim.wait(10000, function() local tree = build.current() @@ -539,6 +565,7 @@ describe("changeset tree", function() it("redraws the open sidebar on the tree gh's answer builds", function() changeset.open() + Sidebar.await_open() assert.is_true(vim.wait(10000, function() local text = Sidebar.text() @@ -586,7 +613,7 @@ describe("changeset tree", function() it("drops a restored position waiting on it", function() local sidebar_state = require("changeset.sidebar_state") - build.build() + Build.build() assert.is_true(vim.wait(10000, function() return present(build.current()).collected end, 25)) @@ -604,8 +631,112 @@ describe("changeset tree", function() end) end) + describe("opened before its fork point lands", function() + before_each(function() + Fixture.feature_numbered(tmp) + vim.cmd("runtime plugin/changeset.lua") + end) + + ---Wait for the build every ask so far started to land. + local function await_build() + assert.is_true(vim.wait(10000, function() + return build.kept() ~= nil + end, 10)) + Sidebar.flush() + end + + it("`:Changeset` returns before the fork point lands, then opens the sidebar with its ref", function() + vim.cmd.edit("mod.lua") + + vim.cmd("Changeset") + + assert.is_false(window.is_visible()) + Sidebar.await_open() + local winbar = vim.api.nvim_get_option_value("winbar", { win = present(window.win()) }) + assert.truthy(winbar:find("trunk", 1, true)) + end) + + it("a second toggle before it lands opens nothing", function() + vim.cmd.edit("mod.lua") + + changeset.toggle() + changeset.toggle() + await_build() + + assert.is_false(window.is_visible()) + end) + + it("a toggle after a cold `]c` opens and focuses", function() + vim.cmd.edit("mod.lua") + + changeset.step(1) + changeset.toggle() + Sidebar.await_open() + + assert.is_true(window.is_focused()) + end) + + it("opens when asked right after its window closes another way", function() + vim.cmd.edit("mod.lua") + changeset.open() + Sidebar.await_open() + Fixture.git({ "checkout", "-q", "-b", "feature2" }, tmp) + + vim.api.nvim_win_close(present(window.win()), true) + changeset.open() + + Sidebar.await_open() + end) + + it("a toggle followed by a move to another window before it lands opens the sidebar unfocused", function() + vim.cmd.edit("mod.lua") + vim.cmd.vsplit("other.lua") + local other = vim.api.nvim_get_current_win() + + changeset.toggle() + vim.cmd.wincmd("w") + local moved_to = vim.api.nvim_get_current_win() + Sidebar.await_open() + + assert.not_equal(other, moved_to) + assert.equal(moved_to, vim.api.nvim_get_current_win()) + end) + + it("a `]c` followed by `w` before it lands takes no step", function() + vim.cmd.edit("mod.lua") + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + local win = vim.api.nvim_get_current_win() + vim.cmd.vsplit("plain.lua") + vim.cmd.wincmd("w") + assert.equal(win, vim.api.nvim_get_current_win()) + + changeset.step(1) + vim.cmd.wincmd("w") + Sidebar.settle() + Sidebar.flush() + + assert.same({ "mod.lua", 2 }, { Sidebar.shown(win) }) + end) + + it("an `update()` for another repository's switched branch while `:Changeset` waits leaves it to open", function() + local other = vim.fn.tempname() + vim.fn.mkdir(other, "p") + Fixture.feature_one_file(other) + vim.cmd.edit(other .. "/mod.lua") + assert.equal("built", Build.build()) + vim.cmd.edit(tmp .. "/mod.lua") + Fixture.git({ "checkout", "-q", "-b", "feature2" }, other) + + vim.cmd("Changeset") + build.update() + Sidebar.await_open() + + vim.fn.delete(other, "rf") + end) + end) + describe("with nothing to diff against", function() - local notes + local notes ---@type support.notify.Note[] local restore ---@type fun() before_each(function() @@ -616,10 +747,24 @@ describe("changeset tree", function() restore() end) + it("a repository with no merge base warns and opens no window", function() + Fixture.init_repo("work", tmp) + local windows = #vim.api.nvim_list_wins() + + changeset.open() + assert.is_true(vim.wait(10000, function() + return #Notify.messages(notes, vim.log.levels.WARN) > 0 + end, 10)) + Sidebar.flush() + + assert.same({ "Changeset: no merge base with the default branch" }, Notify.messages(notes, vim.log.levels.WARN)) + assert.equal(windows, #vim.api.nvim_list_wins()) + end) + it("builds nothing, silently, outside a repository", function() local tree = build.current() - assert.is_false(build.build()) + assert.equal("no_base", Build.build()) assert.equal(tree, build.current()) assert.equal(0, #notes) @@ -629,7 +774,7 @@ describe("changeset tree", function() Fixture.init_repo("work", tmp) local tree = build.current() - assert.is_false(build.build()) + assert.equal("no_base", Build.build()) assert.equal(tree, build.current()) assert.equal(0, #notes) diff --git a/tests/unified_diff_pending_spec.lua b/tests/unified_diff_pending_spec.lua new file mode 100644 index 0000000..86d02f7 --- /dev/null +++ b/tests/unified_diff_pending_spec.lua @@ -0,0 +1,39 @@ +vim.opt.rtp:prepend(require("support.deps").path("gitsigns.nvim")) +require("gitsigns").setup() +require("support.gh") + +local build = require("changeset.build") +local Fixture = require("support.git") +local Notify = require("support.notify") + +describe(":Changeset diff before the first build lands", function() + local dir, previous ---@type string, string + local notes, restore ---@type support.notify.Note[], fun() + + before_each(function() + dir, previous = Fixture.enter_tempdir() + Fixture.feature_two_files(dir) + vim.cmd.edit("mod.lua") + notes, restore = Notify.capture() + end) + + after_each(function() + restore() + vim.cmd("silent! %bwipeout!") + vim.fn.chdir(previous) + vim.fn.delete(dir, "rf") + end) + + it("twice leaves the diff off, and says so", function() + vim.cmd("runtime plugin/changeset.lua") + + vim.cmd("Changeset diff") + vim.cmd("Changeset diff") + assert.is_true(vim.wait(10000, function() + return build.kept() ~= nil + end, 10)) + + assert.is_false(require("changeset.unified_diff").on()) + assert.same({ "Changeset: unified diff off" }, Notify.messages(notes)) + end) +end) diff --git a/tests/unified_diff_session_spec.lua b/tests/unified_diff_session_spec.lua index e6b4e54..b352c10 100644 --- a/tests/unified_diff_session_spec.lua +++ b/tests/unified_diff_session_spec.lua @@ -72,7 +72,7 @@ describe("the unified diff over a session", function() changeset.refresh() assert.is_true(vim.wait(5000, function() - return present(build.current()).collected + return (build.current() or {}).collected end, 20)) assert.is_true(bare(file_window("mod.lua"))) end) diff --git a/tests/unified_diff_spec.lua b/tests/unified_diff_spec.lua index cf29065..08afd43 100644 --- a/tests/unified_diff_spec.lua +++ b/tests/unified_diff_spec.lua @@ -1,3 +1,4 @@ +local Build = require("support.build") local Fixture = require("support.git") local Paths = require("changeset.paths") local build = require("changeset.build") @@ -41,7 +42,7 @@ describe("changeset.unified_diff", function() base = Fixture.commit("base", dir) Fixture.git({ "switch", "-q", "-c", "feature" }, dir) vim.fn.writefile({ "line 1", "line 3", "line 4", "changed 5", "line 6" }, "a.txt") - build.build() + Build.build() assert.is_true(vim.wait(5000, function() return present(build.current()).collected end, 20)) @@ -206,7 +207,7 @@ describe("changeset.unified_diff", function() Fixture.feature_one_file(other) vim.cmd.split(other .. "/mod.lua") - build.build() + Build.build() assert.is_true(vim.wait(5000, function() local tree = present(build.current()) diff --git a/tests/unified_diff_toggle_spec.lua b/tests/unified_diff_toggle_spec.lua index b49c751..765ec56 100644 --- a/tests/unified_diff_toggle_spec.lua +++ b/tests/unified_diff_toggle_spec.lua @@ -25,6 +25,9 @@ describe(":Changeset diff", function() vim.fn.chdir(nowhere) changeset.diff() + assert.is_true(vim.wait(10000, function() + return #notes > 0 + end, 10)) vim.fn.chdir(dir) assert.equal(1, #notes) @@ -36,6 +39,9 @@ describe(":Changeset diff", function() vim.cmd.edit("mod.lua") changeset.diff() + assert.is_true(vim.wait(10000, function() + return #notes > 1 + end, 10)) assert.same({ "Changeset: unified diff on" }, Notify.messages({ notes[2] })) assert.is_true(vim.wait(5000, function()