Skip to content

EXC-1937 Measure the fork point without blocking Neovim - #42

Merged
macintacos merged 7 commits into
mainfrom
EXC-1937/fork-point-async
Oct 12, 2026
Merged

macintacos merged 7 commits into
mainfrom
EXC-1937/fork-point-async

Conversation

@macintacos

Copy link
Copy Markdown
Owner

Part of: EXC-1937

Change Kind: perf

What changed

Four paths measured the branch's fork point with blocking git calls on Neovim's main loop: the first sidebar open, the gutter's first sync when gitsigns attaches, the re-measure after gh names a PR target, and the rebuild after a branch switch. All four now go through the existing async fork_point.get_async, and the blocking fork_point.get is deleted, so the editor never freezes while the fork point is measured. The sidebar opens once the fork point lands, tens of ms later. Keys pressed meanwhile act as typeahead used to: a second toggle cancels the open, and ]c presses still add up.

Measured before the change, each git spawn costs about 5 ms whatever the repository's size. A measure makes 4–5 spawns, or about 13 on a stacked branch, and all of it blocked the editor:

Repo Commits HEAD reflog Blocking measure, median
changeset.nvim, main — 132 25 ms
chezmoi source, trunk 2,737 291 28 ms
melpa clone, detached 12,499 2 33 ms
synthetic: melpa + 20k reflog entries, stacked branch 12,450 19,984 107 ms

What to test

  • In a repo on a PR branch, :Changeset opens with its header and ref, and keys typed right after it reach the file window without a hitch.
  • Two quick toggles leave nothing open, and ]c from a closed sidebar opens it and steps.
  • A saved session restores the sidebar in its own tabpage, on its row.
  • Switching branch moves the gitsigns gutter onto the new fork point without a stall.

- After gh names a PR target, the re-measure runs through Git.async
  rather than on the main loop.
- The gutter's sync measures through fork_point.get_async, and holds
  every buffer move until that measure lands, so no buffer moves onto
  the base of the branch or repository just left.
- A sync replaced by another repository's still puts its own
  repository's buffers on the base it measured.
- tests/support/spawns.lua records the processes a spec starts, and
  tells a blocking git call that only a measure runs.
- build_at measures through fork_point.get_async and calls back once:
  true when built, false with no merge base, nil when an ask for
  another repository replaced it. An ask for the same repository
  joins the build in flight and has it measure once more, so it never
  lands on a point measured before the ask.
- The sidebar opens in the tabpage it was asked from once the fork
  point lands; the no-base warning still comes before any window.
- A second toggle before it lands cancels the open, a toggle after a
  cold ]c focuses it, and ]c presses meanwhile still add up.
- rows(), diff(), refresh(), restore() and a session load follow the
  async build.
- A sidebar window closed another way no longer cancels an open made
  since.
- Delete fork_point.get: nothing outside the specs called it, and a
  blocking get left in place invites a caller to block again.
- ask returns only the point; get_async takes over get's contract.
- Specs await get_async through tests/support/fork_point.lua; the
  parity specs that only pinned get against get_async are pruned.
- The sidebar opens once its fork point lands, in the tabpage it was
  asked from; the no-base warning still comes before any window.
- The picker waits a moment for the fork point as well as the diff.
- Keys pressed while it measures act on the editor as it is, so a
  second toggle cancels the open it would have closed.
- The settle loop's three-measure cap keeps a measure from spawning git
  for as long as another process keeps moving HEAD.
- The build callback names its outcome ("built", "no_base",
  "replaced"); the picker answers a replaced build at once rather
  than waiting it out.
- A build lands after three measures, however many asks join it or
  however often HEAD moves, and a background rebuild yields to a
  build in flight for another repository rather than replace it.
- A step from a closed sidebar on a kept tree says nothing about
  reading; a second :Changeset diff before the build lands says the
  diff is off.
- The gutter threads the repository and base it moves buffers onto
  through parameters rather than swapping module state.
- The pending open is a changeset.Opening the open hands back; token
  checks are named predicates rather than casts.
- Tests share Sidebar.shown and one process spy, check a measure did
  run, and drop a redundant sleep; docs cover the gutter's held
  moves.
- build_spec's build callbacks receive an outcome string, not a
  readiness flag.
- review.lua's token check reads in_flight, as build.lua's twin does.
- rows() waits up to 10 s for the fork point, then up to 2 s for the
  diff, rather than both inside one 2 s budget: under load the measure
  alone can run for seconds, which left the picker answering "still
  reading the diff".
- Specs written for the blocking measure wait long enough for an
  async rebuild, the no-base spec waits for its own warning rather
  than any note, and the kept-tree spec waits for its own open.
@linear-code

linear-code Bot commented Oct 12, 2026

Copy link
Copy Markdown

EXC-1937

@macintacos
macintacos merged commit a607436 into main Oct 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant