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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions doc/agents/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions doc/changeset.nvim.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
121 changes: 98 additions & 23 deletions lua/changeset/build.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down
55 changes: 26 additions & 29 deletions lua/changeset/fork_point.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading