From 91fcf8638971a0f9dc3490e265505b91a7c5bdf1 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:34:05 +1100 Subject: [PATCH 01/37] test: add benchmarks for typing and completion latency Nothing measured the cost of cmp-git while the user types or the time until the items show, so a change could make either worse without a sign. benchmarks/ has three groups of cases. keystroke/ measures the checks that nvim-cmp and blink.cmp make on each keystroke, and requests that cmp-git declines. request/ measures routing, parsing, and item formatting. e2e/ runs requests from start to the final callback in a git repository with 1000 commits, with a fake gh, so that no case uses the network. Each case reports its time and the processes that it starts. Sync cases also report the Lua memory that they allocate, and e2e cases report their main-thread time and longest main-thread block. make bench runs the cases. make bench-compare REF= runs the cases of the working tree on the lua/ code of and of the working tree, and reports the regressions. The directory is not named bench/, because common global gitignore files ignore that name. --- CONTRIBUTING.md | 69 +++++- Makefile | 13 +- benchmarks/cases/e2e.lua | 84 +++++++ benchmarks/cases/keystroke.lua | 103 +++++++++ benchmarks/cases/request.lua | 213 +++++++++++++++++ benchmarks/compare.lua | 310 +++++++++++++++++++++++++ benchmarks/fixtures.lua | 408 +++++++++++++++++++++++++++++++++ benchmarks/harness.lua | 344 +++++++++++++++++++++++++++ benchmarks/run.lua | 143 ++++++++++++ scripts/bench-compare.sh | 46 ++++ 10 files changed, 1721 insertions(+), 12 deletions(-) create mode 100644 benchmarks/cases/e2e.lua create mode 100644 benchmarks/cases/keystroke.lua create mode 100644 benchmarks/cases/request.lua create mode 100644 benchmarks/compare.lua create mode 100644 benchmarks/fixtures.lua create mode 100644 benchmarks/harness.lua create mode 100644 benchmarks/run.lua create mode 100755 scripts/bench-compare.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de671ac..cb4a1bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,16 +39,18 @@ extra-trusted-public-keys = nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq ## Make targets -| Target | Action | -| ---------------------------- | --------------------------------------------------------------------------------------------------- | -| `make test` | Runs all tests. | -| `make test-file FILE=` | Runs one test file. | -| `make format` | Formats `lua/` and `tests/` with StyLua. | -| `make format-check` | Checks the formatting. | -| `make lint` | Lints `lua/` and `tests/` with Selene. Warnings fail the target. | -| `make typecheck` | Checks the types with lua-language-server. Warnings fail the target. | -| `make ci` | Runs `format-check`, `lint`, `typecheck`, and `test`. | -| `make test-deps` | Clones mini.nvim at `MINI_VERSION` into `deps/`. `test`, `test-file`, and `typecheck` run it first. | +| Target | Action | +| ------------------------------ | --------------------------------------------------------------------------------------------------- | +| `make test` | Runs all tests. | +| `make test-file FILE=` | Runs one test file. | +| `make format` | Formats `lua/`, `tests/`, and `benchmarks/` with StyLua. | +| `make format-check` | Checks the formatting. | +| `make lint` | Lints `lua/`, `tests/`, and `benchmarks/` with Selene. Warnings fail the target. | +| `make typecheck` | Checks the types with lua-language-server. Warnings fail the target. | +| `make ci` | Runs `format-check`, `lint`, `typecheck`, and `test`. | +| `make test-deps` | Clones mini.nvim at `MINI_VERSION` into `deps/`. `test`, `test-file`, and `typecheck` run it first. | +| `make bench` | Runs the benchmarks. Options go in `BENCH_ARGS`. | +| `make bench-compare REF=` | Compares the benchmarks of `` (default `main`) with the working tree. | To use a different tool, set its variable: `NVIM`, `STYLUA`, `SELENE`, or `LUA_LANGUAGE_SERVER`. For example: @@ -101,6 +103,53 @@ nix develop .#nightly --no-write-lock-file \ --override-input neovim-nightly-overlay "github:nix-community/neovim-nightly-overlay/$rev" -c make test ``` +## Benchmarks + +The benchmarks in `benchmarks/` measure the two costs that users feel: the work that runs while they type, and the time +until the completion items show. Nothing in them uses the network. + +| Group | Cases | Measures | +| ------------ | -------------------------------- | ------------------------------------------------------------------------------------- | +| `keystroke/` | `benchmarks/cases/keystroke.lua` | The checks that nvim-cmp and blink.cmp make on each keystroke, and declined requests. | +| `request/` | `benchmarks/cases/request.lua` | Routing, parsing, and item formatting for one request. Also the startup cost. | +| `e2e/` | `benchmarks/cases/e2e.lua` | One request from start to the final callback, in a git repository with 1000 commits. | + +The `e2e/` cases run the real git commands. A fake `gh` in a temporary directory answers the GitHub requests at once, +so the times include the process starts, but not the network or the run time of the real `gh`. `cold` cases use a new +source for each request. `warm` cases repeat a request that the cache can answer. + +Each line of the report has these values: + +| Value | Meaning | +| ---------- | -------------------------------------------------------------------------------------------------------- | +| `median` | The median time of one operation. | +| `alloc` | The Lua memory that one operation allocates. It does not change between runs, so it shows small changes. | +| `main` | `e2e/` only. The main-thread time of one request. Neovim cannot handle a keystroke during this time. | +| `block` | `e2e/` only. The longest uninterrupted main-thread slice of one request. | +| `spawns` | The processes that one operation starts. | +| `blocking` | The synchronous process calls (`vim.fn.system`) of one operation. These stop Neovim until they finish. | + +To run the benchmarks: + +```sh +make bench +make bench BENCH_ARGS="--filter keystroke" +``` + +To compare your changes with `main`: + +```sh +make bench-compare +``` + +`scripts/bench-compare.sh` runs the cases of the working tree on the `lua/` code of both revisions, two runs each, +and keeps the best value of each side. It reports a regression when a time grows by more than 25%, an allocation +grows by more than 10%, or a process count grows. Very small changes do not count. The options of +`benchmarks/compare.lua` change the limits. + +To add a case, add it to the file of its group. A case that starts a process must be an async case that calls `done`. +Use the fixtures in `benchmarks/fixtures.lua` so that the case does not use the network. + ## CI The workflow in `.github/workflows/ci.yml` runs on pushes to `main`, on pull requests, and on manual dispatch. It diff --git a/Makefile b/Makefile index c4bf8c9..6822879 100644 --- a/Makefile +++ b/Makefile @@ -4,9 +4,10 @@ SELENE ?= selene LUA_LANGUAGE_SERVER ?= lua-language-server MINI_VERSION := v0.18.0 MINI_DIR := deps/mini.nvim -LUA_PATHS := lua tests +LUA_PATHS := lua tests benchmarks +REF ?= main -.PHONY: test-deps test test-file format format-check lint typecheck ci +.PHONY: test-deps test test-file bench bench-compare format format-check lint typecheck ci test-deps: @if [ ! -d "$(MINI_DIR)/.git" ]; then \ @@ -27,6 +28,14 @@ test-file: test-deps TEST_FILE="$(FILE)" $(NVIM) --headless --noplugin -u tests/minimal_init.lua \ -c "lua MiniTest.run_file(vim.env.TEST_FILE)" +# Prints one line for each case. Options go in BENCH_ARGS, for example BENCH_ARGS="--filter keystroke". +bench: + $(NVIM) --headless --clean -l benchmarks/run.lua $(BENCH_ARGS) + +# Compares the benchmarks of REF with the working tree +bench-compare: + NVIM="$(NVIM)" scripts/bench-compare.sh "$(REF)" + format: $(STYLUA) $(LUA_PATHS) diff --git a/benchmarks/cases/e2e.lua b/benchmarks/cases/e2e.lua new file mode 100644 index 0000000..64e67a9 --- /dev/null +++ b/benchmarks/cases/e2e.lua @@ -0,0 +1,84 @@ +---Completion requests from start to the final callback, in a git repository with 1000 commits. Each request runs the +---real git commands and a fake `gh` (see `fixtures.isolate_environment`), so the times include process starts but no +---network. `cold` cases use a new source for each request, and `warm` cases repeat a request that the cache can +---answer. +local fixtures = require("benchmarks.fixtures") + +local COMMITS = 1000 + +local LINES = { + "Fix the parser", + "", + "See the notes", + "", + "Co-authored-by: Ad", + "Refs: AB", + "Sig", +} + +---@type table +local REQUESTS = { + commits = { row = 3, line = "See :", trigger_character = ":" }, + trailer_keys = { row = 7, line = "Sig" }, + trailer_identities = { row = 5, line = "Co-authored-by: Ad" }, + trailer_values = { row = 6, line = "Refs: AB" }, + github_issues_and_prs = { row = 3, line = "See #", trigger_character = "#" }, + github_mentions = { row = 3, line = "See @", trigger_character = "@" }, +} + +---@param name string A key of `REQUESTS`. +---@param cold boolean +---@return bench.AsyncCase +local function request_case(name, cold) + local request = REQUESTS[name] + return { + name = string.format("e2e/%s/%s", name, cold and "cold" or "warm"), + async = true, + setup = function() + local dir = fixtures.repo(COMMITS) + return { + bufnr = fixtures.commit_buffer(dir, LINES), + core = require("cmp_git.core").new({}), + } + end, + before_each = function(state) + vim.api.nvim_set_current_buf(state.bufnr) + if cold then + state.core = require("cmp_git.core").new({}) + end + end, + run = function(state, done) + state.core:complete({ + trigger_character = request.trigger_character, + cursor = { row = request.row, character = #request.line }, + line = request.line, + bufnr = state.bufnr, + }, function(list) + if list.isIncomplete then + return + end + if #list.items == 0 then + done("the request gave no items") + return + end + done() + end) + end, + } +end + +---@type bench.Case[] +local cases = {} +for _, name in ipairs({ + "commits", + "trailer_keys", + "trailer_identities", + "trailer_values", + "github_issues_and_prs", + "github_mentions", +}) do + table.insert(cases, request_case(name, true)) + table.insert(cases, request_case(name, false)) +end + +return cases diff --git a/benchmarks/cases/keystroke.lua b/benchmarks/cases/keystroke.lua new file mode 100644 index 0000000..5912f6a --- /dev/null +++ b/benchmarks/cases/keystroke.lua @@ -0,0 +1,103 @@ +---Work that runs while the user types: the checks that nvim-cmp and blink.cmp make on each keystroke, and requests +---that cmp-git declines without starting a process. +local fixtures = require("benchmarks.fixtures") + +local LINES = { + "Fix the parser", + "", + "Body text", + "", + "Co-authored-by: Ada Lovelace ", +} + +local function noop() end + +---nvim-cmp finds the keyword start on each keystroke with the source's keyword pattern. It compiles the regex once +---(`cmp.utils.pattern.offset`). +---@param line string +---@return bench.SyncCase +local function keyword_offset(name, line) + return { + name = "keystroke/keyword_offset/" .. name, + setup = function() + local core = require("cmp_git.core").new({}) + return { regex = vim.regex([[\%(]] .. core:get_keyword_pattern() .. [[\)\m$]]), line = line } + end, + run = function(state) + state.regex:match_str(state.line) + end, + } +end + +---An nvim-cmp request from `cmp.Source.complete`. +---@param line string +---@param row integer +---@return cmp_git.CmpCompletionParams +local function cmp_params(line, row) + return { + context = { + cursor_before_line = line, + cursor = { row = row, col = #line + 1, character = #line }, + }, + completion_context = { triggerKind = 1 }, + } +end + +---@type bench.Case[] +return { + { + name = "keystroke/is_available", + setup = function() + fixtures.scratch_buffer(LINES) + return require("cmp_git.core").new({}) + end, + run = function(core) + core:is_available() + end, + }, + keyword_offset("prose_80", fixtures.prose(80)), + keyword_offset("prose_300", fixtures.prose(300)), + keyword_offset("prose_1000", fixtures.prose(1000)), + keyword_offset("trailer_value", "Co-authored-by: Ada Lov"), + { + -- The first word of the subject matches the trailer key pattern, but trailers start below the subject + name = "keystroke/nvim_cmp/complete_declined", + setup = function() + fixtures.scratch_buffer(LINES) + return { source = require("cmp_git.source").new({}), params = cmp_params("Fix", 1) } + end, + run = function(state) + state.source:complete(state.params, noop) + end, + }, + { + name = "keystroke/nvim_cmp/complete_declined_prose_300", + setup = function() + local line = fixtures.prose(300) + fixtures.scratch_buffer({ "Fix the parser", "", line }) + return { source = require("cmp_git.source").new({}), params = cmp_params(line, 3) } + end, + run = function(state) + state.source:complete(state.params, noop) + end, + }, + { + name = "keystroke/blink/get_completions_declined", + setup = function() + local bufnr = fixtures.scratch_buffer(LINES) + return { + source = require("cmp_git.blink").new({}), + ctx = { + bufnr = bufnr, + line = "Fix the parser", + cursor = { 1, 3 }, + trigger = { kind = "keyword" }, + }, + } + end, + run = function(state) + local cancel = state.source:get_completions(state.ctx, noop) + cancel() + end, + }, +} diff --git a/benchmarks/cases/request.lua b/benchmarks/cases/request.lua new file mode 100644 index 0000000..058ac12 --- /dev/null +++ b/benchmarks/cases/request.lua @@ -0,0 +1,213 @@ +---Work that runs on the main thread for each completion request: routing, parsing command output, and making the +---completion items. Cache hits make the items again, so the `items` cases are the cost of a cached request. +local fixtures = require("benchmarks.fixtures") + +local COMMITS = 1000 + +---@type bench.Case[] +return { + { + name = "request/route/trailer_key", + setup = function() + local bufnr = fixtures.scratch_buffer({ "Fix the parser", "", "Body text", "", "Sig" }) + return { + core = require("cmp_git.core").new({}), + context = { bufnr = bufnr, cursor = { row = 5, character = 3 }, line = "Sig" }, + } + end, + run = function(state) + state.core:_route(state.context) + end, + }, + { + name = "request/route/trailer_value", + setup = function() + local bufnr = fixtures.scratch_buffer({ "Fix the parser", "", "Body text", "", "Co-authored-by: Ad" }) + return { + core = require("cmp_git.core").new({}), + context = { bufnr = bufnr, cursor = { row = 5, character = 18 }, line = "Co-authored-by: Ad" }, + } + end, + run = function(state) + state.core:_route(state.context) + end, + }, + { + name = "request/commits/parse_1000", + setup = function() + return { + output = fixtures.commits_log(fixtures.commits(COMMITS)), + config = require("cmp_git.config").normalize({}).git.commits, + } + end, + run = function(state) + require("cmp_git.provider.capabilities.commits").parse(state.output, state.config) + end, + }, + { + name = "request/commits/items_100", + setup = function() + local commits = require("cmp_git.provider.capabilities.commits") + local config = require("cmp_git.config").normalize({}).git.commits + return { + commits = commits, + config = config, + parsed = commits.parse(fixtures.commits_log(fixtures.commits(100)), config), + } + end, + run = function(state) + state.commits.items(state.parsed, state.config, ":") + end, + }, + { + name = "request/trailers/keys_parse_100", + setup = function() + return { + output = fixtures.trailer_keys_log(fixtures.commits(100)), + config = require("cmp_git.config").normalize({}).git.trailers.keys, + } + end, + run = function(state) + require("cmp_git.provider.capabilities.trailers").keys.parse(state.output, state.config) + end, + }, + { + name = "request/trailers/identities_parse_1000", + setup = function() + return { output = fixtures.trailer_identities_log(fixtures.commits(COMMITS)) } + end, + run = function(state) + require("cmp_git.provider.capabilities.trailers").identities.parse( + state.output, + "Bench User 1700000000 +0000\n" + ) + end, + }, + { + name = "request/trailers/identities_items_1000", + setup = function() + local trailers = require("cmp_git.provider.capabilities.trailers") + return { + trailers = trailers, + config = require("cmp_git.config").normalize({}).git.trailers.identities, + parsed = trailers.identities.parse(fixtures.trailer_identities_log(fixtures.commits(COMMITS)), ""), + } + end, + run = function(state) + state.trailers.identities.items(state.parsed, state.config, nil) + end, + }, + { + name = "request/trailers/values_parse_1000", + setup = function() + return { output = fixtures.trailer_values_log(fixtures.commits(COMMITS)) } + end, + run = function(state) + require("cmp_git.provider.capabilities.trailers").values.parse(state.output) + end, + }, + { + name = "request/github/issues_100", + setup = function() + local config = require("cmp_git.config").normalize({}).github.issues + local git_info = { host = "github.com", owner = "owner", repo = "repo" } + return { + json = fixtures.github_issues_json(100), + request = require("cmp_git.provider.adapters.github").issues_request(git_info, "#", config), + } + end, + run = function(state) + require("cmp_git.response").completion_list( + state.json, + state.request.handle_item, + state.request.handle_parsed + ) + end, + }, + { + name = "request/github/mentions_100", + setup = function() + local config = require("cmp_git.config").normalize({}).github.mentions + local git_info = { host = "github.com", owner = "owner", repo = "repo" } + local adapter = require("cmp_git.provider.adapters.github") + return { + json = fixtures.github_mentions_json(100), + request = adapter.mentions_request(git_info, "@", config, { page = 1, page_size = 100 }), + } + end, + run = function(state) + require("cmp_git.response").completion_list( + state.json, + state.request.handle_item, + state.request.handle_parsed + ) + end, + }, + { + name = "request/gitlab/merge_requests_100", + setup = function() + local config = require("cmp_git.config").normalize({}).gitlab.merge_requests + local git_info = { host = "gitlab.com", owner = "owner", repo = "repo" } + return { + json = fixtures.gitlab_merge_requests_json(100), + request = require("cmp_git.provider.adapters.gitlab").change_requests_request(git_info, "!", config), + } + end, + run = function(state) + require("cmp_git.response").completion_list( + state.json, + state.request.handle_item, + state.request.handle_parsed + ) + end, + }, + { + name = "request/text_edits_100", + setup = function() + local list = require("cmp_git.response").completion_list(fixtures.github_issues_json(100), function(item) + return { label = "#" .. item.number, insertText = "#" .. item.number } + end) + return { + apply = require("cmp_git.core").apply_text_edits, + items = list.items, + context = { cursor = { row = 3, character = 7 }, line = "See #12", trigger_character = "#" }, + } + end, + run = function(state) + state.apply(state.items, state.context) + end, + }, + { + name = "startup/require", + run = function() + for name in pairs(package.loaded) do + if name == "cmp_git" or name:find("^cmp_git%.") then + package.loaded[name] = nil + end + end + require("cmp_git.source") + require("cmp_git.blink") + end, + }, + { + name = "startup/new_source", + run = function() + require("cmp_git.source").new({}) + end, + }, + { + name = "startup/new_source_with_overrides", + run = function() + require("cmp_git.source").new({ + filetypes = { "gitcommit", "markdown" }, + github = { hosts = { "github.example.com" }, issues = { limit = 50 } }, + git = { commits = { limit = 200 } }, + }) + end, + }, + { + -- The overhead of the measurement loop. Results near this value are at the resolution of the harness. + name = "harness/noop", + run = function() end, + }, +} diff --git a/benchmarks/compare.lua b/benchmarks/compare.lua new file mode 100644 index 0000000..54b053e --- /dev/null +++ b/benchmarks/compare.lua @@ -0,0 +1,310 @@ +-- Compares two sets of benchmark results from benchmarks/run.lua and exits with 1 when the head regressed. +-- +-- nvim --headless --clean -l benchmarks/compare.lua BASE HEAD [options] +-- +-- BASE and HEAD are result files. To lower the noise, give the files of more than one run as a comma-separated list. +-- Each side then uses the lowest median of its runs. +-- +-- Options: +-- --markdown FILE Also append the report as a Markdown table to FILE, for example $GITHUB_STEP_SUMMARY. +-- --time-threshold R A time regresses when it grows by more than this ratio (default 0.25). +-- --alloc-threshold R An allocation regresses when it grows by more than this ratio (default 0.10). +-- +-- A time must also grow by more than 0.5 us (sync cases) or 2 ms (async cases), and an allocation by more than +-- 0.5 KB, so that the noise of very small values does not count. Any increase of the process counts regresses. + +local TIME_FLOOR = { us = 0.5, ms = 2 } +local ALLOC_FLOOR_KB = 0.5 + +---@param argv string[] +local function parse_args(argv) + local opts = { files = {}, time_threshold = 0.25, alloc_threshold = 0.10 } + local i = 1 + while i <= #argv do + local name = argv[i] + local function value() + i = i + 1 + return argv[i] or error("missing value for " .. name, 0) + end + + if name == "--markdown" then + opts.markdown = value() + elseif name == "--time-threshold" then + opts.time_threshold = assert(tonumber(value()), "--time-threshold needs a number") + elseif name == "--alloc-threshold" then + opts.alloc_threshold = assert(tonumber(value()), "--alloc-threshold needs a number") + elseif name:sub(1, 2) == "--" then + error("unknown option " .. name, 0) + else + table.insert(opts.files, name) + end + i = i + 1 + end + if #opts.files ~= 2 then + error("usage: compare.lua BASE HEAD [--markdown FILE] [--time-threshold R] [--alloc-threshold R]", 0) + end + return opts +end + +---@class bench.Side +---@field order string[] +---@field results table +---@field errors table +---@field meta table + +---Read the runs of one side and keep the best value of each metric. +---@param list string Comma-separated result files. +---@return bench.Side +local function load_side(list) + local side = { order = {}, results = {}, errors = {}, meta = {} } + for path in list:gmatch("[^,]+") do + local file = assert(io.open(path, "r"), "cannot read " .. path) + local data = vim.json.decode(file:read("*a")) + file:close() + + side.meta = data.meta + for _, name in ipairs(data.order) do + if not vim.tbl_contains(side.order, name) then + table.insert(side.order, name) + end + end + for name, message in pairs(data.errors or {}) do + side.errors[name] = message + end + for name, result in pairs(data.results) do + local best = side.results[name] + if not best then + side.results[name] = result + else + best.median = math.min(best.median, result.median) + best.min = math.min(best.min, result.min) + if result.alloc_kb then + best.alloc_kb = math.min(best.alloc_kb, result.alloc_kb) + end + if result.main_ms then + best.main_ms = math.min(best.main_ms, result.main_ms) + best.block_ms = math.min(best.block_ms, result.block_ms) + end + -- Process counts do not depend on the machine, so a difference between runs is real + best.spawns = math.max(best.spawns, result.spawns) + best.blocking = math.max(best.blocking, result.blocking) + end + end + end + return side +end + +---@param base number +---@param head number +---@param threshold number +---@param floor number +---@return "regressed" | "improved" | nil +local function classify(base, head, threshold, floor) + if math.abs(head - base) <= floor then + return nil + end + if head > base * (1 + threshold) then + return "regressed" + end + if head < base / (1 + threshold) then + return "improved" + end + return nil +end + +---@param value number? +---@param unit "us" | "ms" +local function format_time(value, unit) + if value == nil then + return "-" + end + if unit == "ms" then + return string.format("%.2f ms", value) + end + if value >= 1000 then + return string.format("%.2f ms", value / 1000) + end + return string.format("%.2f us", value) +end + +---@param base number? +---@param head number? +local function format_change(base, head) + if not base or not head or base == 0 then + return "-" + end + return string.format("%+.0f%%", (head / base - 1) * 100) +end + +local function main() + -- `nvim -l` gives the script arguments in the global `arg` + -- selene: allow(global_usage) + local opts = parse_args(_G.arg) + local base = load_side(opts.files[1]) + local head = load_side(opts.files[2]) + + local names = vim.list_extend({}, head.order) + for _, name in ipairs(base.order) do + if not vim.tbl_contains(names, name) then + table.insert(names, name) + end + end + + local rows = {} + local regressions = 0 + for _, name in ipairs(names) do + local b, h = base.results[name], head.results[name] + local notes = {} + local status = "ok" + + if head.errors[name] then + status = base.errors[name] and "error in both" or "error" + if not base.errors[name] then + regressions = regressions + 1 + end + elseif not h then + status = "removed" + elseif not b then + status = base.errors[name] and "base error" or "new" + else + local unit = h.unit + local changes = { + { "time", classify(b.median, h.median, opts.time_threshold, TIME_FLOOR[unit]) }, + } + if h.main_ms and b.main_ms then + table.insert(changes, { + "main thread", + classify(b.main_ms, h.main_ms, opts.time_threshold, TIME_FLOOR.ms), + }) + end + if h.alloc_kb and b.alloc_kb then + table.insert(changes, { + "alloc", + classify(b.alloc_kb, h.alloc_kb, opts.alloc_threshold, ALLOC_FLOOR_KB), + }) + end + if h.spawns > b.spawns + 0.01 then + table.insert(changes, { "spawns", "regressed" }) + elseif h.spawns < b.spawns - 0.01 then + table.insert(changes, { "spawns", "improved" }) + end + if h.blocking > b.blocking + 0.01 then + table.insert(changes, { "blocking calls", "regressed" }) + elseif h.blocking < b.blocking - 0.01 then + table.insert(changes, { "blocking calls", "improved" }) + end + + local regressed, improved = {}, {} + for _, change in ipairs(changes) do + if change[2] == "regressed" then + table.insert(regressed, change[1]) + elseif change[2] == "improved" then + table.insert(improved, change[1]) + end + end + if #regressed > 0 then + status = "REGRESSED" + regressions = regressions + 1 + table.insert(notes, "worse: " .. table.concat(regressed, ", ")) + end + if #improved > 0 then + if status == "ok" then + status = "improved" + end + table.insert(notes, "better: " .. table.concat(improved, ", ")) + end + end + + local unit = (h or b or { unit = "us" }).unit + table.insert(rows, { + name = name, + base = format_time(b and b.median, unit), + head = format_time(h and h.median, unit), + change = format_change(b and b.median, h and h.median), + alloc = (b and b.alloc_kb and h and h.alloc_kb) + and string.format("%.1f -> %.1f KB", b.alloc_kb, h.alloc_kb) + or "-", + spawns = (b and h) and string.format("%g -> %g", b.spawns, h.spawns) or "-", + status = status .. (#notes > 0 and " (" .. table.concat(notes, "; ") .. ")" or ""), + }) + end + + local header = string.format( + "base %s (%s), head %s (%s), Neovim %s", + tostring(base.meta.revision), + tostring(base.meta.date), + tostring(head.meta.revision), + tostring(head.meta.date), + tostring(head.meta.nvim) + ) + print(header) + print( + string.format( + "%-52s %12s %12s %7s %22s %12s %s", + "case", + "base", + "head", + "change", + "alloc/op", + "spawns/op", + "status" + ) + ) + for _, row in ipairs(rows) do + print( + string.format( + "%-52s %12s %12s %7s %22s %12s %s", + row.name, + row.base, + row.head, + row.change, + row.alloc, + row.spawns, + row.status + ) + ) + end + print(string.format("%d regression(s)", regressions)) + + if opts.markdown then + local lines = { + "## Benchmarks", + "", + header, + "", + "| Case | Base | Head | Change | Alloc/op | Spawns/op | Status |", + "| --- | ---: | ---: | ---: | ---: | ---: | --- |", + } + for _, row in ipairs(rows) do + table.insert( + lines, + string.format( + "| `%s` | %s | %s | %s | %s | %s | %s |", + row.name, + row.base, + row.head, + row.change, + row.alloc, + row.spawns, + row.status + ) + ) + end + table.insert(lines, "") + table.insert(lines, string.format("%d regression(s)", regressions)) + table.insert(lines, "") + local file = assert(io.open(opts.markdown, "a")) + file:write(table.concat(lines, "\n")) + file:close() + end + + return regressions == 0 and 0 or 1 +end + +local ok, code = xpcall(main, debug.traceback) +if not ok then + -- selene: allow(incorrect_standard_library_use) + io.stderr:write(tostring(code) .. "\n") + os.exit(2) +end +os.exit(code) diff --git a/benchmarks/fixtures.lua b/benchmarks/fixtures.lua new file mode 100644 index 0000000..0f71a6b --- /dev/null +++ b/benchmarks/fixtures.lua @@ -0,0 +1,408 @@ +---Deterministic data for the benchmarks: commits, the output of the git commands that cmp-git runs, API responses, +---and a git repository with a fake `gh`. Nothing here uses the network. +local M = {} + +local US = "\031" +local NUL = "\0" + +---@class bench.Person +---@field name string +---@field email string + +---@param prefix string +---@param count integer +---@return bench.Person[] +local function people(prefix, count) + local result = {} + for i = 1, count do + table.insert(result, { + name = string.format("%s %d", prefix, i), + email = string.format("%s.%d@example.com", prefix:lower(), i), + }) + end + return result +end + +local AUTHORS = people("Author", 40) +local COAUTHORS = people("Contributor", 300) + +local WORDS = { + "parser", + "cache", + "request", + "trailer", + "remote", + "buffer", + "provider", + "format", + "history", + "option", + "window", + "message", +} + +---@class bench.Commit +---@field sha string +---@field subject string +---@field body string The body that `%b` prints, with the trailer block. +---@field author bench.Person +---@field timestamp integer +---@field trailers { key: string, value: string }[] + +---@param count integer +---@return bench.Commit[] commits Newest first, like `git log`. +function M.commits(count) + local commits = {} + for i = 1, count do + local word = WORDS[i % #WORDS + 1] + local author = AUTHORS[i % #AUTHORS + 1] + local trailers = {} + if i % 3 == 0 then + local coauthor = COAUTHORS[(i * 7) % #COAUTHORS + 1] + table.insert(trailers, { key = "Co-authored-by", value = coauthor.name .. " <" .. coauthor.email .. ">" }) + end + if i % 5 == 0 then + table.insert(trailers, { key = "Fixes", value = "#" .. (1000 + i) }) + end + if i % 7 == 0 then + table.insert(trailers, { key = "Refs", value = "ABC-" .. (i % 50) }) + end + if i % 11 == 0 then + local reviewer = COAUTHORS[(i * 13) % #COAUTHORS + 1] + table.insert(trailers, { key = "Reviewed-by", value = reviewer.name .. " <" .. reviewer.email .. ">" }) + end + if i % 13 == 0 then + table.insert(trailers, { key = "Assisted-by", value = "Tool " .. (i % 3) }) + end + table.insert(trailers, { key = "Signed-off-by", value = author.name .. " <" .. author.email .. ">" }) + + local trailer_lines = {} + for _, trailer in ipairs(trailers) do + table.insert(trailer_lines, trailer.key .. ": " .. trailer.value) + end + + local body = table.concat({ + string.format("Change how the %s handles the case that commit %d found. The old code read the", word, i), + string.format("whole %s each time, so it was slow when the history was long.", word), + "", + table.concat(trailer_lines, "\n"), + }, "\n") .. "\n" + + table.insert(commits, { + sha = string.rep(string.format("%08x", i * 2654435761 % 4294967296), 5), + subject = string.format("fix(%s): handle the case from commit %d", word, i), + body = body, + author = author, + timestamp = 1700000000 - i * 3600, + trailers = trailers, + }) + end + return commits +end + +---The output of the `git log` command in `provider/capabilities/commits.lua`. `--pretty=format:` puts a newline +---between entries. +---@param commits bench.Commit[] +---@return string +function M.commits_log(commits) + local entries = {} + for _, commit in ipairs(commits) do + table.insert( + entries, + table.concat({ + commit.sha, + commit.subject, + commit.body, + commit.author.name, + commit.author.email, + tostring(commit.timestamp), + "", + }, NUL) + ) + end + return table.concat(entries, "\n") +end + +---The output of `trailers.keys.request`. `--format=` ends each entry with a newline. +---@param commits bench.Commit[] +---@return string +function M.trailer_keys_log(commits) + local entries = {} + for _, commit in ipairs(commits) do + local keys = {} + for _, trailer in ipairs(commit.trailers) do + table.insert(keys, trailer.key) + end + table.insert(entries, table.concat(keys, US) .. NUL .. "\n") + end + return table.concat(entries) +end + +---The output of `trailers.identities.request` for the default identity keys. +---@param commits bench.Commit[] +---@return string +function M.trailer_identities_log(commits) + local identity_keys = { ["Co-authored-by"] = true, ["Reviewed-by"] = true, ["Signed-off-by"] = true } + local entries = {} + for _, commit in ipairs(commits) do + local fields = { tostring(commit.timestamp), commit.author.name, commit.author.email } + local trailers = {} + for _, trailer in ipairs(commit.trailers) do + if identity_keys[trailer.key] then + table.insert(trailers, trailer.key .. ": " .. trailer.value) + end + end + table.insert(fields, table.concat(trailers, US)) + table.insert(entries, table.concat(fields, US) .. NUL .. "\n") + end + return table.concat(entries) +end + +---The output of `trailers.values.request`. +---@param commits bench.Commit[] +---@return string +function M.trailer_values_log(commits) + local entries = {} + for _, commit in ipairs(commits) do + local fields = { tostring(commit.timestamp) } + for _, trailer in ipairs(commit.trailers) do + table.insert(fields, trailer.key .. ": " .. trailer.value) + end + table.insert(entries, table.concat(fields, US) .. NUL .. "\n") + end + return table.concat(entries) +end + +---@param i integer +---@return string +local function issue_body(i) + local lines = {} + for line = 1, 8 do + table.insert( + lines, + string.format("Line %d of issue %d. The %s fails when the input is long.", line, i, WORDS[line]) + ) + end + return table.concat(lines, "\r\n") +end + +---@param i integer +---@return string +local function iso_date(i) + return string.format("2024-%02d-%02dT%02d:04:05Z", i % 12 + 1, i % 28 + 1, i % 24) +end + +---`gh issue list --json` or `gh pr list --json` output with the default fields. +---@param count integer +---@return string +function M.github_issues_json(count) + local issues = {} + for i = 1, count do + table.insert(issues, { + number = 2000 + i, + title = string.format("The %s fails when the input is long (%d)", WORDS[i % #WORDS + 1], i), + body = issue_body(i), + updatedAt = iso_date(i), + state = "OPEN", + }) + end + return vim.json.encode(issues) +end + +---@param count integer +---@return string +function M.github_mentions_json(count) + local users = {} + for i = 1, count do + table.insert(users, { login = string.format("user-%d", i), id = i, type = "User" }) + end + return vim.json.encode(users) +end + +---GitLab merge requests API output. +---@param count integer +---@return string +function M.gitlab_merge_requests_json(count) + local merge_requests = {} + for i = 1, count do + table.insert(merge_requests, { + iid = 300 + i, + title = string.format("Change the %s (%d)", WORDS[i % #WORDS + 1], i), + description = issue_body(i), + updated_at = iso_date(i), + state = "opened", + }) + end + return vim.json.encode(merge_requests) +end + +---@param length integer +---@return string +function M.prose(length) + local words = {} + local size = 0 + local i = 0 + while size < length do + i = i + 1 + local word = WORDS[i % #WORDS + 1] + table.insert(words, word) + size = size + #word + 1 + end + return table.concat(words, " "):sub(1, length) +end + +local temp_root = nil ---@type string? + +---The temporary directory of this run. `cleanup` deletes it. +---@return string +function M.temp_root() + if not temp_root then + temp_root = vim.fn.tempname() + vim.fn.mkdir(temp_root, "p") + temp_root = assert(vim.uv.fs_realpath(temp_root)) + end + return temp_root +end + +function M.cleanup() + if temp_root then + vim.fn.delete(temp_root, "rf") + temp_root = nil + end +end + +---Run git and stop the benchmark run when it fails. +---@param args string[] +---@param opts? { stdin?: string } +---@return string stdout +local function git(args, opts) + local result = vim.system(vim.list_extend({ "git" }, args), { text = true, stdin = opts and opts.stdin }):wait() + if result.code ~= 0 then + error(string.format("git %s failed: %s", table.concat(args, " "), result.stderr), 0) + end + return result.stdout +end + +---Set the environment that the fixture repository and the fake `gh` need. Git does not read the user's config, and +---the `gh` in the fixture directory comes first in `PATH`. +function M.isolate_environment() + local root = M.temp_root() + local home = root .. "/home" + local bin = root .. "/bin" + local data = root .. "/data" + vim.fn.mkdir(home, "p") + vim.fn.mkdir(bin, "p") + vim.fn.mkdir(data, "p") + + vim.env.HOME = home + vim.env.XDG_CONFIG_HOME = home .. "/.config" + vim.env.GIT_CONFIG_GLOBAL = "/dev/null" + vim.env.GIT_CONFIG_NOSYSTEM = "1" + vim.env.GIT_CEILING_DIRECTORIES = root + vim.env.GITHUB_API_TOKEN = nil + vim.env.GITLAB_TOKEN = nil + vim.env.CMP_GIT_BENCH_DATA = data + vim.env.PATH = bin .. ":" .. vim.env.PATH + vim.g.cmp_git_debug = nil + + local function write(path, content) + local file = assert(io.open(path, "wb")) + file:write(content) + file:close() + end + + write(data .. "/github_issues.json", M.github_issues_json(100)) + write(data .. "/github_pull_requests.json", M.github_issues_json(40)) + write(data .. "/github_mentions.json", M.github_mentions_json(30)) + -- `gh repo set-default --view` fails, as when no default repository is set + write( + bin .. "/gh", + table.concat({ + "#!/bin/sh", + 'case "$1 $2" in', + '"issue list") cat "$CMP_GIT_BENCH_DATA/github_issues.json" ;;', + '"pr list") cat "$CMP_GIT_BENCH_DATA/github_pull_requests.json" ;;', + '"api "*)', + ' case "$2" in', + " */permission) printf '%s' '{\"permission\":\"write\"}' ;;", + ' *"&page=1") cat "$CMP_GIT_BENCH_DATA/github_mentions.json" ;;', + " *) printf '%s' '[]' ;;", + " esac", + " ;;", + "*) exit 1 ;;", + "esac", + "", + }, "\n") + ) + vim.uv.fs_chmod(bin .. "/gh", tonumber("755", 8)) +end + +local repo_dir = nil ---@type string? + +---A git repository with `count` commits from `M.commits` and a GitHub `origin`. It is made once for each run. +---@param count integer +---@return string dir +function M.repo(count) + if repo_dir then + return repo_dir + end + + local dir = M.temp_root() .. "/repo" + vim.fn.mkdir(dir, "p") + git({ "-C", dir, "-c", "init.defaultBranch=main", "init", "-q" }) + git({ "-C", dir, "remote", "add", "origin", "https://github.com/owner/repo.git" }) + + -- fast-import makes the history in one process. The commits have empty trees. + local commits = M.commits(count) + local stream = {} + for i = #commits, 1, -1 do + local commit = commits[i] + local message = commit.subject .. "\n\n" .. commit.body + local ident = string.format("%s <%s> %d +0000", commit.author.name, commit.author.email, commit.timestamp) + table.insert(stream, "commit refs/heads/main") + table.insert(stream, "mark :" .. i) + table.insert(stream, "author " .. ident) + table.insert(stream, "committer " .. ident) + table.insert(stream, "data " .. #message) + table.insert(stream, message) + if i < #commits then + table.insert(stream, "from :" .. (i + 1)) + end + table.insert(stream, "") + end + git({ "-C", dir, "fast-import", "--quiet" }, { stdin = table.concat(stream, "\n") }) + git({ "-C", dir, "config", "user.name", "Bench User" }) + git({ "-C", dir, "config", "user.email", "bench@example.com" }) + + repo_dir = dir + return dir +end + +---A commit message buffer in `.git` of the repository, as git opens it. It becomes the current buffer, because +---cmp-git reads the cwd and the filetype from the current buffer. +---@param dir string The repository. +---@param lines string[] +---@return integer bufnr +function M.commit_buffer(dir, lines) + local bufnr = vim.fn.bufadd(dir .. "/.git/COMMIT_EDITMSG") + vim.fn.bufload(bufnr) + vim.bo[bufnr].buftype = "nofile" + vim.bo[bufnr].swapfile = false + vim.bo[bufnr].filetype = "gitcommit" + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.api.nvim_set_current_buf(bufnr) + return bufnr +end + +---A scratch commit message buffer for cases that start no process. It becomes the current buffer. +---@param lines string[] +---@return integer bufnr +function M.scratch_buffer(lines) + local bufnr = vim.api.nvim_create_buf(true, true) + vim.bo[bufnr].filetype = "gitcommit" + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.api.nvim_set_current_buf(bufnr) + return bufnr +end + +return M diff --git a/benchmarks/harness.lua b/benchmarks/harness.lua new file mode 100644 index 0000000..e61942a --- /dev/null +++ b/benchmarks/harness.lua @@ -0,0 +1,344 @@ +---Measurement functions for the cases in `benchmarks/cases/`. +local M = {} + +local hrtime = vim.uv.hrtime + +---A case whose operation finishes before `run` returns. +---@class bench.SyncCase +---@field name string +---@field setup? fun(): any Runs once. Its result goes to `run` and `teardown`. +---@field run fun(state: any) One operation. +---@field teardown? fun(state: any) + +---A case whose operation finishes when it calls `done`, for example a completion request that starts processes. +---@class bench.AsyncCase +---@field name string +---@field async true +---@field setup? fun(): any Runs once. Its result goes to the other functions. +---@field before_each? fun(state: any) Runs before each operation, outside the measurement. +---@field run fun(state: any, done: fun(err?: string)) One operation. An error message fails the case. +---@field teardown? fun(state: any) + +---@alias bench.Case bench.SyncCase | bench.AsyncCase + +---@class bench.Options +---@field samples integer Samples for each sync case. +---@field async_samples integer Samples for each async case. +---@field min_sample_ns number A sync sample repeats the operation until it takes at least this long. +---@field alloc_ops integer Operations in the allocation pass of a sync case. +---@field timeout_ms integer Time limit for one async operation. + +---@class bench.Result +---@field kind "sync" | "async" +---@field unit "us" | "ms" +---@field samples number[] +---@field median number +---@field min number +---@field alloc_kb? number Lua heap allocated for each operation of a sync case. +---@field main_ms? number Median main-thread time of an async operation. +---@field block_ms? number Median of the longest uninterrupted main-thread slice of an async operation. +---@field spawns number Processes started for each operation. +---@field blocking number Synchronous process calls (`vim.fn.system`) for each operation. + +---@param values number[] +---@return number +local function median(values) + local sorted = vim.list_extend({}, values) + table.sort(sorted) + local middle = math.floor(#sorted / 2) + 1 + if #sorted % 2 == 1 then + return sorted[middle] + end + return (sorted[middle - 1] + sorted[middle]) / 2 +end + +---@param values number[] +---@return number +local function minimum(values) + return math.min(unpack(values)) +end + +M.median = median + +---Counts the processes that the code under test starts, and times its main-thread work. The code reads +---`vim.system`, `vim.fn.system`, and `vim.schedule` when it calls them, so replacing them here is enough. +local probe = { + spawns = 0, + blocking = 0, + pending = 0, + slices = {}, ---@type number[] +} + +local originals = nil ---@type { system: function, fn_system: function, fn_systemlist: function, schedule: function }? + +function probe.reset() + probe.spawns = 0 + probe.blocking = 0 + probe.slices = {} +end + +function probe.install() + originals = { + system = vim.system, + fn_system = vim.fn.system, + fn_systemlist = vim.fn.systemlist, + schedule = vim.schedule, + } + local o = originals + + ---@diagnostic disable-next-line: duplicate-set-field + vim.system = function(cmd, opts, on_exit) + probe.spawns = probe.spawns + 1 + if not on_exit then + return o.system(cmd, opts) + end + + probe.pending = probe.pending + 1 + return o.system(cmd, opts, function(...) + probe.pending = probe.pending - 1 + return on_exit(...) + end) + end + + local function blocking(original) + return function(...) + probe.spawns = probe.spawns + 1 + probe.blocking = probe.blocking + 1 + return original(...) + end + end + vim.fn.system = blocking(o.fn_system) + vim.fn.systemlist = blocking(o.fn_systemlist) + + ---@diagnostic disable-next-line: duplicate-set-field + vim.schedule = function(fn) + return o.schedule(function() + local start = hrtime() + fn() + table.insert(probe.slices, hrtime() - start) + end) + end +end + +function probe.uninstall() + if not originals then + return + end + + vim.system = originals.system + vim.fn.system = originals.fn_system + vim.fn.systemlist = originals.fn_systemlist + vim.schedule = originals.schedule + originals = nil +end + +---Wait until the processes that the code started exit and their scheduled callbacks run, so that the work of one +---operation does not run during the next one. +---@param timeout_ms integer +local function settle(timeout_ms) + vim.wait(timeout_ms, function() + return probe.pending == 0 + end, 1) + for _ = 1, 3 do + vim.wait(0) + end +end + +---@param run fun(state: any) +---@param state any +---@param iterations integer +---@return number ns Time for each operation. +local function time_batch(run, state, iterations) + local start = hrtime() + for _ = 1, iterations do + run(state) + end + return (hrtime() - start) / iterations +end + +---@param case bench.SyncCase +---@param opts bench.Options +---@return bench.Result +function M.measure_sync(case, opts) + local state = case.setup and case.setup() + + probe.install() + local ok, result = pcall(function() + -- The first runs warm up the JIT compiler and the caches that the case fills + probe.reset() + local per_op = time_batch(case.run, state, 1) + local spawns, blocking = probe.spawns, probe.blocking + settle(opts.timeout_ms) + + local iterations = 1 + while per_op * iterations < opts.min_sample_ns and iterations < 2 ^ 20 do + iterations = iterations * 2 + per_op = time_batch(case.run, state, iterations) + end + + local samples = {} + for i = 1, opts.samples do + samples[i] = time_batch(case.run, state, iterations) / 1e3 + end + + -- With the collector stopped, the heap grows by exactly the bytes that the operations allocate + collectgarbage("collect") + collectgarbage("stop") + local before = collectgarbage("count") + for _ = 1, opts.alloc_ops do + case.run(state) + end + local alloc_kb = (collectgarbage("count") - before) / opts.alloc_ops + collectgarbage("restart") + + ---@type bench.Result + return { + kind = "sync", + unit = "us", + samples = samples, + median = median(samples), + min = minimum(samples), + alloc_kb = alloc_kb, + spawns = spawns, + blocking = blocking, + } + end) + collectgarbage("restart") + probe.uninstall() + + if case.teardown then + case.teardown(state) + end + if not ok then + error(result, 0) + end + return result +end + +---@param case bench.AsyncCase +---@param opts bench.Options +---@return bench.Result +function M.measure_async(case, opts) + local state = case.setup and case.setup() + + probe.install() + local ok, result = pcall(function() + local wall, main, block = {}, {}, {} + local spawns, blocking = 0, 0 + local warmup = 1 + + for i = 1, warmup + opts.async_samples do + if case.before_each then + case.before_each(state) + end + settle(opts.timeout_ms) + probe.reset() + + local finished_at = nil ---@type number? + local failure = nil ---@type string? + local start = hrtime() + case.run(state, function(err) + finished_at = finished_at or hrtime() + failure = failure or err + end) + local sync_ns = hrtime() - start + + if + not vim.wait(opts.timeout_ms, function() + return finished_at ~= nil + end, 1) + then + error(string.format("the operation did not finish within %d ms", opts.timeout_ms), 0) + end + if failure then + error(failure, 0) + end + settle(opts.timeout_ms) + + if i > warmup then + local main_ns, block_ns = sync_ns, sync_ns + for _, slice in ipairs(probe.slices) do + main_ns = main_ns + slice + block_ns = math.max(block_ns, slice) + end + ---@cast finished_at number + table.insert(wall, (finished_at - start) / 1e6) + table.insert(main, main_ns / 1e6) + table.insert(block, block_ns / 1e6) + spawns = spawns + probe.spawns + blocking = blocking + probe.blocking + end + end + + ---@type bench.Result + return { + kind = "async", + unit = "ms", + samples = wall, + median = median(wall), + min = minimum(wall), + main_ms = median(main), + block_ms = median(block), + spawns = spawns / opts.async_samples, + blocking = blocking / opts.async_samples, + } + end) + probe.uninstall() + + if case.teardown then + case.teardown(state) + end + if not ok then + error(result, 0) + end + return result +end + +---@param case bench.Case +---@param opts bench.Options +---@return bench.Result +function M.measure(case, opts) + if case.async then + ---@cast case bench.AsyncCase + return M.measure_async(case, opts) + end + ---@cast case bench.SyncCase + return M.measure_sync(case, opts) +end + +---@param value number +---@param unit "us" | "ms" +---@return string +function M.format_time(value, unit) + if unit == "ms" then + return string.format("%.2f ms", value) + end + if value >= 1000 then + return string.format("%.2f ms", value / 1000) + end + return string.format("%.2f us", value) +end + +---@param name string +---@param result bench.Result +---@return string +function M.format_result(name, result) + local parts = { + string.format("%-52s", name), + string.format("median %10s", M.format_time(result.median, result.unit)), + string.format("min %10s", M.format_time(result.min, result.unit)), + } + if result.kind == "sync" then + table.insert(parts, string.format("alloc %8.2f KB", result.alloc_kb)) + else + table.insert(parts, string.format("main %8.2f ms", result.main_ms)) + table.insert(parts, string.format("block %7.2f ms", result.block_ms)) + end + table.insert(parts, string.format("spawns %4.1f", result.spawns)) + if result.blocking > 0 then + table.insert(parts, string.format("blocking %3.1f", result.blocking)) + end + return table.concat(parts, " ") +end + +return M diff --git a/benchmarks/run.lua b/benchmarks/run.lua new file mode 100644 index 0000000..0142c10 --- /dev/null +++ b/benchmarks/run.lua @@ -0,0 +1,143 @@ +-- Runs the benchmarks and prints one line for each case. +-- +-- nvim --headless --clean -l benchmarks/run.lua [options] +-- +-- Options: +-- --root DIR Measure the cmp-git code in DIR (default: this repository). The cases always come from this +-- repository, so that two revisions run the same cases. +-- --json FILE Write the results to FILE for benchmarks/compare.lua. +-- --filter PATTERN Run only the cases whose names match the Lua pattern. +-- --samples N Samples for each sync case (default 15). +-- --async-samples N Samples for each async case (default 10). +-- --revision REV The revision to record in the results (default: `git rev-parse --short HEAD` in the root). +-- --allow-errors Exit with 0 when a case fails. Use it for an older revision that lacks an API that a case +-- needs. + +local bench_root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h") +package.path = string.format("%s/?.lua;%s", bench_root, package.path) + +local CASE_MODULES = { "benchmarks.cases.keystroke", "benchmarks.cases.request", "benchmarks.cases.e2e" } + +---@param argv string[] +---@return { root: string, json?: string, filter?: string, revision?: string, samples: integer, async_samples: integer, allow_errors: boolean } +local function parse_args(argv) + local opts = { root = bench_root, samples = 15, async_samples = 10, allow_errors = false } + local i = 1 + while i <= #argv do + local name = argv[i] + local function value() + i = i + 1 + return argv[i] or error("missing value for " .. name, 0) + end + + if name == "--root" then + opts.root = value() + elseif name == "--json" then + opts.json = value() + elseif name == "--filter" then + opts.filter = value() + elseif name == "--revision" then + opts.revision = value() + elseif name == "--samples" then + opts.samples = assert(tonumber(value()), "--samples needs a number") + elseif name == "--async-samples" then + opts.async_samples = assert(tonumber(value()), "--async-samples needs a number") + elseif name == "--allow-errors" then + opts.allow_errors = true + else + error("unknown option " .. name, 0) + end + i = i + 1 + end + opts.root = vim.fs.normalize(vim.fn.fnamemodify(opts.root, ":p")) + return opts +end + +---@param root string +---@return string? +local function revision(root) + local result = vim.system({ "git", "-C", root, "rev-parse", "--short", "HEAD" }, { text = true }):wait() + if result.code ~= 0 then + return nil + end + return vim.trim(result.stdout) +end + +local function main() + -- `nvim -l` gives the script arguments in the global `arg` + -- selene: allow(global_usage) + local opts = parse_args(_G.arg) + + vim.opt.runtimepath:prepend(opts.root) + local core_path = vim.api.nvim_get_runtime_file("lua/cmp_git/core.lua", false)[1] + if not core_path or vim.fs.normalize(core_path) ~= opts.root .. "/lua/cmp_git/core.lua" then + error(string.format("cmp-git does not load from %s (found %s)", opts.root, tostring(core_path)), 0) + end + + local harness = require("benchmarks.harness") + local fixtures = require("benchmarks.fixtures") + fixtures.isolate_environment() + + ---@type bench.Options + local measure_opts = { + samples = opts.samples, + async_samples = opts.async_samples, + min_sample_ns = 5e6, + alloc_ops = 32, + timeout_ms = 10000, + } + + local order = {} + local results = {} ---@type table + local errors = {} ---@type table + + for _, module in ipairs(CASE_MODULES) do + for _, case in ipairs(require(module)) do + if not opts.filter or case.name:find(opts.filter) then + table.insert(order, case.name) + local ok, result = pcall(harness.measure, case, measure_opts) + if ok then + results[case.name] = result + print(harness.format_result(case.name, result)) + else + errors[case.name] = tostring(result) + print(string.format("%-52s ERROR %s", case.name, tostring(result))) + end + end + end + end + + if opts.json then + local version = vim.version() + local uname = vim.uv.os_uname() + local file = assert(io.open(opts.json, "w")) + file:write(vim.json.encode({ + version = 1, + meta = { + date = os.date("!%Y-%m-%dT%H:%M:%SZ"), + nvim = string.format("%d.%d.%d", version.major, version.minor, version.patch), + luajit = package.loaded.jit and package.loaded.jit.version or _VERSION, + os = string.format("%s %s", uname.sysname, uname.machine), + root = opts.root, + revision = opts.revision or revision(opts.root), + }, + order = order, + -- An empty table encodes as a JSON array + results = next(results) and results or vim.empty_dict(), + errors = next(errors) and errors or vim.empty_dict(), + })) + file:close() + end + + fixtures.cleanup() + return (next(errors) == nil or opts.allow_errors) and 0 or 1 +end + +local ok, code = xpcall(main, debug.traceback) +if not ok then + -- selene: allow(incorrect_standard_library_use) + io.stderr:write(tostring(code) .. "\n") + require("benchmarks.fixtures").cleanup() + os.exit(2) +end +os.exit(code) diff --git a/scripts/bench-compare.sh b/scripts/bench-compare.sh new file mode 100755 index 0000000..86093c1 --- /dev/null +++ b/scripts/bench-compare.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Compares the benchmarks of a git revision with the working tree. +# +# scripts/bench-compare.sh [revision] [compare options] +# The revision defaults to main. The other arguments go to benchmarks/compare.lua, for example +# `--time-threshold 0.5`. Both sides run the cases of the working tree, so that the report shows only the +# change in lua/. The script exits with 1 when the working tree regressed. +# +# Environment: +# NVIM The Neovim to run (default: nvim). +# BENCH_RUNS Runs of each side (default: 2). The sides alternate, and each side uses its best run. +# GITHUB_STEP_SUMMARY When it is set, the report is also appended there as Markdown. +set -euo pipefail + +rev=${1:-main} +shift || true +nvim=${NVIM:-nvim} +runs=${BENCH_RUNS:-2} +root=$(git rev-parse --show-toplevel) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +base_rev=$(git -C "$root" rev-parse --short "$rev") +mkdir "$tmp/base" +git -C "$root" archive "$rev" lua | tar -x -C "$tmp/base" + +run() { + "$nvim" --headless --clean -l "$root/benchmarks/run.lua" --allow-errors "$@" >/dev/null +} + +base_files="" +head_files="" +for i in $(seq "$runs"); do + echo "Run $i of $runs: $rev" >&2 + run --root "$tmp/base" --revision "$base_rev" --json "$tmp/base-$i.json" + echo "Run $i of $runs: working tree" >&2 + run --root "$root" --json "$tmp/head-$i.json" + base_files="${base_files:+$base_files,}$tmp/base-$i.json" + head_files="${head_files:+$head_files,}$tmp/head-$i.json" +done + +args=("$base_files" "$head_files" "$@") +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + args+=(--markdown "$GITHUB_STEP_SUMMARY") +fi +"$nvim" --headless --clean -l "$root/benchmarks/compare.lua" "${args[@]}" From 28b959e3747f8b29e46b705512085a1d90eb4706 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:34:05 +1100 Subject: [PATCH 02/37] ci: compare the benchmarks on pull requests The Benchmarks job runs scripts/bench-compare.sh on each pull request against the first parent of the merge commit, three runs on each side, and appends the report to the job summary. Shared runners are noisy, so the job does not block the merge. --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ CONTRIBUTING.md | 15 ++++++++------- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2716a7d..253fb6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,30 @@ jobs: - name: Test run: nix develop ".#${{ matrix.shell }}" -c make test + # Shared runners are noisy, so a failed comparison is a warning to read in the job summary, not a failed check + bench: + name: Benchmarks + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 30 + continue-on-error: true + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The pull request merge commit and its parents. The first parent is the base branch. + fetch-depth: 2 + - name: Install Nix + uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2 + with: + extra_nix_config: | + extra-substituters = https://nix-community.cachix.org + extra-trusted-public-keys = nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs= + - name: Compare with the base branch + env: + BENCH_RUNS: 3 + run: nix develop -c scripts/bench-compare.sh HEAD^1 + # Dependabot and `nix flake update` can lock an overlay commit that is not in the cache yet. Then # `nix develop .#nightly` builds Neovim from source. nightly-cache: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb4a1bc..ef5945e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -155,13 +155,14 @@ Use the fixtures in `benchmarks/fixtures.lua` so that the case does not use the The workflow in `.github/workflows/ci.yml` runs on pushes to `main`, on pull requests, and on manual dispatch. It has these jobs: -| Job | Steps | Blocks | -| ---------------- | --------------------------------------------------------------------------------------------------------- | ------ | -| `Quality` | `make format-check`, `make lint`, and `make typecheck` in the default shell | Yes | -| `Neovim stable` | `make test` in the default shell | Yes | -| `Neovim 0.12.0` | `make test` in the `nvim-0_12_0` shell | Yes | -| `Nightly cache` | `scripts/nightly-cache.sh check` (the locked nightly Neovim is in the cache for each system) | Yes | -| `Neovim nightly` | `make test` with the newest `neovim-nightly-overlay` commit that is in the cache, not the locked revision | No | +| Job | Steps | Blocks | +| ---------------- | ---------------------------------------------------------------------------------------------------------- | ------ | +| `Quality` | `make format-check`, `make lint`, and `make typecheck` in the default shell | Yes | +| `Neovim stable` | `make test` in the default shell | Yes | +| `Neovim 0.12.0` | `make test` in the `nvim-0_12_0` shell | Yes | +| `Nightly cache` | `scripts/nightly-cache.sh check` (the locked nightly Neovim is in the cache for each system) | Yes | +| `Neovim nightly` | `make test` with the newest `neovim-nightly-overlay` commit that is in the cache, not the locked revision | No | +| `Benchmarks` | Pull requests only. `scripts/bench-compare.sh` against the base branch, with the report in the job summary | No | A weekly schedule (Monday 06:00 UTC) runs only the nightly job. GitHub disables scheduled workflows after 60 days without repository activity. To enable the schedule again, use the Actions tab of the repository. From 0bf8c63982a7f706767339b54e5e391807f1fd46 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:45:20 +1100 Subject: [PATCH 03/37] fix: parse GitLab subgroups, ports, and credentials in remote URLs The remote URL parser removed ".git" anywhere in the URL, so the repo of https://github.com/user/user.github.io.git was "userhub.io". For https://gitlab.com/group/sub/repo, the host was "gitlab.com/group", and for ssh://git@host:2222/group/sub/repo, the owner was "sub". Credentials in an https URL stayed in the host, and a trailing slash stayed in the repo. The parser now reads the scheme, the authority, and the path. The host has no user info. An https or http remote keeps its port, because the API is on the same origin. Other remotes, such as ssh:// remotes, drop the port. The owner is all path segments before the last one, so a GitLab subgroup gives the owner "group/sub", as the scp-like form already did. The repo is the last segment without a trailing ".git" or "/". Local paths and file:// URLs give no repository. --- lua/cmp_git/repository/remote_url.lua | 56 +++++++++++++++++++++----- tests/repository/test_remote_url.lua | 58 +++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/lua/cmp_git/repository/remote_url.lua b/lua/cmp_git/repository/remote_url.lua index 4d2704a..6ec617f 100644 --- a/lua/cmp_git/repository/remote_url.lua +++ b/lua/cmp_git/repository/remote_url.lua @@ -5,26 +5,64 @@ local M = {} ---@field owner string? ---@field repo string? +---@param authority string `[user[:password]@]host[:port]` +---@return string? host +---@return string? port Empty when the authority has no port. +local function split_authority(authority) + -- Credentials are not part of the host + authority = authority:gsub("^.*@", "") + + local host, port = authority:match("^(%[.*%]):?(%d*)$") + if host == nil then + host, port = authority:match("^([^:]*):?(%d*)$") + end + return host, port +end + ---@param remote_url string? ---@return cmp_git.RepositoryMetadata function M.parse(remote_url) - if remote_url == nil or remote_url == "" then + if remote_url == nil then return {} end - local clean_remote_url = remote_url:gsub("%.git", ""):gsub("%s", "") - local host, owner, repo = string.match(clean_remote_url, "^git.*@(.+):(.+)/(.+)$") + local url = vim.trim(remote_url) + local host, port, path ---@type string?, string?, string? + local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$") + if scheme ~= nil then + scheme = scheme:lower() + if scheme == "file" then + return {} + end - if host == nil then - host, owner, repo = string.match(clean_remote_url, "^https?://(.+)/(.+)/(.+)$") + local authority + authority, path = rest:match("^([^/]*)(.*)$") + host, port = split_authority(authority) + -- The API of an http(s) remote is on the same origin. Other ports, such as the one of an `ssh://` remote, + -- are not the port of the API. + if host ~= nil and port ~= "" and (scheme == "http" or scheme == "https") then + host = host .. ":" .. port + end + else + -- Git reads `[user@]host:path` as an scp-like ssh URL when no slash comes before the first colon. Anything + -- else is a local path. + local authority + authority, path = url:match("^([^/:]+):(.*)$") + if authority == nil then + return {} + end + host = authority:gsub("^.*@", "") end - if host == nil then - host, owner, repo = string.match(clean_remote_url, "^ssh://git@([^:]+):*.*/(.+)/(.+)$") + if host == nil or host == "" or path == nil then + return {} end - if host == nil then - host, owner, repo = string.match(clean_remote_url, "^([^:]+):(.+)/(.+)$") + path = path:gsub("/+$", ""):gsub("%.git$", ""):gsub("^/+", ""):gsub("//+", "/") + -- The owner is all segments before the last one, for example a GitLab group and its subgroups + local owner, repo = path:match("^(.+)/([^/]+)$") + if owner == nil then + return {} end return { host = host, owner = owner, repo = repo } diff --git a/tests/repository/test_remote_url.lua b/tests/repository/test_remote_url.lua index be09152..a096dfd 100644 --- a/tests/repository/test_remote_url.lua +++ b/tests/repository/test_remote_url.lua @@ -27,4 +27,62 @@ T["parse_remote_url"] = function() eq(alias.repo, "repo", "alias remote repo") end +T["parse_remote_url_repo_with_git_in_name"] = function() + eq( + remote_url.parse("https://github.com/user/user.github.io.git"), + { host = "github.com", owner = "user", repo = "user.github.io" }, + "only a trailing .git is removed" + ) + eq(remote_url.parse("git@github.com:owner/git.git").repo, "git", "a repo named git") +end + +T["parse_remote_url_subgroups"] = function() + local expected = { host = "gitlab.com", owner = "group/sub", repo = "repo" } + eq(remote_url.parse("https://gitlab.com/group/sub/repo"), expected, "https remote in a subgroup") + eq(remote_url.parse("https://gitlab.com/group/sub/repo.git"), expected, "https remote in a subgroup with .git") + eq(remote_url.parse("git@gitlab.com:group/sub/repo.git"), expected, "scp-like remote in a subgroup") + eq(remote_url.parse("ssh://git@gitlab.com/group/sub/repo.git"), expected, "ssh url remote in a subgroup") +end + +T["parse_remote_url_ports"] = function() + eq( + remote_url.parse("ssh://git@host:2222/group/sub/repo"), + { host = "host", owner = "group/sub", repo = "repo" }, + "the ssh port is not part of the host" + ) + eq( + remote_url.parse("https://gitlab.example.com:8443/group/repo.git"), + { host = "gitlab.example.com:8443", owner = "group", repo = "repo" }, + "the https port is part of the host, because the API is on the same origin" + ) + eq(remote_url.parse("git://example.com:9418/owner/repo.git").host, "example.com", "the git daemon port") +end + +T["parse_remote_url_credentials"] = function() + eq( + remote_url.parse("https://user:token@github.com/o/r"), + { host = "github.com", owner = "o", repo = "r" }, + "credentials are not part of the host" + ) + eq(remote_url.parse("https://token@github.com:8443/o/r").host, "github.com:8443", "a token and a port") + eq(remote_url.parse("org-123@github.com:owner/repo.git").host, "github.com", "an scp-like user other than git") + eq(remote_url.parse("git+ssh://git@github.com/owner/repo.git").host, "github.com", "a git+ssh remote") +end + +T["parse_remote_url_trailing_slash"] = function() + local expected = { host = "github.com", owner = "owner", repo = "repo" } + eq(remote_url.parse("https://github.com/owner/repo/"), expected, "a trailing slash") + eq(remote_url.parse("https://github.com/owner/repo.git/"), expected, "a trailing slash after .git") + eq(remote_url.parse("git@github.com:owner/repo.git\n"), expected, "a trailing newline") +end + +T["parse_remote_url_unsupported"] = function() + eq(remote_url.parse(nil), {}, "no URL") + eq(remote_url.parse(""), {}, "an empty URL") + eq(remote_url.parse("/srv/git/project.git"), {}, "a local path") + eq(remote_url.parse("./project"), {}, "a relative path") + eq(remote_url.parse("file:///srv/git/owner/project.git"), {}, "a file URL") + eq(remote_url.parse("https://github.com/repo"), {}, "a URL without an owner") +end + return T From 338cb0e9359a09ebdd279bd7d0d293be1a55dfe2 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:45:34 +1100 Subject: [PATCH 04/37] fix: compare ssh_aliases with the host as plain strings The alias was put in a Lua pattern, with only "-" and "." escaped. An alias such as "gh+work" never matched its host, and a "%" in the replacement host was read as a capture reference. The host is now looked up in ssh_aliases, so that each alias and host is used as it is written. --- lua/cmp_git/repository/init.lua | 6 ++++-- tests/repository/test_init.lua | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lua/cmp_git/repository/init.lua b/lua/cmp_git/repository/init.lua index acb7c01..5c70aaa 100644 --- a/lua/cmp_git/repository/init.lua +++ b/lua/cmp_git/repository/init.lua @@ -58,8 +58,10 @@ local function apply_ssh_aliases(metadata, ssh_aliases) return metadata end - for alias, host in pairs(ssh_aliases) do - metadata.host = metadata.host:gsub("^" .. alias:gsub("%-", "%%-"):gsub("%.", "%%.") .. "$", host, 1) + -- A plain lookup, because an alias or a host can have Lua pattern characters, such as `+` or `%` + local host = ssh_aliases[metadata.host] + if host ~= nil then + metadata.host = host end return metadata diff --git a/tests/repository/test_init.lua b/tests/repository/test_init.lua index 68a78f1..b97baae 100644 --- a/tests/repository/test_init.lua +++ b/tests/repository/test_init.lua @@ -165,6 +165,16 @@ T["discover_with_ssh_aliases"] = function() ) end +T["discover_with_ssh_aliases_as_plain_strings"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "git@gh+work:owner/repo.git" } } }) + vim.fn.chdir(repo.dir) + + eq(discover({ ssh_aliases = { ["gh+work"] = "github.com" } }).host, "github.com", "an alias with a +") + eq(discover({ ssh_aliases = { ["gh+work"] = "git%1.example.com" } }).host, "git%1.example.com", "a host with a %") + eq(discover({ ssh_aliases = { ["gh.work"] = "github.com" } }).host, "gh+work", "a . in an alias is not a pattern") +end + T["discover_octo_buffer"] = function() local octo_config = { values = { github_hostname = "" } } helpers.stub(package.loaded, "octo.config", octo_config) From a655d00db95f3e14faaf71730349cd91e0328374 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:45:49 +1100 Subject: [PATCH 05/37] fix: use the cwd when the buffer directory does not exist A buffer of a new file can be in a directory that does not exist yet. get_cwd() returned that directory, and vim.system throws ENOENT for a cwd that does not exist, so the error escaped from the completion request. get_cwd() now returns the buffer directory only when it exists, and Neovim's cwd in the other cases. --- lua/cmp_git/repository/init.lua | 6 +++++- tests/repository/test_init.lua | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lua/cmp_git/repository/init.lua b/lua/cmp_git/repository/init.lua index 5c70aaa..afdde75 100644 --- a/lua/cmp_git/repository/init.lua +++ b/lua/cmp_git/repository/init.lua @@ -20,7 +20,11 @@ end ---@return string function M.get_cwd() if vim.fn.getreg("%") ~= "" and vim.bo.filetype ~= "octo" then - return vim.fn.expand("%:p:h") + local dir = vim.fn.expand("%:p:h") + -- `vim.system` throws for a `cwd` that does not exist, for example for a new file in a new directory + if vim.fn.isdirectory(dir) == 1 then + return dir + end end return vim.fn.getcwd() end diff --git a/tests/repository/test_init.lua b/tests/repository/test_init.lua index b97baae..83531e6 100644 --- a/tests/repository/test_init.lua +++ b/tests/repository/test_init.lua @@ -96,6 +96,17 @@ T["is_git_repo_falls_back_to_cwd"] = function() eq(is_git_repo(), true, "the cwd is checked when the buffer directory is not in a repository") end +T["get_cwd_buffer_in_missing_directory"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "https://github.com/owner/repo.git" } } }) + vim.fn.chdir(repo.dir) + edit(helpers.tempdir() .. "/new/dir/notes.md", "markdown") + + eq(repository.get_cwd(), repo.dir, "a directory that does not exist falls back to the cwd") + eq(is_git_repo(), true, "the cwd repository") + eq(discover(), { host = "github.com", owner = "owner", repo = "repo" }, "the cwd repository") +end + T["discover_first_remote_matches"] = function() require_git() local repo = helpers.git_repo({ From 2147e6ba9f177b2be23d78e4b3ecb5684e785117 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:50:53 +1100 Subject: [PATCH 06/37] perf: discover the repository once for each buffer Each completion request started 3 to 5 processes before it could use its cache: "sh -c git rev-parse" in is_git_repo, "sh -c git config" for each remote in turn, and all of it again in Neovim's cwd when nothing was found. A warm commits request took 14.6 ms, and about 4 ms of it was main-thread time for the process starts. Discovery now runs git without sh, and needs two processes: one git rev-parse that tells whether the directory is in a repository and where its config is, and one git config --get-regexp for the URLs of all remotes. With enableRemoteUrlRewrites, git remote -v gives the rewritten URLs of all remotes in one process. The remote order of the remotes option still decides which URL is used. The result is kept for each buffer, so a repeated request starts no process, and concurrent requests share one discovery. The buffer directory and, when the repository was not found there, Neovim's cwd are part of the key, so a renamed buffer or a :cd gives a new discovery. A change to the config file of the repository, for example a new remote, also gives a new discovery. A directory outside a repository is checked again after 5 seconds, because git init in any parent directory can change it. The entry of a buffer is dropped when the buffer is wiped. Octo buffers get their repository from the buffer name and start no process. Discovery now reads the remotes of the repository that it found. When that repository had no matching remote, it used the remotes of the repository in Neovim's cwd, which is not the repository of the buffer. --- lua/cmp_git/repository/init.lua | 409 ++++++++++++++++++++++++-------- tests/repository/test_init.lua | 209 ++++++++++++++++ 2 files changed, 524 insertions(+), 94 deletions(-) diff --git a/lua/cmp_git/repository/init.lua b/lua/cmp_git/repository/init.lua index afdde75..023c0ef 100644 --- a/lua/cmp_git/repository/init.lua +++ b/lua/cmp_git/repository/init.lua @@ -2,56 +2,299 @@ local remote_url = require("cmp_git.repository.remote_url") local M = {} ----@param cmd string ----@param opts { on_complete: fun(success: boolean, output: string[]): nil; cwd?: string } +---@alias cmp_git.GitInfo cmp_git.RepositoryMetadata + +---@alias cmp_git.RemoteUrlKind "configured" | "rewritten" + +---What discovery found for one buffer. A field is nil until the git process that gives it exits. +---@class cmp_git.RepositoryState +---@field dir string? The buffer directory when discovery started. +---@field nvim_cwd string Neovim's cwd when discovery started. +---@field cwd string? The directory in which discovery found the repository. +---@field is_repo boolean? +---@field checked_at number? When discovery found no repository, in `vim.uv.now()` milliseconds. +---@field config_path string? The config file of the repository. +---@field config_stamp string? The stamp of the config file when discovery found the repository. +---@field remote_urls table> The URL of each remote, by remote name. +---@field waiting table The callbacks that wait for a running git process, by step. + +-- `git init` in the directory or in any parent makes a directory part of a repository, and one stat cannot see +-- that, so discovery checks a directory outside a repository again after this time +local NOT_A_REPOSITORY_TTL_MS = 5000 + +---The discovery state of each buffer. The buffer directory and Neovim's cwd are part of the key, see `matches`. +---@type table +local states = {} + +vim.api.nvim_create_autocmd("BufWipeout", { + group = vim.api.nvim_create_augroup("cmp_git.repository", { clear = true }), + desc = "Forget the cmp-git repository of the buffer", + callback = function(args) + states[args.buf] = nil + end, +}) + +---@param cmd string[] +---@param cwd string +---@param on_complete fun(success: boolean, stdout: string): nil ---@return nil -local function run_cmd_async(cmd, opts) - vim.system({ "sh", "-c", cmd }, { - text = true, - cwd = opts.cwd, - }, function(result) +local function run_async(cmd, cwd, on_complete) + local ok = pcall(vim.system, cmd, { text = true, cwd = cwd }, function(result) vim.schedule(function() - local output = vim.split(result.stdout or "", "\n", { trimempty = true }) - opts.on_complete(result.code == 0, output) + on_complete(result.code == 0, result.stdout or "") end) end) + if not ok then + -- `vim.system` throws when git is not installed or when `cwd` was removed + vim.schedule(function() + on_complete(false, "") + end) + end +end + +---@param bufnr integer? +---@return integer +local function resolve_bufnr(bufnr) + if bufnr == nil or bufnr == 0 then + return vim.api.nvim_get_current_buf() + end + return bufnr +end + +---@param bufnr integer +---@return string? dir The directory of the buffer file, nil when it has none or the directory does not exist. +local function buffer_dir(bufnr) + if vim.bo[bufnr].filetype == "octo" then + return nil + end + + local name = vim.api.nvim_buf_get_name(bufnr) + if name == "" then + return nil + end + + local dir = vim.fn.fnamemodify(name, ":p:h") + -- `vim.system` throws for a `cwd` that does not exist, for example for a new file in a new directory + if vim.fn.isdirectory(dir) ~= 1 then + return nil + end + return dir +end + +---@param path string? +---@return string? stamp A value that changes when git writes the file. +local function file_stamp(path) + local stat = path and vim.uv.fs_stat(path) + if not stat then + return nil + end + return string.format("%d.%d:%d:%d", stat.mtime.sec, stat.mtime.nsec, stat.ino, stat.size) end +---@param state cmp_git.RepositoryState +---@param dir string? +---@return boolean +local function matches(state, dir) + if state.dir ~= dir then + return false + end + + -- Neovim's cwd matters unless the repository is in the buffer directory + return (dir ~= nil and state.cwd == dir) or state.nvim_cwd == vim.fn.getcwd() +end + +---@param bufnr? integer The buffer, by default the current one. ---@return string -function M.get_cwd() - if vim.fn.getreg("%") ~= "" and vim.bo.filetype ~= "octo" then - local dir = vim.fn.expand("%:p:h") - -- `vim.system` throws for a `cwd` that does not exist, for example for a new file in a new directory - if vim.fn.isdirectory(dir) == 1 then - return dir +function M.get_cwd(bufnr) + return buffer_dir(resolve_bufnr(bufnr)) or vim.fn.getcwd() +end + +---@param state cmp_git.RepositoryState +---@return boolean +local function is_fresh(state) + if state.is_repo == false then + return vim.uv.now() - state.checked_at < NOT_A_REPOSITORY_TTL_MS + end + if state.is_repo == true then + -- Git writes the config file when a remote is added, removed, or changed + return file_stamp(state.config_path) == state.config_stamp + end + return true +end + +---@param bufnr integer +---@return cmp_git.RepositoryState +local function get_state(bufnr) + local dir = buffer_dir(bufnr) + local state = states[bufnr] + if state ~= nil and matches(state, dir) and is_fresh(state) then + return state + end + + state = { dir = dir, nvim_cwd = vim.fn.getcwd(), remote_urls = {}, waiting = {} } + states[bufnr] = state + return state +end + +---Run `step` and then call `callback`. A call while the step runs waits for the same run, so that concurrent +---requests start one process. +---@param state cmp_git.RepositoryState +---@param name string +---@param step fun(state: cmp_git.RepositoryState, done: fun()): nil +---@param callback fun(): nil +---@return nil +local function run_step(state, name, step, callback) + local waiting = state.waiting[name] + if waiting ~= nil then + table.insert(waiting, callback) + return + end + + waiting = { callback } + state.waiting[name] = waiting + step(state, function() + state.waiting[name] = nil + for _, waiter in ipairs(waiting) do + waiter() + end + end) +end + +---@param path string +---@return boolean +local function is_absolute(path) + return path:sub(1, 1) == "/" or path:match("^%a:[/\\]") ~= nil +end + +---Find the repository of the buffer directory, or else of Neovim's cwd. +---@param state cmp_git.RepositoryState +---@param done fun() +---@return nil +local function find_repository(state, done) + local candidates = {} ---@type string[] + if state.dir ~= nil then + table.insert(candidates, state.dir) + end + if state.nvim_cwd ~= state.dir then + table.insert(candidates, state.nvim_cwd) + end + + local index = 0 + local function check_next() + index = index + 1 + local cwd = candidates[index] + if cwd == nil then + state.is_repo = false + state.checked_at = vim.uv.now() + done() + return end + + local cmd = { "git", "rev-parse", "--is-inside-work-tree", "--is-inside-git-dir", "--git-common-dir" } + run_async(cmd, cwd, function(success, stdout) + local lines = vim.split(stdout, "\n", { trimempty = true }) + -- Inside `.git`, only the second line (`--is-inside-git-dir`) is true + if not success or (lines[1] ~= "true" and lines[2] ~= "true") or lines[3] == nil then + check_next() + return + end + + -- A linked worktree has its config in the common directory. The path is relative to `cwd`. + local common_dir = is_absolute(lines[3]) and lines[3] or (cwd .. "/" .. lines[3]) + state.cwd = cwd + state.config_path = common_dir .. "/config" + state.config_stamp = file_stamp(state.config_path) + state.is_repo = true + done() + end) end - return vim.fn.getcwd() + check_next() end ----@alias cmp_git.GitInfo cmp_git.RepositoryMetadata +---@param state cmp_git.RepositoryState +---@param callback fun(): nil +---@return nil +local function with_repository(state, callback) + if state.is_repo ~= nil then + callback() + return + end + run_step(state, "repository", find_repository, callback) +end + +---@param stdout string The output of `git config -z --get-regexp`. +---@return table +local function parse_configured_urls(stdout) + local urls = {} + for entry in vim.gsplit(stdout, "\0", { plain = true, trimempty = true }) do + local key, url = entry:match("^([^\n]*)\n(.*)$") + local name = key and key:match("^remote%.(.+)%.url$") + if name ~= nil then + -- The last URL wins, as with `git config --get` + urls[name] = url + end + end + return urls +end + +---@param stdout string The output of `git remote -v`. +---@return table +local function parse_rewritten_urls(stdout) + local urls = {} + for line in vim.gsplit(stdout, "\n", { plain = true, trimempty = true }) do + -- The fetch URL is the one that `git remote get-url` prints + local name, url = line:match("^(%S+)\t(.*) %(fetch%)$") + if name ~= nil then + urls[name] = url + end + end + return urls +end + +---@param state cmp_git.RepositoryState +---@param kind cmp_git.RemoteUrlKind +---@param callback fun(urls: table): nil +---@return nil +local function with_remote_urls(state, kind, callback) + local function call() + callback(state.remote_urls[kind]) + end + if state.remote_urls[kind] ~= nil then + call() + return + end + + run_step(state, kind, function(_, done) + -- One process gives the URLs of all remotes. Only `git remote` applies the `url..insteadOf` rewrites. + local cmd = { "git", "config", "-z", "--get-regexp", [[^remote\..*\.url$]] } + local parse = parse_configured_urls + if kind == "rewritten" then + cmd = { "git", "remote", "-v" } + parse = parse_rewritten_urls + end + + run_async(cmd, state.cwd, function(_, stdout) + state.remote_urls[kind] = parse(stdout) + done() + end) + end, call) +end ---@param on_result fun(is_git_repo: boolean): nil +---@param bufnr? integer The buffer, by default the current one. ---@return nil -function M.is_git_repo(on_result) - local cwd = M.get_cwd() ---@type string? - local function check_in_git_repo() - local cmd = "git rev-parse --is-inside-work-tree --is-inside-git-dir" - run_cmd_async(cmd, { - on_complete = function(success, output) - -- Inside `.git`, only the second line (`--is-inside-git-dir`) is true - local is_git_repo = success and vim.tbl_contains(output, "true") - if not is_git_repo and cwd ~= nil then - cwd = nil - check_in_git_repo() - return - end - on_result(is_git_repo) - end, - cwd = cwd, - }) +function M.is_git_repo(on_result, bufnr) + bufnr = resolve_bufnr(bufnr) + -- An octo buffer gets its repository from its name + if vim.bo[bufnr].filetype == "octo" then + on_result(true) + return end - check_in_git_repo() + + local state = get_state(bufnr) + with_repository(state, function() + on_result(state.is_repo == true) + end) end ---@param metadata cmp_git.RepositoryMetadata @@ -71,9 +314,10 @@ local function apply_ssh_aliases(metadata, ssh_aliases) return metadata end +---@param bufnr integer ---@return cmp_git.RepositoryMetadata? -local function discover_octo() - if vim.bo.filetype ~= "octo" then +local function discover_octo(bufnr) + if vim.bo[bufnr].filetype ~= "octo" then return nil end @@ -82,80 +326,57 @@ local function discover_octo() host = "github.com" end - local filename = vim.fn.expand("%:p:h") - local owner, repo = string.match(filename, "^octo://([^/]+)/([^/]+)") + local owner, repo = string.match(vim.api.nvim_buf_get_name(bufnr), "^octo://([^/]+)/([^/]+)") return { host = host, owner = owner, repo = repo } end +---@class cmp_git.DiscoverOptions +---@field enableRemoteUrlRewrites boolean +---@field ssh_aliases {[string]: string} +---@field on_complete fun(metadata: cmp_git.RepositoryMetadata): nil +---@field bufnr? integer The buffer, by default the current one. + +---The repository of the first remote in `remotes` that has a supported URL. The result is a new table for each +---call, because callers change it. ---@param remotes string|string[] ----@param opts {enableRemoteUrlRewrites: boolean, ssh_aliases: {[string]: string}, on_complete: fun(metadata: cmp_git.RepositoryMetadata): nil} +---@param opts cmp_git.DiscoverOptions ---@return nil function M.discover(remotes, opts) opts = opts or {} - local cwd = M.get_cwd() ---@type string? - - local discover ---@type fun(): nil + if type(remotes) == "string" then + remotes = { remotes } + end + local bufnr = resolve_bufnr(opts.bufnr) ---@param metadata cmp_git.RepositoryMetadata - local function handle_metadata(metadata) - if metadata.host == nil and cwd ~= nil then - cwd = nil - discover() - return - end - + local function complete(metadata) opts.on_complete(apply_ssh_aliases(metadata, opts.ssh_aliases)) end - discover = function() - if type(remotes) == "string" then - remotes = { remotes } - end + local octo_metadata = discover_octo(bufnr) + if octo_metadata ~= nil then + complete(octo_metadata) + return + end - local octo_metadata = discover_octo() - if octo_metadata ~= nil then - handle_metadata(octo_metadata) + local state = get_state(bufnr) + with_repository(state, function() + if not state.is_repo then + complete({}) return end - local remote_index = 1 - local function check_remote() - if remote_index > #remotes then - handle_metadata({}) - return - end - - local remote = remotes[remote_index] - local cmd ---@type string - if opts.enableRemoteUrlRewrites then - cmd = "git remote get-url " .. remote - else - cmd = "git config --get remote." .. remote .. ".url" + with_remote_urls(state, opts.enableRemoteUrlRewrites and "rewritten" or "configured", function(urls) + for _, remote in ipairs(remotes) do + local metadata = remote_url.parse(urls[remote]) + if metadata.host ~= nil and metadata.owner ~= nil and metadata.repo ~= nil then + complete(metadata) + return + end end - - run_cmd_async(cmd, { - on_complete = function(success, output) - remote_index = remote_index + 1 - if not success then - check_remote() - return - end - - local metadata = remote_url.parse(output[1]) - if metadata.host ~= nil and metadata.owner ~= nil and metadata.repo ~= nil then - handle_metadata(metadata) - return - end - - check_remote() - end, - cwd = cwd, - }) - end - check_remote() - end - - discover() + complete({}) + end) + end) end return M diff --git a/tests/repository/test_init.lua b/tests/repository/test_init.lua index 83531e6..9ab6baa 100644 --- a/tests/repository/test_init.lua +++ b/tests/repository/test_init.lua @@ -54,10 +54,37 @@ local function edit(path, filetype) end end +---Make a new buffer without a name current. Discovery caches its result for each buffer, so this keeps the result of +---an earlier case for the same directory out of the case. +local function new_buffer() + vim.api.nvim_set_current_buf(vim.api.nvim_create_buf(false, true)) +end + +---Record the commands that start a process, until the end of the case. +---@return string[][] commands +local function record_processes() + local commands = {} + local system = vim.system + helpers.stub(vim, "system", function(cmd, ...) + table.insert(commands, cmd) + return system(cmd, ...) + end) + return commands +end + +---@param commands string[][] +---@return string[] lines Each command as one line, to show in a failure. +local function command_lines(commands) + return vim.tbl_map(function(cmd) + return table.concat(cmd, " ") + end, commands) +end + T["is_git_repo_in_work_tree"] = function() require_git() local repo = helpers.git_repo() vim.fn.mkdir(repo.dir .. "/sub", "p") + new_buffer() vim.fn.chdir(repo.dir .. "/sub") eq(is_git_repo(), true, "a subdirectory of a work tree is in a repository") @@ -65,6 +92,7 @@ end T["is_git_repo_outside_repository"] = function() require_git() + new_buffer() vim.fn.chdir(helpers.tempdir()) eq(is_git_repo(), false, "a directory outside a repository") @@ -73,6 +101,7 @@ end T["is_git_repo_inside_git_dir"] = function() require_git() local repo = helpers.git_repo() + new_buffer() vim.fn.chdir(repo.dir .. "/.git") eq(is_git_repo(), true, "the .git directory is in a repository") @@ -115,6 +144,7 @@ T["discover_first_remote_matches"] = function() { "upstream", "https://github.com/owner/repo.git" }, }, }) + new_buffer() vim.fn.chdir(repo.dir) eq(discover(), { host = "github.com", owner = "owner", repo = "repo" }, "the first configured remote wins") @@ -123,6 +153,7 @@ end T["discover_falls_back_to_next_remote"] = function() require_git() local repo = helpers.git_repo({ remotes = { { "origin", "git@gitlab.com:group/project.git" } } }) + new_buffer() vim.fn.chdir(repo.dir) eq(discover(), { host = "gitlab.com", owner = "group", repo = "project" }, "a missing remote falls back") @@ -136,6 +167,7 @@ T["discover_skips_unparsable_remote"] = function() { "origin", "https://github.com/owner/repo.git" }, }, }) + new_buffer() vim.fn.chdir(repo.dir) eq(discover(), { host = "github.com", owner = "owner", repo = "repo" }, "a local path remote falls back") @@ -144,6 +176,7 @@ end T["discover_without_matching_remote"] = function() require_git() local repo = helpers.git_repo({ remotes = { { "fork", "https://github.com/owner/repo.git" } } }) + new_buffer() vim.fn.chdir(repo.dir) eq(discover().host, nil, "no configured remote, no host") @@ -153,6 +186,7 @@ T["discover_with_remote_url_rewrites"] = function() require_git() local repo = helpers.git_repo({ remotes = { { "origin", "work:owner/repo" } } }) repo.git("config", "url.https://github.example.com/.insteadOf", "work:") + new_buffer() vim.fn.chdir(repo.dir) eq(discover().host, "work", "the configured URL without rewrites") @@ -166,6 +200,7 @@ end T["discover_with_ssh_aliases"] = function() require_git() local repo = helpers.git_repo({ remotes = { { "origin", "git@github-work:owner/repo.git" } } }) + new_buffer() vim.fn.chdir(repo.dir) eq(discover().host, "github-work", "the alias without ssh_aliases") @@ -179,6 +214,7 @@ end T["discover_with_ssh_aliases_as_plain_strings"] = function() require_git() local repo = helpers.git_repo({ remotes = { { "origin", "git@gh+work:owner/repo.git" } } }) + new_buffer() vim.fn.chdir(repo.dir) eq(discover({ ssh_aliases = { ["gh+work"] = "github.com" } }).host, "github.com", "an alias with a +") @@ -226,4 +262,177 @@ T["discover_inside_git_dir"] = function() eq(discover(), { host = "github.com", owner = "owner", repo = "repo" }, "a commit message in .git") end +---What core does for each completion request. +---@param opts? { remotes?: string[], enableRemoteUrlRewrites?: boolean, ssh_aliases?: table } +---@return cmp_git.RepositoryMetadata? +local function request(opts) + if not is_git_repo() then + return nil + end + return discover(opts) +end + +T["discovery_runs_git_without_a_shell"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "work:owner/repo" } } }) + new_buffer() + vim.fn.chdir(repo.dir) + local commands = record_processes() + + request() + request({ enableRemoteUrlRewrites = true }) + + helpers.ok(#commands > 0, "discovery runs git") + for _, cmd in ipairs(commands) do + eq(cmd[1], "git", "git runs without sh: " .. table.concat(cmd, " ")) + end +end + +T["discovery_starts_two_processes"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "https://github.com/owner/repo.git" } } }) + local bare = helpers.git_repo() + vim.fn.chdir(helpers.tempdir()) + + edit(repo.dir .. "/notes.md", "markdown") + local commands = record_processes() + eq(request(), { host = "github.com", owner = "owner", repo = "repo" }, "the second remote") + eq(#commands, 2, "a request in a new buffer: " .. vim.inspect(command_lines(commands))) + + edit(bare.dir .. "/notes.md", "markdown") + commands = record_processes() + eq(request(), {}, "a repository without remotes") + eq(#commands, 2, "a repository without remotes: " .. vim.inspect(command_lines(commands))) + + edit(repo.dir .. "/other.md", "markdown") + commands = record_processes() + eq(request({ enableRemoteUrlRewrites = true }).owner, "owner", "the URL from git remote") + eq(#commands, 2, "URL rewrites: " .. vim.inspect(command_lines(commands))) +end + +T["discovery_repeated_request_starts_no_process"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "work:owner/repo" } } }) + repo.git("config", "url.https://github.example.com/.insteadOf", "work:") + edit(repo.dir .. "/notes.md", "markdown") + local expected = request() + local rewritten = request({ enableRemoteUrlRewrites = true }) + local commands = record_processes() + + eq(request(), expected, "the same repository") + eq(request({ enableRemoteUrlRewrites = true }), rewritten, "the same rewritten repository") + eq(request({ ssh_aliases = { work = "github.com" } }).host, "github.com", "ssh_aliases apply to the cache") + eq(request().host, "work", "ssh_aliases do not change the cache") + eq(#commands, 0, "repeated requests: " .. vim.inspect(command_lines(commands))) +end + +T["discovery_outside_repository_starts_no_process"] = function() + require_git() + local dir = helpers.tempdir() + new_buffer() + vim.fn.chdir(dir) + eq(is_git_repo(), false, "a directory outside a repository") + local commands = record_processes() + + eq(is_git_repo(), false, "still outside a repository") + eq(discover(), {}, "no repository") + eq(#commands, 0, "repeated requests: " .. vim.inspect(command_lines(commands))) + + vim.system({ "git", "init", "-q" }, { cwd = dir }):wait() + local now = vim.uv.now() + helpers.stub(vim.uv, "now", function() + return now + 60 * 1000 + end) + eq(is_git_repo(), true, "a directory outside a repository is checked again later, after git init") +end + +T["discovery_concurrent_requests_share_processes"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "https://github.com/owner/repo.git" } } }) + edit(repo.dir .. "/notes.md", "markdown") + local commands = record_processes() + + local results = {} + for _ = 1, 3 do + repository.discover({ "upstream", "origin" }, { + enableRemoteUrlRewrites = false, + ssh_aliases = {}, + on_complete = function(metadata) + table.insert(results, metadata) + end, + }) + end + helpers.wait_for(function() + return #results == 3 + end, "each discover calls back") + + for _, metadata in ipairs(results) do + eq(metadata, { host = "github.com", owner = "owner", repo = "repo" }, "each request gets the repository") + end + helpers.ok(results[1] ~= results[2], "each request gets its own table, because callers change it") + eq(#commands, 2, "concurrent requests: " .. vim.inspect(command_lines(commands))) +end + +T["discovery_octo_buffer_starts_no_process"] = function() + helpers.stub(package.loaded, "octo.config", { values = { github_hostname = "" } }) + vim.fn.chdir(helpers.tempdir()) + edit("octo://owner/repo/issue/12", "octo") + local commands = record_processes() + + eq(request(), { host = "github.com", owner = "owner", repo = "repo" }, "the repository of the buffer name") + eq(#commands, 0, "an octo buffer: " .. vim.inspect(command_lines(commands))) +end + +T["discovery_again_when_buffer_directory_changes"] = function() + require_git() + local first = helpers.git_repo({ remotes = { { "origin", "https://github.com/first/repo.git" } } }) + local second = helpers.git_repo({ remotes = { { "origin", "https://github.com/second/repo.git" } } }) + edit(first.dir .. "/notes.md", "markdown") + eq(request().owner, "first", "the first repository") + + vim.api.nvim_buf_set_name(0, second.dir .. "/notes.md") + eq(request().owner, "second", "the buffer is now in the second repository") +end + +T["discovery_again_when_cwd_changes"] = function() + require_git() + local first = helpers.git_repo({ remotes = { { "origin", "https://github.com/first/repo.git" } } }) + local second = helpers.git_repo({ remotes = { { "origin", "https://github.com/second/repo.git" } } }) + new_buffer() + vim.fn.chdir(first.dir) + eq(request().owner, "first", "the first repository") + + vim.fn.chdir(second.dir) + eq(request().owner, "second", "the cwd is now the second repository") +end + +T["discovery_again_when_remotes_change"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "https://github.com/fork/repo.git" } } }) + edit(repo.dir .. "/notes.md", "markdown") + eq(request().owner, "fork", "the origin remote") + + repo.git("remote", "add", "upstream", "https://github.com/owner/repo.git") + eq(request().owner, "owner", "the new upstream remote comes first") +end + +T["discovery_remote_name_with_a_dot"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "my.fork", "https://github.com/fork/repo.git" } } }) + edit(repo.dir .. "/notes.md", "markdown") + + eq(request({ remotes = { "my.fork" } }).owner, "fork", "the configured URL") + eq(request({ remotes = { "my.fork" }, enableRemoteUrlRewrites = true }).owner, "fork", "the URL from git remote") +end + +T["discovery_reads_remotes_of_the_found_repository"] = function() + require_git() + local buffer_repo = helpers.git_repo() + local cwd_repo = helpers.git_repo({ remotes = { { "origin", "https://github.com/cwd/repo.git" } } }) + vim.fn.chdir(cwd_repo.dir) + edit(buffer_repo.dir .. "/notes.md", "markdown") + + eq(request(), {}, "the remotes of another repository do not apply") +end + return T From 41a1846c8c517562492d061c9e711a6d4f1713f9 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:51:41 +1100 Subject: [PATCH 07/37] fix: run commands in the directory where the repository was found When the buffer directory was not in a repository, is_git_repo and discover found the repository in Neovim's cwd. But get_cwd() still returned the buffer directory, and command.lua runs every git, gh, glab, and curl command there. Discovery succeeded, and then git log failed outside the repository. get_cwd() now returns the directory where discovery found the repository of the buffer, while the buffer directory and Neovim's cwd are the same as at discovery. In the other cases, it returns the buffer directory if it exists, or else Neovim's cwd, as before. --- lua/cmp_git/repository/init.lua | 10 ++++++++- tests/repository/test_init.lua | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/lua/cmp_git/repository/init.lua b/lua/cmp_git/repository/init.lua index 023c0ef..72b7771 100644 --- a/lua/cmp_git/repository/init.lua +++ b/lua/cmp_git/repository/init.lua @@ -103,10 +103,18 @@ local function matches(state, dir) return (dir ~= nil and state.cwd == dir) or state.nvim_cwd == vim.fn.getcwd() end +---The directory in which the commands for the buffer run. It always exists. ---@param bufnr? integer The buffer, by default the current one. ---@return string function M.get_cwd(bufnr) - return buffer_dir(resolve_bufnr(bufnr)) or vim.fn.getcwd() + bufnr = resolve_bufnr(bufnr) + local dir = buffer_dir(bufnr) + local state = states[bufnr] + -- The commands must see the repository that discovery found, which can be in Neovim's cwd + if state ~= nil and state.cwd ~= nil and matches(state, dir) then + return state.cwd + end + return dir or vim.fn.getcwd() end ---@param state cmp_git.RepositoryState diff --git a/tests/repository/test_init.lua b/tests/repository/test_init.lua index 9ab6baa..7b28e35 100644 --- a/tests/repository/test_init.lua +++ b/tests/repository/test_init.lua @@ -136,6 +136,46 @@ T["get_cwd_buffer_in_missing_directory"] = function() eq(discover(), { host = "github.com", owner = "owner", repo = "repo" }, "the cwd repository") end +---Run `git rev-parse --show-toplevel` as the completion commands run. +---@return string? toplevel +local function command_toplevel() + local result + local job = require("cmp_git.command").build( + { exec = "git", args = { "rev-parse", "--show-toplevel" } }, + function(stdout) + result = vim.trim(stdout) + end + ) + helpers.not_nil(job, "git command"):start() + helpers.wait_for(function() + return result ~= nil + end, "the command calls back") + return result +end + +T["get_cwd_is_the_repository_directory"] = function() + require_git() + local repo = helpers.git_repo() + edit(helpers.tempdir() .. "/notes.md", "markdown") + vim.fn.chdir(repo.dir) + + eq(is_git_repo(), true, "the cwd repository") + eq(repository.get_cwd(), repo.dir, "the directory where discovery found the repository") + eq(command_toplevel(), repo.dir, "the commands run in the repository") +end + +T["get_cwd_is_the_buffer_repository_directory"] = function() + require_git() + local repo = helpers.git_repo() + local other = helpers.git_repo() + vim.fn.chdir(other.dir) + edit(repo.dir .. "/notes.md", "markdown") + + eq(is_git_repo(), true, "the buffer repository") + eq(repository.get_cwd(), repo.dir, "the buffer directory") + eq(command_toplevel(), repo.dir, "the commands run in the buffer repository") +end + T["discover_first_remote_matches"] = function() require_git() local repo = helpers.git_repo({ From 04c0730dc9aaa8a01857676913bbfcd6f94b8111 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:42:40 +1100 Subject: [PATCH 08/37] fix: report a command that cannot start as a failed command When vim.system could not start a command, for example because the buffer directory no longer exists, it threw from the completion request, and the request never answered. The command now catches the error and reports a failed command. It also no longer adds PATH to the environment of the command: vim.system adds env to the inherited environment, so the comment that said otherwise was wrong. --- lua/cmp_git/command.lua | 83 +++++++++++++++++++---------------------- tests/test_command.lua | 40 ++++++++++++++++++++ 2 files changed, 79 insertions(+), 44 deletions(-) diff --git a/lua/cmp_git/command.lua b/lua/cmp_git/command.lua index 31ea6a1..c8921b0 100644 --- a/lua/cmp_git/command.lua +++ b/lua/cmp_git/command.lua @@ -12,24 +12,7 @@ local M = {} ---@class cmp_git.CommandSpec ---@field exec string ---@field args string[] ----@field env? table - ----@param spec cmp_git.CommandSpec ----@return table? env -local function command_env(spec) - if spec.env == nil then - return nil - end - - -- NOTE: setting env causes it to not inherit it from the parent environment - local env = vim.tbl_extend("force", spec.env, { - PATH = vim.fn.getenv("PATH"), - }) - for key, value in pairs(env) do - env[key] = tostring(value) - end - return env -end +---@field env? table Added to the environment of Neovim. ---@param spec cmp_git.CommandSpec ---@param callback fun(result: string, success: boolean): nil @@ -40,44 +23,56 @@ function M.build(spec, callback) return nil end - local job_env = command_env(spec) - return { command = spec.exec, handle = nil, cancelled = false, start = function(_, on_complete, suppress_failure_callback) local job = _ - job.handle = vim.system(vim.list_extend({ spec.exec }, spec.args), { + + ---@param result vim.SystemCompleted + local function complete(result) + if job.cancelled then + if on_complete then + on_complete(false) + end + return + end + + local success = result.code == 0 + if not success then + log.fmt_debug("%s returned with exit code %d", spec.exec, result.code) + if result.stderr and result.stderr ~= "" then + log.fmt_debug("%s stderr: %s", spec.exec, result.stderr) + end + else + log.fmt_debug("%s returned with a result", spec.exec) + end + if success or not suppress_failure_callback then + callback(result.stdout or "", success) + end + if on_complete then + on_complete(success) + end + end + + local ok, handle = pcall(vim.system, vim.list_extend({ spec.exec }, spec.args), { text = true, - env = job_env, + env = spec.env, cwd = repository.get_cwd(), }, function(result) vim.schedule(function() - if job.cancelled then - if on_complete then - on_complete(false) - end - return - end - - local success = result.code == 0 - if not success then - log.fmt_debug("%s returned with exit code %d", spec.exec, result.code) - if result.stderr and result.stderr ~= "" then - log.fmt_debug("%s stderr: %s", spec.exec, result.stderr) - end - else - log.fmt_debug("%s returned with a result", spec.exec) - end - if success or not suppress_failure_callback then - callback(result.stdout or "", success) - end - if on_complete then - on_complete(success) - end + complete(result) end) end) + if not ok then + -- For example, a cwd that does not exist. The caller gets a failed command, not an error. + vim.schedule(function() + complete({ code = -1, signal = 0, stdout = "", stderr = tostring(handle) }) + end) + return + end + job.handle = handle end, cancel = function(_) _.cancelled = true diff --git a/tests/test_command.lua b/tests/test_command.lua index e0439ed..92b3275 100644 --- a/tests/test_command.lua +++ b/tests/test_command.lua @@ -1,5 +1,6 @@ local helpers = require("tests.helpers") local command = require("cmp_git.command") +local repository = require("cmp_git.repository") local eq = helpers.eq @@ -91,4 +92,43 @@ T["build_job"] = function() eq(list.isIncomplete, false, "json job completion flag") end +T["spawn_error_is_a_failed_command"] = function() + helpers.stub(repository, "get_cwd", function() + return helpers.tempdir() .. "/missing" + end) + local result + local completed + local job = helpers.not_nil(command.build({ exec = "sh", args = { "-c", "printf out" } }, function(stdout, success) + result = { stdout = stdout, success = success } + end)) + + job:start(function(success) + completed = success + end) + helpers.wait_for(function() + return completed ~= nil + end, "the job completes") + + eq(result, { stdout = "", success = false }, "the callback gets a failure") + eq(completed, false, "the job fails") +end + +T["env_adds_to_the_environment"] = function() + helpers.stub(vim.env, "CMP_GIT_TEST_INHERITED", "inherited") + local seen + local job = helpers.not_nil(command.build({ + exec = "sh", + args = { "-c", 'printf "%s %s" "$CMP_GIT_TEST_INHERITED" "$CMP_GIT_TEST_ADDED"' }, + env = { CMP_GIT_TEST_ADDED = 1 }, + }, function(stdout) + seen = stdout + end)) + job:start() + helpers.wait_for(function() + return seen ~= nil + end, "the command completes") + + eq(seen, "inherited 1", "the command gets the variables of Neovim and of the spec") +end + return T From bf97816733a8ffaa40b05dadd7642ccf02552f73 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:44:26 +1100 Subject: [PATCH 09/37] fix: give the API token to curl on stdin The curl fallback put the Authorization header with GITHUB_API_TOKEN or GITLAB_TOKEN in its arguments, so other local users could read the token in the process list. curl now reads the header from stdin with -H @- (curl 7.55 and later). A command spec has a new optional stdin field for this. --- lua/cmp_git/command.lua | 2 + lua/cmp_git/provider/adapters/github.lua | 89 +++++++++++------------- lua/cmp_git/provider/adapters/gitlab.lua | 73 +++++++++---------- tests/provider/adapters/test_github.lua | 47 ++++++++++++- tests/provider/adapters/test_gitlab.lua | 20 ++++-- tests/test_command.lua | 13 ++++ 6 files changed, 149 insertions(+), 95 deletions(-) diff --git a/lua/cmp_git/command.lua b/lua/cmp_git/command.lua index c8921b0..d7f464b 100644 --- a/lua/cmp_git/command.lua +++ b/lua/cmp_git/command.lua @@ -13,6 +13,7 @@ local M = {} ---@field exec string ---@field args string[] ---@field env? table Added to the environment of Neovim. +---@field stdin? string Written to the standard input. Secrets go here, because other users can read the arguments. ---@param spec cmp_git.CommandSpec ---@param callback fun(result: string, success: boolean): nil @@ -59,6 +60,7 @@ function M.build(spec, callback) local ok, handle = pcall(vim.system, vim.list_extend({ spec.exec }, spec.args), { text = true, env = spec.env, + stdin = spec.stdin, cwd = repository.get_cwd(), }, function(result) vim.schedule(function() diff --git a/lua/cmp_git/provider/adapters/github.lua b/lua/cmp_git/provider/adapters/github.lua index b2b79cf..2c6f3c8 100644 --- a/lua/cmp_git/provider/adapters/github.lua +++ b/lua/cmp_git/provider/adapters/github.lua @@ -34,22 +34,26 @@ local function env() } end -local function curl_args(curl_url) +---@param curl_url string +---@return cmp_git.CommandSpec +local function curl_command(curl_url) local args = { "-s", "-L", "-H", "Accept: application/vnd.github.v3+json", - curl_url, } + local stdin = nil - if vim.fn.exists("$GITHUB_API_TOKEN") == 1 then - local token = vim.fn.getenv("GITHUB_API_TOKEN") - table.insert(args, "-H") - table.insert(args, string.format("Authorization: token %s", token)) + local token = vim.env.GITHUB_API_TOKEN + if token and token ~= "" then + -- `-H @-` reads the header from stdin, so that the token is not in the process list + vim.list_extend(args, { "-H", "@-" }) + stdin = string.format("Authorization: token %s\n", token) end - return args + table.insert(args, curl_url) + return { exec = "curl", args = args, stdin = stdin } end local function normalize_body(item) @@ -100,7 +104,7 @@ function M.issues_request(git_info, trigger_char, config) return { commands = { { exec = "gh", args = gh_args, env = env() }, - { exec = "curl", args = curl_args(github_url(git_info.host, curl_path)) }, + curl_command(github_url(git_info.host, curl_path)), }, handle_item = function(issue) normalize_body(issue) @@ -142,22 +146,19 @@ function M.change_requests_request(git_info, trigger_char, config) }, env = env(), }, - { - exec = "curl", - args = curl_args( - github_url( - git_info.host, - string.format( - "repos/%s/%s/pulls?state=%s&per_page=%d&page=%d", - git_info.owner, - git_info.repo, - config.state, - config.limit, - 1 - ) + curl_command( + github_url( + git_info.host, + string.format( + "repos/%s/%s/pulls?state=%s&per_page=%d&page=%d", + git_info.owner, + git_info.repo, + config.state, + config.limit, + 1 ) - ), - }, + ) + ), }, handle_item = function(pr) normalize_body(pr) @@ -179,15 +180,12 @@ function M.mentions_preflight_request(git_info) }, env = env(), }, - { - exec = "curl", - args = curl_args( - github_url( - git_info.host, - string.format("repos/%s/%s/collaborators/testing/permission", git_info.owner, git_info.repo) - ) - ), - }, + curl_command( + github_url( + git_info.host, + string.format("repos/%s/%s/collaborators/testing/permission", git_info.owner, git_info.repo) + ) + ), }, handle_result = function(result) local ok, parsed = pcall(vim.json.decode, result) @@ -221,22 +219,19 @@ function M.mentions_request(git_info, trigger_char, config, opts) }, env = env(), }, - { - exec = "curl", - args = curl_args( - github_url( - git_info.host, - string.format( - "repos/%s/%s/%s?per_page=%d&page=%d", - git_info.owner, - git_info.repo, - member_type, - opts.page_size, - opts.page - ) + curl_command( + github_url( + git_info.host, + string.format( + "repos/%s/%s/%s?per_page=%d&page=%d", + git_info.owner, + git_info.repo, + member_type, + opts.page_size, + opts.page ) - ), - }, + ) + ), }, handle_item = function(mention) return format.item(config, trigger_char, mention) diff --git a/lua/cmp_git/provider/adapters/gitlab.lua b/lua/cmp_git/provider/adapters/gitlab.lua index 6dc192b..3fee9b6 100644 --- a/lua/cmp_git/provider/adapters/gitlab.lua +++ b/lua/cmp_git/provider/adapters/gitlab.lua @@ -31,19 +31,21 @@ local function env() } end -local function curl_args(curl_url) - local args = { - "-s", - curl_url, - } +---@param curl_url string +---@return cmp_git.CommandSpec +local function curl_command(curl_url) + local args = { "-s" } + local stdin = nil - if vim.fn.exists("$GITLAB_TOKEN") == 1 then - local token = vim.fn.getenv("GITLAB_TOKEN") - table.insert(args, "-H") - table.insert(args, string.format("Authorization: Bearer %s", token)) + local token = vim.env.GITLAB_TOKEN + if token and token ~= "" then + -- `-H @-` reads the header from stdin, so that the token is not in the process list + vim.list_extend(args, { "-H", "@-" }) + stdin = string.format("Authorization: Bearer %s\n", token) end - return args + table.insert(args, curl_url) + return { exec = "curl", args = args, stdin = stdin } end function M.issues_request(git_info, trigger_char, config) @@ -58,18 +60,15 @@ function M.issues_request(git_info, trigger_char, config) }, env = env(), }, - { - exec = "curl", - args = curl_args( - string.format( - "https://%s/api/v4/projects/%s/issues?per_page=%d&state=%s", - git_info.host, - id, - config.limit, - config.state - ) - ), - }, + curl_command( + string.format( + "https://%s/api/v4/projects/%s/issues?per_page=%d&state=%s", + git_info.host, + id, + config.limit, + config.state + ) + ), }, handle_item = function(issue) if issue.description == vim.NIL then @@ -92,18 +91,15 @@ function M.change_requests_request(git_info, trigger_char, config) }, env = env(), }, - { - exec = "curl", - args = curl_args( - string.format( - "https://%s/api/v4/projects/%s/merge_requests?per_page=%d&state=%s", - git_info.host, - id, - config.limit, - config.state - ) - ), - }, + curl_command( + string.format( + "https://%s/api/v4/projects/%s/merge_requests?per_page=%d&state=%s", + git_info.host, + id, + config.limit, + config.state + ) + ), }, handle_item = function(mr) return format.item(config, trigger_char, mr) @@ -123,12 +119,9 @@ function M.mentions_request(git_info, trigger_char, config) }, env = env(), }, - { - exec = "curl", - args = curl_args( - string.format("https://%s/api/v4/projects/%s/users?per_page=%d", git_info.host, id, config.limit) - ), - }, + curl_command( + string.format("https://%s/api/v4/projects/%s/users?per_page=%d", git_info.host, id, config.limit) + ), }, handle_item = function(mention) return format.item(config, trigger_char, mention) diff --git a/tests/provider/adapters/test_github.lua b/tests/provider/adapters/test_github.lua index f43a988..6a04ed1 100644 --- a/tests/provider/adapters/test_github.lua +++ b/tests/provider/adapters/test_github.lua @@ -114,15 +114,56 @@ T["enterprise_host"] = function() ) end +---The requests that run curl, so that a case can check each of them. +---@return table +local function all_requests() + return { + issues = adapter.issues_request(github, "#", github_config().issues), + change_requests = adapter.change_requests_request(github, "#", github_config().pull_requests), + preflight = adapter.mentions_preflight_request(github), + mentions = adapter.mentions_request(github, "@", github_config().mentions, { page = 1, page_size = 100 }), + } +end + T["token_header"] = function() local request = adapter.issues_request(github, "#", github_config().issues) eq(curl_header(request.commands[2], "Authorization"), nil, "no token, no header") + eq(request.commands[2].stdin, nil, "no token, no stdin") eq(request.commands[1].env.GITHUB_API_TOKEN, nil, "no token for gh") vim.env.GITHUB_API_TOKEN = "secret" - request = adapter.issues_request(github, "#", github_config().issues) - eq(curl_header(request.commands[2], "Authorization"), "token secret", "curl sends the token") - eq(request.commands[1].env.GITHUB_API_TOKEN, "secret", "gh gets the token") + for name, token_request in pairs(all_requests()) do + local curl = token_request.commands[2] + eq(curl.stdin, "Authorization: token secret\n", name .. ": curl reads the token header from stdin") + eq(curl_header(curl, "Authorization"), nil, name .. ": the token is not an argument") + eq(vim.tbl_contains(curl.args, "@-"), true, name .. ": curl reads headers from stdin") + eq(token_request.commands[1].env.GITHUB_API_TOKEN, "secret", name .. ": gh gets the token") + end +end + +T["token_reaches_curl_on_stdin"] = function() + local bin = helpers.tempdir() + local file = assert(io.open(bin .. "/curl", "w")) + -- Prints the arguments on the first line, then stdin + file:write('#!/bin/sh\nprintf "%s\\n" "$*"\ncat\n') + file:close() + vim.uv.fs_chmod(bin .. "/curl", tonumber("755", 8)) + helpers.stub(vim.env, "PATH", bin .. ":" .. vim.env.PATH) + vim.env.GITHUB_API_TOKEN = "secret" + + local output + local curl = adapter.issues_request(github, "#", github_config().issues).commands[2] + local job = helpers.not_nil(command.build(curl, function(stdout) + output = stdout + end)) + job:start() + helpers.wait_for(function() + return output ~= nil + end, "the command completes") + + local lines = vim.split(output, "\n", { trimempty = true }) + eq(lines[1]:find("secret", 1, true), nil, "the arguments have no token") + eq(lines[2], "Authorization: token secret", "stdin has the token header") end T["unset_token_does_not_reach_gh"] = function() diff --git a/tests/provider/adapters/test_gitlab.lua b/tests/provider/adapters/test_gitlab.lua index 9b6c666..23ae359 100644 --- a/tests/provider/adapters/test_gitlab.lua +++ b/tests/provider/adapters/test_gitlab.lua @@ -76,13 +76,23 @@ T["token_header"] = function() local request = adapter.issues_request(gitlab, "#", gitlab_config().issues) eq(request.commands[1].env.GITLAB_TOKEN, nil, "no token for glab") eq(#request.commands[2].args, 2, "no token, no curl header") + eq(request.commands[2].stdin, nil, "no token, no stdin") vim.env.GITLAB_TOKEN = "secret" - request = adapter.issues_request(gitlab, "#", gitlab_config().issues) - - eq(request.commands[1].env.GITLAB_TOKEN, "secret", "glab gets the token") - eq(request.commands[2].args[3], "-H", "curl header flag") - eq(request.commands[2].args[4], "Authorization: Bearer secret", "curl sends the token") + local requests = { + issues = adapter.issues_request(gitlab, "#", gitlab_config().issues), + merge_requests = adapter.change_requests_request(gitlab, "!", gitlab_config().merge_requests), + mentions = adapter.mentions_request(gitlab, "@", gitlab_config().mentions), + } + for name, token_request in pairs(requests) do + local curl = token_request.commands[2] + eq(token_request.commands[1].env.GITLAB_TOKEN, "secret", name .. ": glab gets the token") + eq(curl.stdin, "Authorization: Bearer secret\n", name .. ": curl reads the token header from stdin") + eq(vim.tbl_contains(curl.args, "@-"), true, name .. ": curl reads headers from stdin") + for _, arg in ipairs(curl.args) do + eq(arg:find("secret", 1, true), nil, name .. ": the token is not an argument") + end + end end T["unset_token_does_not_reach_glab"] = function() diff --git a/tests/test_command.lua b/tests/test_command.lua index 92b3275..4434f7b 100644 --- a/tests/test_command.lua +++ b/tests/test_command.lua @@ -113,6 +113,19 @@ T["spawn_error_is_a_failed_command"] = function() eq(completed, false, "the job fails") end +T["stdin"] = function() + local seen + local job = helpers.not_nil(command.build({ exec = "sh", args = { "-c", "cat" }, stdin = "from stdin\n" }, function(stdout) + seen = stdout + end)) + job:start() + helpers.wait_for(function() + return seen ~= nil + end, "the command completes") + + eq(seen, "from stdin\n", "the command reads the stdin of the spec") +end + T["env_adds_to_the_environment"] = function() helpers.stub(vim.env, "CMP_GIT_TEST_INHERITED", "inherited") local seen From f874bd1c8365126997c5d9bab5a2df6b4c1688d3 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:47:04 +1100 Subject: [PATCH 10/37] fix: answer issue and change request requests when the commands fail When gh and curl (or glab and curl) failed, or when neither was installed, the request never called back. The fallback hid the failure of its last command, and a missing command gave no job. blink.cmp then held back the items of all sources for 2 s, and nvim-cmp waited up to 500 ms. The fallback now reports the failure of the last command that runs. build_list calls back on a failure with an empty list and success set to false. The issue and change request capabilities give one empty list when the commands fail or cannot run, and do not cache it, so the next request tries again. --- lua/cmp_git/command.lua | 20 ++-- .../provider/capabilities/change_requests.lua | 9 +- lua/cmp_git/provider/capabilities/issues.lua | 31 +++++-- .../capabilities/test_change_requests.lua | 43 ++++++++- tests/provider/capabilities/test_issues.lua | 57 +++++++++++- tests/provider/capabilities/test_mentions.lua | 2 +- tests/sources/test_github.lua | 2 +- tests/sources/test_gitlab.lua | 52 +++++++++++ tests/test_command.lua | 91 ++++++++++++++++++- 9 files changed, 278 insertions(+), 29 deletions(-) diff --git a/lua/cmp_git/command.lua b/lua/cmp_git/command.lua index d7f464b..0efee4d 100644 --- a/lua/cmp_git/command.lua +++ b/lua/cmp_git/command.lua @@ -87,21 +87,23 @@ end ---@generic TItem ---@param spec cmp_git.CommandSpec ----@param callback fun(list: cmp_git.CompletionList) +---@param callback fun(list: cmp_git.CompletionList, success: boolean) The list is empty when the command fails. ---@param handle_item fun(item: TItem): cmp_git.CompletionItem ---@param handle_parsed? fun(parsed: any): TItem[] ---@return cmp_git.SystemJob? function M.build_list(spec, callback, handle_item, handle_parsed) return M.build(spec, function(result, success) if not success then + callback({ items = {}, isIncomplete = false }, false) return end - callback(response.completion_list(result, handle_item, handle_parsed)) + callback(response.completion_list(result, handle_item, handle_parsed), true) end) end ----Start the second job if the first one fails, handle cases if the first or second job is nil. +---Start the second job if the first one fails, handle cases if the first or second job is nil. Only the last job that +---runs reports a failure to its callback. ---@param first cmp_git.SystemJob? ---@param second cmp_git.SystemJob? ---@return cmp_git.SystemJob? @@ -116,7 +118,7 @@ function M.fallback(first, second) command = first and first.command or (second --[[@as cmp_git.SystemJob]]).command, active = nil, cancelled = false, - start = function(_, on_complete) + start = function(_, on_complete, suppress_failure_callback) local job = _ local function done(success) if on_complete then @@ -131,7 +133,7 @@ function M.fallback(first, second) return end job.active = second - second:start(done) + second:start(done, suppress_failure_callback) return end @@ -149,11 +151,11 @@ function M.fallback(first, second) if second then job.active = second - second:start(done) + second:start(done, suppress_failure_callback) else done(false) end - end, true) + end, second ~= nil or suppress_failure_callback) end, cancel = function(_) _.cancelled = true @@ -177,10 +179,10 @@ end ---@generic TItem ---@param specs cmp_git.CommandSpec[] ----@param callback fun(list: cmp_git.CompletionList) +---@param callback fun(list: cmp_git.CompletionList, success: boolean) The list is empty when all commands fail. ---@param handle_item fun(item: TItem): cmp_git.CompletionItem ---@param handle_parsed? fun(parsed: any): TItem[] ----@return cmp_git.SystemJob? +---@return cmp_git.SystemJob? job nil when no command is executable. function M.build_fallback_list(specs, callback, handle_item, handle_parsed) local job for i = #specs, 1, -1 do diff --git a/lua/cmp_git/provider/capabilities/change_requests.lua b/lua/cmp_git/provider/capabilities/change_requests.lua index 7861721..b434c8f 100644 --- a/lua/cmp_git/provider/capabilities/change_requests.lua +++ b/lua/cmp_git/provider/capabilities/change_requests.lua @@ -1,4 +1,4 @@ -local command = require("cmp_git.command") +local issues = require("cmp_git.provider.capabilities.issues") local M = {} @@ -12,12 +12,7 @@ function M.job(args) return nil end - local request = args.adapter.change_requests_request(args.git_info, args.trigger_char, args.config) - local runner = args.runner or command - return runner.build_fallback_list(request.commands, function(list) - args.cache[bufnr] = list.items - args.callback(list) - end, request.handle_item, request.handle_parsed) + return issues.list_job(args, args.adapter.change_requests_request(args.git_info, args.trigger_char, args.config)) end ---@param args cmp_git.Provider.CapabilityArgs diff --git a/lua/cmp_git/provider/capabilities/issues.lua b/lua/cmp_git/provider/capabilities/issues.lua index 5dfd869..26582fd 100644 --- a/lua/cmp_git/provider/capabilities/issues.lua +++ b/lua/cmp_git/provider/capabilities/issues.lua @@ -17,6 +17,30 @@ local M = {} ---@field runner? table ---@field bufnr? integer +---Make the job of a list request. The callback gets exactly one list, also when the commands fail or cannot run. A +---failure gives an empty list and is not cached, so that the next request tries again. +---@param args cmp_git.Provider.CapabilityArgs +---@param request cmp_git.Provider.ListRequest +---@return cmp_git.SystemJob? job nil when the callback already has its list. +function M.list_job(args, request) + local bufnr = args.bufnr or vim.api.nvim_get_current_buf() + local runner = args.runner or command + local job = runner.build_fallback_list(request.commands, function(list, success) + if not success then + args.callback({ items = {}, isIncomplete = false }) + return + end + + args.cache[bufnr] = list.items + args.callback(list) + end, request.handle_item, request.handle_parsed) + + if not job then + args.callback({ items = {}, isIncomplete = false }) + end + return job +end + ---@param args cmp_git.Provider.CapabilityArgs ---@return cmp_git.SystemJob? function M.job(args) @@ -27,12 +51,7 @@ function M.job(args) return nil end - local request = args.adapter.issues_request(args.git_info, args.trigger_char, args.config) - local runner = args.runner or command - return runner.build_fallback_list(request.commands, function(list) - args.cache[bufnr] = list.items - args.callback(list) - end, request.handle_item, request.handle_parsed) + return M.list_job(args, args.adapter.issues_request(args.git_info, args.trigger_char, args.config)) end ---@param args cmp_git.Provider.CapabilityArgs diff --git a/tests/provider/capabilities/test_change_requests.lua b/tests/provider/capabilities/test_change_requests.lua index 89cf69c..7b640f3 100644 --- a/tests/provider/capabilities/test_change_requests.lua +++ b/tests/provider/capabilities/test_change_requests.lua @@ -1,6 +1,7 @@ local helpers = require("tests.helpers") local change_request_capability = require("cmp_git.provider.capabilities.change_requests") +local eq = helpers.eq local neq = helpers.neq local T = helpers.new_set() @@ -9,7 +10,7 @@ T["change_request_capability_returns_job"] = function() local runner = { build_fallback_list = function(_commands, callback, handle_item) return helpers.fake_job(function() - callback({ items = { handle_item({ name = "pr" }) }, isIncomplete = false }) + callback({ items = { handle_item({ name = "pr" }) }, isIncomplete = false }, true) end) end, } @@ -38,4 +39,44 @@ T["change_request_capability_returns_job"] = function() neq(job, nil, "change request capability returns started job") end +T["failure_gives_an_empty_list_and_is_not_cached"] = function() + local runs = 0 + local cache = {} + local callback, lists = helpers.recorder() + local args = { + adapter = { + change_requests_request = function() + return { commands = {}, handle_item = function() end } + end, + }, + cache = cache, + callback = callback, + config = {}, + git_info = {}, + trigger_char = "#", + runner = { + build_fallback_list = function(_commands, list_callback) + if runs == 2 then + return nil + end + return helpers.fake_job(function() + runs = runs + 1 + list_callback({ items = {}, isIncomplete = false }, false) + end) + end, + }, + bufnr = 46, + } + + change_request_capability.complete(args) + change_request_capability.complete(args) + -- The third request has no runnable command + change_request_capability.complete(args) + + local empty = { items = {}, isIncomplete = false } + eq(lists, { empty, empty, empty }, "each request gets one empty list") + eq(cache[46], nil, "a failure is not cached") + eq(runs, 2, "the next request tries again") +end + return T diff --git a/tests/provider/capabilities/test_issues.lua b/tests/provider/capabilities/test_issues.lua index e740e35..41d63bc 100644 --- a/tests/provider/capabilities/test_issues.lua +++ b/tests/provider/capabilities/test_issues.lua @@ -27,7 +27,7 @@ T["issue_capability_uses_cache_and_runner"] = function() runner_count = runner_count + 1 eq(commands[1].exec, "fake", "issue capability forwards commands") return helpers.fake_job(function() - callback({ items = { handle_item({ name = "one" }) }, isIncomplete = false }) + callback({ items = { handle_item({ name = "one" }) }, isIncomplete = false }, true) end) end, } @@ -56,4 +56,59 @@ T["issue_capability_uses_cache_and_runner"] = function() eq(cached_job, nil, "issue capability cache hit returns no job") end +local EMPTY = { items = {}, isIncomplete = false } + +---@param runner table +---@return cmp_git.Provider.CapabilityArgs args +---@return cmp_git.CompletionList[] lists +local function failing_args(runner) + local callback, lists = helpers.recorder() + return { + adapter = { + issues_request = function() + return { commands = {}, handle_item = function() end } + end, + }, + cache = {}, + callback = callback, + config = {}, + git_info = {}, + trigger_char = "#", + runner = runner, + bufnr = 42, + }, + lists +end + +T["failure_gives_an_empty_list_and_is_not_cached"] = function() + local runs = 0 + local args, lists = failing_args({ + build_fallback_list = function(_commands, callback) + return helpers.fake_job(function() + runs = runs + 1 + callback({ items = {}, isIncomplete = false }, false) + end) + end, + }) + + issue_capability.complete(args) + issue_capability.complete(args) + + eq(lists, { EMPTY, EMPTY }, "each request gets one empty list") + eq(args.cache[42], nil, "a failure is not cached") + eq(runs, 2, "the next request tries again") +end + +T["no_runnable_command_gives_an_empty_list"] = function() + local args, lists = failing_args({ + build_fallback_list = function() + return nil + end, + }) + + eq(issue_capability.complete(args), nil, "no job") + eq(lists, { EMPTY }, "one empty list") + eq(args.cache[42], nil, "nothing is cached") +end + return T diff --git a/tests/provider/capabilities/test_mentions.lua b/tests/provider/capabilities/test_mentions.lua index 5545fdc..f9fe875 100644 --- a/tests/provider/capabilities/test_mentions.lua +++ b/tests/provider/capabilities/test_mentions.lua @@ -23,7 +23,7 @@ T["mention_capability_preflight_and_pagination"] = function() for _, item in ipairs(raw_items) do table.insert(items, handle_item(item)) end - callback({ items = items, isIncomplete = false }) + callback({ items = items, isIncomplete = false }, true) end) end, } diff --git a/tests/sources/test_github.lua b/tests/sources/test_github.lua index 855d2c7..dd69fc2 100644 --- a/tests/sources/test_github.lua +++ b/tests/sources/test_github.lua @@ -58,7 +58,7 @@ local function fake_github() command = "fake", start = function(_, on_complete) requests[kind] = requests[kind] + 1 - callback({ items = { handle_item(vim.deepcopy(records[kind])) }, isIncomplete = false }) + callback({ items = { handle_item(vim.deepcopy(records[kind])) }, isIncomplete = false }, true) if on_complete then on_complete(true) end diff --git a/tests/sources/test_gitlab.lua b/tests/sources/test_gitlab.lua index f5297f0..b5059e5 100644 --- a/tests/sources/test_gitlab.lua +++ b/tests/sources/test_gitlab.lua @@ -34,4 +34,56 @@ T["gitlab_instance_state_isolation"] = function() ok(first.cache.merge_requests ~= second.cache.merge_requests, "GitLab MR cache table is instance-local") end +local git_info = { host = "gitlab.com", owner = "owner", repo = "repo" } +local EMPTY = { items = {}, isIncomplete = false } + +---Make PATH a directory with only these commands until the end of the case. +---@param scripts table The shell script body of each command, by name. +local function fake_commands(scripts) + local bin = helpers.tempdir() + for name, body in pairs(scripts) do + local path = bin .. "/" .. name + local file = assert(io.open(path, "w")) + file:write("#!/bin/sh\n" .. body .. "\n") + file:close() + vim.uv.fs_chmod(path, tonumber("755", 8)) + end + helpers.stub(vim.env, "PATH", bin) +end + +---Run a request of the source and return the lists that it gave. +---@param source cmp_git.Source.Gitlab +---@param method string +---@param trigger_char string +---@return cmp_git.CompletionList[] +local function answers(source, method, trigger_char) + local callback, lists = helpers.recorder() + local handled = source[method](source, callback, git_info, trigger_char) + eq(handled, true, method .. " handles the host") + helpers.wait_for(function() + return #lists > 0 + end, method .. " answers") + helpers.flush() + return lists +end + +T["failed_commands_give_an_empty_list"] = function() + fake_commands({ glab = "exit 1", curl = "exit 22" }) + local source = GitLab.new(config.normalize({}).gitlab) + local bufnr = vim.api.nvim_get_current_buf() + + eq(answers(source, "complete_issues", "#"), { EMPTY }, "issues") + eq(answers(source, "complete_change_requests", "!"), { EMPTY }, "merge requests") + eq(source.cache.issues[bufnr], nil, "failed issues are not cached") + eq(source.cache.change_requests[bufnr], nil, "failed merge requests are not cached") +end + +T["missing_commands_give_an_empty_list"] = function() + fake_commands({}) + local source = GitLab.new(config.normalize({}).gitlab) + + eq(answers(source, "complete_issues", "#"), { EMPTY }, "issues") + eq(answers(source, "complete_change_requests", "!"), { EMPTY }, "merge requests") +end + return T diff --git a/tests/test_command.lua b/tests/test_command.lua index 4434f7b..cc96027 100644 --- a/tests/test_command.lua +++ b/tests/test_command.lua @@ -31,6 +31,71 @@ T["fallback"] = function() eq(result.success, true, "fallback success") end +---@param code integer +---@return cmp_git.CommandSpec +local function exit_with(code) + return { exec = "sh", args = { "-c", "exit " .. code } } +end + +T["fallback_reports_the_last_failure"] = function() + for count = 1, 3 do + local specs = {} + for i = 1, count do + table.insert(specs, exit_with(i)) + end + local calls = {} + local completed + local job = helpers.not_nil(command.build_fallback(specs, function(stdout, success) + table.insert(calls, { stdout = stdout, success = success }) + end)) + + job:start(function(success) + completed = success + end) + helpers.wait_for(function() + return completed ~= nil + end, "the fallback completes") + + eq(calls, { { stdout = "", success = false } }, count .. " failed commands give one failure callback") + eq(completed, false, count .. " failed commands fail the job") + end +end + +T["fallback_list_reports_the_last_failure"] = function() + local calls = {} + local job = helpers.not_nil(command.build_fallback_list({ exit_with(1), exit_with(2) }, function(list, success) + table.insert(calls, { list = list, success = success }) + end, function(item) + return item + end)) + job:start() + helpers.wait_for(function() + return #calls > 0 + end, "the fallback completes") + helpers.flush() + + eq(calls, { { list = { items = {}, isIncomplete = false }, success = false } }, "one empty list") +end + +T["fallback_skips_a_missing_executable"] = function() + local calls = {} + local job = helpers.not_nil( + command.build_fallback( + { exit_with(1), { exec = "cmp-git-missing-executable", args = {} } }, + function(_, success) + table.insert(calls, success) + end + ) + ) + job:start() + helpers.wait_for(function() + return #calls > 0 + end, "the fallback completes") + helpers.flush() + + eq(calls, { false }, "the first command reports its failure when the second cannot run") +end + T["command_fallback_cancels_active_job"] = function() local first_cancelled = false local second_started = false @@ -92,6 +157,25 @@ T["build_job"] = function() eq(list.isIncomplete, false, "json job completion flag") end +T["build_list_reports_a_failure"] = function() + local calls = {} + local job = helpers.not_nil(command.build_list(exit_with(1), function(list, success) + table.insert(calls, { list = list, success = success }) + end, function(item) + return item + end)) + job:start() + helpers.wait_for(function() + return #calls > 0 + end, "the job completes") + + eq( + calls, + { { list = { items = {}, isIncomplete = false }, success = false } }, + "a failed command gives an empty list" + ) +end + T["spawn_error_is_a_failed_command"] = function() helpers.stub(repository, "get_cwd", function() return helpers.tempdir() .. "/missing" @@ -115,9 +199,10 @@ end T["stdin"] = function() local seen - local job = helpers.not_nil(command.build({ exec = "sh", args = { "-c", "cat" }, stdin = "from stdin\n" }, function(stdout) - seen = stdout - end)) + local job = + helpers.not_nil(command.build({ exec = "sh", args = { "-c", "cat" }, stdin = "from stdin\n" }, function(stdout) + seen = stdout + end)) job:start() helpers.wait_for(function() return seen ~= nil From abc5596f95080b9e81a28f6c459faec1bea23bc2 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:48:37 +1100 Subject: [PATCH 11/37] fix: do not cache HTTP errors as empty lists curl -s exits with 0 on HTTP errors such as 401, 403, 404, and 422. The error object then parsed to no items, and the empty list stayed in the cache for the life of the buffer, for example after the hourly request limit of unauthenticated GitHub. curl now runs with --fail (and -S, so that the debug log shows the HTTP error), so an HTTP error is a failed command. The mention preflight keeps the error body, because it reads the status from it. --fail-with-body is not used, because it needs curl 7.76. A parsed result that is not a list after handle_parsed, such as an error object or null, is now a failure, not an empty list. A null response no longer throws in the mention handlers. --- lua/cmp_git/command.lua | 8 ++-- lua/cmp_git/provider/adapters/github.lua | 27 +++++++---- lua/cmp_git/provider/adapters/gitlab.lua | 3 +- lua/cmp_git/provider/capabilities/issues.lua | 2 +- lua/cmp_git/response.lua | 50 ++++++++++---------- tests/provider/adapters/test_github.lua | 21 ++++++-- tests/provider/adapters/test_gitlab.lua | 11 ++++- tests/test_command.lua | 21 ++++++++ tests/test_response.lua | 23 +++++++++ 9 files changed, 121 insertions(+), 45 deletions(-) diff --git a/lua/cmp_git/command.lua b/lua/cmp_git/command.lua index 0efee4d..3849b11 100644 --- a/lua/cmp_git/command.lua +++ b/lua/cmp_git/command.lua @@ -89,7 +89,7 @@ end ---@param spec cmp_git.CommandSpec ---@param callback fun(list: cmp_git.CompletionList, success: boolean) The list is empty when the command fails. ---@param handle_item fun(item: TItem): cmp_git.CompletionItem ----@param handle_parsed? fun(parsed: any): TItem[] +---@param handle_parsed? fun(parsed: any): TItem[]? A result that is not a list is a failure. ---@return cmp_git.SystemJob? function M.build_list(spec, callback, handle_item, handle_parsed) return M.build(spec, function(result, success) @@ -98,7 +98,9 @@ function M.build_list(spec, callback, handle_item, handle_parsed) return end - callback(response.completion_list(result, handle_item, handle_parsed), true) + -- An output that is not a list, for example an error object, is a failure + local list, ok = response.completion_list(result, handle_item, handle_parsed) + callback(list, ok) end) end @@ -181,7 +183,7 @@ end ---@param specs cmp_git.CommandSpec[] ---@param callback fun(list: cmp_git.CompletionList, success: boolean) The list is empty when all commands fail. ---@param handle_item fun(item: TItem): cmp_git.CompletionItem ----@param handle_parsed? fun(parsed: any): TItem[] +---@param handle_parsed? fun(parsed: any): TItem[]? A result that is not a list is a failure. ---@return cmp_git.SystemJob? job nil when no command is executable. function M.build_fallback_list(specs, callback, handle_item, handle_parsed) local job diff --git a/lua/cmp_git/provider/adapters/github.lua b/lua/cmp_git/provider/adapters/github.lua index 2c6f3c8..bd26881 100644 --- a/lua/cmp_git/provider/adapters/github.lua +++ b/lua/cmp_git/provider/adapters/github.lua @@ -35,14 +35,19 @@ local function env() end ---@param curl_url string +---@param opts? { error_body?: boolean } `error_body` gives the body of an HTTP error with exit code 0. ---@return cmp_git.CommandSpec -local function curl_command(curl_url) - local args = { - "-s", +local function curl_command(curl_url, opts) + local args = { "-s", "-S" } + if not (opts and opts.error_body) then + -- Without it, curl exits with 0 on an HTTP error, and the error object looks like an empty list + table.insert(args, "--fail") + end + vim.list_extend(args, { "-L", "-H", "Accept: application/vnd.github.v3+json", - } + }) local stdin = nil local token = vim.env.GITHUB_API_TOKEN @@ -112,11 +117,11 @@ function M.issues_request(git_info, trigger_char, config) end, -- The REST API also returns pull requests, with a `pull_request` field. `gh issue list` does not. handle_parsed = function(parsed) - local result = {} - if type(parsed) ~= "table" then - return result + if not vim.islist(parsed) then + return nil end + local result = {} for _, issue in ipairs(parsed) do if issue.pull_request == nil or issue.pull_request == vim.NIL then table.insert(result, issue) @@ -180,17 +185,19 @@ function M.mentions_preflight_request(git_info) }, env = env(), }, + -- `handle_result` reads the status of an HTTP error from the body curl_command( github_url( git_info.host, string.format("repos/%s/%s/collaborators/testing/permission", git_info.owner, git_info.repo) - ) + ), + { error_body = true } ), }, handle_result = function(result) local ok, parsed = pcall(vim.json.decode, result) local member_type = "contributors" - if ok then + if ok and type(parsed) == "table" then member_type = (parsed.status ~= "403" and parsed.status ~= "401") and "collaborators" or "contributors" end return { member_type = member_type } @@ -237,7 +244,7 @@ function M.mentions_request(git_info, trigger_char, config, opts) return format.item(config, trigger_char, mention) end, handle_parsed = function(parsed) - if parsed["mentionableUsers"] then + if type(parsed) == "table" and parsed["mentionableUsers"] then return parsed["mentionableUsers"] end return parsed diff --git a/lua/cmp_git/provider/adapters/gitlab.lua b/lua/cmp_git/provider/adapters/gitlab.lua index 3fee9b6..fb93189 100644 --- a/lua/cmp_git/provider/adapters/gitlab.lua +++ b/lua/cmp_git/provider/adapters/gitlab.lua @@ -34,7 +34,8 @@ end ---@param curl_url string ---@return cmp_git.CommandSpec local function curl_command(curl_url) - local args = { "-s" } + -- Without --fail, curl exits with 0 on an HTTP error, and the error object looks like an empty list + local args = { "-s", "-S", "--fail" } local stdin = nil local token = vim.env.GITLAB_TOKEN diff --git a/lua/cmp_git/provider/capabilities/issues.lua b/lua/cmp_git/provider/capabilities/issues.lua index 26582fd..a015e9f 100644 --- a/lua/cmp_git/provider/capabilities/issues.lua +++ b/lua/cmp_git/provider/capabilities/issues.lua @@ -5,7 +5,7 @@ local M = {} ---@class cmp_git.Provider.ListRequest ---@field commands cmp_git.CommandSpec[] ---@field handle_item fun(item: any): cmp_git.CompletionItem ----@field handle_parsed? fun(parsed: any): any[] +---@field handle_parsed? fun(parsed: any): any[]? A result that is not a list is a failure. ---@class cmp_git.Provider.CapabilityArgs ---@field adapter table diff --git a/lua/cmp_git/response.lua b/lua/cmp_git/response.lua index 1e85de7..9d9cef0 100644 --- a/lua/cmp_git/response.lua +++ b/lua/cmp_git/response.lua @@ -5,42 +5,42 @@ local M = {} ---@generic TItem ---@param response string ---@param handle_item fun(item: TItem): cmp_git.CompletionItem ----@param handle_parsed? fun(parsed: any): TItem[] ----@return cmp_git.CompletionItem[] +---@param handle_parsed? fun(parsed: any): TItem[]? +---@return cmp_git.CompletionItem[] items +---@return boolean ok false when the response is not JSON, or not a list after `handle_parsed`. function M.handle_response(response, handle_item, handle_parsed) - local items = {} - - local function process_data(ok, parsed) - if not ok then - log.warn("Failed to parse api result") - return - end - - if handle_parsed then - parsed = handle_parsed(parsed) - end + local ok, parsed = pcall(vim.json.decode, response) + if not ok then + log.warn("Failed to parse api result") + return {}, false + end - for _, item in ipairs(parsed) do - table.insert(items, handle_item(item)) - end + if handle_parsed then + parsed = handle_parsed(parsed) end - local ok, parsed = pcall(vim.json.decode, response) - process_data(ok, parsed) + -- An error object or `null` must not look like a list without items, because the caller caches an empty list + if not vim.islist(parsed) then + log.debug("The api result is not a list") + return {}, false + end - return items + local items = {} + for _, item in ipairs(parsed) do + table.insert(items, handle_item(item)) + end + return items, true end ---@generic TItem ---@param result string ---@param handle_item fun(item: TItem): cmp_git.CompletionItem ----@param handle_parsed? fun(parsed: any): TItem[] ----@return cmp_git.CompletionList +---@param handle_parsed? fun(parsed: any): TItem[]? +---@return cmp_git.CompletionList list +---@return boolean ok false when the result is not JSON, or not a list after `handle_parsed`. Then the list is empty. function M.completion_list(result, handle_item, handle_parsed) - return { - items = M.handle_response(result, handle_item, handle_parsed), - isIncomplete = false, - } + local items, ok = M.handle_response(result, handle_item, handle_parsed) + return { items = items, isIncomplete = false }, ok end return M diff --git a/tests/provider/adapters/test_github.lua b/tests/provider/adapters/test_github.lua index 6a04ed1..dcdbb09 100644 --- a/tests/provider/adapters/test_github.lua +++ b/tests/provider/adapters/test_github.lua @@ -115,7 +115,7 @@ T["enterprise_host"] = function() end ---The requests that run curl, so that a case can check each of them. ----@return table +---@return table local function all_requests() return { issues = adapter.issues_request(github, "#", github_config().issues), @@ -141,6 +141,21 @@ T["token_header"] = function() end end +T["curl_fails_on_http_errors"] = function() + for name, request in pairs(all_requests()) do + -- The preflight reads the status of the error body + eq(vim.tbl_contains(request.commands[2].args, "--fail"), name ~= "preflight", name .. ": --fail") + end +end + +T["error_and_null_responses_are_not_lists"] = function() + local requests = all_requests() + + eq(requests.mentions.handle_parsed(vim.NIL), vim.NIL, "a null mention response stays null") + eq(requests.preflight.handle_result("null"), { member_type = "contributors" }, "a null preflight falls back") + eq(requests.preflight.handle_result("[]"), { member_type = "collaborators" }, "a list is not an error") +end + T["token_reaches_curl_on_stdin"] = function() local bin = helpers.tempdir() local file = assert(io.open(bin .. "/curl", "w")) @@ -292,8 +307,8 @@ T["issues_request_drops_pull_requests"] = function() { { number = 1 }, { number = 3 } }, "the REST issues endpoint also returns pull requests" ) - eq(request.handle_parsed({ message = "Not Found" }), {}, "an error response gives no issues") - eq(request.handle_parsed(vim.NIL), {}, "a null response gives no issues") + eq(request.handle_parsed({ message = "Not Found" }), nil, "an error response is not a list") + eq(request.handle_parsed(vim.NIL), nil, "a null response is not a list") end T["handle_item_normalizes_body"] = function() diff --git a/tests/provider/adapters/test_gitlab.lua b/tests/provider/adapters/test_gitlab.lua index 23ae359..9d1e7b3 100644 --- a/tests/provider/adapters/test_gitlab.lua +++ b/tests/provider/adapters/test_gitlab.lua @@ -26,6 +26,8 @@ T["issues_request"] = function() eq(curl.exec, "curl", "curl command") eq(curl.args, { "-s", + "-S", + "--fail", "https://gitlab.com/api/v4/projects/group%2Fsubgroup%2Fproject/issues?per_page=100&state=opened", }, "curl arguments") end @@ -43,6 +45,8 @@ T["change_requests_request"] = function() eq(request.commands[1].env.NO_COLOR, 1, "glab output has no colors") eq(request.commands[2].args, { "-s", + "-S", + "--fail", "https://gitlab.example.com/api/v4/projects/group%2Fproject/merge_requests?per_page=3&state=merged", }, "curl arguments for a self-hosted host") end @@ -54,6 +58,8 @@ T["mentions_request"] = function() eq(request.commands[1].env.NO_COLOR, 1, "glab output has no colors") eq(request.commands[2].args, { "-s", + "-S", + "--fail", "https://gitlab.com/api/v4/projects/group%2Fsubgroup%2Fproject/users?per_page=20", }, "curl arguments") eq(adapter.mentions_has_more(), false, "one page of mentions") @@ -65,8 +71,9 @@ T["issue_state_and_limit"] = function() adapter.issues_request(gitlab, "#", gitlab_config({ issues = { state = "closed", limit = 5 } }).issues) eq(request.commands[1].args[2], "/projects/group%2Fsubgroup%2Fproject/issues?per_page=5&state=closed", "glab") + local curl_args = request.commands[2].args eq( - request.commands[2].args[2], + curl_args[#curl_args], "https://gitlab.com/api/v4/projects/group%2Fsubgroup%2Fproject/issues?per_page=5&state=closed", "curl" ) @@ -75,7 +82,7 @@ end T["token_header"] = function() local request = adapter.issues_request(gitlab, "#", gitlab_config().issues) eq(request.commands[1].env.GITLAB_TOKEN, nil, "no token for glab") - eq(#request.commands[2].args, 2, "no token, no curl header") + eq(vim.tbl_contains(request.commands[2].args, "-H"), false, "no token, no curl header") eq(request.commands[2].stdin, nil, "no token, no stdin") vim.env.GITLAB_TOKEN = "secret" diff --git a/tests/test_command.lua b/tests/test_command.lua index cc96027..8335a3b 100644 --- a/tests/test_command.lua +++ b/tests/test_command.lua @@ -157,6 +157,27 @@ T["build_job"] = function() eq(list.isIncomplete, false, "json job completion flag") end +T["build_list_reports_an_error_object"] = function() + local calls = {} + local job = helpers.not_nil( + command.build_list( + { exec = "sh", args = { "-c", 'printf \'{"message":"Bad credentials"}\'' } }, + function(list, success) + table.insert(calls, { list = list, success = success }) + end, + function(item) + return item + end + ) + ) + job:start() + helpers.wait_for(function() + return #calls > 0 + end, "the job completes") + + eq(calls, { { list = { items = {}, isIncomplete = false }, success = false } }, "an error object is a failure") +end + T["build_list_reports_a_failure"] = function() local calls = {} local job = helpers.not_nil(command.build_list(exit_with(1), function(list, success) diff --git a/tests/test_response.lua b/tests/test_response.lua index 4cd52c6..ac19b34 100644 --- a/tests/test_response.lua +++ b/tests/test_response.lua @@ -33,4 +33,27 @@ T["handle_response"] = function() eq(helpers.notifications[1].message, "Failed to parse api result", "invalid JSON logs a warning") end +T["responses_that_are_not_lists_fail"] = function() + local function label(item) + return { label = item.name } + end + + eq({ response.handle_response('[{"name":"one"}]', label) }, { { { label = "one" } }, true }, "a list") + eq({ response.handle_response("[]", label) }, { {}, true }, "an empty list") + eq({ response.handle_response("not json", label) }, { {}, false }, "invalid JSON") + eq({ response.handle_response('{"message":"Bad credentials"}', label) }, { {}, false }, "an error object") + eq({ response.handle_response("null", label) }, { {}, false }, "null") + eq({ response.handle_response("{}", label) }, { {}, false }, "an empty object") + local function items_field(parsed) + return parsed.items + end + eq({ response.handle_response('{"items":null}', label, items_field) }, { {}, false }, "handle_parsed gives no list") + + eq( + { response.completion_list('{"message":"Not Found"}', label) }, + { { items = {}, isIncomplete = false }, false }, + "the completion list of an error" + ) +end + return T From 1309965234dabcc8144cdf8809311eb3886badcb Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:51:48 +1100 Subject: [PATCH 12/37] fix: fetch mentions again after a cancel or a failure, and page them The mention cache got an entry that was in progress before the fetch started, and the entry stayed after a cancel or a failure. Then each later @ in the buffer got an empty, incomplete list, and nothing was fetched again. blink.cmp cancels the request of a list that it no longer shows, so this happened in normal use. Requests that arrive during a fetch now share it: they get the users so far, then the final list. The fetch stops only when all of its requests are cancelled, and then the entry is removed. A failure also removes the entry and gives one final list, with the users of the pages before it, so the next @ tries again. When no command can run, the request gives an empty list. Paging also changes. A page with fewer users than the page size is the last one, so @ no longer waits for an empty second page when the repository has fewer users than limit. The page size stays the same on each page, and the users are cut to limit. Before, limit = 150 asked for per_page=50&page=2, which gave users 51 to 100 again. --- .../provider/capabilities/mentions.lua | 198 ++++++++++---- lua/cmp_git/sources/github.lua | 4 - tests/provider/capabilities/test_mentions.lua | 256 +++++++++++++++++- tests/sources/test_gitlab.lua | 3 + 4 files changed, 399 insertions(+), 62 deletions(-) diff --git a/lua/cmp_git/provider/capabilities/mentions.lua b/lua/cmp_git/provider/capabilities/mentions.lua index f538d1c..12519d7 100644 --- a/lua/cmp_git/provider/capabilities/mentions.lua +++ b/lua/cmp_git/provider/capabilities/mentions.lua @@ -2,108 +2,198 @@ local command = require("cmp_git.command") local M = {} +---The most users that the REST APIs give on one page. +local MAX_PAGE_SIZE = 100 + +---The mentions of a buffer. While `in_progress`, `items` has the pages so far and `fetch` gets more. +---@class cmp_git.AsyncItemList +---@field in_progress boolean +---@field items cmp_git.CompletionItem[] +---@field fetch? cmp_git.Provider.MentionFetch + +---The requests that wait for the pages of a buffer. They share one fetch. +---@class cmp_git.Provider.MentionFetch +---@field subscribers { callback: fun(list: cmp_git.CompletionList) }[] +---@field active? cmp_git.SystemJob +---@field cancelled boolean + ---@class cmp_git.Provider.MentionCapabilityArgs: cmp_git.Provider.CapabilityArgs ---@field cache table ----@param args cmp_git.Provider.MentionCapabilityArgs -function M.complete(args) - local bufnr = args.bufnr or vim.api.nvim_get_current_buf() - local runner = args.runner or command - local state = { cancelled = false, active = nil } - local controller = { +---@param subscribers { callback: fun(list: cmp_git.CompletionList) }[] +---@param list cmp_git.CompletionList +local function notify(subscribers, list) + -- A copy, because a callback can cancel its request + for _, subscriber in ipairs(vim.list_extend({}, subscribers)) do + subscriber.callback(list) + end +end + +---Add a request to the fetch of the entry. The fetch stops when all its requests are cancelled. +---@param cache table +---@param bufnr integer +---@param entry cmp_git.AsyncItemList +---@param fetch cmp_git.Provider.MentionFetch The fetch of the entry. +---@param callback fun(list: cmp_git.CompletionList) +local function subscribe(cache, bufnr, entry, fetch, callback) + local subscriber = { callback = callback } + table.insert(fetch.subscribers, subscriber) + + return { command = "mentions", cancel = function() - state.cancelled = true - if state.active and state.active.cancel then - state.active:cancel() + for i, other in ipairs(fetch.subscribers) do + if other == subscriber then + table.remove(fetch.subscribers, i) + break + end + end + if #fetch.subscribers > 0 or fetch.cancelled or not entry.in_progress then + return + end + + -- Otherwise the entry stays in progress, and later requests wait for pages that never come + fetch.cancelled = true + if cache[bufnr] == entry then + cache[bufnr] = nil + end + if fetch.active and fetch.active.cancel then + fetch.active:cancel() end end, } +end - if args.cache[bufnr] then - local mentions_cache = args.cache[bufnr] - args.callback({ items = mentions_cache.items, isIncomplete = mentions_cache.in_progress }) - return nil - end - - args.cache[bufnr] = { items = {}, in_progress = true } - - local function fetch_mentions(page, context) - if state.cancelled then - return +---@param args cmp_git.Provider.MentionCapabilityArgs +---@param bufnr integer +---@param entry cmp_git.AsyncItemList +---@param fetch cmp_git.Provider.MentionFetch The fetch of the entry. +local function start_fetch(args, bufnr, entry, fetch) + local runner = args.runner or command + local limit = args.config.limit + -- A constant page size, so that page N starts after the users of the pages before it + local page_size = math.min(limit, MAX_PAGE_SIZE) + + ---@param success boolean + local function finish(success) + entry.in_progress = false + entry.fetch = nil + -- A failure is not cached, so that the next request tries again. The pages before it are still shown. + if not success and args.cache[bufnr] == entry then + args.cache[bufnr] = nil end + local subscribers = fetch.subscribers + fetch.subscribers = {} + notify(subscribers, { items = entry.items, isIncomplete = false }) + end - local mentions_cache = args.cache[bufnr] - local remaining = args.config.limit - #mentions_cache.items - if remaining <= 0 then - mentions_cache.in_progress = false - args.callback({ items = mentions_cache.items, isIncomplete = false }) - return - end + if limit < 1 then + finish(true) + return + end - local page_size = math.min(remaining, 100) + local function fetch_page(page, context) local request = args.adapter.mentions_request(args.git_info, args.trigger_char, args.config, { context = context, page = page, page_size = page_size, }) - local job = runner.build_fallback_list(request.commands, function(list) - if state.cancelled then + local job = runner.build_fallback_list(request.commands, function(list, success) + if fetch.cancelled then + return + end + if not success then + finish(false) return end - vim.list_extend(mentions_cache.items, list.items) - + local items = vim.list_extend(vim.list_extend({}, entry.items), list.items) + local has_more if args.adapter.mentions_has_more then - mentions_cache.in_progress = - args.adapter.mentions_has_more(list, mentions_cache.items, args.config, context) + has_more = args.adapter.mentions_has_more(list, items, args.config, context) else - mentions_cache.in_progress = #list.items ~= 0 and #mentions_cache.items < args.config.limit + -- A page that is not full is the last one. Asking for the next page would wait for an empty one. + has_more = #list.items >= page_size end + if #items >= limit then + has_more = false + for i = #items, limit + 1, -1 do + items[i] = nil + end + end + entry.items = items - args.callback({ items = mentions_cache.items, isIncomplete = mentions_cache.in_progress }) - - if mentions_cache.in_progress then - fetch_mentions(page + 1, context) + if not has_more then + finish(true) + return end + + notify(fetch.subscribers, { items = items, isIncomplete = true }) + fetch_page(page + 1, context) end, request.handle_item, request.handle_parsed) - if job then - state.active = job - job:start() - else - args.cache[bufnr] = nil + if not job then + finish(false) + return end + fetch.active = job + job:start() end local preflight = args.adapter.mentions_preflight_request and args.adapter.mentions_preflight_request(args.git_info, args.trigger_char, args.config) - if not preflight then - fetch_mentions(1, nil) - return controller + fetch_page(1, nil) + return end local job = runner.build_fallback(preflight.commands, function(result, success) - if state.cancelled then + if fetch.cancelled then return end + -- `handle_result` also gets a failed preflight, and chooses the users to ask for local context = nil if preflight.handle_result then context = preflight.handle_result(result, success) end - fetch_mentions(1, context) + fetch_page(1, context) end) - if job then - state.active = job - job:start() - else - args.cache[bufnr] = nil + if not job then + fetch_page(1, nil) + return + end + fetch.active = job + job:start() +end + +---Give the mentions of the buffer. The callback gets a list with `isIncomplete = true` for each page before the last, +---then exactly one list with `isIncomplete = false`, unless the request is cancelled. +---@param args cmp_git.Provider.MentionCapabilityArgs +---@return { command: string, cancel: fun() }? controller nil when the callback already has its final list. +function M.complete(args) + local bufnr = args.bufnr or vim.api.nvim_get_current_buf() + local entry = args.cache[bufnr] + + if entry and not entry.in_progress then + args.callback({ items = entry.items, isIncomplete = false }) + return nil + end + + if entry then + if #entry.items > 0 then + args.callback({ items = entry.items, isIncomplete = true }) + end + return subscribe(args.cache, bufnr, entry, entry.fetch --[[@as cmp_git.Provider.MentionFetch]], args.callback) end + local fetch = { subscribers = {}, cancelled = false } + entry = { items = {}, in_progress = true, fetch = fetch } + args.cache[bufnr] = entry + local controller = subscribe(args.cache, bufnr, entry, fetch, args.callback) + start_fetch(args, bufnr, entry, fetch) return controller end diff --git a/lua/cmp_git/sources/github.lua b/lua/cmp_git/sources/github.lua index 7145942..eff75e3 100644 --- a/lua/cmp_git/sources/github.lua +++ b/lua/cmp_git/sources/github.lua @@ -4,10 +4,6 @@ local mentions = require("cmp_git.provider.capabilities.mentions") local change_requests = require("cmp_git.provider.capabilities.change_requests") local log = require("cmp_git.log") ----@class cmp_git.AsyncItemList ----@field in_progress boolean ----@field items cmp_git.CompletionItem[] - ---@class cmp_git.Source.GitHub.Cache ---@field issues table ---@field mentions table diff --git a/tests/provider/capabilities/test_mentions.lua b/tests/provider/capabilities/test_mentions.lua index f9fe875..f953cd8 100644 --- a/tests/provider/capabilities/test_mentions.lua +++ b/tests/provider/capabilities/test_mentions.lua @@ -18,10 +18,11 @@ T["mention_capability_preflight_and_pagination"] = function() local page = commands[1].page table.insert(request_pages, page) return helpers.fake_job(function() - local raw_items = page == 1 and { { name = "one" } } or {} + -- A full first page, so that the second page is requested + local count = page == 1 and 100 or 1 local items = {} - for _, item in ipairs(raw_items) do - table.insert(items, handle_item(item)) + for i = 1, count do + table.insert(items, handle_item({ name = page .. "-" .. i })) end callback({ items = items, isIncomplete = false }, true) end) @@ -53,7 +54,7 @@ T["mention_capability_preflight_and_pagination"] = function() adapter = adapter, cache = {}, callback = callback, - config = { limit = 2 }, + config = { limit = 200 }, git_info = {}, trigger_char = "@", runner = runner, @@ -64,6 +65,253 @@ T["mention_capability_preflight_and_pagination"] = function() eq(#callbacks, 2, "mention capability returns partial and final callbacks") eq(callbacks[1].isIncomplete, true, "first mention callback is incomplete") eq(callbacks[2].isIncomplete, false, "final mention callback is complete") + eq(#callbacks[2].items, 101, "the final callback has the users of both pages") +end + +---@class tests.MentionJob: cmp_git.SystemJob +---@field page integer +---@field page_size integer +---@field cancelled boolean +---@field respond fun(users: string[]?) Complete the job with the users, or fail it when `users` is nil. + +---A runner whose jobs wait until the case completes them, with an adapter that makes users from strings. +---@return table runner +---@return table adapter +---@return tests.MentionJob[] jobs The started jobs, in order. +local function manual_provider() + local jobs = {} + local runner = { + build_fallback_list = function(commands, callback, handle_item) + local job = { command = "fake", page = commands[1].page, page_size = commands[1].page_size } + function job.start() + table.insert(jobs, job) + end + function job.cancel() + job.cancelled = true + end + function job.respond(users) + if not users then + callback({ items = {}, isIncomplete = false }, false) + return + end + callback({ items = vim.tbl_map(handle_item, users), isIncomplete = false }, true) + end + return job + end, + } + local adapter = { + mentions_request = function(_git_info, _trigger_char, _config, opts) + return { + commands = { { exec = "fake", args = {}, page = opts.page, page_size = opts.page_size } }, + handle_item = function(user) + return { label = user } + end, + } + end, + } + return runner, adapter, jobs +end + +---@param count integer +---@param first? integer +---@return string[] +local function users(count, first) + local result = {} + for i = first or 1, (first or 1) + count - 1 do + table.insert(result, "user" .. i) + end + return result +end + +---@param list cmp_git.CompletionList +---@return string[] +local function labels(list) + return vim.tbl_map(function(item) + return item.label + end, list.items) +end + +---@param runner table +---@param adapter table +---@param cache table +---@param limit integer +---@return cmp_git.CompletionList[] lists +---@return { cancel: fun() }? controller +local function request(runner, adapter, cache, limit) + local callback, lists = helpers.recorder() + local controller = mention_capability.complete({ + adapter = adapter, + cache = cache, + callback = callback, + config = { limit = limit }, + git_info = {}, + trigger_char = "@", + runner = runner, + bufnr = 7, + }) + return lists, controller +end + +T["fewer_users_than_the_limit_need_one_page"] = function() + local runner, adapter, jobs = manual_provider() + local lists = request(runner, adapter, {}, 100) + + jobs[1].respond(users(30)) + + eq(#jobs, 1, "a page that is not full is the last one") + eq(#lists, 1, "one list") + eq(lists[1].isIncomplete, false, "the list is final") + eq(#lists[1].items, 30, "all users") +end + +T["the_page_size_is_constant_and_the_result_is_cut_to_the_limit"] = function() + local runner, adapter, jobs = manual_provider() + local lists = request(runner, adapter, {}, 150) + + jobs[1].respond(users(100)) + jobs[2].respond(users(100, 101)) + + eq({ jobs[1].page, jobs[1].page_size }, { 1, 100 }, "the first page") + eq({ jobs[2].page, jobs[2].page_size }, { 2, 100 }, "the second page has the same size") + eq(#lists, 2, "a list for each page") + eq(lists[1].isIncomplete, true, "the first page is incomplete") + eq(lists[2].isIncomplete, false, "the second page is final") + eq(labels(lists[2]), users(150), "users 1 to 150") +end + +T["a_cancel_removes_the_entry"] = function() + local runner, adapter, jobs = manual_provider() + local cache = {} + local lists, controller = request(runner, adapter, cache, 100) + + helpers.not_nil(controller):cancel() + + eq(jobs[1].cancelled, true, "the job is cancelled") + eq(cache[7], nil, "the cancelled fetch is not cached") + eq(lists, {}, "a cancelled request gets no list") + + local next_lists = request(runner, adapter, cache, 100) + eq(#jobs, 2, "the next request fetches again") + jobs[2].respond(users(3)) + eq(next_lists, { + { + items = vim.tbl_map(function(user) + return { label = user } + end, users(3)), + isIncomplete = false, + }, + }, "the next request gets the users") +end + +T["a_failure_gives_an_empty_list_and_is_not_cached"] = function() + local runner, adapter, jobs = manual_provider() + local cache = {} + local lists = request(runner, adapter, cache, 100) + + jobs[1].respond(nil) + + eq(lists, { { items = {}, isIncomplete = false } }, "one empty list") + eq(cache[7], nil, "a failure is not cached") + + request(runner, adapter, cache, 100) + eq(#jobs, 2, "the next request fetches again") +end + +T["a_failed_later_page_keeps_the_earlier_users"] = function() + local runner, adapter, jobs = manual_provider() + local cache = {} + local lists = request(runner, adapter, cache, 200) + + jobs[1].respond(users(100)) + jobs[2].respond(nil) + + eq(#lists, 2, "an incomplete list, then the final list") + eq(lists[2].isIncomplete, false, "the last list is final") + eq(#lists[2].items, 100, "the users of the first page") + eq(cache[7], nil, "a failure is not cached") +end + +T["no_runnable_command_gives_an_empty_list"] = function() + local cache = {} + local runner = { + build_fallback = function() + return nil + end, + build_fallback_list = function() + return nil + end, + } + local adapter = { + mentions_preflight_request = function() + return { commands = {} } + end, + mentions_request = function() + return { commands = {}, handle_item = function() end } + end, + } + + local lists = request(runner, adapter, cache, 100) + + eq(lists, { { items = {}, isIncomplete = false } }, "one empty list") + eq(cache[7], nil, "nothing is cached") +end + +T["a_failed_preflight_still_fetches"] = function() + local runner, adapter, jobs = manual_provider() + local preflight_results = {} + runner.build_fallback = function(_commands, callback) + return helpers.fake_job(function() + callback("", false) + end) + end + adapter.mentions_preflight_request = function() + return { + commands = {}, + handle_result = function(result, success) + table.insert(preflight_results, { result, success }) + return { member_type = "contributors" } + end, + } + end + + local lists = request(runner, adapter, {}, 100) + jobs[1].respond(users(2)) + + eq(preflight_results, { { "", false } }, "handle_result gets the failure") + eq(#lists, 1, "one list") + eq(#lists[1].items, 2, "the users") +end + +T["requests_during_a_fetch_share_it"] = function() + local runner, adapter, jobs = manual_provider() + local cache = {} + local first, first_controller = request(runner, adapter, cache, 300) + local second = request(runner, adapter, cache, 300) + eq(#jobs, 1, "the second request does not fetch") + eq(second, {}, "no users yet") + + jobs[1].respond(users(100)) + local third = request(runner, adapter, cache, 300) + eq(#third, 1, "a request during the fetch gets the users so far") + eq(third[1].isIncomplete, true, "and more come") + + -- blink.cmp cancels the request of a list that it no longer shows + helpers.not_nil(first_controller):cancel() + eq(jobs[2].cancelled, nil, "the fetch goes on for the other requests") + + jobs[2].respond(users(5, 101)) + + for name, lists in pairs({ second = second, third = third }) do + local final = lists[#lists] + eq(final.isIncomplete, false, name .. " gets a final list") + eq(#final.items, 105, name .. " gets all users") + end + eq(#first, 1, "the cancelled request gets no more lists") + eq(#helpers.not_nil(cache[7]).items, 105, "the users are cached") + + local cached = request(runner, adapter, cache, 300) + eq(#jobs, 2, "a cache hit does not fetch") + eq(cached[1].isIncomplete, false, "a cache hit is final") end return T diff --git a/tests/sources/test_gitlab.lua b/tests/sources/test_gitlab.lua index b5059e5..1080028 100644 --- a/tests/sources/test_gitlab.lua +++ b/tests/sources/test_gitlab.lua @@ -74,8 +74,10 @@ T["failed_commands_give_an_empty_list"] = function() eq(answers(source, "complete_issues", "#"), { EMPTY }, "issues") eq(answers(source, "complete_change_requests", "!"), { EMPTY }, "merge requests") + eq(answers(source, "complete_mentions", "@"), { EMPTY }, "mentions") eq(source.cache.issues[bufnr], nil, "failed issues are not cached") eq(source.cache.change_requests[bufnr], nil, "failed merge requests are not cached") + eq(source.cache.mentions[bufnr], nil, "failed mentions are not cached") end T["missing_commands_give_an_empty_list"] = function() @@ -84,6 +86,7 @@ T["missing_commands_give_an_empty_list"] = function() eq(answers(source, "complete_issues", "#"), { EMPTY }, "issues") eq(answers(source, "complete_change_requests", "!"), { EMPTY }, "merge requests") + eq(answers(source, "complete_mentions", "@"), { EMPTY }, "mentions") end return T From 8d74f8fcbc845b332402cf3795f59aa764309e89 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:53:03 +1100 Subject: [PATCH 13/37] perf: request GitHub issues and pull requests at the same time The # request fetched the pull requests only after the issues, so a cold # waited for two network calls in a row. Both requests now start at once, and the callback runs when both finish, with the issues first and then the pull requests, as before. When one request fails, the list has the items of the other. --- lua/cmp_git/sources/github.lua | 73 +++++++++++++-------------- tests/sources/test_github.lua | 92 ++++++++++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 42 deletions(-) diff --git a/lua/cmp_git/sources/github.lua b/lua/cmp_git/sources/github.lua index eff75e3..0fc1262 100644 --- a/lua/cmp_git/sources/github.lua +++ b/lua/cmp_git/sources/github.lua @@ -136,56 +136,53 @@ function GitHub:complete_issues_and_change_requests(callback, git_info, trigger_ git_info = self:_normalize_git_info(git_info) - local issue_items = {} - local state = { cancelled = false, active = nil } - local controller = { - command = "github_issues_and_change_requests", - cancel = function() - state.cancelled = true - if state.active and state.active.cancel then - state.active:cancel() + local results = {} + local pending = 2 + local cancelled = false + + -- Each capability calls back once: at once on a cache hit, and with an empty list on a failure. So a failure of + -- one request still gives the items of the other. + ---@param kind "issues" | "change_requests" + local function collect(kind) + return function(list) + if cancelled then + return end - end, - } - - -- The capability jobs fill the caches. On a cache hit, a capability calls the callback at once and gives no job, - -- so the pull requests are requested only after the issues are known. - local function complete_change_requests() - if state.cancelled then - return - end - - local change_requests_job = self:_change_requests_job(function(args) - if state.cancelled then + results[kind] = list.items + pending = pending - 1 + if pending > 0 then return end + -- A new list, so that the issue cache does not get the pull requests - local items = vim.list_extend(vim.list_extend({}, issue_items), args.items) + local items = vim.list_extend(vim.list_extend({}, results.issues), results.change_requests) log.fmt_debug("Got %d issues and prs", #items) callback({ items = items, isIncomplete = false }) - end, git_info, trigger_char) - - if change_requests_job then - state.active = change_requests_job - change_requests_job:start() end end - local issues_job = self:_issues_job(function(args) - if state.cancelled then - return - end - issue_items = args.items - end, git_info, trigger_char) - + local issues_job = self:_issues_job(collect("issues"), git_info, trigger_char) + local change_requests_job = self:_change_requests_job(collect("change_requests"), git_info, trigger_char) + -- Both commands run at once, so that a cold request waits for the slower one, not for both in turn if issues_job then - state.active = issues_job - issues_job:start(complete_change_requests) - else - complete_change_requests() + issues_job:start() + end + if change_requests_job then + change_requests_job:start() end - return true, controller + return true, + { + command = "github_issues_and_change_requests", + cancel = function() + cancelled = true + for _, job in ipairs({ issues_job or false, change_requests_job or false }) do + if job and job.cancel then + job:cancel() + end + end + end, + } end ---@param callback fun(list: cmp_git.CompletionList) diff --git a/tests/sources/test_github.lua b/tests/sources/test_github.lua index dd69fc2..e19c589 100644 --- a/tests/sources/test_github.lua +++ b/tests/sources/test_github.lua @@ -37,16 +37,17 @@ end local git_info = { host = "github.com", owner = "owner", repo = "repo" } +local records = { + issue = { number = 1, title = "an issue", body = "", updatedAt = "2024-01-01T00:00:00Z" }, + pr = { number = 2, title = "a pull request", body = "", updatedAt = "2024-01-01T00:00:00Z" }, +} + ---A GitHub source whose `gh` commands give one issue and one pull request. `requests` counts the commands that ran. ---@return cmp_git.Source.GitHub source ---@return { issue: integer, pr: integer } requests local function fake_github() local source = GitHub.new(config.normalize({}).github) local requests = { issue = 0, pr = 0 } - local records = { - issue = { number = 1, title = "an issue", body = "", updatedAt = "2024-01-01T00:00:00Z" }, - pr = { number = 2, title = "a pull request", body = "", updatedAt = "2024-01-01T00:00:00Z" }, - } -- Do not run `gh repo set-default` helpers.stub(source, "_normalize_git_info", function(_, info) @@ -114,4 +115,87 @@ T["issues_and_change_requests_after_a_cached_change_request"] = function() eq(requests, { issue = 1, pr = 1 }, "each command runs once") end +---@class tests.GitHubJob: cmp_git.SystemJob +---@field started boolean +---@field cancelled boolean +---@field respond fun(success: boolean) Complete the job with its record, or fail it. + +---A GitHub source whose `gh` jobs wait until the case completes them. +---@return cmp_git.Source.GitHub source +---@return table jobs The last job of each kind, `issue` or `pr`. +local function manual_github() + local source = GitHub.new(config.normalize({}).github) + local jobs = {} + + -- Do not run `gh repo set-default` + helpers.stub(source, "_normalize_git_info", function(_, info) + return info + end) + helpers.stub(command, "build_fallback_list", function(commands, callback, handle_item) + local kind = commands[1].args[1] + local job = { command = "fake", started = false, cancelled = false } + function job.start() + job.started = true + end + function job.cancel() + job.cancelled = true + end + function job.respond(success) + if success then + callback({ items = { handle_item(vim.deepcopy(records[kind])) }, isIncomplete = false }, true) + else + callback({ items = {}, isIncomplete = false }, false) + end + end + jobs[kind] = job + return job + end) + + return source, jobs +end + +T["issues_and_change_requests_start_at_once"] = function() + local source, jobs = manual_github() + local callback, lists = helpers.recorder() + + source:complete_issues_and_change_requests(callback, git_info, "#") + eq({ jobs.issue.started, jobs.pr.started }, { true, true }, "both commands run at once") + + jobs.pr.respond(true) + eq(#lists, 0, "the list waits for the issues") + jobs.issue.respond(true) + + eq(#lists, 1, "one list") + eq(labels(lists[1].items), { "#1: an issue", "#2: a pull request" }, "issues, then pull requests") +end + +T["issues_and_change_requests_keep_the_items_of_a_request_that_succeeds"] = function() + for _, failed in ipairs({ "issue", "pr" }) do + local source, jobs = manual_github() + local callback, lists = helpers.recorder() + + source:complete_issues_and_change_requests(callback, git_info, "#") + jobs.issue.respond(failed ~= "issue") + jobs.pr.respond(failed ~= "pr") + + eq(#lists, 1, failed .. " failed: one list") + eq( + labels(lists[1].items), + failed == "issue" and { "#2: a pull request" } or { "#1: an issue" }, + failed .. " failed: the items of the other request" + ) + end +end + +T["issues_and_change_requests_cancel_both"] = function() + local source, jobs = manual_github() + local callback, lists = helpers.recorder() + + local _, controller = source:complete_issues_and_change_requests(callback, git_info, "#") + helpers.not_nil(controller):cancel() + + eq({ jobs.issue.cancelled, jobs.pr.cancelled }, { true, true }, "both jobs are cancelled") + eq(lists, {}, "a cancelled request gets no list") +end + return T From 4e81ecedcabca312982efb6d2b86297388f2ff2d Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:53:44 +1100 Subject: [PATCH 14/37] fix: show an empty text for null titles and descriptions A GitLab merge request with a null description showed the text "vim.NIL" in its documentation, because JSON null decodes to vim.NIL. Only the issue description handled it. The adapters now replace a null or missing title, body, description, and GitLab mention name with an empty string for each item. --- lua/cmp_git/provider/adapters/github.lua | 12 +++++++--- lua/cmp_git/provider/adapters/gitlab.lua | 24 +++++++++++++++----- tests/provider/adapters/test_github.lua | 14 ++++++++++++ tests/provider/adapters/test_gitlab.lua | 28 ++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/lua/cmp_git/provider/adapters/github.lua b/lua/cmp_git/provider/adapters/github.lua index bd26881..a8511b5 100644 --- a/lua/cmp_git/provider/adapters/github.lua +++ b/lua/cmp_git/provider/adapters/github.lua @@ -8,6 +8,7 @@ local M = {} ---@field title string ---@field body string ---@field updatedAt string +---@field updated_at? string The name of `updatedAt` in the REST API. ---@field state string ---A pull request from `gh pr list --json` or the REST API. @@ -61,7 +62,12 @@ local function curl_command(curl_url, opts) return { exec = "curl", args = args, stdin = stdin } end -local function normalize_body(item) +---@param item cmp_git.GitHub.Issue +local function normalize_issue(item) + -- JSON null is `vim.NIL`, which the formats would show as the text "vim.NIL" + if item.title == nil or item.title == vim.NIL then + item.title = "" + end if item.body ~= vim.NIL then item.body = string.gsub(item.body or "", "\r", "") else @@ -112,7 +118,7 @@ function M.issues_request(git_info, trigger_char, config) curl_command(github_url(git_info.host, curl_path)), }, handle_item = function(issue) - normalize_body(issue) + normalize_issue(issue) return format.item(config, trigger_char, issue) end, -- The REST API also returns pull requests, with a `pull_request` field. `gh issue list` does not. @@ -166,7 +172,7 @@ function M.change_requests_request(git_info, trigger_char, config) ), }, handle_item = function(pr) - normalize_body(pr) + normalize_issue(pr) return format.item(config, trigger_char, pr) end, } diff --git a/lua/cmp_git/provider/adapters/gitlab.lua b/lua/cmp_git/provider/adapters/gitlab.lua index fb93189..fb12dfe 100644 --- a/lua/cmp_git/provider/adapters/gitlab.lua +++ b/lua/cmp_git/provider/adapters/gitlab.lua @@ -31,6 +31,21 @@ local function env() } end +---Replace the missing and null text fields that the formats read. JSON null is `vim.NIL`, which a format would show as +---the text "vim.NIL". +---@generic T: table +---@param item T +---@param fields string[] +---@return T item +local function empty_nulls(item, fields) + for _, field in ipairs(fields) do + if item[field] == nil or item[field] == vim.NIL then + item[field] = "" + end + end + return item +end + ---@param curl_url string ---@return cmp_git.CommandSpec local function curl_command(curl_url) @@ -72,10 +87,7 @@ function M.issues_request(git_info, trigger_char, config) ), }, handle_item = function(issue) - if issue.description == vim.NIL then - issue.description = "" - end - return format.item(config, trigger_char, issue) + return format.item(config, trigger_char, empty_nulls(issue, { "title", "description" })) end, } end @@ -103,7 +115,7 @@ function M.change_requests_request(git_info, trigger_char, config) ), }, handle_item = function(mr) - return format.item(config, trigger_char, mr) + return format.item(config, trigger_char, empty_nulls(mr, { "title", "description" })) end, } end @@ -125,7 +137,7 @@ function M.mentions_request(git_info, trigger_char, config) ), }, handle_item = function(mention) - return format.item(config, trigger_char, mention) + return format.item(config, trigger_char, empty_nulls(mention, { "name" })) end, } end diff --git a/tests/provider/adapters/test_github.lua b/tests/provider/adapters/test_github.lua index dcdbb09..e2bfb65 100644 --- a/tests/provider/adapters/test_github.lua +++ b/tests/provider/adapters/test_github.lua @@ -324,4 +324,18 @@ T["handle_item_normalizes_body"] = function() eq(null_body.data.body, "", "a null body is empty") end +T["handle_item_fills_null_text"] = function() + local requests = all_requests() + for _, name in ipairs({ "issues", "change_requests" }) do + local item = requests[name].handle_item({ + number = 3, + title = vim.NIL, + body = vim.NIL, + updated_at = "2024-01-01T00:00:00Z", + }) + eq(item.label, "#3: ", name .. ": a null title is empty") + eq(item.documentation.value, "# \n\n", name .. ": the documentation has no vim.NIL text") + end +end + return T diff --git a/tests/provider/adapters/test_gitlab.lua b/tests/provider/adapters/test_gitlab.lua index 9d1e7b3..ff5658c 100644 --- a/tests/provider/adapters/test_gitlab.lua +++ b/tests/provider/adapters/test_gitlab.lua @@ -135,4 +135,32 @@ T["handle_item_fills_null_description"] = function() eq(item.data.description, "", "a null description is empty") end +T["handle_item_fills_null_text"] = function() + local cases = { + issue = adapter.issues_request(gitlab, "#", gitlab_config().issues).handle_item({ + iid = 3, + title = vim.NIL, + description = vim.NIL, + updated_at = "2024-01-01T00:00:00.000Z", + }), + merge_request = adapter.change_requests_request(gitlab, "!", gitlab_config().merge_requests).handle_item({ + iid = 4, + title = vim.NIL, + description = vim.NIL, + updated_at = "2024-01-01T00:00:00.000Z", + }), + mention = adapter.mentions_request(gitlab, "@", gitlab_config().mentions).handle_item({ + username = "ada", + name = vim.NIL, + }), + } + + for name, item in pairs(cases) do + for _, text in ipairs({ item.label, item.filterText, item.insertText, item.documentation.value }) do + eq(text:find("vim.NIL", 1, true), nil, name .. ": no vim.NIL text in " .. vim.inspect(text)) + end + end + eq(cases.merge_request.documentation.value, "# \n\n", "a merge request with a null title and description") +end + return T From 5bcb338f8d5b1d1ef176845d640ed58ae08bd2f7 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:57:34 +1100 Subject: [PATCH 15/37] fix: look up the gh default repository without blocking Each GitHub # request ran gh repo set-default --view with vim.fn.system before it checked the cache. With gh, this blocked Neovim for about 60 ms on each #, also when the items were cached. Without gh, each # threw E475, although gh is optional. The lookup ran in the cwd of Neovim, not of the buffer, it replaced the repository of octo buffers, and @ did not use it, so @ and # could ask different repositories. The lookup now runs with vim.system in the directory of the buffer, only when a cache misses and gh is installed, and at most once for each repository. Requests that arrive during the lookup wait for the same result. Issues, pull requests, and mentions all use it, and octo buffers keep their repository. The git info of the caller no longer changes. --- lua/cmp_git/sources/github.lua | 218 ++++++++++++++++++++++++--------- tests/sources/test_github.lua | 207 +++++++++++++++++++++++++++++-- 2 files changed, 357 insertions(+), 68 deletions(-) diff --git a/lua/cmp_git/sources/github.lua b/lua/cmp_git/sources/github.lua index 0fc1262..63ba1fe 100644 --- a/lua/cmp_git/sources/github.lua +++ b/lua/cmp_git/sources/github.lua @@ -1,4 +1,5 @@ local adapter = require("cmp_git.provider.adapters.github") +local command = require("cmp_git.command") local issues = require("cmp_git.provider.capabilities.issues") local mentions = require("cmp_git.provider.capabilities.mentions") local change_requests = require("cmp_git.provider.capabilities.change_requests") @@ -10,9 +11,17 @@ local log = require("cmp_git.log") ---@field change_requests table ---@field pull_requests table The `change_requests` table. +---The result of `gh repo set-default --view` for a repository. Until it is `done`, the requests wait in `waiters`. +---@class cmp_git.Source.GitHub.DefaultRepo +---@field done boolean +---@field owner? string +---@field repo? string +---@field waiters fun()[] + ---@class cmp_git.Source.GitHub ---@field config cmp_git.Config.GitHub ---@field cache cmp_git.Source.GitHub.Cache +---@field default_repos table By `host/owner/repo` of the remote. local GitHub = {} ---@param overrides cmp_git.Config.GitHub @@ -28,26 +37,13 @@ function GitHub.new(overrides) change_requests = change_requests_cache, pull_requests = change_requests_cache, } + self.default_repos = {} self.config = overrides or {} return self end ----@param git_info cmp_git.GitInfo -local function use_gh_default_repo_if_set(git_info) - local gh_default_repo = vim.fn.system({ "gh", "repo", "set-default", "--view" }) - if vim.v.shell_error ~= 0 then - return git_info - end - local owner, repo = string.match(vim.fn.trim(gh_default_repo), "^(.+)/(.+)$") - if owner ~= nil and repo ~= nil then - git_info.owner = owner - git_info.repo = repo - end - return git_info -end - ---@param git_info cmp_git.GitInfo function GitHub:is_valid_host(git_info) if @@ -61,15 +57,93 @@ function GitHub:is_valid_host(git_info) return true end +---Call `on_resolved` with the repository that `gh repo set-default` chose, or with `git_info` when none is set or gh +---is not installed. gh runs at most once for each repository, and without blocking: it takes about 60 ms. +---@param git_info cmp_git.GitInfo +---@param on_resolved fun(git_info: cmp_git.GitInfo) +function GitHub:_resolve_default_repo(git_info, on_resolved) + local key = string.format("%s/%s/%s", git_info.host, git_info.owner, git_info.repo) + local lookup = self.default_repos[key] + + local function resolve() + ---@cast lookup -nil + if lookup.owner and lookup.repo then + on_resolved(vim.tbl_extend("force", git_info, { owner = lookup.owner, repo = lookup.repo })) + else + on_resolved(git_info) + end + end + + if lookup and lookup.done then + resolve() + return + end + if lookup then + table.insert(lookup.waiters, resolve) + return + end + + local job = command.build({ exec = "gh", args = { "repo", "set-default", "--view" } }, function(result, success) + ---@cast lookup -nil + -- Without a default repository, gh prints nothing. Outside a repository, it fails. + if success then + lookup.owner, lookup.repo = string.match(vim.trim(result), "^([^/%s]+)/([^/%s]+)$") + end + lookup.done = true + local waiters = lookup.waiters + lookup.waiters = {} + for _, waiter in ipairs(waiters) do + waiter() + end + end) + if not job then + -- gh is optional + on_resolved(git_info) + return + end + + lookup = { done = false, waiters = { resolve } } + self.default_repos[key] = lookup + job:start() +end + +---Run `start` with the repository to complete from. On a cache miss, that is the repository that `gh repo set-default` +---chose, so that issues, pull requests, and mentions come from the same repository. An octo buffer names its +---repository, and a cache hit needs no repository. ---@param git_info cmp_git.GitInfo -function GitHub:_normalize_git_info(git_info) - return use_gh_default_repo_if_set(git_info) +---@param bufnr integer +---@param cached boolean +---@param start fun(git_info: cmp_git.GitInfo): table? Starts the request and gives its job, if any. +---@return table? job nil or a table with an optional `cancel` method. +function GitHub:_with_repo(git_info, bufnr, cached, start) + if cached or vim.bo[bufnr].filetype == "octo" then + return start(git_info) + end + + local state = { cancelled = false, active = nil } + self:_resolve_default_repo(git_info, function(resolved) + if state.cancelled then + return + end + state.active = start(resolved) + end) + + return { + command = "github", + cancel = function() + state.cancelled = true + if state.active and state.active.cancel then + state.active:cancel() + end + end, + } end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitHub:_issues_job(callback, git_info, trigger_char) +---@param bufnr integer +function GitHub:_issues_job(callback, git_info, trigger_char, bufnr) return issues.job({ adapter = adapter, cache = self.cache.issues, @@ -77,13 +151,15 @@ function GitHub:_issues_job(callback, git_info, trigger_char) config = self.config.issues, git_info = git_info, trigger_char = trigger_char, + bufnr = bufnr, }) end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitHub:_change_requests_job(callback, git_info, trigger_char) +---@param bufnr integer +function GitHub:_change_requests_job(callback, git_info, trigger_char, bufnr) return change_requests.job({ adapter = adapter, cache = self.cache.change_requests, @@ -91,6 +167,7 @@ function GitHub:_change_requests_job(callback, git_info, trigger_char) config = self.config.pull_requests, git_info = git_info, trigger_char = trigger_char, + bufnr = bufnr, }) end @@ -102,12 +179,16 @@ function GitHub:complete_issues(callback, git_info, trigger_char) return false end - git_info = self:_normalize_git_info(git_info) - local job = self:_issues_job(callback, git_info, trigger_char) - if job then - job:start() - end - return true, job + -- The buffer of the request, also when the lookup of the default repository ends in another buffer + local bufnr = vim.api.nvim_get_current_buf() + return true, + self:_with_repo(git_info, bufnr, self.cache.issues[bufnr] ~= nil, function(repo_info) + local job = self:_issues_job(callback, repo_info, trigger_char, bufnr) + if job then + job:start() + end + return job + end) end ---@param callback fun(list: cmp_git.CompletionList) @@ -118,24 +199,22 @@ function GitHub:complete_change_requests(callback, git_info, trigger_char) return false end - git_info = self:_normalize_git_info(git_info) - local job = self:_change_requests_job(callback, git_info, trigger_char) - if job then - job:start() - end - return true, job + local bufnr = vim.api.nvim_get_current_buf() + return true, + self:_with_repo(git_info, bufnr, self.cache.change_requests[bufnr] ~= nil, function(repo_info) + local job = self:_change_requests_job(callback, repo_info, trigger_char, bufnr) + if job then + job:start() + end + return job + end) end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitHub:complete_issues_and_change_requests(callback, git_info, trigger_char) - if not self:is_valid_host(git_info) then - return false - end - - git_info = self:_normalize_git_info(git_info) - +---@param bufnr integer +function GitHub:_issues_and_change_requests(callback, git_info, trigger_char, bufnr) local results = {} local pending = 2 local cancelled = false @@ -161,8 +240,8 @@ function GitHub:complete_issues_and_change_requests(callback, git_info, trigger_ end end - local issues_job = self:_issues_job(collect("issues"), git_info, trigger_char) - local change_requests_job = self:_change_requests_job(collect("change_requests"), git_info, trigger_char) + local issues_job = self:_issues_job(collect("issues"), git_info, trigger_char, bufnr) + local change_requests_job = self:_change_requests_job(collect("change_requests"), git_info, trigger_char, bufnr) -- Both commands run at once, so that a cold request waits for the slower one, not for both in turn if issues_job then issues_job:start() @@ -171,38 +250,57 @@ function GitHub:complete_issues_and_change_requests(callback, git_info, trigger_ change_requests_job:start() end - return true, - { - command = "github_issues_and_change_requests", - cancel = function() - cancelled = true - for _, job in ipairs({ issues_job or false, change_requests_job or false }) do - if job and job.cancel then - job:cancel() - end + return { + command = "github_issues_and_change_requests", + cancel = function() + cancelled = true + for _, job in ipairs({ issues_job or false, change_requests_job or false }) do + if job and job.cancel then + job:cancel() end - end, - } + end + end, + } end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitHub:complete_mentions(callback, git_info, trigger_char) +function GitHub:complete_issues_and_change_requests(callback, git_info, trigger_char) if not self:is_valid_host(git_info) then return false end - local job = mentions.complete({ - adapter = adapter, - cache = self.cache.mentions, - callback = callback, - config = self.config.mentions, - git_info = git_info, - trigger_char = trigger_char, - }) + local bufnr = vim.api.nvim_get_current_buf() + local cached = self.cache.issues[bufnr] ~= nil and self.cache.change_requests[bufnr] ~= nil + return true, + self:_with_repo(git_info, bufnr, cached, function(repo_info) + return self:_issues_and_change_requests(callback, repo_info, trigger_char, bufnr) + end) +end + +---@param callback fun(list: cmp_git.CompletionList) +---@param git_info cmp_git.GitInfo +---@param trigger_char string +function GitHub:complete_mentions(callback, git_info, trigger_char) + if not self:is_valid_host(git_info) then + return false + end - return true, job + -- A request during a fetch joins it, so it needs no repository either + local bufnr = vim.api.nvim_get_current_buf() + return true, + self:_with_repo(git_info, bufnr, self.cache.mentions[bufnr] ~= nil, function(repo_info) + return mentions.complete({ + adapter = adapter, + cache = self.cache.mentions, + callback = callback, + config = self.config.mentions, + git_info = repo_info, + trigger_char = trigger_char, + bufnr = bufnr, + }) + end) end function GitHub:get_issues(callback, git_info, trigger_char) diff --git a/tests/sources/test_github.lua b/tests/sources/test_github.lua index e19c589..fb91604 100644 --- a/tests/sources/test_github.lua +++ b/tests/sources/test_github.lua @@ -1,6 +1,7 @@ local helpers = require("tests.helpers") local command = require("cmp_git.command") local config = require("cmp_git.config") +local repository = require("cmp_git.repository") local GitHub = require("cmp_git.sources.github") local eq = helpers.eq @@ -37,6 +38,22 @@ end local git_info = { host = "github.com", owner = "owner", repo = "repo" } +---Make PATH a directory with only these commands until the end of the case. +---@param scripts table The shell script body of each command, by name. +---@return string bin The directory. +local function fake_commands(scripts) + local bin = helpers.tempdir() + for name, body in pairs(scripts) do + local path = bin .. "/" .. name + local file = assert(io.open(path, "w")) + file:write("#!/bin/sh\n" .. body .. "\n") + file:close() + vim.uv.fs_chmod(path, tonumber("755", 8)) + end + helpers.stub(vim.env, "PATH", bin) + return bin +end + local records = { issue = { number = 1, title = "an issue", body = "", updatedAt = "2024-01-01T00:00:00Z" }, pr = { number = 2, title = "a pull request", body = "", updatedAt = "2024-01-01T00:00:00Z" }, @@ -49,10 +66,8 @@ local function fake_github() local source = GitHub.new(config.normalize({}).github) local requests = { issue = 0, pr = 0 } - -- Do not run `gh repo set-default` - helpers.stub(source, "_normalize_git_info", function(_, info) - return info - end) + -- No gh on PATH, so that the default repository lookup does not run + fake_commands({}) helpers.stub(command, "build_fallback_list", function(commands, callback, handle_item) local kind = commands[1].args[1] return { @@ -127,10 +142,8 @@ local function manual_github() local source = GitHub.new(config.normalize({}).github) local jobs = {} - -- Do not run `gh repo set-default` - helpers.stub(source, "_normalize_git_info", function(_, info) - return info - end) + -- No gh on PATH, so that the default repository lookup does not run + fake_commands({}) helpers.stub(command, "build_fallback_list", function(commands, callback, handle_item) local kind = commands[1].args[1] local job = { command = "fake", started = false, cancelled = false } @@ -198,4 +211,182 @@ T["issues_and_change_requests_cancel_both"] = function() eq(lists, {}, "a cancelled request gets no list") end +---A GitHub source whose jobs answer at once and record the repository that they ask for. With `default_repo`, a fake +---`gh repo set-default --view` prints it and records the directory that it runs in. +---@param default_repo? string nil for no gh on PATH. +---@return cmp_git.Source.GitHub source +---@return string[] repos The `owner/repo` of each job, in order. +---@return fun(): string[] gh_calls The directories of the `gh repo set-default` calls so far. +local function recording_github(default_repo) + local log_path = helpers.tempdir() .. "/gh_calls" + local scripts = {} + if default_repo then + scripts.gh = table.concat({ + 'if [ "$1 $2 $3" != "repo set-default --view" ]; then exit 1; fi', + string.format("pwd -P >> '%s'", log_path), + string.format("printf '%%s\\n' '%s'", default_repo), + }, "\n") + end + fake_commands(scripts) + + local repos = {} + ---@param args string[] + local function record(args) + -- `gh issue list --repo host/owner/repo` or `gh api repos/owner/repo/...` + if args[1] == "api" then + table.insert(repos, args[2]:match("^repos/([^/]+/[^/]+)/")) + else + table.insert(repos, args[4]:match("^[^/]+/(.+)$")) + end + end + helpers.stub(command, "build_fallback_list", function(commands, callback) + record(commands[1].args) + return helpers.fake_job(function() + callback({ items = {}, isIncomplete = false }, true) + end) + end) + helpers.stub(command, "build_fallback", function(commands, callback) + record(commands[1].args) + return helpers.fake_job(function() + callback("", false) + end) + end) + + local function gh_calls() + if vim.fn.filereadable(log_path) == 0 then + return {} + end + return vim.fn.readfile(log_path) + end + + return GitHub.new(config.normalize({}).github), repos, gh_calls +end + +local REQUESTS = { "complete_issues", "complete_change_requests", "complete_issues_and_change_requests" } + +---A new git info for each request, so that a change by one request cannot reach the next one. +---@return cmp_git.GitInfo +local function remote() + return { host = "github.com", owner = "owner", repo = "repo" } +end + +T["the_default_repository_is_looked_up_once_without_blocking"] = function() + local source, repos, gh_calls = recording_github("upstream/project") + local cwd = helpers.tempdir() + helpers.stub(repository, "get_cwd", function() + return cwd + end) + local callback, lists = helpers.recorder() + + local info = remote() + eq(source:complete_issues(callback, info, "#"), true, "the host is handled") + eq(#lists, 0, "the request does not wait for gh") + helpers.wait_for(function() + return #lists == 1 + end, "the issues answer") + eq(repos, { "upstream/project" }, "the issues come from the default repository") + eq(info, remote(), "the git info of the caller does not change") + eq(gh_calls(), { cwd }, "gh runs in the directory of the buffer") + + -- A buffer without cached lists, so that the requests need the repository + vim.api.nvim_set_current_buf(helpers.buffer({}, "markdown")) + source:complete_mentions(callback, remote(), "@") + source:complete_issues_and_change_requests(callback, remote(), "#") + + eq(#lists, 3, "the known default repository answers at once") + eq(#repos, 5, "issues, mention preflight, mentions, issues, pull requests") + for i, repo in ipairs(repos) do + eq(repo, "upstream/project", "request " .. i .. " uses the default repository") + end + eq(gh_calls(), { cwd }, "gh runs once for the repository") +end + +T["requests_during_the_lookup_share_it"] = function() + local source, repos, gh_calls = recording_github("upstream/project") + local callback, lists = helpers.recorder() + + source:complete_issues(callback, remote(), "#") + source:complete_mentions(callback, remote(), "@") + helpers.wait_for(function() + return #lists == 2 + end, "both requests answer") + + eq(#gh_calls(), 1, "one gh call") + eq(repos, { "upstream/project", "upstream/project", "upstream/project" }, "the default repository") +end + +T["without_a_default_repository_the_remote_is_used"] = function() + local source, repos, gh_calls = recording_github("") + local callback, lists = helpers.recorder() + + source:complete_issues(callback, remote(), "#") + helpers.wait_for(function() + return #lists == 1 + end, "the issues answer") + vim.api.nvim_set_current_buf(helpers.buffer({}, "markdown")) + source:complete_issues(callback, remote(), "#") + + eq(#lists, 2, "the second request answers at once") + eq(repos, { "owner/repo", "owner/repo" }, "the remote repository") + eq(#gh_calls(), 1, "gh runs once") +end + +T["a_cache_hit_does_not_run_gh"] = function() + local source, repos = recording_github("upstream/project") + local bufnr = vim.api.nvim_get_current_buf() + source.cache.issues[bufnr] = {} + source.cache.change_requests[bufnr] = {} + source.cache.mentions[bufnr] = { items = {}, in_progress = false } + local callback, lists = helpers.recorder() + + for _, method in ipairs(vim.list_extend({ "complete_mentions" }, REQUESTS)) do + source[method](source, callback, remote(), "#") + end + + eq(#lists, 4, "each cache hit answers at once") + eq(repos, {}, "no command runs") + eq(source.default_repos, {}, "no lookup starts") +end + +T["without_gh_the_remote_is_used"] = function() + local source, repos = recording_github(nil) + local callback, lists = helpers.recorder() + + for _, method in ipairs(REQUESTS) do + vim.api.nvim_set_current_buf(helpers.buffer({}, "markdown")) + source[method](source, callback, remote(), "#") + end + + eq(#lists, 3, "each request answers at once") + eq(repos, { "owner/repo", "owner/repo", "owner/repo", "owner/repo" }, "the remote repository") +end + +T["an_octo_buffer_keeps_its_repository"] = function() + local source, repos, gh_calls = recording_github("upstream/project") + vim.api.nvim_set_current_buf(helpers.buffer({}, "octo")) + local callback, lists = helpers.recorder() + + source:complete_issues(callback, remote(), "#") + source:complete_mentions(callback, remote(), "@") + + eq(#lists, 2, "each request answers at once") + eq(repos, { "owner/repo", "owner/repo", "owner/repo" }, "the repository of the octo buffer") + eq(gh_calls(), {}, "gh does not run") +end + +T["a_cancel_during_the_lookup_starts_no_request"] = function() + local source, repos = recording_github("upstream/project") + local callback, lists = helpers.recorder() + + local _, controller = source:complete_issues(callback, remote(), "#") + helpers.not_nil(controller):cancel() + helpers.wait_for(function() + local lookup = source.default_repos["github.com/owner/repo"] + return lookup ~= nil and lookup.done + end, "the lookup ends") + + eq(repos, {}, "no request starts") + eq(lists, {}, "a cancelled request gets no list") +end + return T From 499f8211fcb997207117a9df0abc54d5c113c681 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:46:51 +1100 Subject: [PATCH 16/37] perf: match the trailer value pattern in linear time The lookbehind in the trailer value pattern made Vim use its backtracking regex engine, and the time of a match grew with the square of the line length: 13 ms at 1000 characters and about 300 ms at 5000. nvim-cmp matches the keyword pattern on each keystroke, so typing in a long line, such as a Markdown paragraph in an octo buffer, became slow. The value pattern now anchors the key and starts the value with `\zs`. A new `keyword_pattern` field of a pattern entry gives the keyword pattern a separate form that starts at the colon in `Key:` and `Key:value`, so the keyword offsets do not change. Entries that still have the old pattern get the new patterns. --- lua/cmp_git/config.lua | 30 ++++++++++- lua/cmp_git/core.lua | 23 +++++--- tests/test_config.lua | 24 +++++++++ tests/test_core.lua | 119 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 7 deletions(-) diff --git a/lua/cmp_git/config.lua b/lua/cmp_git/config.lua index be2a03d..9706077 100644 --- a/lua/cmp_git/config.lua +++ b/lua/cmp_git/config.lua @@ -12,13 +12,24 @@ local sort = require("cmp_git.sort") ---@class cmp_git.Config.PatternTriggerActions ---@field pattern string Vim regex (magic mode) that must match up to the cursor. The match start is the start of the replaced text. +---@field keyword_pattern? string Vim regex that cmp-git adds to the nvim-cmp keyword pattern in place of `pattern`. nvim-cmp filters the items on the text from the start of the match. ---@field actions string[] -- Keep these in sync with the trailer entries in the README. local TRAILER_KEY_PATTERN = [[^[[:alnum:]-]\+]] -- The value cannot contain `:#@!` outside `<...>`. nvim-cmp starts the keyword at the leftmost match, so a looser -- value would hide the other triggers in body lines such as `Note: see #12`. -local TRAILER_VALUE_PATTERN = [[\%(^[[:alnum:]-]\+:\s*\)\@<=\%([^[:space:]<>:#@!][^<>:#@!]*\%(<[^>]*\)\?\)\?]] +local TRAILER_VALUE = [[\%([^[:space:]<>:#@!][^<>:#@!]*\%(<[^>]*\)\?\)\?]] +-- `\zs` and not a lookbehind, because a lookbehind makes Vim use its backtracking engine. Its time grows with the +-- square of the line length, and nvim-cmp matches the keyword pattern on each keystroke. +local TRAILER_VALUE_PATTERN = [[^[[:alnum:]-]\+:\s*\zs]] .. TRAILER_VALUE +-- Vim gives the match that begins first, and `\zs` moves only the start that it reports. The value match begins at the +-- line start, before the `:` trigger match, so the keyword of `Key:` or `Key:value` would start after the colon. The +-- identities and values formats filter on the colon, so the first branch starts the keyword at the colon. +local TRAILER_VALUE_KEYWORD_PATTERN = [[^[[:alnum:]-]\+\zs:\S*\|]] .. TRAILER_VALUE_PATTERN +-- The value pattern of earlier versions. It matches the same text as `TRAILER_VALUE_PATTERN`, slowly. The docs tell +-- users who set their own `trigger_actions` to copy the default entries, so `normalize` replaces it. +local LEGACY_TRAILER_VALUE_PATTERN = [[\%(^[[:alnum:]-]\+:\s*\)\@<=]] .. TRAILER_VALUE local IDENTITY_TRAILER_KEYS = { "Co-authored-by", @@ -148,6 +159,7 @@ local defaults = { }, { pattern = TRAILER_VALUE_PATTERN, + keyword_pattern = TRAILER_VALUE_KEYWORD_PATTERN, actions = { "git_trailer_identities", "git_trailer_values" }, }, { @@ -190,6 +202,20 @@ local function apply_filter_fn(provider_config, capability_names) end end +---@param trigger_action table +---@return table +local function upgrade_trigger_action(trigger_action) + if trigger_action.pattern ~= LEGACY_TRAILER_VALUE_PATTERN or trigger_action.keyword_pattern ~= nil then + return trigger_action + end + + -- A new table, because the entry can be a table of the user config + return vim.tbl_extend("force", trigger_action, { + pattern = TRAILER_VALUE_PATTERN, + keyword_pattern = TRAILER_VALUE_KEYWORD_PATTERN, + }) +end + ---@param overrides? cmp_git.Config Partial user config. ---@return cmp_git.Config function M.normalize(overrides) @@ -204,6 +230,8 @@ function M.normalize(overrides) apply_filter_fn(normalized.github, { "issues", "mentions", "pull_requests" }) apply_filter_fn(normalized.gitlab, { "issues", "mentions", "merge_requests" }) + normalized.trigger_actions = vim.tbl_map(upgrade_trigger_action, normalized.trigger_actions) + return normalized end diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index 4f8bd24..a93da5d 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -75,11 +75,12 @@ local builtin_trigger_availability = { } ---@param pattern string +---@param field "pattern" | "keyword_pattern" The config field, for the warning. ---@return any? regex -local function compile_pattern(pattern) +local function compile_pattern(pattern, field) local ok, regex = pcall(vim.regex, [[\%(]] .. pattern .. [[\)$]]) if not ok then - log.warn(string.format("Invalid trigger action pattern %s: %s", vim.inspect(pattern), regex)) + log.warn(string.format("Invalid trigger action %s %s: %s", field, vim.inspect(pattern), regex)) return nil end return regex @@ -109,13 +110,23 @@ local function normalize_trigger_action(configured_action) end local regex = nil + local keyword_pattern = nil local trigger_character = configured_action.trigger_character if configured_action.pattern then - regex = compile_pattern(configured_action.pattern) + regex = compile_pattern(configured_action.pattern, "pattern") if not regex then return {} end trigger_character = nil + + keyword_pattern = configured_action.pattern + -- An invalid keyword pattern would make nvim-cmp fail on each keystroke + if + configured_action.keyword_pattern + and compile_pattern(configured_action.keyword_pattern, "keyword_pattern") + then + keyword_pattern = configured_action.keyword_pattern + end end local normalized = {} @@ -124,7 +135,7 @@ local function normalize_trigger_action(configured_action) table.insert(normalized, { debug_name = action_name, trigger_character = trigger_character, - pattern = configured_action.pattern, + keyword_pattern = keyword_pattern, regex = regex, available = builtin_trigger_availability[action_name], action = function(core, callback, context, git_info, trigger_char) @@ -176,8 +187,8 @@ function Core.new(overrides) for _, trigger_action in ipairs(normalized) do table.insert(self.trigger_actions, trigger_action) if trigger_action.regex then - if not vim.tbl_contains(patterns, trigger_action.pattern) then - table.insert(patterns, trigger_action.pattern) + if not vim.tbl_contains(patterns, trigger_action.keyword_pattern) then + table.insert(patterns, trigger_action.keyword_pattern) end elseif trigger_action.trigger_character diff --git a/tests/test_config.lua b/tests/test_config.lua index 3645894..b301b4e 100644 --- a/tests/test_config.lua +++ b/tests/test_config.lua @@ -160,6 +160,30 @@ T["config_trailer_defaults"] = function() ok(not vim.tbl_contains(normalized.git.trailers.filetypes, "octo"), "no trailers in octo") end +T["config_trailer_value_pattern_has_no_lookbehind"] = function() + local value_entry = config.normalize({}).trigger_actions[2] + + -- A lookbehind makes Vim use its backtracking engine, which is slow on long lines + eq(value_entry.pattern:find([[\@<=]], 1, true), nil, "value pattern has no lookbehind") + eq(value_entry.keyword_pattern:find([[\@<=]], 1, true), nil, "value keyword pattern has no lookbehind") +end + +T["config_normalize_upgrades_the_lookbehind_value_pattern"] = function() + local lookbehind = [[\%(^[[:alnum:]-]\+:\s*\)\@<=\%([^[:space:]<>:#@!][^<>:#@!]*\%(<[^>]*\)\?\)\?]] + local user_entry = { pattern = lookbehind, actions = { "git_trailer_values" } } + local custom_entry = { pattern = [[^Refs:\s*\zs\S*]], actions = { "git_trailer_values" } } + local defaults = config.normalize({}).trigger_actions[2] + + local normalized = config.normalize({ trigger_actions = { user_entry, custom_entry } }) + + eq(normalized.trigger_actions[1].pattern, defaults.pattern, "lookbehind pattern becomes the default pattern") + eq(normalized.trigger_actions[1].keyword_pattern, defaults.keyword_pattern, "upgrade adds the keyword pattern") + eq(normalized.trigger_actions[1].actions, { "git_trailer_values" }, "upgrade keeps the actions") + eq(user_entry.pattern, lookbehind, "upgrade does not change the user config") + eq(user_entry.keyword_pattern, nil, "upgrade does not add fields to the user config") + ok(normalized.trigger_actions[2] == custom_entry, "other pattern entries are kept") +end + T["config_git_filter_alias_skips_trailers"] = function() local git_filter = function() return "git" diff --git a/tests/test_core.lua b/tests/test_core.lua index b946563..9406013 100644 --- a/tests/test_core.lua +++ b/tests/test_core.lua @@ -4,6 +4,7 @@ local Source = require("cmp_git.source") local repository = require("cmp_git.repository") local eq = helpers.eq +local ok = helpers.ok -- The cases replace the provider sources with partial fakes ---@diagnostic disable: missing-fields @@ -74,6 +75,24 @@ T["invalid_trigger_action_pattern_is_skipped"] = function() ) end +T["trigger_action_keyword_pattern"] = function() + local core = Core.new({ + trigger_actions = { + { pattern = [[^Refs:\s*\zs\S*]], keyword_pattern = [[^Refs:\zs\S*]], actions = { "git_trailer_values" } }, + { pattern = [[^Key\S*]], keyword_pattern = [[\%(]], actions = { "git_trailer_keys" } }, + }, + }) + + eq(core.keyword_pattern, [[\%(^Refs:\zs\S*\|^Key\S*\)]], "keyword patterns replace the patterns") + eq(core.trigger_actions[1].regex:match_str("Refs: 12"), 6, "routing uses the pattern") + helpers.flush() + eq( + helpers.notifications[1].message:match("^Invalid trigger action keyword_pattern"), + "Invalid trigger action keyword_pattern", + "invalid keyword pattern logs a warning" + ) +end + T["core_keyword_pattern_offsets"] = function() local core = Core.new({}) local regex = vim.regex([[\%(]] .. core.keyword_pattern .. [[\)\m$]]) @@ -97,6 +116,106 @@ T["core_keyword_pattern_offsets"] = function() end end +-- The trailer value pattern of earlier versions. Its lookbehind is slow, so the default changed to a `\zs` form that +-- must give the same results. +local LOOKBEHIND_VALUE_PATTERN = [[\%(^[[:alnum:]-]\+:\s*\)\@<=\%([^[:space:]<>:#@!][^<>:#@!]*\%(<[^>]*\)\?\)\?]] + +---Deterministic lines over the characters that the trailer patterns treat differently. +---@param count integer +---@return string[] +local function random_lines(count) + local chars = { "a", "B", "-", ":", " ", "#", "@", "!", "<", ">", "\t", "é" } + local seed = 1 + local function next_int(n) + seed = (seed * 1103515245 + 12345) % 2147483648 + return seed % n + 1 + end + + local lines = {} + for _ = 1, count do + local chunks = {} + for _ = 1, next_int(16) - 1 do + table.insert(chunks, chars[next_int(#chars)]) + end + table.insert(lines, table.concat(chunks)) + end + return lines +end + +---@param core table +---@param debug_name string +---@return any regex +local function action_regex(core, debug_name) + for _, trigger_action in ipairs(core.trigger_actions) do + if trigger_action.debug_name == debug_name then + return trigger_action.regex + end + end + error("no trigger action " .. debug_name) +end + +T["trailer_value_pattern_matches_the_lookbehind_pattern"] = function() + local core = Core.new({}) + -- nvim-cmp wraps the keyword pattern like this + local keyword = vim.regex([[\%(]] .. core.keyword_pattern .. [[\)\m$]]) + local lookbehind_keyword = + vim.regex([[\%(\%(^[[:alnum:]-]\+\|]] .. LOOKBEHIND_VALUE_PATTERN .. [[\|[:#@!]\S*\)\)\m$]]) + local value = action_regex(core, "git_trailer_values") + local lookbehind_value = vim.regex([[\%(]] .. LOOKBEHIND_VALUE_PATTERN .. [[\)$]]) + + local lines = { + "", + "Co-auth", + "Co-authored-by:", + "Co-authored-by: ", + "Co-authored-by:Ad", + "Co-authored-by: Ada Lo", + "Co-authored-by: Ada x", + "Co-authored-by: Ada @x", + "Fixes:", + "Fixes:abc", + "Fixes: #12", + "Fixes: 12 #", + "Fixes:\tvalue", + "Note: see #12", + "Note: thanks @ada", + "Note: see :abc", + "Key : value", + " Key: value", + "-: value", + "é-key: value", + "refs :abc", + "body text he", + } + vim.list_extend(lines, random_lines(3000)) + + for _, line in ipairs(lines) do + eq(keyword:match_str(line), lookbehind_keyword:match_str(line), "keyword offset for " .. vim.inspect(line)) + eq(value:match_str(line), lookbehind_value:match_str(line), "value edit start for " .. vim.inspect(line)) + end +end + +T["trailer_patterns_are_fast_on_long_lines"] = function() + local core = Core.new({}) + local regexes = { vim.regex([[\%(]] .. core.keyword_pattern .. [[\)\m$]]) } + for _, trigger_action in ipairs(core.trigger_actions) do + table.insert(regexes, trigger_action.regex) + end + local line = string.rep("parser cache ", 400) + + local start = vim.uv.hrtime() + for _ = 1, 3 do + for _, regex in ipairs(regexes) do + regex:match_str(line) + end + end + local elapsed_ms = (vim.uv.hrtime() - start) / 1e6 + + -- The lookbehind took about 2 s here, and the `\zs` form takes about 1 ms + ok(elapsed_ms < 200, string.format("patterns take %.1f ms on a %d character line", elapsed_ms, #line)) +end + T["trailer_routing"] = function() local core = Core.new({}) local calls = {} From b7d9c88c9c4cc335ed1bdd1b85582528236b5505 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:47:56 +1100 Subject: [PATCH 17/37] perf: check availability before the trigger action regex Routing matched the regex of each pattern action before its cheap `available` check, so a long line in a buffer without trailers, such as an octo buffer, still paid for the trailer regexes. The two trailer value actions matched the same regex twice, and a request routed twice, once before the repository check and once after discovery. Routing now runs `available` first, matches each distinct regex once, and a request keeps the routes that it found first. Entries with the same pattern share one compiled regex. --- lua/cmp_git/core.lua | 103 +++++++++++++++++++++++++++---------------- tests/test_core.lua | 61 +++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 37 deletions(-) diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index a93da5d..7b917df 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -86,26 +86,39 @@ local function compile_pattern(pattern, field) return regex end -local function normalize_trigger_action(configured_action) +---@class cmp_git.TriggerAction +---@field debug_name string +---@field trigger_character? string +---@field regex? any The compiled `pattern`. Entries with the same pattern share it. +---@field keyword_pattern? string +---@field available? fun(core: table, context: table): boolean +---@field action fun(core: table, callback: fun(list: cmp_git.CompletionList), context: table, git_info: cmp_git.GitInfo, trigger_char: string?): string, table? + +---@param configured_action table +---@param regexes table The compiled patterns, by pattern. +---@return cmp_git.TriggerAction[] +local function normalize_trigger_action(configured_action, regexes) if configured_action.action then if configured_action.pattern then log.warn("Trigger action patterns need named `actions`. The pattern of a legacy `action` is ignored.") end return { - debug_name = configured_action.debug_name, - trigger_character = configured_action.trigger_character, - action = function(core, _callback, context, git_info, trigger_char) - return route_status( - configured_action.action( - core.sources, - trigger_char, - context.original_callback, - context.params, - git_info + { + debug_name = configured_action.debug_name, + trigger_character = configured_action.trigger_character, + action = function(core, _callback, context, git_info, trigger_char) + return route_status( + configured_action.action( + core.sources, + trigger_char, + context.original_callback, + context.params, + git_info + ) ) - ) - end, + end, + }, } end @@ -113,10 +126,11 @@ local function normalize_trigger_action(configured_action) local keyword_pattern = nil local trigger_character = configured_action.trigger_character if configured_action.pattern then - regex = compile_pattern(configured_action.pattern, "pattern") + regex = regexes[configured_action.pattern] or compile_pattern(configured_action.pattern, "pattern") if not regex then return {} end + regexes[configured_action.pattern] = regex trigger_character = nil keyword_pattern = configured_action.pattern @@ -178,13 +192,9 @@ function Core.new(overrides) self.sources.github = github.new(self.config.github) local patterns = {} + local regexes = {} for _, configured_action in ipairs(self.config.trigger_actions) do - local normalized = normalize_trigger_action(configured_action) - if normalized.action then - normalized = { normalized } - end - - for _, trigger_action in ipairs(normalized) do + for _, trigger_action in ipairs(normalize_trigger_action(configured_action, regexes)) do table.insert(self.trigger_actions, trigger_action) if trigger_action.regex then if not vim.tbl_contains(patterns, trigger_action.keyword_pattern) then @@ -265,38 +275,54 @@ function Core:_wrap_callback(context, callback) end end +---@class cmp_git.Route +---@field trigger_action cmp_git.TriggerAction +---@field edit_start? integer The match start of a pattern entry. + ---Find the trigger actions that apply to the context, in config order. This must stay synchronous and start no ---process, because it runs for every completion request. ----@return { trigger_action: table, edit_start: integer? }[] +---@return cmp_git.Route[] function Core:_route(context) local routes = {} + -- The match start of each regex, or false. The actions of one entry share its regex. + local matches = nil for _, trigger_action in ipairs(self.trigger_actions) do - local matched = false - local edit_start = nil - if trigger_action.regex then - edit_start = context.line and trigger_action.regex:match_str(context.line) - matched = edit_start ~= nil - else - matched = trigger_action.trigger_character ~= nil + local regex = trigger_action.regex + local candidate = regex ~= nil + or ( + trigger_action.trigger_character ~= nil and trigger_action.trigger_character == context.trigger_character - end + ) + + -- `available` runs before the regex, because it is cheap, and a regex can take long on a long line + if candidate and (not trigger_action.available or trigger_action.available(self, context)) then + local edit_start = nil + if regex then + matches = matches or {} + if matches[regex] == nil then + matches[regex] = context.line and regex:match_str(context.line) or false + end + edit_start = matches[regex] or nil + end - if matched and (not trigger_action.available or trigger_action.available(self, context)) then - table.insert(routes, { trigger_action = trigger_action, edit_start = edit_start }) + if not regex or edit_start then + table.insert(routes, { trigger_action = trigger_action, edit_start = edit_start }) + end end end return routes end -function Core:_run_trigger_actions(trigger_character, callback, context, git_info) +---@param routes? cmp_git.Route[] The routes of the context, when the caller has them. +function Core:_run_trigger_actions(trigger_character, callback, context, git_info, routes) context = context or {} context.trigger_character = trigger_character context.original_callback = callback local wrapped_callback = self:_wrap_callback(context, callback) - for _, route in ipairs(self:_route(context)) do + for _, route in ipairs(routes or self:_route(context)) do -- Set before the action runs, because a cache hit calls the callback synchronously context.edit_start = route.edit_start local result, job = route.trigger_action.action(self, wrapped_callback, context, git_info, trigger_character) @@ -306,7 +332,8 @@ function Core:_run_trigger_actions(trigger_character, callback, context, git_inf end end -function Core:_complete(context, callback) +---@param routes? cmp_git.Route[] The routes of the context, when the caller has them. +function Core:_complete(context, callback, routes) local state = { cancelled = false, active = nil } local controller = { command = "cmp_git_complete", @@ -325,7 +352,7 @@ function Core:_complete(context, callback) if state.cancelled then return end - state.active = self:_run_trigger_actions(context.trigger_character, callback, context, git_info) + state.active = self:_run_trigger_actions(context.trigger_character, callback, context, git_info, routes) end, }) @@ -344,7 +371,9 @@ function Core:complete(context, callback) end, } - if #self:_route(context) == 0 then + -- Routing once for each request matches each regex once + local routes = self:_route(context) + if #routes == 0 then callback({ items = {}, isIncomplete = false }) return controller end @@ -356,7 +385,7 @@ function Core:complete(context, callback) if not is_git_repo then return end - state.active = self:_complete(context, callback) + state.active = self:_complete(context, callback, routes) end) return controller diff --git a/tests/test_core.lua b/tests/test_core.lua index 9406013..04bb3f5 100644 --- a/tests/test_core.lua +++ b/tests/test_core.lua @@ -269,6 +269,67 @@ T["trailer_routing"] = function() end end +---Replace the regexes of the trigger actions with wrappers that count the matches. +---@param core table +---@return table counts By the name of the first action of each regex. +local function count_regex_matches(core) + local counts = {} + local wrappers = {} + for _, trigger_action in ipairs(core.trigger_actions) do + local regex = trigger_action.regex + if regex then + if not wrappers[regex] then + local name = trigger_action.debug_name + counts[name] = 0 + wrappers[regex] = { + match_str = function(_, line) + counts[name] = counts[name] + 1 + return regex:match_str(line) + end, + } + end + trigger_action.regex = wrappers[regex] + end + end + return counts +end + +T["routing_checks_availability_first_and_matches_each_regex_once"] = function() + local core = Core.new({}) + local counts = count_regex_matches(core) + local line = "Co-authored-by: Ad" + + local octo = helpers.buffer({ "title", "", line }, "octo") + eq(#core:_route({ cursor = { row = 3, character = #line }, line = line, bufnr = octo }), 0, "no trailers in octo") + eq(counts, { git_trailer_keys = 0, git_trailer_identities = 0 }, "unavailable actions match no regex") + + local bufnr = helpers.buffer({ "feat: subject", "", line }) + local context = { cursor = { row = 3, character = #line }, line = line, bufnr = bufnr } + local routes = core:_route(context) + eq(#routes, 2, "identities and values routes") + eq(routes[1].edit_start, 16, "identities edit start") + eq(routes[2].edit_start, 16, "values edit start") + eq(counts, { git_trailer_keys = 1, git_trailer_identities = 1 }, "the value actions share one match") + + helpers.stub(repository, "is_git_repo", function(on_result) + on_result(true) + end) + helpers.stub(repository, "discover", function(_, opts) + opts.on_complete({}) + end) + core.sources.git = { + get_trailer_identities = function() + return true + end, + may_have_trailer_values = function() + return true + end, + } + counts.git_trailer_keys, counts.git_trailer_identities = 0, 0 + core:complete(context, function() end) + eq(counts, { git_trailer_keys = 1, git_trailer_identities = 1 }, "a request matches each regex once") +end + T["core_pre_route_skips_repository"] = function() local checks = 0 helpers.stub(repository, "is_git_repo", function() From 113c8e1fbb7c27894392b55613326be7a061f756 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:48:30 +1100 Subject: [PATCH 18/37] fix: answer requests that no action handles Core never called back when the directory was not a git repository, or when every matched action declined the request, for example `#`, `@`, or `!` with a Gitea or Bitbucket remote, or with no remote. An unknown action name had the same result. blink.cmp then held back the items of all sources until its timeout of 2 s, and nvim-cmp waited when cmp-git was its first source. Core now sends an empty final list in these cases, unless the request was cancelled. --- lua/cmp_git/core.lua | 13 +++++++- tests/test_core.lua | 71 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index 7b917df..f8425d0 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -10,6 +10,13 @@ local ROUTE_HANDLED = "handled" local ROUTE_UNSUPPORTED_HOST = "unsupported_host" local ROUTE_UNAVAILABLE = "unavailable" +---A final list without items. The completion engines wait for a final list: blink.cmp holds back the items of all +---sources until its timeout, and nvim-cmp waits when cmp-git is its first source. +---@return cmp_git.CompletionList +local function empty_list() + return { items = {}, isIncomplete = false } +end + local function route_status(handled, job) if handled then return ROUTE_HANDLED, job @@ -330,6 +337,9 @@ function Core:_run_trigger_actions(trigger_character, callback, context, git_inf return job end end + + -- No action handled the request, for example `#` with a remote on another host + callback(empty_list()) end ---@param routes? cmp_git.Route[] The routes of the context, when the caller has them. @@ -374,7 +384,7 @@ function Core:complete(context, callback) -- Routing once for each request matches each regex once local routes = self:_route(context) if #routes == 0 then - callback({ items = {}, isIncomplete = false }) + callback(empty_list()) return controller end @@ -383,6 +393,7 @@ function Core:complete(context, callback) return end if not is_git_repo then + callback(empty_list()) return end state.active = self:_complete(context, callback, routes) diff --git a/tests/test_core.lua b/tests/test_core.lua index 04bb3f5..d0701c8 100644 --- a/tests/test_core.lua +++ b/tests/test_core.lua @@ -360,6 +360,77 @@ T["core_pre_route_skips_repository"] = function() eq(checks, 1, "matched request checks the repository") end +---A request for a trigger character on line 1 of a scratch buffer. +---@param trigger string +---@return table context +local function trigger_context(trigger) + local line = "see " .. trigger + return { + trigger_character = trigger, + cursor = { row = 1, character = #line }, + line = line, + bufnr = helpers.buffer({ line }), + } +end + +---@param git_info cmp_git.GitInfo +local function stub_repository(git_info) + helpers.stub(repository, "is_git_repo", function(on_result) + on_result(true) + end) + helpers.stub(repository, "discover", function(_, opts) + opts.on_complete(git_info) + end) +end + +---@param lists cmp_git.CompletionList[] +---@param reason string +local function expect_one_empty_list(lists, reason) + eq(#lists, 1, reason .. ": one callback") + eq(#lists[1].items, 0, reason .. ": no items") + eq(lists[1].isIncomplete, false, reason .. ": final list") +end + +T["complete_answers_when_no_action_handles_the_request"] = function() + helpers.stub(repository, "is_git_repo", function(on_result) + on_result(false) + end) + local core = Core.new({}) + local callback, lists = helpers.recorder() + core:complete(trigger_context("#"), callback) + expect_one_empty_list(lists, "outside a git repository") + + -- Neither GitHub nor GitLab handles these hosts, so the sources decline without a process + for _, git_info in ipairs({ { host = "gitea.com", owner = "owner", repo = "repo" }, {} }) do + stub_repository(git_info) + for _, trigger in ipairs({ "#", "@", "!" }) do + callback, lists = helpers.recorder() + core:complete(trigger_context(trigger), callback) + expect_one_empty_list(lists, string.format("%s with host %s", trigger, git_info.host)) + end + end + + local unknown = Core.new({ trigger_actions = { { trigger_character = "#", actions = { "no_such_action" } } } }) + callback, lists = helpers.recorder() + unknown:complete(trigger_context("#"), callback) + expect_one_empty_list(lists, "unknown action") +end + +T["complete_does_not_answer_after_cancel"] = function() + local pending + helpers.stub(repository, "is_git_repo", function(on_result) + pending = on_result + end) + local core = Core.new({}) + local callback, lists = helpers.recorder() + + local controller = core:complete(trigger_context("#"), callback) + controller:cancel() + pending(false) + + eq(#lists, 0, "cancelled request does not call back") +end + T["trailer_text_edits"] = function() local core = Core.new({}) local bufnr = helpers.buffer({ "subject", "", "" }) From 6b0d4f37e39cd6b0a5f65ad19dddcacd210a22b9 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:49:55 +1100 Subject: [PATCH 19/37] perf: skip remote discovery when no action needs the remote Each request ran `git config` for each configured remote before it ran the actions, but the commit and trailer actions read only the local repository. A request for them started up to two processes that it did not need. Core now discovers the remote only when an action that can run needs it. The git actions always handle the request, so the actions after them do not count. Legacy `action` entries still get the remote. --- lua/cmp_git/core.lua | 50 +++++++++++++++++++++++---- tests/test_core.lua | 80 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index f8425d0..c900f57 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -67,6 +67,14 @@ local builtin_trigger_actions = { end, } +-- Built-in actions that read only the local repository. They need no remote, and they always handle the request. +local LOCAL_ACTIONS = { + git_commits = true, + git_trailer_keys = true, + git_trailer_identities = true, + git_trailer_values = true, +} + -- Synchronous checks that run before repository discovery, so that a declined request starts no git process. local builtin_trigger_availability = { git_trailer_keys = function(core, context) @@ -99,6 +107,8 @@ end ---@field regex? any The compiled `pattern`. Entries with the same pattern share it. ---@field keyword_pattern? string ---@field available? fun(core: table, context: table): boolean +---@field needs_remote boolean The action reads `git_info`. +---@field always_handles boolean The action never declines the request, so the actions after it never run. ---@field action fun(core: table, callback: fun(list: cmp_git.CompletionList), context: table, git_info: cmp_git.GitInfo, trigger_char: string?): string, table? ---@param configured_action table @@ -114,6 +124,8 @@ local function normalize_trigger_action(configured_action, regexes) { debug_name = configured_action.debug_name, trigger_character = configured_action.trigger_character, + needs_remote = true, + always_handles = false, action = function(core, _callback, context, git_info, trigger_char) return route_status( configured_action.action( @@ -159,6 +171,8 @@ local function normalize_trigger_action(configured_action, regexes) keyword_pattern = keyword_pattern, regex = regex, available = builtin_trigger_availability[action_name], + needs_remote = action ~= nil and not LOCAL_ACTIONS[action_name], + always_handles = LOCAL_ACTIONS[action_name] == true, action = function(core, callback, context, git_info, trigger_char) if not action then return ROUTE_UNAVAILABLE @@ -342,8 +356,24 @@ function Core:_run_trigger_actions(trigger_character, callback, context, git_inf callback(empty_list()) end +---Whether an action that can run reads `git_info`. +---@param routes cmp_git.Route[] +---@return boolean +local function routes_need_remote(routes) + for _, route in ipairs(routes) do + if route.trigger_action.needs_remote then + return true + end + if route.trigger_action.always_handles then + return false + end + end + return false +end + ---@param routes? cmp_git.Route[] The routes of the context, when the caller has them. function Core:_complete(context, callback, routes) + routes = routes or self:_route(context) local state = { cancelled = false, active = nil } local controller = { command = "cmp_git_complete", @@ -355,15 +385,23 @@ function Core:_complete(context, callback, routes) end, } + local function run(git_info) + if state.cancelled then + return + end + state.active = self:_run_trigger_actions(context.trigger_character, callback, context, git_info, routes) + end + + -- Discovery starts a git process for each remote that it checks + if not routes_need_remote(routes) then + run({}) + return controller + end + repository.discover(self.config.remotes, { enableRemoteUrlRewrites = self.config.enableRemoteUrlRewrites, ssh_aliases = self.config.ssh_aliases, - on_complete = function(git_info) - if state.cancelled then - return - end - state.active = self:_run_trigger_actions(context.trigger_character, callback, context, git_info, routes) - end, + on_complete = run, }) return controller diff --git a/tests/test_core.lua b/tests/test_core.lua index d0701c8..23247c5 100644 --- a/tests/test_core.lua +++ b/tests/test_core.lua @@ -431,6 +431,86 @@ T["complete_does_not_answer_after_cancel"] = function() eq(#lists, 0, "cancelled request does not call back") end +---Stub the repository checks and the sources, and record the calls. +---@param core table +---@param is_git_repo boolean +---@return string[] calls +local function record_calls(core, is_git_repo) + local calls = {} + helpers.stub(repository, "is_git_repo", function(on_result) + table.insert(calls, "is_git_repo") + on_result(is_git_repo) + end) + helpers.stub(repository, "discover", function(_, opts) + table.insert(calls, "discover") + opts.on_complete({ host = "github.com", owner = "owner", repo = "repo" }) + end) + + local function record(name, handled) + return function() + table.insert(calls, name) + return handled + end + end + core.sources.git = { + get_commits = record("commits", true), + get_trailer_identities = record("identities", true), + may_have_trailer_values = function() + return true + end, + } + core.sources.gitlab = { + complete_issues = record("gitlab_issues", false), + complete_mentions = record("gitlab_mentions", false), + } + core.sources.github = { + complete_issues = record("github_issues", true), + complete_issues_and_change_requests = record("github_issues", true), + complete_mentions = record("github_mentions", true), + } + return calls +end + +T["complete_discovers_the_remote_only_for_actions_that_need_it"] = function() + local core = Core.new({}) + local calls = record_calls(core, true) + local function run(context) + for key in pairs(calls) do + calls[key] = nil + end + core:complete(context, function() end) + return table.concat(calls, ",") + end + + eq(run(trigger_context(":")), "is_git_repo,commits", "commits need no remote") + eq(run(trigger_context("#")), "is_git_repo,discover,gitlab_issues,github_issues", "issues need the remote") + + -- The identities action handles the request, so the mention actions after it never run + local line = "Co-authored-by: Ada Date: Thu, 22 Oct 2026 20:50:02 +1100 Subject: [PATCH 20/37] fix: complete in octo buffers outside a git repository Octo buffers name their repository in the buffer name, and discovery reads it from there, but core first required a local git repository. Issues, pull requests, and mentions did not complete in an octo buffer that was opened outside a repository. Core now skips the repository check in octo buffers when an action that can run needs the remote. The commit and trailer actions still need a local repository. --- lua/cmp_git/core.lua | 14 ++++++++++++++ tests/test_core.lua | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index c900f57..1cb38b2 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -371,6 +371,13 @@ local function routes_need_remote(routes) return false end +---@param bufnr? integer +---@return boolean +local function is_octo_buffer(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + return vim.api.nvim_buf_is_valid(bufnr) and vim.bo[bufnr].filetype == "octo" +end + ---@param routes? cmp_git.Route[] The routes of the context, when the caller has them. function Core:_complete(context, callback, routes) routes = routes or self:_route(context) @@ -426,6 +433,13 @@ function Core:complete(context, callback) return controller end + -- Discovery reads the repository of an octo buffer from the buffer name, so the actions that need a remote work + -- outside a local repository + if is_octo_buffer(context.bufnr) and routes_need_remote(routes) then + state.active = self:_complete(context, callback, routes) + return controller + end + repository.is_git_repo(function(is_git_repo) if state.cancelled then return diff --git a/tests/test_core.lua b/tests/test_core.lua index 23247c5..f353dc6 100644 --- a/tests/test_core.lua +++ b/tests/test_core.lua @@ -511,6 +511,28 @@ T["complete_discovers_the_remote_only_for_actions_that_need_it"] = function() eq(run(trigger_context("!")), "is_git_repo,discover,github_issues", "unknown actions do not stop discovery") end +T["complete_in_octo_buffers_outside_a_git_repository"] = function() + local core = Core.new({}) + local calls = record_calls(core, false) + local function run(trigger) + for key in pairs(calls) do + calls[key] = nil + end + local context = trigger_context(trigger) + vim.bo[context.bufnr].filetype = "octo" + local callback, lists = helpers.recorder() + core:complete(context, callback) + return table.concat(calls, ","), lists + end + + eq(run("#"), "discover,gitlab_issues,github_issues", "octo issues skip the repository check") + eq(run("@"), "discover,gitlab_mentions,github_mentions", "octo mentions skip the repository check") + + local commits, lists = run(":") + eq(commits, "is_git_repo", "octo commits need a local repository") + eq(#lists, 1, "octo commits outside a repository answer") +end + T["trailer_text_edits"] = function() local core = Core.new({}) local bufnr = helpers.buffer({ "subject", "", "" }) From 904532f98c7ae5b51aaed345b4b5ab99c54436ee Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:52:35 +1100 Subject: [PATCH 21/37] fix: give each request its own completion item tables The issue, pull request, and mention capabilities give the same cached item tables to each request, and core wrote the text edit into them. blink.cmp writes `pos` and `score_offset` into the items that it gets. On a later request at another column, blink.cmp moved the edit by the old position and replaced the wrong range, and a configured score offset grew with each request. Core now gives each request shallow copies of the items with a new text edit. The items of one response share one range, so a request costs no more time than before and allocates less. --- lua/cmp_git/core.lua | 60 +++++++++++++++++++++++++++----------------- tests/test_core.lua | 50 ++++++++++++++++++++++++++++++++---- 2 files changed, 82 insertions(+), 28 deletions(-) diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index 1cb38b2..8d0d253 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -262,37 +262,51 @@ local function find_trigger_start(line, trigger_character, cursor_character) return trigger_start or cursor_character end +---Copy the items, and give each copy a text edit that replaces the typed text. The capabilities give the same cached +---items to each request, and blink.cmp writes into the items that it gets (`pos` and `score_offset`), so each request +---needs its own tables. +---@param items? cmp_git.CompletionItem[] +---@param context? table +---@return cmp_git.CompletionItem[] function Core.apply_text_edits(items, context) - if not context or not context.cursor then - return - end - - local row = context.cursor.row or context.cursor.line or 1 - local character = context.cursor.character or context.cursor.col or 0 - local start_character = context.edit_start or find_trigger_start(context.line, context.trigger_character, character) - - for _, item in ipairs(items or {}) do - local new_text = item.insertText or item.label - item.textEdit = { - range = { - start = { - line = row - 1, - character = start_character, - }, - ["end"] = { - line = row - 1, - character = character, - }, + local range = nil + if context and context.cursor then + local row = context.cursor.row or context.cursor.line or 1 + local character = context.cursor.character or context.cursor.col or 0 + local start_character = context.edit_start + or find_trigger_start(context.line, context.trigger_character, character) + -- One range for all the items, which halves the cost. blink.cmp copies a range before it changes it, and + -- nvim-cmp shares equal ranges between its entries anyway. + range = { + start = { + line = row - 1, + character = start_character, + }, + ["end"] = { + line = row - 1, + character = character, }, - newText = new_text, } end + + local edited = {} + for index, item in ipairs(items or {}) do + local copy = {} + for key, value in pairs(item) do + copy[key] = value + end + if range then + copy.textEdit = { range = range, newText = item.insertText or item.label } + end + edited[index] = copy + end + + return edited end function Core:_wrap_callback(context, callback) return function(list) - Core.apply_text_edits(list.items, context) - callback(list) + callback({ items = Core.apply_text_edits(list.items, context), isIncomplete = list.isIncomplete }) end end diff --git a/tests/test_core.lua b/tests/test_core.lua index f353dc6..633a25f 100644 --- a/tests/test_core.lua +++ b/tests/test_core.lua @@ -12,9 +12,10 @@ local ok = helpers.ok local T = helpers.new_set() T["git_commit_edit_range_is_preserved"] = function() - local items = { { insertText = "abcdef1" } } - - Core.apply_text_edits(items, { cursor = { row = 3, character = 5 }, line = ":abcd", trigger_character = ":" }) + local items = Core.apply_text_edits( + { { insertText = "abcdef1" } }, + { cursor = { row = 3, character = 5 }, line = ":abcd", trigger_character = ":" } + ) eq(items[1].textEdit.range.start.line, 2, "commit edit range start line") eq(items[1].textEdit.range.start.character, 0, "commit edit range start character") @@ -32,8 +33,7 @@ T["core_text_edit_ranges_for_all_triggers"] = function() } for _, case in ipairs(cases) do - local items = { { insertText = case.insert } } - Core.apply_text_edits(items, { + local items = Core.apply_text_edits({ { insertText = case.insert } }, { cursor = { row = 1, character = case.cursor }, line = case.line, trigger_character = case.trigger, @@ -46,6 +46,46 @@ T["core_text_edit_ranges_for_all_triggers"] = function() end end +T["requests_get_new_item_tables"] = function() + local data = { number = 12 } + local cached = { { label = "#12: Fix", insertText = "#12", data = data } } + local core = Core.new({}) + core.sources.gitlab = { + complete_issues = function() + return false + end, + } + core.sources.github = { + -- Like a cache hit of the issues capability + complete_issues_and_change_requests = function(_, callback) + callback({ items = cached, isIncomplete = false }) + return true + end, + } + local callback, lists = helpers.recorder() + local function request(line) + core:_run_trigger_actions("#", callback, { cursor = { row = 1, character = #line }, line = line }, {}) + return lists[#lists].items[1] + end + + local first = request("see #1") + -- blink.cmp writes these into the items that it gets + first.pos = { 0, 6 } + first.score_offset = 3 + local second = request("x #") + + ok(first ~= cached[1] and second ~= first, "each request gets new item tables") + eq(cached[1].textEdit, nil, "the cached item has no text edit") + eq(first.textEdit.range["end"].character, 6, "a later request does not change the first text edit") + eq(second.textEdit.range.start.character, 2, "the second text edit starts at its trigger") + eq(second.textEdit.range["end"].character, 3, "the second text edit ends at its cursor") + eq(second.pos, nil, "the second item has no position of the first request") + eq(second.score_offset, nil, "the score offset does not grow") + eq(second.label, "#12: Fix", "the copy keeps the label") + ok(second.data == data, "the copy keeps the data") + eq(lists[2].isIncomplete, false, "the list keeps isIncomplete") +end + T["custom_trigger_actions_remove_trailers"] = function() local core = Core.new({ trigger_actions = { From eadeffdac437dccd0d40e46723e75b5bdf351192 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:53:54 +1100 Subject: [PATCH 22/37] fix: use byte columns for the nvim-cmp text edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nvim-cmp gives the cursor as a UTF-16 index in `cursor.character`, and it reads the text edit ranges as UTF-16 unless the source names another encoding. cmp-git found the start of the edit in bytes, so a multibyte character before the cursor moved the edit: `für #12` became `für ##123`, and `héllo @ab` became `héllo @@login`. The nvim-cmp source now reports `utf-8` in `get_position_encoding_kind`, and takes the cursor from the byte column `cursor.col`. The blink.cmp path already used bytes. --- lua/cmp_git/core.lua | 18 ++++++++++-- lua/cmp_git/source.lua | 21 ++++++++++++-- tests/test_source.lua | 64 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index 8d0d253..2b9da8a 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -6,6 +6,16 @@ local log = require("cmp_git.log") local repository = require("cmp_git.repository") local trailers = require("cmp_git.provider.capabilities.trailers") +---A completion request from the nvim-cmp or blink.cmp adapter. +---@class cmp_git.CompletionContext +---@field trigger_character? string +---@field cursor? { row: integer, character: integer } A 1-based row and a 0-based byte column. The text edits use bytes, and the nvim-cmp source says so in `get_position_encoding_kind`. +---@field line? string The text before the cursor. +---@field bufnr? integer +---@field params? cmp_git.CmpCompletionParams The nvim-cmp parameters, for legacy `action` entries. +---@field original_callback? fun(list: cmp_git.CompletionList) +---@field edit_start? integer The match start of the pattern entry that runs. + local ROUTE_HANDLED = "handled" local ROUTE_UNSUPPORTED_HOST = "unsupported_host" local ROUTE_UNAVAILABLE = "unavailable" @@ -266,13 +276,13 @@ end ---items to each request, and blink.cmp writes into the items that it gets (`pos` and `score_offset`), so each request ---needs its own tables. ---@param items? cmp_git.CompletionItem[] ----@param context? table +---@param context? cmp_git.CompletionContext ---@return cmp_git.CompletionItem[] function Core.apply_text_edits(items, context) local range = nil if context and context.cursor then - local row = context.cursor.row or context.cursor.line or 1 - local character = context.cursor.character or context.cursor.col or 0 + local row = context.cursor.row + local character = context.cursor.character local start_character = context.edit_start or find_trigger_start(context.line, context.trigger_character, character) -- One range for all the items, which halves the cost. blink.cmp copies a range before it changes it, and @@ -428,6 +438,8 @@ function Core:_complete(context, callback, routes) return controller end +---@param context cmp_git.CompletionContext +---@param callback fun(list: cmp_git.CompletionList) function Core:complete(context, callback) local state = { cancelled = false, active = nil } local controller = { diff --git a/lua/cmp_git/source.lua b/lua/cmp_git/source.lua index 8fd2f95..1f868df 100644 --- a/lua/cmp_git/source.lua +++ b/lua/cmp_git/source.lua @@ -2,7 +2,7 @@ local Core = require("cmp_git.core") ---The fields of nvim-cmp's `cmp.SourceCompletionApiParams` that cmp-git reads. ---@class cmp_git.CmpCompletionParams ----@field context { cursor_before_line: string, cursor: { row: integer, col: integer, character: integer } } +---@field context { cursor_before_line: string, cursor: { row: integer, col: integer, character: integer } } `col` is a 1-based byte column, and `character` counts UTF-16 code units. ---@field completion_context { triggerKind: integer, triggerCharacter?: string } local Source = {} @@ -39,12 +39,20 @@ local function trigger_character_from_params(params, trigger_characters_str) return nil end +---@param cursor? { row: integer, col: integer } +---@return { row: integer, character: integer }? +local function byte_cursor(cursor) + return cursor and { row = cursor.row, character = cursor.col - 1 } +end + +---@param params cmp_git.CmpCompletionParams +---@return cmp_git.CompletionContext function Source:_context_from_params(params) local trigger_character = trigger_character_from_params(params, self.trigger_characters_str) return { trigger_character = trigger_character, - cursor = params.context.cursor, + cursor = byte_cursor(params.context.cursor), line = params.context.cursor_before_line, params = params, bufnr = vim.api.nvim_get_current_buf(), @@ -64,7 +72,7 @@ end function Source:_run_trigger_actions(trigger_character, callback, params, git_info) return self.core:_run_trigger_actions(trigger_character, callback, { trigger_character = trigger_character, - cursor = params and params.context and params.context.cursor, + cursor = params and params.context and byte_cursor(params.context.cursor), line = params and params.context and params.context.cursor_before_line, params = params, bufnr = vim.api.nvim_get_current_buf(), @@ -77,6 +85,13 @@ function Source:complete(params, callback) return self.core:complete(self:_context_from_params(params), callback) end +---nvim-cmp reads the text edit ranges as UTF-16 unless the source names another encoding. cmp-git finds the edit +---start in bytes. +---@return string +function Source:get_position_encoding_kind() + return "utf-8" +end + function Source:get_keyword_pattern() return self.core:get_keyword_pattern() end diff --git a/tests/test_source.lua b/tests/test_source.lua index 879d1c1..6c28ab3 100644 --- a/tests/test_source.lua +++ b/tests/test_source.lua @@ -1,5 +1,6 @@ local helpers = require("tests.helpers") local Source = require("cmp_git.source") +local repository = require("cmp_git.repository") local eq = helpers.eq local ok = helpers.ok @@ -173,6 +174,69 @@ T["legacy_trigger_action_arguments"] = function() ok(actual.git_info == git_info, "legacy trigger action receives git info") end +---A source whose GitHub actions answer from a fake cache, in a repository with a GitHub remote. +---@return table source +---@return string[] calls +local function github_source() + helpers.stub(repository, "is_git_repo", function(on_result) + on_result(true) + end) + helpers.stub(repository, "discover", function(_, opts) + opts.on_complete({ host = "github.com", owner = "owner", repo = "repo" }) + end) + + local source = Source.new({}) + local calls = {} + local function answer(name, insert) + return function(_, callback) + table.insert(calls, name) + callback({ items = { { label = insert, insertText = insert } }, isIncomplete = false }) + return true + end + end + source.sources.gitlab = { + complete_issues = function() + return false + end, + complete_mentions = function() + return false + end, + } + source.sources.github = { + complete_issues_and_change_requests = answer("issues", "#123"), + complete_mentions = answer("mentions", "@login"), + } + return source, calls +end + +---The nvim-cmp parameters of a request at the end of `line`. +---@param line string +---@param trigger_kind integer +---@return cmp_git.CmpCompletionParams +local function cmp_params(line, trigger_kind) + return { + context = { + cursor_before_line = line, + cursor = { row = 1, col = #line + 1, character = vim.str_utfindex(line, "utf-16") }, + }, + completion_context = { triggerKind = trigger_kind }, + } +end + +T["nvim_cmp_text_edits_use_bytes"] = function() + local source = github_source() + eq(source:get_position_encoding_kind(), "utf-8", "the source reports byte positions") + + for _, line in ipairs({ "für #12", "héllo @ab" }) do + local callback, lists = helpers.recorder() + source:complete(cmp_params(line, 1), callback) + + local range = lists[1].items[1].textEdit.range + eq(range.start.character, #line:match("^.* "), "edit starts at the trigger byte of " .. vim.inspect(line)) + eq(range["end"].character, #line, "edit ends at the cursor byte of " .. vim.inspect(line)) + end +end + T["source_new_uses_normalized_config"] = function() local source = Source.new({ github = { From d18665a4f7c172ae283d6fe24f201d49a5a2e2d5 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:54:07 +1100 Subject: [PATCH 23/37] fix: find the trigger character in incomplete-list requests When the user typed after an incomplete list, such as the first page of mentions, nvim-cmp asked again with the trigger kind TriggerForIncompleteCompletions. cmp-git read no trigger character for that kind, so the request got an empty, complete answer, and the list stopped updating while the mentions loaded. The source now reads the trigger character from the line for this kind, as it does for Invoked requests. --- lua/cmp_git/source.lua | 7 +++++-- tests/test_source.lua | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lua/cmp_git/source.lua b/lua/cmp_git/source.lua index 1f868df..6626301 100644 --- a/lua/cmp_git/source.lua +++ b/lua/cmp_git/source.lua @@ -30,9 +30,12 @@ end ---@param trigger_characters_str string ---@return string? local function trigger_character_from_params(params, trigger_characters_str) - if params.completion_context.triggerKind == 1 then + local trigger_kind = params.completion_context.triggerKind + -- nvim-cmp asks again with TriggerForIncompleteCompletions (3) when the user types after an incomplete list, such + -- as the first page of mentions. Like Invoked (1), it has no trigger character. + if trigger_kind == 1 or trigger_kind == 3 then return string.match(params.context.cursor_before_line, "%s*([" .. trigger_characters_str .. "])%S*$") - elseif params.completion_context.triggerKind == 2 then + elseif trigger_kind == 2 then return params.completion_context.triggerCharacter end diff --git a/tests/test_source.lua b/tests/test_source.lua index 6c28ab3..1c8cd0f 100644 --- a/tests/test_source.lua +++ b/tests/test_source.lua @@ -237,6 +237,17 @@ T["nvim_cmp_text_edits_use_bytes"] = function() end end +T["nvim_cmp_incomplete_list_requests_find_the_trigger"] = function() + local source, calls = github_source() + + -- TriggerForIncompleteCompletions, after the user typed `ad` behind an incomplete list of mentions + local callback, lists = helpers.recorder() + source:complete(cmp_params("cc @ad", 3), callback) + + eq(calls, { "mentions" }, "the request completes mentions") + eq(lists[1].items[1].label, "@login", "the request gets the mentions") +end + T["source_new_uses_normalized_config"] = function() local source = Source.new({ github = { From dbf32543b6aa2647757cfca0d5ebd23847f85e71 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:55:57 +1100 Subject: [PATCH 24/37] fix: escape the trigger characters in patterns Core put the trigger characters into a Vim regex collection, and the nvim-cmp and blink.cmp adapters put them into Lua pattern sets, without escaping. A trigger character such as `]`, `^`, or `-` changed the meaning of the pattern or made it invalid, and `%` made the Lua pattern throw an error. Core now escapes the characters for each pattern language, and both adapters find the trigger character with the same function. A config without trigger characters and patterns gets no keyword pattern, in place of an invalid one, so nvim-cmp uses its default. --- lua/cmp_git/blink.lua | 7 +------ lua/cmp_git/core.lua | 30 +++++++++++++++++++++++++++--- lua/cmp_git/source.lua | 8 ++++---- tests/test_blink.lua | 18 ++++++++++++++++++ tests/test_core.lua | 40 ++++++++++++++++++++++++++++++++++++++++ tests/test_source.lua | 23 +++++++++++++++++++++++ 6 files changed, 113 insertions(+), 13 deletions(-) diff --git a/lua/cmp_git/blink.lua b/lua/cmp_git/blink.lua index a60b1b3..9bc11a4 100644 --- a/lua/cmp_git/blink.lua +++ b/lua/cmp_git/blink.lua @@ -29,12 +29,7 @@ local function trigger_character_from_context(ctx, line, trigger_characters) return ctx.trigger_character end - if #trigger_characters == 0 then - return nil - end - - local escaped = table.concat(trigger_characters, "") - return string.match(line, "%s*([" .. escaped .. "])%S*$") + return Core.find_trigger_character(line, trigger_characters) end local function cursor_from_context(ctx) diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index 2b9da8a..b3ec49f 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -241,10 +241,20 @@ function Core.new(overrides) end self.trigger_characters_str = table.concat(self.trigger_characters, "") - self.keyword_pattern = string.format("[%s]\\S*", self.trigger_characters_str) + local trigger_pattern = nil + if #self.trigger_characters > 0 then + -- In a Vim collection, `]`, `^`, `-`, and `\` need a backslash. A backslash before another character would + -- add a backslash to the collection. + local collection = string.gsub(self.trigger_characters_str, "[%]%^%-\\]", "\\%0") + trigger_pattern = "[" .. collection .. [[]\S*]] + end + + -- nil when there is nothing to match, because an empty collection is an invalid regex. nvim-cmp then uses its + -- default keyword pattern. + self.keyword_pattern = trigger_pattern if #patterns > 0 then - if #self.trigger_characters > 0 then - table.insert(patterns, self.keyword_pattern) + if trigger_pattern then + table.insert(patterns, trigger_pattern) end self.keyword_pattern = [[\%(]] .. table.concat(patterns, [[\|]]) .. [[\)]] end @@ -252,6 +262,20 @@ function Core.new(overrides) return self end +---The trigger character that starts the last word of the text before the cursor. A request that the user did not +---start with a trigger character, such as one for the next character after `#`, has none. +---@param line string The text before the cursor. +---@param trigger_characters string[] +---@return string? +function Core.find_trigger_character(line, trigger_characters) + if #trigger_characters == 0 then + return nil + end + + -- `vim.pesc` escapes each character that is special in a Lua pattern set + return string.match(line, "([" .. vim.pesc(table.concat(trigger_characters)) .. "])%S*$") +end + local function find_trigger_start(line, trigger_character, cursor_character) if not line or not trigger_character then return cursor_character diff --git a/lua/cmp_git/source.lua b/lua/cmp_git/source.lua index 6626301..77a5fd8 100644 --- a/lua/cmp_git/source.lua +++ b/lua/cmp_git/source.lua @@ -27,14 +27,14 @@ function Source.new(overrides) end ---@param params cmp_git.CmpCompletionParams ----@param trigger_characters_str string +---@param trigger_characters string[] ---@return string? -local function trigger_character_from_params(params, trigger_characters_str) +local function trigger_character_from_params(params, trigger_characters) local trigger_kind = params.completion_context.triggerKind -- nvim-cmp asks again with TriggerForIncompleteCompletions (3) when the user types after an incomplete list, such -- as the first page of mentions. Like Invoked (1), it has no trigger character. if trigger_kind == 1 or trigger_kind == 3 then - return string.match(params.context.cursor_before_line, "%s*([" .. trigger_characters_str .. "])%S*$") + return Core.find_trigger_character(params.context.cursor_before_line, trigger_characters) elseif trigger_kind == 2 then return params.completion_context.triggerCharacter end @@ -51,7 +51,7 @@ end ---@param params cmp_git.CmpCompletionParams ---@return cmp_git.CompletionContext function Source:_context_from_params(params) - local trigger_character = trigger_character_from_params(params, self.trigger_characters_str) + local trigger_character = trigger_character_from_params(params, self.core:get_trigger_characters()) return { trigger_character = trigger_character, diff --git a/tests/test_blink.lua b/tests/test_blink.lua index 53ff9c0..e74f583 100644 --- a/tests/test_blink.lua +++ b/tests/test_blink.lua @@ -78,6 +78,24 @@ T["blink_adapter_suppresses_stale_callbacks_after_cancel"] = function() eq(called, false, "blink suppresses stale callback after cancellation") end +T["blink_adapter_special_trigger_characters"] = function() + local blink = Blink.new({}) + local received + blink.core = { + get_trigger_characters = function() + return { "%", "]", "-" } + end, + complete = function(_, context, _callback) + received = context + return { cancel = function() end } + end, + } + + blink:get_completions({ cursor = { 1, 7 }, line = "see %ab", bufnr = 1 }, function() end) + + eq(received.trigger_character, "%", "blink detects a special trigger character") +end + T["blink_adapter_matches_text_before_cursor"] = function() local blink = Blink.new({}) local received diff --git a/tests/test_core.lua b/tests/test_core.lua index 633a25f..6042e8e 100644 --- a/tests/test_core.lua +++ b/tests/test_core.lua @@ -97,6 +97,46 @@ T["custom_trigger_actions_remove_trailers"] = function() eq(core.keyword_pattern, "[:]\\S*", "keyword pattern without pattern entries is unchanged") end +-- Characters that are special in a Vim collection or a Lua pattern set. `^` comes first, where it would negate the +-- set, and `-` between `#` and `@` would make a range that contains `+`. +local SPECIAL_TRIGGER_CHARACTERS = { "^", "#", "-", "@", "%", "]", "\\", "[" } + +---@param characters string[] +---@return table core +local function core_with_triggers(characters) + local trigger_actions = {} + for _, character in ipairs(characters) do + table.insert(trigger_actions, { trigger_character = character, actions = { "git_commits" } }) + end + return Core.new({ trigger_actions = trigger_actions }) +end + +T["trigger_characters_are_escaped"] = function() + local core = core_with_triggers(SPECIAL_TRIGGER_CHARACTERS) + local regex = vim.regex([[\%(]] .. core.keyword_pattern .. [[\)\m$]]) + + for _, character in ipairs(SPECIAL_TRIGGER_CHARACTERS) do + local line = "see " .. character .. "ab" + eq(regex:match_str(line), 4, "keyword offset of " .. vim.inspect(line)) + eq( + Core.find_trigger_character(line, core:get_trigger_characters()), + character, + "trigger character of " .. vim.inspect(line) + ) + end + for _, line in ipairs({ "see ab", "see +ab" }) do + eq(regex:match_str(line), nil, "no keyword offset for " .. vim.inspect(line)) + end + eq(Core.find_trigger_character("see +ab", core:get_trigger_characters()), nil, "no trigger in a range") + + local brackets = core_with_triggers({ "[", "]" }) + eq(vim.regex(brackets.keyword_pattern):match_str("see \\ab"), nil, "brackets do not add a backslash") + + local without_triggers = Core.new({ trigger_actions = {} }) + eq(without_triggers:get_keyword_pattern(), nil, "no keyword pattern without trigger characters") + eq(Core.find_trigger_character("see #ab", {}), nil, "no trigger character without trigger characters") +end + T["invalid_trigger_action_pattern_is_skipped"] = function() local core = Core.new({ trigger_actions = { diff --git a/tests/test_source.lua b/tests/test_source.lua index 1c8cd0f..2d0bbb1 100644 --- a/tests/test_source.lua +++ b/tests/test_source.lua @@ -248,6 +248,29 @@ T["nvim_cmp_incomplete_list_requests_find_the_trigger"] = function() eq(lists[1].items[1].label, "@login", "the request gets the mentions") end +T["nvim_cmp_special_trigger_characters"] = function() + local source = Source.new({ + trigger_actions = { + { trigger_character = "%", actions = { "github_issues" } }, + { trigger_character = "]", actions = { "github_issues" } }, + }, + }) + local triggers = {} + source.sources.github = { + complete_issues = function(_, _, _, trigger_char) + table.insert(triggers, trigger_char) + return true + end, + } + + for _, line in ipairs({ "see %ab", "see ]ab" }) do + local context = source:_context_from_params(cmp_params(line, 1)) + source:_run_trigger_actions(context.trigger_character, function() end, cmp_params(line, 1), {}) + end + + eq(triggers, { "%", "]" }, "special trigger characters route") +end + T["source_new_uses_normalized_config"] = function() local source = Source.new({ github = { From b7866545306a2eac3bcde7c8ab2c6b3555b08c66 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:56:26 +1100 Subject: [PATCH 25/37] fix: send blink.cmp one response for each request blink.cmp adds the items of each response of a request to the items of the earlier responses. A request that called back more than once, such as mentions that load a first page and then the full list, showed each item of the first page twice. The blink.cmp adapter now forwards only the first response of a request. blink.cmp keeps `is_incomplete_forward` of the first response, so an incomplete first page makes it ask again on the next keystroke, and that request gets the cached items. blink.cmp cancels the earlier request when it asks again, so a request that has answered is not cancelled: it continues to fill the cache. --- lua/cmp_git/blink.lua | 13 +++++++++++-- tests/test_blink.lua | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/lua/cmp_git/blink.lua b/lua/cmp_git/blink.lua index 9bc11a4..923c0a9 100644 --- a/lua/cmp_git/blink.lua +++ b/lua/cmp_git/blink.lua @@ -52,6 +52,7 @@ end function Blink:get_completions(ctx, callback) local cancelled = false + local responded = false local cursor = cursor_from_context(ctx) -- blink.cmp gives the full line in `ctx.line`. Pattern trigger actions must match up to the cursor. local line = ctx.cursor_before_line or string.sub(ctx.line or vim.api.nvim_get_current_line(), 1, cursor.character) @@ -61,9 +62,14 @@ function Blink:get_completions(ctx, callback) line = line, bufnr = ctx.bufnr or vim.api.nvim_get_current_buf(), }, function(list) - if cancelled then + -- blink.cmp adds the items of each response of a request to the items of the earlier responses, but each list + -- of cmp-git repeats the earlier items, for example the next page of mentions. Only the first response is + -- needed: blink.cmp keeps its `is_incomplete_forward`, so it asks again on the next keystroke, and that request + -- gets the cached items. + if cancelled or responded then return end + responded = true callback({ items = list.items, @@ -72,9 +78,12 @@ function Blink:get_completions(ctx, callback) }) end) + -- blink.cmp cancels a request when it asks again, which it does on each keystroke after an incomplete response. + -- After the first response, the request only fills the cache that the next request reads, for example with the + -- next pages of mentions, so it continues. return function() cancelled = true - if controller and controller.cancel then + if not responded and controller and controller.cancel then controller:cancel() end end diff --git a/tests/test_blink.lua b/tests/test_blink.lua index e74f583..0c54656 100644 --- a/tests/test_blink.lua +++ b/tests/test_blink.lua @@ -46,13 +46,14 @@ T["blink_adapter_api_and_cancellation"] = function() eq(callback_result.is_incomplete_forward, true, "blink forwards incomplete state") cancel() - eq(cancelled, true, "blink cancellation delegates to core job") + eq(cancelled, false, "a request that answered continues to fill the cache") end T["blink_adapter_suppresses_stale_callbacks_after_cancel"] = function() local blink = Blink.new({}) local pending_callback local called = false + local cancelled = false blink.core = { is_available = function() return true @@ -62,7 +63,11 @@ T["blink_adapter_suppresses_stale_callbacks_after_cancel"] = function() end, complete = function(_, _context, callback) pending_callback = callback - return { cancel = function() end } + return { + cancel = function() + cancelled = true + end, + } end, } @@ -75,9 +80,35 @@ T["blink_adapter_suppresses_stale_callbacks_after_cancel"] = function() cancel() pending_callback({ items = { { label = "stale" } }, isIncomplete = false }) + eq(cancelled, true, "blink cancellation delegates to core job") eq(called, false, "blink suppresses stale callback after cancellation") end +T["blink_adapter_forwards_the_first_response_only"] = function() + local blink = Blink.new({}) + blink.core = { + get_trigger_characters = function() + return { "@" } + end, + -- Like the mentions capability: a first page, then the cached list with the next page + complete = function(_, _context, callback) + local first = { label = "@ada" } + callback({ items = { first }, isIncomplete = true }) + callback({ items = { first, { label = "@grace" } }, isIncomplete = false }) + return { cancel = function() end } + end, + } + local responses = {} + + blink:get_completions({ trigger = { character = "@" }, cursor = { 1, 4 }, line = "cc @" }, function(response) + table.insert(responses, response) + end) + + eq(#responses, 1, "blink gets one response for each request") + eq(#responses[1].items, 1, "the response has the first page") + eq(responses[1].is_incomplete_forward, true, "blink asks again on the next keystroke") +end + T["blink_adapter_special_trigger_characters"] = function() local blink = Blink.new({}) local received From c8d0088a7f71c08fda26bac8e1e1fcc3aaa13725 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 21:03:27 +1100 Subject: [PATCH 26/37] fix: discover the repository of the buffer that made the request Core called `repository.is_git_repo` and `repository.discover` without a buffer, so they read the buffer that was current when they ran. Discovery waits for git, and the user can switch buffers in that time, so a request could get the repository and remotes of another buffer. Core now passes the buffer of the request to both functions. --- lua/cmp_git/core.lua | 4 +++- tests/test_core.lua | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index b3ec49f..291756a 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -457,6 +457,7 @@ function Core:_complete(context, callback, routes) enableRemoteUrlRewrites = self.config.enableRemoteUrlRewrites, ssh_aliases = self.config.ssh_aliases, on_complete = run, + bufnr = context.bufnr, }) return controller @@ -490,6 +491,7 @@ function Core:complete(context, callback) return controller end + -- The buffer of the request, because the current buffer can change while git runs repository.is_git_repo(function(is_git_repo) if state.cancelled then return @@ -499,7 +501,7 @@ function Core:complete(context, callback) return end state.active = self:_complete(context, callback, routes) - end) + end, context.bufnr) return controller end diff --git a/tests/test_core.lua b/tests/test_core.lua index 6042e8e..3224509 100644 --- a/tests/test_core.lua +++ b/tests/test_core.lua @@ -663,4 +663,31 @@ T["trailer_text_edits"] = function() eq(attached_value.filterText, ": Codex", "attached trailer value filters on the colon") end +T["complete_discovers_the_repository_of_the_request_buffer"] = function() + local core = Core.new({}) + local request_bufnr = helpers.buffer({ "See #" }) + vim.api.nvim_set_current_buf(helpers.buffer({ "Another buffer" })) + + local seen = {} + helpers.stub(repository, "is_git_repo", function(on_result, bufnr) + seen.is_git_repo = bufnr + on_result(true) + end) + helpers.stub(repository, "discover", function(_, opts) + seen.discover = opts.bufnr + opts.on_complete({}) + end) + + local callback, lists = helpers.recorder() + core:complete({ + trigger_character = "#", + cursor = { row = 1, character = 5 }, + line = "See #", + bufnr = request_bufnr, + }, callback) + + eq(seen, { is_git_repo = request_bufnr, discover = request_bufnr }, "discovery reads the request buffer") + eq(#lists, 1, "the request answers") +end + return T From 2aadd15002af394fcb519304225e9336b66519e8 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:48:49 +1100 Subject: [PATCH 27/37] fix: trim long runs of whitespace in linear time utils.trim used the pattern ^%s*(.-)%s*$, which tests the end pattern at each position of a whitespace run. A run of 8000 spaces in the middle of a string took 183 ms, and a run of 32000 spaces took 2.9 s. The commit parser trims each subject and body, so one such commit blocked the editor. trim now finds the first and last non-space bytes with two loops, so it reads each byte at most once. The simpler pattern ^%s*(.*%S) is not used, because it is also quadratic when the string has only whitespace. The new utils.trim_range gives the same bounds for a range of a string, so that a parser can trim a field without a copy of it. --- lua/cmp_git/utils.lua | 26 +++++++++++++++++++++++++- tests/test_utils.lua | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/lua/cmp_git/utils.lua b/lua/cmp_git/utils.lua index db75e29..bb905fd 100644 --- a/lua/cmp_git/utils.lua +++ b/lua/cmp_git/utils.lua @@ -1,9 +1,33 @@ local M = {} +local byte = string.byte + +-- The bytes that `%s` matches +local SPACE = { [9] = true, [10] = true, [11] = true, [12] = true, [13] = true, [32] = true } + +---The bounds of `s:sub(first, last)` without its leading and trailing whitespace, so that a parser can trim a field +---without a copy of it. It reads each byte at most once, unlike patterns such as `^%s*(.-)%s*$` and `^%s*(.*%S)`, +---which take quadratic time on long runs of whitespace. +---@param s string +---@param first integer +---@param last integer +---@return integer first +---@return integer last Less than `first` when the range has only whitespace. +function M.trim_range(s, first, last) + while first <= last and SPACE[byte(s, first)] do + first = first + 1 + end + while last >= first and SPACE[byte(s, last)] do + last = last - 1 + end + return first, last +end + ---@param s string ---@return string function M.trim(s) - return (s:gsub("^%s*(.-)%s*$", "%1")) + local first, last = M.trim_range(s, 1, #s) + return string.sub(s, first, last) end ---Split NUL-terminated records. A trailing record without a NUL is dropped. diff --git a/tests/test_utils.lua b/tests/test_utils.lua index 2903abe..d35f991 100644 --- a/tests/test_utils.lua +++ b/tests/test_utils.lua @@ -2,6 +2,7 @@ local helpers = require("tests.helpers") local utils = require("cmp_git.utils") local eq = helpers.eq +local ok = helpers.ok local T = helpers.new_set() @@ -10,6 +11,47 @@ T["trim"] = function() eq(utils.trim(" "), "", "whitespace only") end +T["trim_matches_the_pattern_trim"] = function() + -- The pattern that `trim` used before, which is correct but quadratic + local function pattern_trim(s) + return (s:gsub("^%s*(.-)%s*$", "%1")) + end + + local cases = { + "", + " ", + "\t\n\v\f\r ", + "a", + " a", + "a ", + "\ta b\tc\n", + "\r\nline one\r\nline two\r\n", + " inner spaces ", + "\0 nul \0", + " \031unit separator\031 ", + " Ünïcödé ", + "a" .. string.rep(" ", 100) .. "b", + } + for _, s in ipairs(cases) do + eq(utils.trim(s), pattern_trim(s), "trim of " .. vim.inspect(s)) + end + + eq({ utils.trim_range(" ab ", 1, 6) }, { 3, 4 }, "range without the surrounding spaces") + eq({ utils.trim_range("x ab x", 2, 7) }, { 4, 5 }, "range inside a string") + local first, last = utils.trim_range("x x", 2, 5) + ok(first > last, "range with only spaces is empty") +end + +T["trim_takes_linear_time"] = function() + -- The old pattern took about 3 s for each of these strings + local run = string.rep(" ", 32000) + local start = os.clock() + eq(utils.trim("a" .. run .. "b"), "a" .. run .. "b", "inner spaces are kept") + eq(utils.trim(run), "", "only spaces") + eq(utils.trim(run .. "a" .. run), "a", "spaces around one character") + ok(os.clock() - start < 0.25, "trim of long runs of spaces is fast") +end + T["split_nul"] = function() eq(utils.split_nul("one\0\0two three\0"), { "one", "", "two three" }, "keeps empty records") eq(utils.split_nul("one\0dangling"), { "one" }, "drops a trailing record without a NUL") From 3cfd7e8dd60458249dc99f0c397f3e7bae8b2658 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:56:28 +1100 Subject: [PATCH 28/37] perf: parse the commit and trailer logs in place The first trailer identities request parsed 1000 commits in one block of 8 ms on the main thread, and allocated 954 KB. The values parse took 3.4 ms and 1 MB, and the commit parse took 7.3 ms. Each parser split the output into a table of records, split each record with vim.split, and trimmed each field with a pattern. The pattern (.-)%s*$ on the trailer values was also quadratic on long runs of spaces. The parsers now find the NUL and unit separator bytes with plain string.find and read each field in place, so that they make no table or string for each record. The identities parse skips a trailer value or an email that it saw before, because a repeat from the log changes nothing, and parses an identity with plain searches for < and >. A key is lowered once for each casing. The output does not change: a test compares the new parsers with copies of the old ones on generated logs with empty fields, spaces, CRLF, folded values, keys in different case, and duplicate emails. With the benchmark fixtures: identities_parse_1000 takes 0.6 ms and 73 KB, values_parse_1000 0.7 ms and 144 KB, commits parse_1000 0.5 ms and 270 KB, and keys_parse_100 34 us and 3 KB. --- lua/cmp_git/provider/capabilities/commits.lua | 75 +++-- .../provider/capabilities/trailers.lua | 248 +++++++++++---- tests/provider/capabilities/test_commits.lua | 103 ++++++ tests/provider/capabilities/test_trailers.lua | 296 ++++++++++++++++++ 4 files changed, 634 insertions(+), 88 deletions(-) diff --git a/lua/cmp_git/provider/capabilities/commits.lua b/lua/cmp_git/provider/capabilities/commits.lua index f774f76..93643f2 100644 --- a/lua/cmp_git/provider/capabilities/commits.lua +++ b/lua/cmp_git/provider/capabilities/commits.lua @@ -4,8 +4,12 @@ local utils = require("cmp_git.utils") local M = {} -local trim = utils.trim -local split_nul = utils.split_nul +local find = string.find +local sub = string.sub +local trim_range = utils.trim_range + +-- The NUL-terminated fields of each commit in the `M.request` format +local FIELDS = 6 ---@param config cmp_git.Config.GitCommits ---@return cmp_git.CommandSpec @@ -22,32 +26,63 @@ function M.request(config) } end +---@param s string +---@param first integer +---@param last integer +---@return string +local function trimmed(s, first, last) + return sub(s, trim_range(s, first, last)) +end + ---@param result string ---@param config cmp_git.Config.GitCommits ---@return cmp_git.Commit[] function M.parse(result, config) - local fields = split_nul(result) local commits = {} + local now = os.time() + local sha_length = config.sha_length + -- The position of the NUL after each field of the current commit. The fields are read in place, so that each + -- field is copied once, after the trim. + local stops = {} + local start = 1 - for i = 1, #fields, 6 do - if fields[i + 5] then - local timestamp = tonumber(trim(fields[i + 5])) or 0 - ---@class cmp_git.Commit - local commit = { - sha = trim(fields[i]):sub(1, config.sha_length), - title = trim(fields[i + 1]), - description = trim(fields[i + 2]), - author_name = fields[i + 3] or "", - author_mail = fields[i + 4] or "", - commit_timestamp = timestamp, - diff = os.difftime(os.time(), timestamp), - } - - table.insert(commits, commit) + while true do + local from = start + for field = 1, FIELDS do + local stop = find(result, "\0", from, true) + -- A trailing commit without all its fields is dropped + if not stop then + return commits + end + stops[field] = stop + from = stop + 1 end - end - return commits + -- `--pretty=format:` puts a newline between commits, so the sha starts with whitespace + local sha_first, sha_last = trim_range(result, start, stops[1] - 1) + local sha + if sha_length >= 0 then + sha = sub(result, sha_first, math.min(sha_last, sha_first + sha_length - 1)) + else + sha = sub(result, sha_first, sha_last):sub(1, sha_length) + end + + -- `tonumber` ignores surrounding whitespace + local timestamp = tonumber(sub(result, stops[5] + 1, stops[6] - 1)) or 0 + ---@class cmp_git.Commit + local commit = { + sha = sha, + title = trimmed(result, stops[1] + 1, stops[2] - 1), + description = trimmed(result, stops[2] + 1, stops[3] - 1), + author_name = sub(result, stops[3] + 1, stops[4] - 1), + author_mail = sub(result, stops[4] + 1, stops[5] - 1), + commit_timestamp = timestamp, + diff = os.difftime(now, timestamp), + } + commits[#commits + 1] = commit + + start = stops[FIELDS] + 1 + end end ---@param commits cmp_git.Commit[] diff --git a/lua/cmp_git/provider/capabilities/trailers.lua b/lua/cmp_git/provider/capabilities/trailers.lua index 3df9c70..dc650b5 100644 --- a/lua/cmp_git/provider/capabilities/trailers.lua +++ b/lua/cmp_git/provider/capabilities/trailers.lua @@ -5,6 +5,12 @@ local utils = require("cmp_git.utils") local UNIT_SEPARATOR = "\031" local KEY_PATTERN = "^[%w-]+$" +local find = string.find +local lower = string.lower +local match = string.match +local sub = string.sub +local trim_range = utils.trim_range + local M = { keys = {}, identities = {}, @@ -48,15 +54,66 @@ local function contains_key(keys, key) return false end +---Calls `on_field` for each field of `output`, a `git log` output whose records end with NUL and whose fields end +---with the unit separator or the NUL of the record. Each record has at least one field, which can be empty. A +---trailing record without a NUL is dropped, like `utils.split_nul` does. The fields are read in place, so that a +---parse makes no table or string for each record. +---@param output string +---@param on_field fun(first: integer, last: integer, index: integer) The field is `output:sub(first, last)`, and +---`index` counts the fields of the record from 1. +local function each_field(output, on_field) + local first = 1 + local index = 1 + local record_end = find(output, "\0", 1, true) + -- The next separator at or after `first`. It can be in a later record, so it is kept until a field reaches it, + -- and each byte is searched once. + local separator = 0 + + while record_end do + if separator < first then + separator = find(output, UNIT_SEPARATOR, first, true) or math.huge + end + + if separator < record_end then + on_field(first, separator - 1, index) + first = separator + 1 + index = index + 1 + else + on_field(first, record_end - 1, index) + first = record_end + 1 + index = 1 + record_end = find(output, "\0", first, true) + end + end +end + +---Parses the identity that `^%s*(.-)%s*<([^>]+)>` would match, without the whitespace around the name and the email. +---Unlike the pattern, which is quadratic on long runs of whitespace, it takes linear time. ---@param value string ----@return string? name ----@return string? email +---@return string? name nil when there is no identity. The name and the email can be empty. +---@return string email local function parse_ident(value) - local name, email = string.match(value, "^%s*(.-)%s*<([^>]+)>") - if not name or name == "" or utils.trim(email) == "" then - return nil, nil + local from = 1 + + while true do + local open = find(value, "<", from, true) + if not open then + return nil, "" + end + + local close = find(value, ">", open + 1, true) + -- A later `<` has no `>` after it either + if not close then + return nil, "" + end + + -- `[^>]+` needs one character, so `<>` is part of the name, and a later `<` can start the email + if close > open + 1 then + return sub(value, trim_range(value, 1, open - 1)), sub(value, trim_range(value, open + 1, close - 1)) + end + + from = close + 1 end - return name, utils.trim(email) end ---@param line string? @@ -148,21 +205,32 @@ function M.keys.parse(result, config) end end - for _, record in ipairs(utils.split_nul(result)) do - for _, key in ipairs(vim.split(record, UNIT_SEPARATOR, { plain = true })) do - key = utils.trim(key) - -- Skips git versions that print the unknown `%(trailers:keyonly)` placeholder as text - if key:match(KEY_PATTERN) then - local trailer = by_key[key:lower()] - if not trailer then - trailer = { key = key, count = 0 } - by_key[key:lower()] = trailer - table.insert(keys, trailer) - end - trailer.count = trailer.count + 1 - end + -- The lowercase form of each key as written, so that each casing is lowered once + local ids = {} + + each_field(result, function(first, last) + first, last = trim_range(result, first, last) + local _, key_last = find(result, "^[%w-]+", first) + -- Skips git versions that print the unknown `%(trailers:keyonly)` placeholder as text + if first > last or key_last ~= last then + return end - end + + local key = sub(result, first, last) + local id = ids[key] + if not id then + id = lower(key) + ids[key] = id + end + + local trailer = by_key[id] + if not trailer then + trailer = { key = key, count = 0 } + by_key[id] = trailer + keys[#keys + 1] = trailer + end + trailer.count = trailer.count + 1 + end) return keys end @@ -208,21 +276,25 @@ function M.identities.parse(log_result, ident_result) local now = os.time() local identities = {} local by_email = {} + -- The log repeats a few identities many times, and a repeat from the log changes nothing. These skip a repeat + -- before the parse of its trailer value or the lowercase copy of its email. + local known_values = {} + local known_emails = {} - ---@param name string - ---@param email string + ---@param name string Without surrounding whitespace. + ---@param email string Without surrounding whitespace. ---@param timestamp integer? ---@param source string ---@param is_self boolean local function add(name, email, timestamp, source, is_self) - name = utils.trim(name) - email = utils.trim(email) - if name == "" or email == "" then + if name == "" or email == "" or (known_emails[email] and not is_self) then return end + known_emails[email] = true -- The log is newest first, so the first record for an email has the most recent name - local existing = by_email[email:lower()] + local id = lower(email) + local existing = by_email[id] if existing then existing.is_self = existing.is_self or is_self return @@ -239,28 +311,51 @@ function M.identities.parse(log_result, ident_result) source = source, -- "author", the trailer key, or "self" is_self = is_self, } - by_email[email:lower()] = entry - table.insert(identities, entry) + by_email[id] = entry + identities[#identities + 1] = entry end - for _, record in ipairs(utils.split_nul(log_result)) do - local fields = vim.split(record, UNIT_SEPARATOR, { plain = true }) - if #fields >= 3 then - local timestamp = tonumber(utils.trim(fields[1])) - add(fields[2], fields[3], timestamp, "author", false) - - for i = 4, #fields do - local key, value = string.match(fields[i], "^%s*([%w-]+):%s*(.-)%s*$") - local name, email = parse_ident(value or "") - if name and email then - add(name, email, timestamp, key, false) - end - end + -- The fields of the current record: the time, the author name and email, then the trailers + local time_first, time_last, author_first, author_last = 1, 0, 1, 0 + local timestamp + + ---@param first integer + ---@param last integer + local function add_trailer(first, last) + local key, value_first = match(log_result, "^%s*([%w-]+):()", first) + if not key then + return + end + + local value = sub(log_result, value_first, last) + if known_values[value] then + return + end + known_values[value] = true + + local name, email = parse_ident(value) + if name then + add(name, email, timestamp, key, false) end end + each_field(log_result, function(first, last, index) + if index == 1 then + time_first, time_last = first, last + elseif index == 2 then + author_first, author_last = first, last + elseif index == 3 then + -- `tonumber` ignores the newline that ends the previous record + timestamp = tonumber(sub(log_result, time_first, time_last)) + local name = sub(log_result, trim_range(log_result, author_first, author_last)) + add(name, sub(log_result, trim_range(log_result, first, last)), timestamp, "author", false) + else + add_trailer(first, last) + end + end) + local name, email = parse_ident(ident_result) - if name and email then + if name then add(name, email, nil, "self", true) end @@ -287,34 +382,51 @@ function M.values.parse(result) local now = os.time() local by_key = {} local by_value = {} + -- The lowercase form of each key as written, so that each casing is lowered once + local ids = {} + local timestamp + + each_field(result, function(first, last, index) + if index == 1 then + -- `tonumber` ignores the newline that ends the previous record + timestamp = tonumber(sub(result, first, last)) + return + end - for _, record in ipairs(utils.split_nul(result)) do - local fields = vim.split(record, UNIT_SEPARATOR, { plain = true }) - local timestamp = tonumber(utils.trim(fields[1] or "")) - - for i = 2, #fields do - local key, value = string.match(fields[i], "^%s*([%w-]+):%s*(.-)%s*$") - if key and value ~= "" then - local id = key:lower() - by_key[id] = by_key[id] or {} - by_value[id] = by_value[id] or {} - - local entry = by_value[id][value] - if not entry then - entry = { - key = key, - value = value, - count = 0, - timestamp = timestamp, - diff = timestamp and os.difftime(now, timestamp) or 0, - } - by_value[id][value] = entry - table.insert(by_key[id], entry) - end - entry.count = entry.count + 1 - end + local key, value_first = match(result, "^%s*([%w-]+):()", first) + if not key then + return end - end + local value_last + value_first, value_last = trim_range(result, value_first, last) + if value_first > value_last then + return + end + + local id = ids[key] + if not id then + id = lower(key) + ids[key] = id + by_key[id] = by_key[id] or {} + by_value[id] = by_value[id] or {} + end + + local value = sub(result, value_first, value_last) + local entry = by_value[id][value] + if not entry then + entry = { + key = key, + value = value, + count = 0, + timestamp = timestamp, + diff = timestamp and os.difftime(now, timestamp) or 0, + } + by_value[id][value] = entry + local values = by_key[id] + values[#values + 1] = entry + end + entry.count = entry.count + 1 + end) return by_key end diff --git a/tests/provider/capabilities/test_commits.lua b/tests/provider/capabilities/test_commits.lua index 1fb9777..ef7b031 100644 --- a/tests/provider/capabilities/test_commits.lua +++ b/tests/provider/capabilities/test_commits.lua @@ -73,6 +73,109 @@ T["commit_capability_parse_marker_collision_and_malformed_records"] = function() eq(commits[1].description, "body with ###CMP_GIT_END### marker", "commit parser allows old entry marker text") end +-- The parser before it read the fields in place. The test below checks that the new parser gives the same output. +local function old_trim(s) + return (s:gsub("^%s*(.-)%s*$", "%1")) +end + +local function old_split_nul(input) + local fields = {} + local start = 1 + while true do + local pos = string.find(input, "\0", start, true) + if not pos then + break + end + table.insert(fields, string.sub(input, start, pos - 1)) + start = pos + 1 + end + return fields +end + +local function old_parse(result, config) + local fields = old_split_nul(result) + local commits = {} + + for i = 1, #fields, 6 do + if fields[i + 5] then + local timestamp = tonumber(old_trim(fields[i + 5])) or 0 + table.insert(commits, { + sha = old_trim(fields[i]):sub(1, config.sha_length), + title = old_trim(fields[i + 1]), + description = old_trim(fields[i + 2]), + author_name = fields[i + 3] or "", + author_mail = fields[i + 4] or "", + commit_timestamp = timestamp, + diff = os.difftime(os.time(), timestamp), + }) + end + end + + return commits +end + +---A deterministic random generator, so that a failure can be reproduced. +---@param seed integer +---@return fun(n: integer): integer random A number from 1 to n. +local function random_generator(seed) + return function(n) + seed = seed * 16807 % 2147483647 + return seed % n + 1 + end +end + +T["commit_capability_parse_matches_the_old_parser"] = function() + helpers.stub(os, "time", function() + return 1700000000 + end) + + local random = random_generator(42) + local function pick(list) + return list[random(#list)] + end + local fragments = { + "", + " ", + "\t", + "\r\n", + "\n", + "fix: subject", + " padded subject ", + "body line one\nbody line two\n", + "body\r\n\r\nCo-authored-by: Ada \r\n", + "Ünïcödé", + "\031", + "a" .. string.rep(" ", 50) .. "b", + } + local function text() + local parts = {} + for i = 1, random(3) - 1 do + parts[i] = pick(fragments) + end + return table.concat(parts) + end + local shas = { "", "abcdef1234567890abcdef1234567890abcdef12", " abc ", "\nabcdef1234567890", "a b c d e f g h" } + local timestamps = { "1700000000", " 1700000000 ", "\n1699990000\r", "", "abc", "1e3", "-5" } + + for _ = 1, 300 do + local commits = {} + for i = 1, random(5) - 1 do + commits[i] = table.concat({ pick(shas), text(), text(), text(), text(), pick(timestamps), "" }, "\0") + end + -- `--pretty=format:` puts a newline between commits. The last commit can lack fields. + local output = table.concat(commits, pick({ "\n", "" })) .. pick({ "", "dangling\0", "a\0b\0c\0d\0e" }) + + for _, sha_length in ipairs({ 7, 0, 40, 100 }) do + local config = { sha_length = sha_length } + eq( + commit_capability.parse(output, config), + old_parse(output, config), + string.format("parse of %s with sha_length %d", vim.inspect(output), sha_length) + ) + end + end +end + T["commit_capability_maps_items_without_cmp_params"] = function() local items = commit_capability.items({ { diff --git a/tests/provider/capabilities/test_trailers.lua b/tests/provider/capabilities/test_trailers.lua index b43a850..d6e11c0 100644 --- a/tests/provider/capabilities/test_trailers.lua +++ b/tests/provider/capabilities/test_trailers.lua @@ -92,6 +92,302 @@ T["trailer_keys_parse_and_sort"] = function() eq(items[1].insertText, "Signed-off-by: ", "trailer key insert text") end +-- The parsers before they read the fields in place. The tests below check that the new parsers give the same output. +local old = {} + +function old.trim(s) + return (s:gsub("^%s*(.-)%s*$", "%1")) +end + +function old.split_nul(input) + local fields = {} + local start = 1 + while true do + local pos = string.find(input, "\0", start, true) + if not pos then + break + end + table.insert(fields, string.sub(input, start, pos - 1)) + start = pos + 1 + end + return fields +end + +function old.parse_ident(value) + local name, email = string.match(value, "^%s*(.-)%s*<([^>]+)>") + if not name or name == "" or old.trim(email) == "" then + return nil, nil + end + return name, old.trim(email) +end + +function old.keys_parse(result, keys_config) + local keys = {} + local by_key = {} + + for index, key in ipairs(keys_config.builtin or {}) do + if not by_key[key:lower()] then + local trailer = { key = key, count = 0, builtin_index = index } + by_key[key:lower()] = trailer + table.insert(keys, trailer) + end + end + + for _, record in ipairs(old.split_nul(result)) do + for _, key in ipairs(vim.split(record, "\031", { plain = true })) do + key = old.trim(key) + if key:match("^[%w-]+$") then + local trailer = by_key[key:lower()] + if not trailer then + trailer = { key = key, count = 0 } + by_key[key:lower()] = trailer + table.insert(keys, trailer) + end + trailer.count = trailer.count + 1 + end + end + end + + return keys +end + +function old.identities_parse(log_result, ident_result) + local now = os.time() + local identities = {} + local by_email = {} + + local function add(name, email, timestamp, source, is_self) + name = old.trim(name) + email = old.trim(email) + if name == "" or email == "" then + return + end + + local existing = by_email[email:lower()] + if existing then + existing.is_self = existing.is_self or is_self + return + end + + local entry = { + name = name, + email = email, + timestamp = timestamp, + diff = timestamp and os.difftime(now, timestamp) or 0, + source = source, + is_self = is_self, + } + by_email[email:lower()] = entry + table.insert(identities, entry) + end + + for _, record in ipairs(old.split_nul(log_result)) do + local fields = vim.split(record, "\031", { plain = true }) + if #fields >= 3 then + local timestamp = tonumber(old.trim(fields[1])) + add(fields[2], fields[3], timestamp, "author", false) + + for i = 4, #fields do + local key, value = string.match(fields[i], "^%s*([%w-]+):%s*(.-)%s*$") + local name, email = old.parse_ident(value or "") + if name and email then + add(name, email, timestamp, key, false) + end + end + end + end + + local name, email = old.parse_ident(ident_result) + if name and email then + add(name, email, nil, "self", true) + end + + return identities +end + +function old.values_parse(result) + local now = os.time() + local by_key = {} + local by_value = {} + + for _, record in ipairs(old.split_nul(result)) do + local fields = vim.split(record, "\031", { plain = true }) + local timestamp = tonumber(old.trim(fields[1] or "")) + + for i = 2, #fields do + local key, value = string.match(fields[i], "^%s*([%w-]+):%s*(.-)%s*$") + if key and value ~= "" then + local id = key:lower() + by_key[id] = by_key[id] or {} + by_value[id] = by_value[id] or {} + + local entry = by_value[id][value] + if not entry then + entry = { + key = key, + value = value, + count = 0, + timestamp = timestamp, + diff = timestamp and os.difftime(now, timestamp) or 0, + } + by_value[id][value] = entry + table.insert(by_key[id], entry) + end + entry.count = entry.count + 1 + end + end + end + + return by_key +end + +---Random `git log` outputs in the formats of the trailer requests, with the edge cases that the parsers handle. +---@param seed integer +---@return fun(): string log An output of `identities.request` or `values.request`. +---@return fun(): string keys_log An output of `keys.request`. +---@return fun(): string ident An output of `identities.ident_request`. +local function trailer_outputs(seed) + -- A deterministic generator, so that a failure can be reproduced + local function random(n) + seed = seed * 16807 % 2147483647 + return seed % n + 1 + end + local function pick(list) + return list[random(#list)] + end + + local times = { "1700000000", "\n1700000000", " 1699990000 ", "", "abc", "\n" } + local names = { "Ada Lovelace", " Ada Lovelace ", "", "Grace Hopper", "Me", "\t" } + local emails = { "ada@example.com", "ADA@Example.com", " grace@example.com ", "", "me@example.com", "x" } + local keys = { "Co-authored-by", "co-AUTHORED-by", "Signed-off-by", "Fixes", "Assisted-by", "bad key", " Refs" } + local separators = { ":", ": ", ": ", " :", ":\t" } + local values = { + "", + " ", + "Ada Lovelace ", + "Ada ", + "Grace Hopper ", + " Grace Hopper < grace@example.com > ", + "", + "No Email", + "Ada <>", + "Ada < >", + "A <> B ", + "a > b ", + "x ", + } + + local function trailer() + return pick(keys) .. pick(separators) .. pick(values) + end + + local function output(fields) + local records = {} + for i = 1, random(6) - 1 do + -- `--format=` ends each record with a newline + records[i] = table.concat(fields(), "\031") .. "\0" .. pick({ "\n", "" }) + end + -- A trailing record without a NUL is dropped + return table.concat(records) .. pick({ "", "", "1700000000\031Ada\031ada@example.com" }) + end + + local function log() + return output(function() + local fields = { pick(times) } + for i = 2, random(6) do + fields[i] = i == 2 and random(2) == 1 and pick(names) + or i == 3 and random(2) == 1 and pick(emails) + or trailer() + end + return fields + end) + end + + local key_names = { + "Signed-off-by", + " co-authored-by ", + "Change-Id", + "%(trailers:keyonly,separator=%x1f)", + "", + "\n", + "bad key", + "Fixes\r", + "FIXES", + } + local function keys_log() + return output(function() + local fields = {} + for i = 1, random(4) do + fields[i] = pick(key_names) + end + return fields + end) + end + + local idents = { + "", + "Me 1 +0000\n", + "Ada Lovelace 1 +0000", + " Spaced Name < spaced@example.com > 1 +0000\n", + "garbage", + } + local function ident() + return pick(idents) + end + + return log, keys_log, ident +end + +T["trailer_parsers_match_the_old_parsers"] = function() + helpers.stub(os, "time", function() + return 1700000000 + end) + + local log, keys_log, ident = trailer_outputs(7) + local keys_config = { builtin = { "Co-authored-by", "Signed-off-by", "fixes", "Fixes" } } + for _ = 1, 300 do + local output = log() + local ident_output = ident() + local reason = vim.inspect(output) .. " and " .. vim.inspect(ident_output) + eq( + trailer_capability.identities.parse(output, ident_output), + old.identities_parse(output, ident_output), + "identities of " .. reason + ) + eq(trailer_capability.values.parse(output), old.values_parse(output), "values of " .. vim.inspect(output)) + + output = keys_log() + eq( + trailer_capability.keys.parse(output, keys_config), + old.keys_parse(output, keys_config), + "keys of " .. vim.inspect(output) + ) + end +end + +T["trailer_parsers_take_linear_time"] = function() + -- The old patterns took about 3 s for each of these values + local run = string.rep(" ", 32000) + local start = os.clock() + + local by_key = trailer_capability.values.parse("1\031Refs: a" .. run .. "b" .. run .. "\0") + eq(by_key["refs"][1].value, "a" .. run .. "b", "value with a long run of spaces") + + local identities = trailer_capability.identities.parse( + "1\031Ada\031ada@example.com\031Co-authored-by: Grace" .. run .. "Hopper" .. run .. "\0", + "" + ) + eq(identities[2].name, "Grace" .. run .. "Hopper", "identity name with a long run of spaces") + ok(os.clock() - start < 0.25, "trailers with long runs of spaces parse fast") +end + T["trailer_identities_request"] = function() local spec = trailer_capability.identities.request({ keys = { "Co-authored-by", "bad key", "Signed-off-by" }, From 452adeac2ed3ff926fafb401435477254684c24f Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 20:59:03 +1100 Subject: [PATCH 29/37] fix: answer each git request when git log or git var fails In a repository without commits, git log fails, and the commit request returned without a callback, so : never answered. Without git, each git capability got no job and gave no callback. blink.cmp then held back the items of all sources until its 2 s timeout, and nvim-cmp waited up to 500 ms. Each commit and trailer request now ends with one final list. A failed commit or values log gives an empty list. A failed keys log still gives the built-in keys, and a failed identities log or git var still gives the result of the other command. A failure is not cached, so the next request runs git again. Before, the keys, identities, and values caches kept the result of a failed log as if it were the history. --- lua/cmp_git/provider/capabilities/commits.lua | 14 ++- .../provider/capabilities/trailers.lua | 58 +++++++++--- tests/provider/capabilities/test_commits.lua | 40 ++++++++ tests/provider/capabilities/test_trailers.lua | 91 ++++++++++++++++++- tests/sources/test_git.lua | 79 ++++++++++++++++ 5 files changed, 262 insertions(+), 20 deletions(-) diff --git a/lua/cmp_git/provider/capabilities/commits.lua b/lua/cmp_git/provider/capabilities/commits.lua index 93643f2..3fea49d 100644 --- a/lua/cmp_git/provider/capabilities/commits.lua +++ b/lua/cmp_git/provider/capabilities/commits.lua @@ -115,9 +115,16 @@ function M.job(args) return nil end + -- A failure, such as a repository without commits or no git, must still answer. Else blink.cmp holds back the + -- items of all sources until its timeout. It is not cached, so that the next request tries again. + local function fail() + args.callback({ items = {}, isIncomplete = false }) + end + local runner = args.runner or command - return runner.build(M.request(args.config), function(result, success) + local job = runner.build(M.request(args.config), function(result, success) if not success then + fail() return end @@ -125,6 +132,11 @@ function M.job(args) args.cache[bufnr] = parsed args.callback({ items = M.items(parsed, args.config, args.trigger_char), isIncomplete = false }) end) + + if not job then + fail() + end + return job end ---@param args cmp_git.Provider.CommitCapabilityArgs diff --git a/lua/cmp_git/provider/capabilities/trailers.lua b/lua/cmp_git/provider/capabilities/trailers.lua index dc650b5..aa3cfa6 100644 --- a/lua/cmp_git/provider/capabilities/trailers.lua +++ b/lua/cmp_git/provider/capabilities/trailers.lua @@ -509,13 +509,25 @@ function M.keys.job(args) end local bufnr = args.bufnr or vim.api.nvim_get_current_buf() - local runner = args.runner or command - return runner.build(M.keys.request(args.config), function(result, success) - -- A failed log (for example, a repository without commits) still gives the built-in keys + + ---@param result string + ---@param success boolean + local function finish(result, success) + -- A failed log (for example, a repository without commits) still gives the built-in keys. They are not + -- cached, so that the next request tries the log again. local parsed = M.keys.parse(success and result or "", args.config) - args.cache[bufnr] = parsed + if success then + args.cache[bufnr] = parsed + end args.callback({ items = items(parsed, args.config, args.trigger_char), isIncomplete = false }) - end) + end + + local runner = args.runner or command + local job = runner.build(M.keys.request(args.config), finish) + if not job then + finish("", false) + end + return job end ---@param args cmp_git.Provider.TrailerIdentityArgs @@ -529,35 +541,42 @@ function M.identities.job(args) local runner = args.runner or command local state = { cancelled = false, active = nil } - local function finish(log_result, ident_result) - local parsed = M.identities.parse(log_result, ident_result) - args.cache[bufnr] = parsed + -- A failed log (for example, a repository without commits) still gives your identity, and a failed `git var` still + -- gives the log. Only a result of both is cached, so that the next request tries a failed command again. + ---@param log_result string + ---@param log_success boolean + ---@param ident_result string + ---@param ident_success boolean + local function finish(log_result, log_success, ident_result, ident_success) + local parsed = M.identities.parse(log_success and log_result or "", ident_success and ident_result or "") + if log_success and ident_success then + args.cache[bufnr] = parsed + end args.callback({ items = items(parsed, args.config, args.trigger_char), isIncomplete = false }) end - -- A failed log (for example, a repository without commits) still gives your identity local log_job = runner.build(M.identities.request(args.config), function(log_result, log_success) if state.cancelled then return end - log_result = log_success and log_result or "" local ident_job = runner.build(M.identities.ident_request(), function(ident_result, ident_success) if state.cancelled then return end - finish(log_result, ident_success and ident_result or "") + finish(log_result, log_success, ident_result, ident_success) end) if ident_job then state.active = ident_job ident_job:start() else - finish(log_result, "") + finish(log_result, log_success, "", false) end end) if not log_job then + finish("", false, "", false) return nil end @@ -593,11 +612,22 @@ function M.values.job(args) end local runner = args.runner or command - return runner.build(M.values.request(args.config), function(result, success) - local parsed = M.values.parse(success and result or "") + local job = runner.build(M.values.request(args.config), function(result, success) + -- A failure is not cached, so that the next request tries again + if not success then + respond({}) + return + end + + local parsed = M.values.parse(result) args.cache[bufnr] = parsed respond(parsed) end) + + if not job then + respond({}) + end + return job end ---@param args cmp_git.Provider.TrailerKeyArgs diff --git a/tests/provider/capabilities/test_commits.lua b/tests/provider/capabilities/test_commits.lua index ef7b031..502883a 100644 --- a/tests/provider/capabilities/test_commits.lua +++ b/tests/provider/capabilities/test_commits.lua @@ -240,4 +240,44 @@ T["commit_capability_uses_parsed_cache_and_runner"] = function() eq(last_list.items[1].label, ":abcdef1: cache me", "commit capability maps cached commits") end +T["commit_capability_answers_failures_without_caching"] = function() + local builds = 0 + local failing_runner = { + build = function(_spec, callback) + builds = builds + 1 + return helpers.fake_job(function() + -- As in a repository without commits + callback("", false) + end) + end, + } + local callback, lists = helpers.recorder() + local args = { + cache = {}, + callback = callback, + config = commit_test_config(), + trigger_char = ":", + runner = failing_runner, + bufnr = 45, + } + + commit_capability.complete(args) + eq(lists, { { items = {}, isIncomplete = false } }, "failed log gives one final empty list") + eq(args.cache[45], nil, "failed log is not cached") + commit_capability.complete(args) + eq(builds, 2, "the next request runs git log again") + eq(#lists, 2, "the next request calls back again") + + callback, lists = helpers.recorder() + args.callback = callback + -- As when git is not installed + args.runner = { + build = function() + return nil + end, + } + eq(commit_capability.complete(args), nil, "no job without git") + eq(lists, { { items = {}, isIncomplete = false } }, "no git gives one final empty list") +end + return T diff --git a/tests/provider/capabilities/test_trailers.lua b/tests/provider/capabilities/test_trailers.lua index d6e11c0..545d0be 100644 --- a/tests/provider/capabilities/test_trailers.lua +++ b/tests/provider/capabilities/test_trailers.lua @@ -441,7 +441,8 @@ T["trailer_capabilities_use_cache_and_runner"] = function() if spec.args[1] == "var" then callback("Me 1 +0000", true) else - callback("fatal: no commits", false) + -- A history without trailers + callback("", true) end end) end, @@ -461,7 +462,7 @@ T["trailer_capabilities_use_cache_and_runner"] = function() eq(table.concat(builds, ","), "log", "trailer keys run once") eq(#key_lists, 2, "trailer keys callback on miss and hit") - eq(#key_lists[1].items, #trailers_config.keys.builtin, "failed log still gives the built-in keys") + eq(#key_lists[1].items, #trailers_config.keys.builtin, "history without trailers gives the built-in keys") builds = {} local identities_callback, identities_lists = helpers.recorder() @@ -477,7 +478,86 @@ T["trailer_capabilities_use_cache_and_runner"] = function() eq(table.concat(builds, ","), "log,var", "trailer identities run log and var once") eq(#identities_lists, 2, "trailer identities callback on miss and hit") - eq(identities_lists[1].items[1].label, "Me ", "failed log still gives your identity") + eq(identities_lists[1].items[1].label, "Me ", "history without authors gives your identity") +end + +---A runner whose commands give the outputs of a history with one commit by Ada, or fail. +---@param fails { log?: boolean, var?: boolean, build?: boolean } The commands that fail. `build` gives no job, as when +---git is not installed. +---@return table runner +---@return string[] builds The commands of the jobs that the runner made. +local function failing_runner(fails) + local builds = {} + local runner = { + build = function(spec, callback) + if fails.build then + return nil + end + + local name = spec.args[1] + table.insert(builds, name) + return helpers.fake_job(function() + if fails[name] then + callback("", false) + elseif name == "var" then + callback("Me 1 +0000\n", true) + else + callback("1700000000\031Ada\031ada@example.com\031Assisted-by: Codex\0\n", true) + end + end) + end, + } + return runner, builds +end + +T["trailer_capabilities_answer_failures_without_caching"] = function() + local trailers_config = config.normalize({}).git.trailers + + ---Runs a request two times, and checks that each request ends with one final list and that nothing is cached. + ---@return string[] labels The labels of the first list. + local function complete(kind, fails, expected_builds) + local runner, builds = failing_runner(fails) + local callback, lists = helpers.recorder() + local args = { + cache = {}, + callback = callback, + config = trailers_config[kind], + key = "Assisted-by", + runner = runner, + bufnr = 54, + } + local reason = string.format("%s with %s", kind, vim.inspect(fails)) + + trailer_capability[kind].complete(args) + eq(#lists, 1, reason .. " calls back once") + eq(lists[1].isIncomplete, false, reason .. " gives a final list") + eq(args.cache[54], nil, reason .. " is not cached") + + trailer_capability[kind].complete(args) + eq(#lists, 2, reason .. " calls back again") + eq(table.concat(builds, ","), expected_builds, reason .. " runs again") + + local labels = {} + for _, item in ipairs(lists[1].items) do + table.insert(labels, item.label) + end + return labels + end + + eq( + #complete("keys", { log = true }, "log,log"), + #trailers_config.keys.builtin, + "failed log gives the built-in keys" + ) + eq(#complete("keys", { build = true }, ""), #trailers_config.keys.builtin, "no git gives the built-in keys") + + eq(complete("identities", { log = true }, "log,var,log,var"), { "Me " }, "failed log") + eq(complete("identities", { var = true }, "log,var,log,var"), { "Ada " }, "failed git var") + eq(complete("identities", { log = true, var = true }, "log,var,log,var"), {}, "failed log and git var") + eq(complete("identities", { build = true }, ""), {}, "no git gives no identities") + + eq(complete("values", { log = true }, "log,log"), {}, "failed log gives no values") + eq(complete("values", { build = true }, ""), {}, "no git gives no values") end T["trailer_identities_job_cancel"] = function() @@ -614,7 +694,8 @@ T["trailer_values_cache_and_fall_through"] = function() build = function(_spec, callback) builds = builds + 1 return helpers.fake_job(function() - callback("fatal: no commits", false) + -- A history without trailers + callback("1700000000\0\n", true) end) end, } @@ -634,7 +715,7 @@ T["trailer_values_cache_and_fall_through"] = function() eq(builds, 1, "trailer values run once") eq(#lists, 2, "trailer values callback on miss and hit") - eq(#lists[1].items, 0, "failed log gives no trailer values") + eq(#lists[1].items, 0, "history without trailers gives no trailer values") ok( not trailer_capability.values.may_have(args.cache, { bufnr = 53, line = "Assisted-by: C" }), "cached history without values for the key" diff --git a/tests/sources/test_git.lua b/tests/sources/test_git.lua index ba01cac..af9e86c 100644 --- a/tests/sources/test_git.lua +++ b/tests/sources/test_git.lua @@ -23,4 +23,83 @@ T["git_instance_state_isolation"] = function() ok(first.cache_trailer_values ~= second.cache_trailer_values, "Git trailer values cache is instance-local") end +---Starts the requests that a commit message buffer can make, and records the lists that each one gives. +---@param git cmp_git.Source.Git +---@return table lists +local function request_all(git) + local bufnr = helpers.buffer({ "feat: subject", "", "" }) + local lists = { commits = {}, keys = {}, identities = {}, values = {} } + local function context(line) + return { bufnr = bufnr, cursor = { row = 3, character = #line }, line = line } + end + local function record(name) + return function(list) + table.insert(lists[name], list) + end + end + + ok(git:get_commits(record("commits"), context("See :"), ":"), "commits are handled") + ok(git:get_trailer_keys(record("keys"), context("Si")), "trailer keys are handled") + ok(git:get_trailer_identities(record("identities"), context("Co-authored-by: Te")), "identities are handled") + ok(git:get_trailer_values(record("values"), context("Assisted-by: C")), "trailer values are handled") + return lists +end + +---@param list cmp_git.CompletionList +---@return string[] +local function labels(list) + local result = {} + for _, item in ipairs(list.items) do + table.insert(result, item.label) + end + return result +end + +T["git_requests_answer_in_a_repository_without_commits"] = function() + if vim.fn.executable("git") ~= 1 then + helpers.skip("git is not installed") + end + + local repo = helpers.git_repo() + vim.env.GIT_AUTHOR_NAME = "Test Self" + vim.env.GIT_AUTHOR_EMAIL = "self@example.com" + vim.fn.chdir(repo.dir) + + local git_config = config.normalize({}).git + local git = Git.new(git_config) + local lists = request_all(git) + helpers.wait_for(function() + return #lists.commits > 0 and #lists.keys > 0 and #lists.identities > 0 and #lists.values > 0 + end, "each request calls back") + helpers.flush() + + eq(lists.commits, { { items = {}, isIncomplete = false } }, "commits give one final empty list") + eq(#lists.keys, 1, "trailer keys call back once") + eq(#lists.keys[1].items, #git_config.trailers.keys.builtin, "trailer keys give the built-in keys") + eq(#lists.identities, 1, "identities call back once") + eq(labels(lists.identities[1]), { "Test Self " }, "identities give your identity") + eq(lists.values, { { items = {}, isIncomplete = false } }, "trailer values give one final empty list") + + eq(next(git.cache_commits), nil, "commits are not cached") + eq(next(git.cache_trailer_keys), nil, "trailer keys are not cached") + eq(next(git.cache_trailer_identities), nil, "identities are not cached") + eq(next(git.cache_trailer_values), nil, "trailer values are not cached") +end + +T["git_requests_answer_without_git"] = function() + -- `command.build` gives no job when git is not installed + helpers.stub(vim.fn, "executable", function() + return 0 + end) + + local git_config = config.normalize({}).git + local lists = request_all(Git.new(git_config)) + + eq(lists.commits, { { items = {}, isIncomplete = false } }, "commits give one final empty list") + eq(#lists.keys, 1, "trailer keys call back once") + eq(#lists.keys[1].items, #git_config.trailers.keys.builtin, "trailer keys give the built-in keys") + eq(lists.identities, { { items = {}, isIncomplete = false } }, "identities give one final empty list") + eq(lists.values, { { items = {}, isIncomplete = false } }, "trailer values give one final empty list") +end + return T From 00488f75f9491aa9b456bb996eedafff9c1e46fa Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 21:00:14 +1100 Subject: [PATCH 30/37] perf: run git log and git var at once for trailer identities The identities request started git var GIT_AUTHOR_IDENT only after git log had finished, so the first list waited for both processes, one after the other. The request now starts both commands at once and gives the list when both have answered. Cancel stops both. A failure of either command still gives the result of the other, and is not cached. --- .../provider/capabilities/trailers.lua | 64 ++++++++-------- tests/provider/capabilities/test_trailers.lua | 75 ++++++++++++++----- 2 files changed, 92 insertions(+), 47 deletions(-) diff --git a/lua/cmp_git/provider/capabilities/trailers.lua b/lua/cmp_git/provider/capabilities/trailers.lua index aa3cfa6..7d69def 100644 --- a/lua/cmp_git/provider/capabilities/trailers.lua +++ b/lua/cmp_git/provider/capabilities/trailers.lua @@ -539,57 +539,61 @@ function M.identities.job(args) local bufnr = args.bufnr or vim.api.nvim_get_current_buf() local runner = args.runner or command - local state = { cancelled = false, active = nil } + local state = { cancelled = false, pending = 0 } + local log = { result = "", success = false } + local ident = { result = "", success = false } -- A failed log (for example, a repository without commits) still gives your identity, and a failed `git var` still -- gives the log. Only a result of both is cached, so that the next request tries a failed command again. - ---@param log_result string - ---@param log_success boolean - ---@param ident_result string - ---@param ident_success boolean - local function finish(log_result, log_success, ident_result, ident_success) - local parsed = M.identities.parse(log_success and log_result or "", ident_success and ident_result or "") - if log_success and ident_success then + local function finish() + local parsed = M.identities.parse(log.success and log.result or "", ident.success and ident.result or "") + if log.success and ident.success then args.cache[bufnr] = parsed end args.callback({ items = items(parsed, args.config, args.trigger_char), isIncomplete = false }) end - local log_job = runner.build(M.identities.request(args.config), function(log_result, log_success) - if state.cancelled then - return - end + ---@type cmp_git.SystemJob[] + local jobs = {} - local ident_job = runner.build(M.identities.ident_request(), function(ident_result, ident_success) - if state.cancelled then - return + ---@param spec cmp_git.CommandSpec + ---@param answer { result: string, success: boolean } + local function build(spec, answer) + local job = runner.build(spec, function(result, success) + answer.result, answer.success = result, success + state.pending = state.pending - 1 + if state.pending == 0 and not state.cancelled then + finish() end - finish(log_result, log_success, ident_result, ident_success) end) - - if ident_job then - state.active = ident_job - ident_job:start() - else - finish(log_result, log_success, "", false) + if job then + state.pending = state.pending + 1 + table.insert(jobs, job) end - end) + end - if not log_job then - finish("", false, "", false) + -- The commands do not depend on each other, so they run at once, and the list waits for both + build(M.identities.request(args.config), log) + build(M.identities.ident_request(), ident) + + if #jobs == 0 then + finish() return nil end return { - command = log_job.command, + command = jobs[1].command, start = function() - state.active = log_job - log_job:start() + for _, job in ipairs(jobs) do + job:start() + end end, cancel = function() state.cancelled = true - if state.active and state.active.cancel then - state.active:cancel() + for _, job in ipairs(jobs) do + if job.cancel then + job:cancel() + end end end, } diff --git a/tests/provider/capabilities/test_trailers.lua b/tests/provider/capabilities/test_trailers.lua index 545d0be..3873744 100644 --- a/tests/provider/capabilities/test_trailers.lua +++ b/tests/provider/capabilities/test_trailers.lua @@ -560,33 +560,74 @@ T["trailer_capabilities_answer_failures_without_caching"] = function() eq(complete("values", { build = true }, ""), {}, "no git gives no values") end -T["trailer_identities_job_cancel"] = function() - local pending - local builds = 0 +---A runner whose jobs answer when the test calls their callbacks. +---@return table runner +---@return { started: string[], cancelled: string[], answer: table } jobs +local function manual_runner() + local jobs = { started = {}, cancelled = {}, answer = {} } local runner = { - build = function(_spec, callback) - builds = builds + 1 - return helpers.fake_job(function() - pending = callback - end) + build = function(spec, callback) + local name = spec.args[1] + return { + command = spec.exec, + start = function() + table.insert(jobs.started, name) + jobs.answer[name] = callback + end, + cancel = function() + table.insert(jobs.cancelled, name) + end, + } end, } - local called = false - local job = trailer_capability.identities.complete({ + return runner, jobs +end + +T["trailer_identities_run_log_and_var_at_once"] = function() + local runner, jobs = manual_runner() + local callback, lists = helpers.recorder() + local args = { cache = {}, - callback = function() - called = true - end, + callback = callback, + config = config.normalize({}).git.trailers.identities, + runner = runner, + bufnr = 55, + } + trailer_capability.identities.complete(args) + + eq(jobs.started, { "log", "var" }, "git log and git var start before either answers") + jobs.answer.var("Me 1 +0000\n", true) + eq(#lists, 0, "the list waits for git log") + jobs.answer.log("1700000000\031Ada\031ada@example.com\0\n", true) + + eq(#lists, 1, "one list when both answer") + local labels = {} + for _, item in ipairs(lists[1].items) do + table.insert(labels, item.label) + end + eq(labels, { "Ada ", "Me " }, "the list has the log and your identity") + ok(args.cache[55] ~= nil, "the result of both commands is cached") +end + +T["trailer_identities_job_cancel"] = function() + local runner, jobs = manual_runner() + local callback, lists = helpers.recorder() + local args = { + cache = {}, + callback = callback, config = config.normalize({}).git.trailers.identities, runner = runner, bufnr = 52, - }) + } + local job = helpers.not_nil(trailer_capability.identities.complete(args)) + jobs.answer.var("Me 1 +0000\n", true) job:cancel() - pending("", true) + jobs.answer.log("", true) - eq(builds, 1, "cancelled trailer identities job does not start git var") - eq(called, false, "cancelled trailer identities job does not call back") + eq(jobs.cancelled, { "log", "var" }, "cancel stops both commands") + eq(#lists, 0, "cancelled trailer identities job does not call back") + eq(args.cache[52], nil, "cancelled trailer identities job does not cache") end T["trailer_capabilities_with_git"] = function() From 1049ac5eed3fdbfa459b1a648ce8d746145536b3 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 21:04:36 +1100 Subject: [PATCH 31/37] perf: check the trailer line before the buffer Core asks the trailer keys, identities, and values whether they are available on each request. Each kind read the filetype through `vim.bo[bufnr]`, which makes a new table on each read, before the checks of the line. A request on a line of prose read the filetype three times, and the trailer checks were most of the allocation of a declined request. The checks of the cursor row and of the line now come first, so a line without a trailer key skips the buffer for identities and values. The filetype is read with `nvim_get_option_value`. A declined nvim-cmp request allocates 1.0 KB in place of 2.6 KB, and takes 1.2 us in place of 4.3 us. --- .../provider/capabilities/trailers.lua | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/lua/cmp_git/provider/capabilities/trailers.lua b/lua/cmp_git/provider/capabilities/trailers.lua index 7d69def..2f01e41 100644 --- a/lua/cmp_git/provider/capabilities/trailers.lua +++ b/lua/cmp_git/provider/capabilities/trailers.lua @@ -137,37 +137,38 @@ end ---@param kind "keys" | "identities" | "values" ---@return boolean function M.available(config, context, kind) - local bufnr = context.bufnr or vim.api.nvim_get_current_buf() - if not vim.api.nvim_buf_is_valid(bufnr) then - return false - end - - if not filetype_matches(config.filetypes or {}, vim.bo[bufnr].filetype) then - return false - end - local row = context.cursor and context.cursor.row if not row or row < (config.min_line or 1) then return false end - -- Line 1 has no previous line, so it counts as blank. A negative index would count from the buffer end. - local previous = row > 1 and vim.api.nvim_buf_get_lines(bufnr, row - 2, row - 1, false)[1] or "" - if not previous:match("^%s*$") and not previous:match("^[%w-]+:%s*%S") then - return false - end - + -- Core asks each kind on each request, so the checks of the line come before the reads of the buffer if kind == "identities" then local key = M.line_key(context.line) - return key ~= nil and contains_key(config.identities.keys or {}, key) + if key == nil or not contains_key(config.identities.keys or {}, key) then + return false + end + elseif kind == "values" then + -- `Key:` alone falls through to the `:` trigger, so `Fixes:` still completes commits + if M.line_key(context.line) == nil or string.match(context.line, "^[%w-]+:$") then + return false + end end - if kind == "values" then - -- `Key:` alone falls through to the `:` trigger, so `Fixes:` still completes commits - return M.line_key(context.line) ~= nil and not string.match(context.line, "^[%w-]+:$") + local bufnr = context.bufnr or vim.api.nvim_get_current_buf() + if not vim.api.nvim_buf_is_valid(bufnr) then + return false end - return true + -- `vim.bo[bufnr]` makes a new table on each read + local filetype = vim.api.nvim_get_option_value("filetype", { buf = bufnr }) + if not filetype_matches(config.filetypes or {}, filetype) then + return false + end + + -- Line 1 has no previous line, so it counts as blank. A negative index would count from the buffer end. + local previous = row > 1 and vim.api.nvim_buf_get_lines(bufnr, row - 2, row - 1, false)[1] or "" + return previous:match("^%s*$") ~= nil or previous:match("^[%w-]+:%s*%S") ~= nil end ---@param config cmp_git.Config.GitTrailerKeys From 1f1d03b7c7d6ba5fe321d49d3730f9eadbe83674 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 21:08:52 +1100 Subject: [PATCH 32/37] fix: run each request in the repository of its buffer Discovery now reads the buffer of the request, but the sources and the commands still read the current buffer. Core calls the sources after discovery, and a command starts after its source, so a buffer switch in that time ran `git log` and `gh repo set-default --view` in the repository of another buffer. The GitHub and GitLab sources also cached the items under the other buffer. Core now passes the buffer of the request to the hosted sources, and the sources and the git capabilities pass it to `command.build` in the new `CommandSpec.bufnr`. A legacy callback action calls a source without a buffer, so the current buffer stays the default. The buffer can be wiped while the request waits. `is_git_repo` and `discover` now answer at once for an invalid buffer, without a repository, and `get_cwd` gives the working directory of Neovim. Before, they threw an error. --- lua/cmp_git/command.lua | 4 +- lua/cmp_git/core.lua | 46 +++++++++++-------- lua/cmp_git/provider/capabilities/commits.lua | 4 +- .../provider/capabilities/trailers.lua | 9 +++- lua/cmp_git/repository/init.lua | 13 ++++++ lua/cmp_git/sources/github.lua | 28 ++++++----- lua/cmp_git/sources/gitlab.lua | 12 +++-- tests/provider/capabilities/test_commits.lua | 1 + tests/provider/capabilities/test_trailers.lua | 25 ++++++++++ tests/repository/test_init.lua | 24 ++++++++++ tests/sources/test_github.lua | 20 ++++++++ tests/test_command.lua | 21 +++++++++ tests/test_core.lua | 17 ++++++- 13 files changed, 186 insertions(+), 38 deletions(-) diff --git a/lua/cmp_git/command.lua b/lua/cmp_git/command.lua index 3849b11..90cc8c8 100644 --- a/lua/cmp_git/command.lua +++ b/lua/cmp_git/command.lua @@ -14,6 +14,8 @@ local M = {} ---@field args string[] ---@field env? table Added to the environment of Neovim. ---@field stdin? string Written to the standard input. Secrets go here, because other users can read the arguments. +---@field bufnr? integer The buffer whose repository the command runs in, by default the current one. The current +---buffer can change while a request waits for discovery. ---@param spec cmp_git.CommandSpec ---@param callback fun(result: string, success: boolean): nil @@ -61,7 +63,7 @@ function M.build(spec, callback) text = true, env = spec.env, stdin = spec.stdin, - cwd = repository.get_cwd(), + cwd = repository.get_cwd(spec.bufnr), }, function(result) vim.schedule(function() complete(result) diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index 291756a..a9d6f90 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -48,32 +48,42 @@ local builtin_trigger_actions = { git_trailer_values = function(core, callback, context, _git_info, trigger_char) return route_status(core.sources.git:get_trailer_values(callback, context, trigger_char)) end, - github_issues = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.github:complete_issues(callback, git_info, trigger_char)) + github_issues = function(core, callback, context, git_info, trigger_char) + return route_status(core.sources.github:complete_issues(callback, git_info, trigger_char, context.bufnr)) end, - github_mentions = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.github:complete_mentions(callback, git_info, trigger_char)) + github_mentions = function(core, callback, context, git_info, trigger_char) + return route_status(core.sources.github:complete_mentions(callback, git_info, trigger_char, context.bufnr)) end, - github_change_requests = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.github:complete_change_requests(callback, git_info, trigger_char)) + github_change_requests = function(core, callback, context, git_info, trigger_char) + return route_status( + core.sources.github:complete_change_requests(callback, git_info, trigger_char, context.bufnr) + ) end, - github_issues_and_change_requests = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.github:complete_issues_and_change_requests(callback, git_info, trigger_char)) + github_issues_and_change_requests = function(core, callback, context, git_info, trigger_char) + return route_status( + core.sources.github:complete_issues_and_change_requests(callback, git_info, trigger_char, context.bufnr) + ) end, - gitlab_issues = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.gitlab:complete_issues(callback, git_info, trigger_char)) + gitlab_issues = function(core, callback, context, git_info, trigger_char) + return route_status(core.sources.gitlab:complete_issues(callback, git_info, trigger_char, context.bufnr)) end, - gitlab_mentions = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.gitlab:complete_mentions(callback, git_info, trigger_char)) + gitlab_mentions = function(core, callback, context, git_info, trigger_char) + return route_status(core.sources.gitlab:complete_mentions(callback, git_info, trigger_char, context.bufnr)) end, - gitlab_change_requests = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.gitlab:complete_change_requests(callback, git_info, trigger_char)) + gitlab_change_requests = function(core, callback, context, git_info, trigger_char) + return route_status( + core.sources.gitlab:complete_change_requests(callback, git_info, trigger_char, context.bufnr) + ) end, - gitlab_mrs = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.gitlab:complete_change_requests(callback, git_info, trigger_char)) + gitlab_mrs = function(core, callback, context, git_info, trigger_char) + return route_status( + core.sources.gitlab:complete_change_requests(callback, git_info, trigger_char, context.bufnr) + ) end, - github_issues_and_prs = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.github:complete_issues_and_change_requests(callback, git_info, trigger_char)) + github_issues_and_prs = function(core, callback, context, git_info, trigger_char) + return route_status( + core.sources.github:complete_issues_and_change_requests(callback, git_info, trigger_char, context.bufnr) + ) end, } diff --git a/lua/cmp_git/provider/capabilities/commits.lua b/lua/cmp_git/provider/capabilities/commits.lua index 3fea49d..9f85640 100644 --- a/lua/cmp_git/provider/capabilities/commits.lua +++ b/lua/cmp_git/provider/capabilities/commits.lua @@ -121,8 +121,10 @@ function M.job(args) args.callback({ items = {}, isIncomplete = false }) end + local spec = M.request(args.config) + spec.bufnr = bufnr local runner = args.runner or command - local job = runner.build(M.request(args.config), function(result, success) + local job = runner.build(spec, function(result, success) if not success then fail() return diff --git a/lua/cmp_git/provider/capabilities/trailers.lua b/lua/cmp_git/provider/capabilities/trailers.lua index 2f01e41..8e011f7 100644 --- a/lua/cmp_git/provider/capabilities/trailers.lua +++ b/lua/cmp_git/provider/capabilities/trailers.lua @@ -523,8 +523,10 @@ function M.keys.job(args) args.callback({ items = items(parsed, args.config, args.trigger_char), isIncomplete = false }) end + local spec = M.keys.request(args.config) + spec.bufnr = bufnr local runner = args.runner or command - local job = runner.build(M.keys.request(args.config), finish) + local job = runner.build(spec, finish) if not job then finish("", false) end @@ -560,6 +562,7 @@ function M.identities.job(args) ---@param spec cmp_git.CommandSpec ---@param answer { result: string, success: boolean } local function build(spec, answer) + spec.bufnr = bufnr local job = runner.build(spec, function(result, success) answer.result, answer.success = result, success state.pending = state.pending - 1 @@ -616,8 +619,10 @@ function M.values.job(args) return nil end + local spec = M.values.request(args.config) + spec.bufnr = bufnr local runner = args.runner or command - local job = runner.build(M.values.request(args.config), function(result, success) + local job = runner.build(spec, function(result, success) -- A failure is not cached, so that the next request tries again if not success then respond({}) diff --git a/lua/cmp_git/repository/init.lua b/lua/cmp_git/repository/init.lua index 72b7771..6ef9c83 100644 --- a/lua/cmp_git/repository/init.lua +++ b/lua/cmp_git/repository/init.lua @@ -108,6 +108,10 @@ end ---@return string function M.get_cwd(bufnr) bufnr = resolve_bufnr(bufnr) + -- The buffer of a request can be wiped while the request waits for git + if not vim.api.nvim_buf_is_valid(bufnr) then + return vim.fn.getcwd() + end local dir = buffer_dir(bufnr) local state = states[bufnr] -- The commands must see the repository that discovery found, which can be in Neovim's cwd @@ -293,6 +297,10 @@ end ---@return nil function M.is_git_repo(on_result, bufnr) bufnr = resolve_bufnr(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + on_result(false) + return + end -- An octo buffer gets its repository from its name if vim.bo[bufnr].filetype == "octo" then on_result(true) @@ -361,6 +369,11 @@ function M.discover(remotes, opts) opts.on_complete(apply_ssh_aliases(metadata, opts.ssh_aliases)) end + if not vim.api.nvim_buf_is_valid(bufnr) then + complete({}) + return + end + local octo_metadata = discover_octo(bufnr) if octo_metadata ~= nil then complete(octo_metadata) diff --git a/lua/cmp_git/sources/github.lua b/lua/cmp_git/sources/github.lua index 63ba1fe..8f65917 100644 --- a/lua/cmp_git/sources/github.lua +++ b/lua/cmp_git/sources/github.lua @@ -60,8 +60,9 @@ end ---Call `on_resolved` with the repository that `gh repo set-default` chose, or with `git_info` when none is set or gh ---is not installed. gh runs at most once for each repository, and without blocking: it takes about 60 ms. ---@param git_info cmp_git.GitInfo +---@param bufnr integer The buffer of the request. gh reads the default repository of its working directory. ---@param on_resolved fun(git_info: cmp_git.GitInfo) -function GitHub:_resolve_default_repo(git_info, on_resolved) +function GitHub:_resolve_default_repo(git_info, bufnr, on_resolved) local key = string.format("%s/%s/%s", git_info.host, git_info.owner, git_info.repo) local lookup = self.default_repos[key] @@ -83,7 +84,8 @@ function GitHub:_resolve_default_repo(git_info, on_resolved) return end - local job = command.build({ exec = "gh", args = { "repo", "set-default", "--view" } }, function(result, success) + local spec = { exec = "gh", args = { "repo", "set-default", "--view" }, bufnr = bufnr } + local job = command.build(spec, function(result, success) ---@cast lookup -nil -- Without a default repository, gh prints nothing. Outside a repository, it fails. if success then @@ -121,7 +123,7 @@ function GitHub:_with_repo(git_info, bufnr, cached, start) end local state = { cancelled = false, active = nil } - self:_resolve_default_repo(git_info, function(resolved) + self:_resolve_default_repo(git_info, bufnr, function(resolved) if state.cancelled then return end @@ -174,13 +176,14 @@ end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitHub:complete_issues(callback, git_info, trigger_char) +---@param bufnr? integer The buffer of the request, by default the current one. +function GitHub:complete_issues(callback, git_info, trigger_char, bufnr) if not self:is_valid_host(git_info) then return false end -- The buffer of the request, also when the lookup of the default repository ends in another buffer - local bufnr = vim.api.nvim_get_current_buf() + bufnr = bufnr or vim.api.nvim_get_current_buf() return true, self:_with_repo(git_info, bufnr, self.cache.issues[bufnr] ~= nil, function(repo_info) local job = self:_issues_job(callback, repo_info, trigger_char, bufnr) @@ -194,12 +197,13 @@ end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitHub:complete_change_requests(callback, git_info, trigger_char) +---@param bufnr? integer The buffer of the request, by default the current one. +function GitHub:complete_change_requests(callback, git_info, trigger_char, bufnr) if not self:is_valid_host(git_info) then return false end - local bufnr = vim.api.nvim_get_current_buf() + bufnr = bufnr or vim.api.nvim_get_current_buf() return true, self:_with_repo(git_info, bufnr, self.cache.change_requests[bufnr] ~= nil, function(repo_info) local job = self:_change_requests_job(callback, repo_info, trigger_char, bufnr) @@ -266,12 +270,13 @@ end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitHub:complete_issues_and_change_requests(callback, git_info, trigger_char) +---@param bufnr? integer The buffer of the request, by default the current one. +function GitHub:complete_issues_and_change_requests(callback, git_info, trigger_char, bufnr) if not self:is_valid_host(git_info) then return false end - local bufnr = vim.api.nvim_get_current_buf() + bufnr = bufnr or vim.api.nvim_get_current_buf() local cached = self.cache.issues[bufnr] ~= nil and self.cache.change_requests[bufnr] ~= nil return true, self:_with_repo(git_info, bufnr, cached, function(repo_info) @@ -282,13 +287,14 @@ end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitHub:complete_mentions(callback, git_info, trigger_char) +---@param bufnr? integer The buffer of the request, by default the current one. +function GitHub:complete_mentions(callback, git_info, trigger_char, bufnr) if not self:is_valid_host(git_info) then return false end -- A request during a fetch joins it, so it needs no repository either - local bufnr = vim.api.nvim_get_current_buf() + bufnr = bufnr or vim.api.nvim_get_current_buf() return true, self:_with_repo(git_info, bufnr, self.cache.mentions[bufnr] ~= nil, function(repo_info) return mentions.complete({ diff --git a/lua/cmp_git/sources/gitlab.lua b/lua/cmp_git/sources/gitlab.lua index d275a48..a41bc32 100644 --- a/lua/cmp_git/sources/gitlab.lua +++ b/lua/cmp_git/sources/gitlab.lua @@ -49,7 +49,8 @@ end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitLab:complete_issues(callback, git_info, trigger_char) +---@param bufnr? integer The buffer of the request, by default the current one. +function GitLab:complete_issues(callback, git_info, trigger_char, bufnr) if not self:is_valid_host(git_info) then return false end @@ -61,6 +62,7 @@ function GitLab:complete_issues(callback, git_info, trigger_char) config = self.config.issues, git_info = git_info, trigger_char = trigger_char, + bufnr = bufnr, }) return true, job @@ -69,7 +71,8 @@ end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitLab:complete_mentions(callback, git_info, trigger_char) +---@param bufnr? integer The buffer of the request, by default the current one. +function GitLab:complete_mentions(callback, git_info, trigger_char, bufnr) if not self:is_valid_host(git_info) then return false end @@ -81,6 +84,7 @@ function GitLab:complete_mentions(callback, git_info, trigger_char) config = self.config.mentions, git_info = git_info, trigger_char = trigger_char, + bufnr = bufnr, }) return true, job @@ -89,7 +93,8 @@ end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitLab:complete_change_requests(callback, git_info, trigger_char) +---@param bufnr? integer The buffer of the request, by default the current one. +function GitLab:complete_change_requests(callback, git_info, trigger_char, bufnr) if not self:is_valid_host(git_info) then return false end @@ -101,6 +106,7 @@ function GitLab:complete_change_requests(callback, git_info, trigger_char) config = self.config.merge_requests, git_info = git_info, trigger_char = trigger_char, + bufnr = bufnr, }) return true, job diff --git a/tests/provider/capabilities/test_commits.lua b/tests/provider/capabilities/test_commits.lua index 502883a..96a255d 100644 --- a/tests/provider/capabilities/test_commits.lua +++ b/tests/provider/capabilities/test_commits.lua @@ -213,6 +213,7 @@ T["commit_capability_uses_parsed_cache_and_runner"] = function() "--pretty=format:%H%x00%s%x00%b%x00%aN%x00%aE%x00%cd%x00", "commit capability command format" ) + eq(spec.bufnr, 44, "commit capability command runs in the repository of the request buffer") return helpers.fake_job(function() callback(raw, true) end) diff --git a/tests/provider/capabilities/test_trailers.lua b/tests/provider/capabilities/test_trailers.lua index 3873744..b3023b7 100644 --- a/tests/provider/capabilities/test_trailers.lua +++ b/tests/provider/capabilities/test_trailers.lua @@ -770,4 +770,29 @@ T["trailer_values_cache_and_fall_through"] = function() eq(#core:_route(context), 0, "body line without history values falls through") end +T["trailer_commands_run_in_the_repository_of_the_request_buffer"] = function() + local seen = {} + local runner = { + build = function(spec, callback) + table.insert(seen, { spec.args[1], spec.bufnr }) + return helpers.fake_job(function() + callback("", true) + end) + end, + } + local trailers = config.normalize({}).git.trailers + for _, kind in ipairs({ "keys", "identities", "values" }) do + trailer_capability[kind].complete({ + cache = {}, + callback = function() end, + config = trailers[kind], + runner = runner, + bufnr = 61, + key = "Assisted-by", + }) + end + + eq(seen, { { "log", 61 }, { "log", 61 }, { "var", 61 }, { "log", 61 } }, "each command gets the request buffer") +end + return T diff --git a/tests/repository/test_init.lua b/tests/repository/test_init.lua index 7b28e35..1aa6fde 100644 --- a/tests/repository/test_init.lua +++ b/tests/repository/test_init.lua @@ -475,4 +475,28 @@ T["discovery_reads_remotes_of_the_found_repository"] = function() eq(request(), {}, "the remotes of another repository do not apply") end +T["a_wiped_buffer_has_no_repository"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "https://github.com/owner/repo.git" } } }) + vim.fn.chdir(repo.dir) + local bufnr = helpers.buffer({}, "markdown") + vim.api.nvim_buf_delete(bufnr, { force = true }) + + local results = {} + repository.is_git_repo(function(value) + results.is_git_repo = value + end, bufnr) + repository.discover({ "origin" }, { + bufnr = bufnr, + enableRemoteUrlRewrites = false, + ssh_aliases = {}, + on_complete = function(metadata) + results.discover = metadata + end, + }) + + eq(results, { is_git_repo = false, discover = {} }, "a wiped buffer answers at once, without a repository") + eq(repository.get_cwd(bufnr), repo.dir, "the commands of a wiped buffer run in the cwd") +end + return T diff --git a/tests/sources/test_github.lua b/tests/sources/test_github.lua index fb91604..06362ad 100644 --- a/tests/sources/test_github.lua +++ b/tests/sources/test_github.lua @@ -389,4 +389,24 @@ T["a_cancel_during_the_lookup_starts_no_request"] = function() eq(lists, {}, "a cancelled request gets no list") end +T["requests_cache_under_the_request_buffer"] = function() + local source = recording_github() + local request_bufnr = helpers.buffer({}, "markdown") + local current_bufnr = helpers.buffer({}, "markdown") + vim.api.nvim_set_current_buf(current_bufnr) + local callback, lists = helpers.recorder() + + for _, request in ipairs(REQUESTS) do + source[request](source, callback, remote(), "#", request_bufnr) + end + helpers.wait_for(function() + return #lists == #REQUESTS + end, "the requests answer") + + ok(source.cache.issues[request_bufnr] ~= nil, "the issues are cached for the request buffer") + ok(source.cache.change_requests[request_bufnr] ~= nil, "the pull requests are cached for the request buffer") + eq(source.cache.issues[current_bufnr], nil, "the current buffer gets no issues") + eq(source.cache.change_requests[current_bufnr], nil, "the current buffer gets no pull requests") +end + return T diff --git a/tests/test_command.lua b/tests/test_command.lua index 8335a3b..4878790 100644 --- a/tests/test_command.lua +++ b/tests/test_command.lua @@ -250,4 +250,25 @@ T["env_adds_to_the_environment"] = function() eq(seen, "inherited 1", "the command gets the variables of Neovim and of the spec") end +T["build_runs_in_the_repository_of_the_spec_buffer"] = function() + local dir = helpers.tempdir() + local seen + helpers.stub(repository, "get_cwd", function(bufnr) + seen = bufnr + return dir + end) + local result + local job = helpers.not_nil(command.build({ exec = "sh", args = { "-c", "pwd -P" }, bufnr = 42 }, function(stdout) + result = stdout + end)) + + job:start() + helpers.wait_for(function() + return result ~= nil + end, "the job completes") + + eq(seen, 42, "the directory comes from the buffer of the spec") + eq(vim.trim(result), dir, "the command runs in that directory") +end + return T diff --git a/tests/test_core.lua b/tests/test_core.lua index 3224509..ada5bd1 100644 --- a/tests/test_core.lua +++ b/tests/test_core.lua @@ -675,7 +675,16 @@ T["complete_discovers_the_repository_of_the_request_buffer"] = function() end) helpers.stub(repository, "discover", function(_, opts) seen.discover = opts.bufnr - opts.on_complete({}) + opts.on_complete({ host = "github.com", owner = "owner", repo = "repo" }) + end) + helpers.stub(core.sources.gitlab, "complete_issues", function(_, _, _, _, bufnr) + seen.gitlab = bufnr + return false + end) + helpers.stub(core.sources.github, "complete_issues_and_change_requests", function(_, on_list, _, _, bufnr) + seen.github = bufnr + on_list({ items = {}, isIncomplete = false }) + return true end) local callback, lists = helpers.recorder() @@ -686,7 +695,11 @@ T["complete_discovers_the_repository_of_the_request_buffer"] = function() bufnr = request_bufnr, }, callback) - eq(seen, { is_git_repo = request_bufnr, discover = request_bufnr }, "discovery reads the request buffer") + eq( + seen, + { is_git_repo = request_bufnr, discover = request_bufnr, gitlab = request_bufnr, github = request_bufnr }, + "discovery and the sources use the request buffer" + ) eq(#lists, 1, "the request answers") end From 7e8ce85c3a02259bf680dc52ca710cc49287cde8 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 21:08:52 +1100 Subject: [PATCH 33/37] docs: describe the changes to discovery, caching, and patterns - Discovery uses the working directory of Neovim when the file is not in a git repository, keeps its result for each buffer, and reads it again when the git config changes. - The gh default repository also applies to mentions, is read once for each repository, and is not used in octo buffers. - Failed commands and HTTP errors are not cached. - Pattern entries can set `keyword_pattern`, a lookbehind is slow in a keyword pattern, and the old trailer value pattern is upgraded. - curl must be 7.55 or later, because the token goes on stdin. - The default trailer value entry has the new patterns. --- README.md | 5 +++-- docs/configuration.md | 29 +++++++++++++++++++++++------ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0e7ce26..c5e8b4c 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Git source for [hrsh7th/nvim-cmp](https://github.com/hrsh7th/nvim-cmp) and [Sagh - Neovim >= 0.12 - git (>= 2.31 to find trailer keys in history) -- curl +- curl (>= 7.55) - [GitHub CLI](https://cli.github.com/) (optional, cmp-git uses curl if it is not available) - [GitLab CLI](https://gitlab.com/gitlab-org/cli) (optional, cmp-git uses curl if it is not available) @@ -220,7 +220,8 @@ require("cmp_git").setup({ actions = { "git_trailer_keys" }, }, { - pattern = [[\%(^[[:alnum:]-]\+:\s*\)\@<=\%([^[:space:]<>:#@!][^<>:#@!]*\%(<[^>]*\)\?\)\?]], + pattern = [[^[[:alnum:]-]\+:\s*\zs\%([^[:space:]<>:#@!][^<>:#@!]*\%(<[^>]*\)\?\)\?]], + keyword_pattern = [[^[[:alnum:]-]\+\zs:\S*\|^[[:alnum:]-]\+:\s*\zs\%([^[:space:]<>:#@!][^<>:#@!]*\%(<[^>]*\)\?\)\?]], actions = { "git_trailer_identities", "git_trailer_values" }, }, { diff --git a/docs/configuration.md b/docs/configuration.md index 1d15f11..e04ed6c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,6 +20,15 @@ An entry can use `pattern` in place of `trigger_character`. `pattern` is a Vim r entry applies when the pattern matches the text before the cursor, up to the cursor. The completed text starts at the start of the match. cmp-git also adds the patterns to the `nvim-cmp` keyword pattern. +An entry can also have `keyword_pattern`. Then cmp-git adds `keyword_pattern` to the `nvim-cmp` keyword pattern in place +of `pattern`. `nvim-cmp` filters the items on the text from the start of the keyword match. + +`nvim-cmp` matches the keyword pattern on each keystroke. Use `\zs`, not a lookbehind such as `\@<=`. A lookbehind +makes Vim use its backtracking engine, and its time grows with the square of the line length. + +The trailer value entry of earlier versions used a lookbehind. If an entry has that pattern and no `keyword_pattern`, +cmp-git uses the current trailer value patterns for it. + The default trailer entries come before the `:` entry. As a result, `Co-authored-by:` completes identities, not commits. @@ -196,10 +205,16 @@ A compound filetype, such as `gitcommit.markdown`, matches when one of its parts cmp-git gets the host, the owner, and the repository from the URL of a git remote. It reads the remotes in the order of `remotes`, and uses the first URL that it can parse. `remotes` can also be a single string. -cmp-git runs git in the directory of the current file. If it finds no remote there, it uses the working directory of -Neovim. +cmp-git uses the git repository of the directory of the file in the buffer. If the buffer has no file, or if that +directory is not in a git repository, cmp-git uses the working directory of Neovim. + +cmp-git keeps the repository and its remote URLs for each buffer. It reads them again in these cases: + +- The git config of the repository changes, for example after `git remote add`. +- The repository came from the working directory of Neovim, and that directory changes. +- cmp-git found no repository, and 5 seconds have passed. -If `enableRemoteUrlRewrites` is `true`, cmp-git reads the URL with `git remote get-url`. This command applies the +If `enableRemoteUrlRewrites` is `true`, cmp-git reads the URLs with `git remote -v`. This command applies the `url..insteadOf` rewrites. If it is `false`, cmp-git reads `remote..url` without rewrites. If a remote uses a host alias from your SSH config, add the alias and the real host to `ssh_aliases`: @@ -215,8 +230,9 @@ require("cmp_git").setup({ With this setup, cmp-git uses `github.com` for the remote `git@github-work:owner/repo.git`. The alias must match the full host. -If you set a default repository with `gh repo set-default`, GitHub issues and pull requests come from that repository. -GitHub mentions come from the repository of the remote. +If you set a default repository with `gh repo set-default`, GitHub issues, pull requests, and mentions come from that +repository. cmp-git reads the default repository once for each repository, so restart Neovim after you change it. +cmp-git does not use the default repository in an `octo` buffer. In an `octo` buffer, cmp-git gets the owner and the repository from the buffer name. It gets the host from `github_hostname` in the Octo config. The default host is `github.com`. @@ -276,4 +292,5 @@ To log missing executables, and the exit code and stderr of failed commands, set sends the messages with `vim.notify`. cmp-git caches the items for each buffer and does not refresh them. To get new items, wipe the buffer with -`:bwipeout` and open the file again, or restart Neovim. +`:bwipeout` and open the file again, or restart Neovim. cmp-git does not cache the result of a failed command or an +HTTP error, so the next request tries again. From ef7da7bbd940358e90fb70775e4a716b9af01533 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 21:19:25 +1100 Subject: [PATCH 34/37] perf: load the sources and the repository module on first use Core required the git, GitHub, and GitLab sources and the repository module at the top of the file, so `setup` loaded all the modules of cmp-git: 388 KB and 2.0 ms. Most sessions use one provider, and a request that cmp-git declines needs no source. Core now makes each source when an action first reads it, and it requires the repository module in the requests that need it. A hosted action checks the host of the remote before it gets the source, so a GitHub remote does not load the GitLab source. The line checks of trailer completion move to `trailer_lines`, so that a declined request does not load the trailer log parsers. `setup` now loads 163 KB in 0.8 ms. A test runs `setup` and a declined request in another Neovim and checks the modules that they load. --- lua/cmp_git/core.lua | 111 ++++++++++-------- .../provider/capabilities/trailer_lines.lua | 90 ++++++++++++++ .../provider/capabilities/trailers.lua | 88 +------------- tests/test_core.lua | 97 +++++++++++++-- tests/test_source.lua | 39 ++++-- 5 files changed, 268 insertions(+), 157 deletions(-) create mode 100644 lua/cmp_git/provider/capabilities/trailer_lines.lua diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index a9d6f90..38b1e83 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -1,10 +1,8 @@ -local github = require("cmp_git.sources.github") -local gitlab = require("cmp_git.sources.gitlab") -local git = require("cmp_git.sources.git") +-- The sources and the repository module load on first use, so that a session loads only the code that its requests +-- need. For example, a declined request loads neither, and a GitHub remote does not load the GitLab source. local config = require("cmp_git.config") local log = require("cmp_git.log") -local repository = require("cmp_git.repository") -local trailers = require("cmp_git.provider.capabilities.trailers") +local trailer_lines = require("cmp_git.provider.capabilities.trailer_lines") ---A completion request from the nvim-cmp or blink.cmp adapter. ---@class cmp_git.CompletionContext @@ -35,6 +33,22 @@ local function route_status(handled, job) return ROUTE_UNSUPPORTED_HOST, job end +---A built-in action of a hosted provider. It checks the host before it gets the source, so that a request loads only +---the provider of the remote. +---@param provider "github" | "gitlab" +---@param method string The method of the source that completes. +---@return fun(core: table, callback: fun(list: cmp_git.CompletionList), context: table, git_info: cmp_git.GitInfo, trigger_char: string?): string, table? +local function hosted_action(provider, method) + return function(core, callback, context, git_info, trigger_char) + if not vim.tbl_contains(core.config[provider].hosts, git_info.host) then + return ROUTE_UNSUPPORTED_HOST + end + + local source = core.sources[provider] + return route_status(source[method](source, callback, git_info, trigger_char, context.bufnr)) + end +end + local builtin_trigger_actions = { git_commits = function(core, callback, context, _git_info, trigger_char) return route_status(core.sources.git:get_commits(callback, context, trigger_char)) @@ -48,43 +62,15 @@ local builtin_trigger_actions = { git_trailer_values = function(core, callback, context, _git_info, trigger_char) return route_status(core.sources.git:get_trailer_values(callback, context, trigger_char)) end, - github_issues = function(core, callback, context, git_info, trigger_char) - return route_status(core.sources.github:complete_issues(callback, git_info, trigger_char, context.bufnr)) - end, - github_mentions = function(core, callback, context, git_info, trigger_char) - return route_status(core.sources.github:complete_mentions(callback, git_info, trigger_char, context.bufnr)) - end, - github_change_requests = function(core, callback, context, git_info, trigger_char) - return route_status( - core.sources.github:complete_change_requests(callback, git_info, trigger_char, context.bufnr) - ) - end, - github_issues_and_change_requests = function(core, callback, context, git_info, trigger_char) - return route_status( - core.sources.github:complete_issues_and_change_requests(callback, git_info, trigger_char, context.bufnr) - ) - end, - gitlab_issues = function(core, callback, context, git_info, trigger_char) - return route_status(core.sources.gitlab:complete_issues(callback, git_info, trigger_char, context.bufnr)) - end, - gitlab_mentions = function(core, callback, context, git_info, trigger_char) - return route_status(core.sources.gitlab:complete_mentions(callback, git_info, trigger_char, context.bufnr)) - end, - gitlab_change_requests = function(core, callback, context, git_info, trigger_char) - return route_status( - core.sources.gitlab:complete_change_requests(callback, git_info, trigger_char, context.bufnr) - ) - end, - gitlab_mrs = function(core, callback, context, git_info, trigger_char) - return route_status( - core.sources.gitlab:complete_change_requests(callback, git_info, trigger_char, context.bufnr) - ) - end, - github_issues_and_prs = function(core, callback, context, git_info, trigger_char) - return route_status( - core.sources.github:complete_issues_and_change_requests(callback, git_info, trigger_char, context.bufnr) - ) - end, + github_issues = hosted_action("github", "complete_issues"), + github_mentions = hosted_action("github", "complete_mentions"), + github_change_requests = hosted_action("github", "complete_change_requests"), + github_issues_and_change_requests = hosted_action("github", "complete_issues_and_change_requests"), + gitlab_issues = hosted_action("gitlab", "complete_issues"), + gitlab_mentions = hosted_action("gitlab", "complete_mentions"), + gitlab_change_requests = hosted_action("gitlab", "complete_change_requests"), + gitlab_mrs = hosted_action("gitlab", "complete_change_requests"), + github_issues_and_prs = hosted_action("github", "complete_issues_and_change_requests"), } -- Built-in actions that read only the local repository. They need no remote, and they always handle the request. @@ -98,13 +84,13 @@ local LOCAL_ACTIONS = { -- Synchronous checks that run before repository discovery, so that a declined request starts no git process. local builtin_trigger_availability = { git_trailer_keys = function(core, context) - return trailers.available(core.config.git.trailers, context, "keys") + return trailer_lines.available(core.config.git.trailers, context, "keys") end, git_trailer_identities = function(core, context) - return trailers.available(core.config.git.trailers, context, "identities") + return trailer_lines.available(core.config.git.trailers, context, "identities") end, git_trailer_values = function(core, context) - return trailers.available(core.config.git.trailers, context, "values") + return trailer_lines.available(core.config.git.trailers, context, "values") and core.sources.git:may_have_trailer_values(context) end, } @@ -211,6 +197,31 @@ end ---@field github cmp_git.Source.GitHub ---@field gitlab cmp_git.Source.Gitlab +local SOURCE_MODULES = { + git = "cmp_git.sources.git", + github = "cmp_git.sources.github", + gitlab = "cmp_git.sources.gitlab", +} + +---The sources, each made on first use. A legacy callback action gets this table, so a source must also load when an +---action reads it. +---@param core_config cmp_git.Config +---@return cmp_git.Sources +local function lazy_sources(core_config) + return setmetatable({}, { + __index = function(sources, name) + local module = SOURCE_MODULES[name] + if module == nil then + return nil + end + + local source = require(module).new(core_config[name]) + rawset(sources, name, source) + return source + end, + }) +end + local Core = {} function Core.new(overrides) @@ -220,7 +231,7 @@ function Core.new(overrides) self.config = config.normalize(overrides) self.filetypes = {} - self.sources = {} + self.sources = lazy_sources(self.config) self.trigger_actions = {} self.trigger_characters = {} @@ -228,10 +239,6 @@ function Core.new(overrides) self.filetypes[item] = true end - self.sources.git = git.new(self.config.git) - self.sources.gitlab = gitlab.new(self.config.gitlab) - self.sources.github = github.new(self.config.github) - local patterns = {} local regexes = {} for _, configured_action in ipairs(self.config.trigger_actions) do @@ -463,7 +470,7 @@ function Core:_complete(context, callback, routes) return controller end - repository.discover(self.config.remotes, { + require("cmp_git.repository").discover(self.config.remotes, { enableRemoteUrlRewrites = self.config.enableRemoteUrlRewrites, ssh_aliases = self.config.ssh_aliases, on_complete = run, @@ -502,7 +509,7 @@ function Core:complete(context, callback) end -- The buffer of the request, because the current buffer can change while git runs - repository.is_git_repo(function(is_git_repo) + require("cmp_git.repository").is_git_repo(function(is_git_repo) if state.cancelled then return end diff --git a/lua/cmp_git/provider/capabilities/trailer_lines.lua b/lua/cmp_git/provider/capabilities/trailer_lines.lua new file mode 100644 index 0000000..b221301 --- /dev/null +++ b/lua/cmp_git/provider/capabilities/trailer_lines.lua @@ -0,0 +1,90 @@ +---The checks of the line at the cursor for trailer completion. Core runs them on each request, and they are apart from +---the trailer capability, so that a request that trailer completion declines does not load the log parsers. +local M = {} + +---@param filetypes string[] +---@param filetype string +---@return boolean +local function filetype_matches(filetypes, filetype) + if vim.tbl_contains(filetypes, filetype) then + return true + end + + for ft in string.gmatch(filetype, "[^%.]+") do + if vim.tbl_contains(filetypes, ft) then + return true + end + end + + return false +end + +---@param keys string[] +---@param key string +---@return boolean +local function contains_key(keys, key) + key = key:lower() + for _, candidate in ipairs(keys) do + if candidate:lower() == key then + return true + end + end + return false +end + +---@param line string? +---@return string? +function M.line_key(line) + return line and string.match(line, "^([%w-]+):") +end + +---The trigger character to give to the identities and values formats: `:` when the value touches the colon, else +---nil. +---@param line string? +---@return string? +function M.attached_trigger(line) + return line and string.match(line, "^[%w-]+(:)%S*$") +end + +---Trailer completion is only offered in commit messages, from `config.min_line` (by default below the subject and +---its blank line), at the start of a trailer block or after another trailer. +---@param config cmp_git.Config.GitTrailers +---@param context { bufnr?: integer, cursor?: { row?: integer }, line?: string } +---@param kind "keys" | "identities" | "values" +---@return boolean +function M.available(config, context, kind) + local row = context.cursor and context.cursor.row + if not row or row < (config.min_line or 1) then + return false + end + + -- Core asks each kind on each request, so the checks of the line come before the reads of the buffer + if kind == "identities" then + local key = M.line_key(context.line) + if key == nil or not contains_key(config.identities.keys or {}, key) then + return false + end + elseif kind == "values" then + -- `Key:` alone falls through to the `:` trigger, so `Fixes:` still completes commits + if M.line_key(context.line) == nil or string.match(context.line, "^[%w-]+:$") then + return false + end + end + + local bufnr = context.bufnr or vim.api.nvim_get_current_buf() + if not vim.api.nvim_buf_is_valid(bufnr) then + return false + end + + -- `vim.bo[bufnr]` makes a new table on each read + local filetype = vim.api.nvim_get_option_value("filetype", { buf = bufnr }) + if not filetype_matches(config.filetypes or {}, filetype) then + return false + end + + -- Line 1 has no previous line, so it counts as blank. A negative index would count from the buffer end. + local previous = row > 1 and vim.api.nvim_buf_get_lines(bufnr, row - 2, row - 1, false)[1] or "" + return previous:match("^%s*$") ~= nil or previous:match("^[%w-]+:%s*%S") ~= nil +end + +return M diff --git a/lua/cmp_git/provider/capabilities/trailers.lua b/lua/cmp_git/provider/capabilities/trailers.lua index 8e011f7..881a21c 100644 --- a/lua/cmp_git/provider/capabilities/trailers.lua +++ b/lua/cmp_git/provider/capabilities/trailers.lua @@ -1,5 +1,6 @@ local command = require("cmp_git.command") local format = require("cmp_git.format") +local trailer_lines = require("cmp_git.provider.capabilities.trailer_lines") local utils = require("cmp_git.utils") local UNIT_SEPARATOR = "\031" @@ -24,36 +25,6 @@ local M = { ---@field timestamp integer? ---@field diff number Seconds since the most recent use. ----@param filetypes string[] ----@param filetype string ----@return boolean -local function filetype_matches(filetypes, filetype) - if vim.tbl_contains(filetypes, filetype) then - return true - end - - for ft in string.gmatch(filetype, "[^%.]+") do - if vim.tbl_contains(filetypes, ft) then - return true - end - end - - return false -end - ----@param keys string[] ----@param key string ----@return boolean -local function contains_key(keys, key) - key = key:lower() - for _, candidate in ipairs(keys) do - if candidate:lower() == key then - return true - end - end - return false -end - ---Calls `on_field` for each field of `output`, a `git log` output whose records end with NUL and whose fields end ---with the unit separator or the NUL of the record. Each record has at least one field, which can be empty. A ---trailing record without a NUL is dropped, like `utils.split_nul` does. The fields are read in place, so that a @@ -116,60 +87,9 @@ local function parse_ident(value) end end ----@param line string? ----@return string? -function M.line_key(line) - return line and string.match(line, "^([%w-]+):") -end - ----The trigger character to give to the identities and values formats: `:` when the value touches the colon, else ----nil. ----@param line string? ----@return string? -function M.attached_trigger(line) - return line and string.match(line, "^[%w-]+(:)%S*$") -end - ----Trailer completion is only offered in commit messages, from `config.min_line` (by default below the subject and ----its blank line), at the start of a trailer block or after another trailer. ----@param config cmp_git.Config.GitTrailers ----@param context { bufnr?: integer, cursor?: { row?: integer }, line?: string } ----@param kind "keys" | "identities" | "values" ----@return boolean -function M.available(config, context, kind) - local row = context.cursor and context.cursor.row - if not row or row < (config.min_line or 1) then - return false - end - - -- Core asks each kind on each request, so the checks of the line come before the reads of the buffer - if kind == "identities" then - local key = M.line_key(context.line) - if key == nil or not contains_key(config.identities.keys or {}, key) then - return false - end - elseif kind == "values" then - -- `Key:` alone falls through to the `:` trigger, so `Fixes:` still completes commits - if M.line_key(context.line) == nil or string.match(context.line, "^[%w-]+:$") then - return false - end - end - - local bufnr = context.bufnr or vim.api.nvim_get_current_buf() - if not vim.api.nvim_buf_is_valid(bufnr) then - return false - end - - -- `vim.bo[bufnr]` makes a new table on each read - local filetype = vim.api.nvim_get_option_value("filetype", { buf = bufnr }) - if not filetype_matches(config.filetypes or {}, filetype) then - return false - end - - -- Line 1 has no previous line, so it counts as blank. A negative index would count from the buffer end. - local previous = row > 1 and vim.api.nvim_buf_get_lines(bufnr, row - 2, row - 1, false)[1] or "" - return previous:match("^%s*$") ~= nil or previous:match("^[%w-]+:%s*%S") ~= nil -end +M.line_key = trailer_lines.line_key +M.attached_trigger = trailer_lines.attached_trigger +M.available = trailer_lines.available ---@param config cmp_git.Config.GitTrailerKeys ---@return cmp_git.CommandSpec diff --git a/tests/test_core.lua b/tests/test_core.lua index ada5bd1..837bdcb 100644 --- a/tests/test_core.lua +++ b/tests/test_core.lua @@ -6,6 +6,20 @@ local repository = require("cmp_git.repository") local eq = helpers.eq local ok = helpers.ok +-- The repository root, for the scripts that run in another Neovim +local ROOT = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h") + +-- Core runs the action of a provider only for the hosts of that provider. A host of both providers lets a case run +-- the GitLab and the GitHub actions of a trigger. +local function shared_hosts() + return { github = { hosts = { "git.example.com" } }, gitlab = { hosts = { "git.example.com" } } } +end + +---@return cmp_git.GitInfo +local function shared_remote() + return { host = "git.example.com", owner = "owner", repo = "repo" } +end + -- The cases replace the provider sources with partial fakes ---@diagnostic disable: missing-fields @@ -49,7 +63,7 @@ end T["requests_get_new_item_tables"] = function() local data = { number = 12 } local cached = { { label = "#12: Fix", insertText = "#12", data = data } } - local core = Core.new({}) + local core = Core.new(shared_hosts()) core.sources.gitlab = { complete_issues = function() return false @@ -64,7 +78,12 @@ T["requests_get_new_item_tables"] = function() } local callback, lists = helpers.recorder() local function request(line) - core:_run_trigger_actions("#", callback, { cursor = { row = 1, character = #line }, line = line }, {}) + core:_run_trigger_actions( + "#", + callback, + { cursor = { row = 1, character = #line }, line = line }, + shared_remote() + ) return lists[#lists].items[1] end @@ -297,7 +316,7 @@ T["trailer_patterns_are_fast_on_long_lines"] = function() end T["trailer_routing"] = function() - local core = Core.new({}) + local core = Core.new(shared_hosts()) local calls = {} local function record(name, handled) return function() @@ -343,7 +362,7 @@ T["trailer_routing"] = function() cursor = { row = 3, character = #case.line }, line = case.line, bufnr = bufnr, - }, {}) + }, shared_remote()) eq(table.concat(calls, ","), case.expected, "route for " .. vim.inspect(case.line)) end @@ -563,7 +582,7 @@ T["complete_discovers_the_remote_only_for_actions_that_need_it"] = function() end eq(run(trigger_context(":")), "is_git_repo,commits", "commits need no remote") - eq(run(trigger_context("#")), "is_git_repo,discover,gitlab_issues,github_issues", "issues need the remote") + eq(run(trigger_context("#")), "is_git_repo,discover,github_issues", "issues need the remote") -- The identities action handles the request, so the mention actions after it never run local line = "Co-authored-by: Ada Date: Thu, 22 Oct 2026 21:19:25 +1100 Subject: [PATCH 35/37] test: benchmark the setup and the load of all modules `startup/require` measures only the modules that load with the nvim-cmp and blink.cmp adapters. The other modules now load on the first request that needs them, so their size no longer shows in a benchmark. `startup/setup` measures the modules and the new source of `setup`, and `startup/require_all` loads every module. CONTRIBUTING explains how to keep a module out of the setup. --- CONTRIBUTING.md | 6 +++++ benchmarks/cases/request.lua | 43 +++++++++++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef5945e..dfec94e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,6 +118,12 @@ The `e2e/` cases run the real git commands. A fake `gh` in a temporary directory so the times include the process starts, but not the network or the run time of the real `gh`. `cold` cases use a new source for each request. `warm` cases repeat a request that the cache can answer. +The `startup/` cases measure the cost to load cmp-git. `startup/setup` is the cost of `setup`. Core loads the sources +and the repository module when a request first needs them, so `startup/require_all` loads all the modules. The test +`setup_and_a_declined_request_load_only_the_core_modules` in `tests/test_core.lua` lists the modules that `setup` and a +declined request load. A new `require` at the top of one of these modules adds to the cost of each session. Put the +`require` in the code that needs the module. + Each line of the report has these values: | Value | Meaning | diff --git a/benchmarks/cases/request.lua b/benchmarks/cases/request.lua index 058ac12..7dd01f0 100644 --- a/benchmarks/cases/request.lua +++ b/benchmarks/cases/request.lua @@ -4,6 +4,15 @@ local fixtures = require("benchmarks.fixtures") local COMMITS = 1000 +---Remove the cmp-git modules from the module cache, so that the next `require` loads them again. +local function unload() + for name in pairs(package.loaded) do + if name == "cmp_git" or name:find("^cmp_git%.") then + package.loaded[name] = nil + end + end +end + ---@type bench.Case[] return { { @@ -180,15 +189,39 @@ return { { name = "startup/require", run = function() - for name in pairs(package.loaded) do - if name == "cmp_git" or name:find("^cmp_git%.") then - package.loaded[name] = nil - end - end + unload() require("cmp_git.source") require("cmp_git.blink") end, }, + { + -- What `setup` costs: the modules of the nvim-cmp source and a new source + name = "startup/setup", + run = function() + unload() + require("cmp_git.source").new({}) + end, + }, + { + -- All the code, also the modules that load on the first request that needs them + name = "startup/require_all", + setup = function() + local modules = {} + for _, path in ipairs(vim.api.nvim_get_runtime_file("lua/cmp_git/**/*.lua", true)) do + local name = path:match("/lua/(cmp_git/.*)%.lua$"):gsub("/init$", ""):gsub("/", ".") + if not vim.tbl_contains(modules, name) then + table.insert(modules, name) + end + end + return { modules = modules } + end, + run = function(state) + unload() + for _, name in ipairs(state.modules) do + require(name) + end + end, + }, { name = "startup/new_source", run = function() From 356936e5f8b3d343118fa44172c8de44042efd77 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 21:19:40 +1100 Subject: [PATCH 36/37] fix: parse remotes of GitLab instances under a path prefix The owner of a remote is all path segments before the last one, so that GitLab subgroups work. A GitLab instance can also be under a path prefix, for example `https://example.com/gitlab`. For the remote `https://example.com/gitlab/group/repo.git`, cmp-git used the host `example.com` and the owner `gitlab/group`. The API of this instance is at `https://example.com/gitlab/api/v4`, and the project is `group/repo`, so each GitLab request failed. An entry of `github.hosts` or `gitlab.hosts` can now have a path prefix, such as `example.com/gitlab`. Core gives the GitHub and GitLab hosts to discovery. After the SSH aliases, discovery moves the prefix of a matching entry from the owner to the host. If more than one entry matches, the longest prefix wins. The path of an SSH remote has no prefix. Discovery then uses the prefixed entry if it is the only one for the host, and if the hosts do not have the plain host. With curl, the GitLab API URL is then `https://example.com/gitlab/api/v4`. --- lua/cmp_git/core.lua | 2 + lua/cmp_git/repository/init.lua | 54 ++++++++++- tests/repository/test_init.lua | 153 +++++++++++++++++++++++++++++++- tests/test_core.lua | 26 ++++++ 4 files changed, 232 insertions(+), 3 deletions(-) diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index 38b1e83..ce8840b 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -230,6 +230,7 @@ function Core.new(overrides) }) self.config = config.normalize(overrides) + self.hosts = vim.list_extend(vim.list_extend({}, self.config.github.hosts), self.config.gitlab.hosts) self.filetypes = {} self.sources = lazy_sources(self.config) self.trigger_actions = {} @@ -473,6 +474,7 @@ function Core:_complete(context, callback, routes) require("cmp_git.repository").discover(self.config.remotes, { enableRemoteUrlRewrites = self.config.enableRemoteUrlRewrites, ssh_aliases = self.config.ssh_aliases, + hosts = self.hosts, on_complete = run, bufnr = context.bufnr, }) diff --git a/lua/cmp_git/repository/init.lua b/lua/cmp_git/repository/init.lua index 6ef9c83..b4f4b15 100644 --- a/lua/cmp_git/repository/init.lua +++ b/lua/cmp_git/repository/init.lua @@ -330,6 +330,57 @@ local function apply_ssh_aliases(metadata, ssh_aliases) return metadata end +---Give the metadata the host entry of an instance under a path prefix, such as `example.com/gitlab`. The URL path of +---such an instance starts with the prefix, which is then not part of the owner. +---@param metadata cmp_git.RepositoryMetadata +---@param hosts string[]? The configured hosts. +---@return cmp_git.RepositoryMetadata +local function apply_host_prefixes(metadata, hosts) + local host, owner = metadata.host, metadata.owner + if host == nil or owner == nil or hosts == nil then + return metadata + end + + -- Plain comparisons, because a host can have Lua pattern characters, such as `.`, `-`, or `[` + local host_slash = host .. "/" + local has_plain_host = false + local prefixed_entry ---@type string? + local has_other_prefixed_entries = false + local match_prefix ---@type string? + for _, entry in ipairs(hosts) do + if entry == host then + has_plain_host = true + elseif #entry > #host_slash and entry:sub(1, #host_slash) == host_slash then + -- The GitHub and GitLab lists can both have the same entry + if prefixed_entry == nil then + prefixed_entry = entry + elseif entry ~= prefixed_entry then + has_other_prefixed_entries = true + end + + local prefix = entry:sub(#host_slash + 1) + -- The longest prefix wins, and an owner must be left after it + if + #owner > #prefix + 1 + and owner:sub(1, #prefix + 1) == prefix .. "/" + and (match_prefix == nil or #prefix > #match_prefix) + then + match_prefix = prefix + end + end + end + + if match_prefix ~= nil then + metadata.host = host_slash .. match_prefix + metadata.owner = owner:sub(#match_prefix + 2) + elseif prefixed_entry ~= nil and not has_other_prefixed_entries and not has_plain_host then + -- The path of an SSH remote has no prefix, for example `git@example.com:group/repo.git` + metadata.host = prefixed_entry + end + + return metadata +end + ---@param bufnr integer ---@return cmp_git.RepositoryMetadata? local function discover_octo(bufnr) @@ -349,6 +400,7 @@ end ---@class cmp_git.DiscoverOptions ---@field enableRemoteUrlRewrites boolean ---@field ssh_aliases {[string]: string} +---@field hosts? string[] The configured GitHub and GitLab hosts. An entry can have a path prefix. ---@field on_complete fun(metadata: cmp_git.RepositoryMetadata): nil ---@field bufnr? integer The buffer, by default the current one. @@ -366,7 +418,7 @@ function M.discover(remotes, opts) ---@param metadata cmp_git.RepositoryMetadata local function complete(metadata) - opts.on_complete(apply_ssh_aliases(metadata, opts.ssh_aliases)) + opts.on_complete(apply_host_prefixes(apply_ssh_aliases(metadata, opts.ssh_aliases), opts.hosts)) end if not vim.api.nvim_buf_is_valid(bufnr) then diff --git a/tests/repository/test_init.lua b/tests/repository/test_init.lua index 1aa6fde..53aca3f 100644 --- a/tests/repository/test_init.lua +++ b/tests/repository/test_init.lua @@ -23,7 +23,13 @@ local function is_git_repo() return result end ----@param opts? { remotes?: string[], enableRemoteUrlRewrites?: boolean, ssh_aliases?: table } +---@class tests.DiscoverOptions +---@field remotes? string[] +---@field enableRemoteUrlRewrites? boolean +---@field ssh_aliases? table +---@field hosts? string[] + +---@param opts? tests.DiscoverOptions ---@return cmp_git.RepositoryMetadata local function discover(opts) opts = opts or {} @@ -31,6 +37,7 @@ local function discover(opts) repository.discover(opts.remotes or { "upstream", "origin" }, { enableRemoteUrlRewrites = opts.enableRemoteUrlRewrites or false, ssh_aliases = opts.ssh_aliases or {}, + hosts = opts.hosts, on_complete = function(result) metadata = result end, @@ -262,6 +269,148 @@ T["discover_with_ssh_aliases_as_plain_strings"] = function() eq(discover({ ssh_aliases = { ["gh.work"] = "github.com" } }).host, "gh+work", "a . in an alias is not a pattern") end +---Make the cwd a repository with the remote `origin`, in a new buffer. +---@param url string +local function with_origin(url) + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", url } } }) + new_buffer() + vim.fn.chdir(repo.dir) +end + +T["discover_with_host_prefix"] = function() + with_origin("https://example.com/gitlab/group/repo.git") + local hosts = { "github.com", "gitlab.com", "example.com/gitlab" } + + eq(discover(), { host = "example.com", owner = "gitlab/group", repo = "repo" }, "the URL without hosts") + eq( + discover({ hosts = hosts }), + { host = "example.com/gitlab", owner = "group", repo = "repo" }, + "the prefix moves from the owner to the host" + ) + eq(discover().owner, "gitlab/group", "the prefix does not change the cache") +end + +T["discover_with_host_prefix_of_several_segments"] = function() + with_origin("https://example.com/tools/gitlab/group/repo.git") + + eq( + discover({ hosts = { "example.com/tools/gitlab" } }), + { host = "example.com/tools/gitlab", owner = "group", repo = "repo" }, + "a prefix with two segments" + ) +end + +T["discover_with_longest_host_prefix"] = function() + with_origin("https://example.com/gitlab/internal/group/repo.git") + local expected = { host = "example.com/gitlab/internal", owner = "group", repo = "repo" } + + eq( + discover({ hosts = { "example.com/gitlab", "example.com/gitlab/internal" } }), + expected, + "the longer prefix last" + ) + eq( + discover({ hosts = { "example.com/gitlab/internal", "example.com/gitlab" } }), + expected, + "the longer prefix first" + ) +end + +T["discover_with_host_prefix_and_subgroups"] = function() + with_origin("https://example.com/gitlab/group/sub/repo.git") + + eq( + discover({ hosts = { "example.com/gitlab" } }), + { host = "example.com/gitlab", owner = "group/sub", repo = "repo" }, + "the subgroups stay in the owner" + ) +end + +T["discover_with_host_prefix_and_port"] = function() + with_origin("https://example.com:8443/gitlab/group/repo.git") + + eq( + discover({ hosts = { "example.com/gitlab", "example.com:8443/gitlab" } }), + { host = "example.com:8443/gitlab", owner = "group", repo = "repo" }, + "the entry with the port" + ) + eq( + discover({ hosts = { "example.com/gitlab" } }), + { host = "example.com:8443", owner = "gitlab/group", repo = "repo" }, + "an entry without the port does not apply" + ) +end + +T["discover_with_host_prefix_and_ipv6_address"] = function() + with_origin("https://[::1]:8443/gitlab/group/repo.git") + + eq( + discover({ hosts = { "[::1]:8443/gitlab" } }), + { host = "[::1]:8443/gitlab", owner = "group", repo = "repo" }, + "an IPv6 address is not a pattern" + ) +end + +T["discover_ssh_remote_with_host_prefix"] = function() + with_origin("git@example.com:group/repo.git") + local expected = { host = "example.com/gitlab", owner = "group", repo = "repo" } + + eq(discover({ hosts = { "github.com", "gitlab.com", "example.com/gitlab" } }), expected, "the only prefixed entry") + eq(discover({ hosts = { "example.com/gitlab", "example.com/gitlab" } }), expected, "the same entry two times") + eq( + discover({ hosts = { "example.com/gitlab", "example.com/tools/gitlab" } }), + { host = "example.com", owner = "group", repo = "repo" }, + "two prefixed entries" + ) +end + +T["discover_ssh_alias_with_host_prefix"] = function() + with_origin("git@gitlab-work:group/repo.git") + + eq( + discover({ ssh_aliases = { ["gitlab-work"] = "example.com" }, hosts = { "example.com/gitlab" } }), + { host = "example.com/gitlab", owner = "group", repo = "repo" }, + "the prefix applies to the real host" + ) +end + +T["discover_with_plain_host_and_host_prefix"] = function() + with_origin("git@example.com:group/repo.git") + + eq( + discover({ hosts = { "example.com", "example.com/gitlab" } }), + { host = "example.com", owner = "group", repo = "repo" }, + "the plain host is an instance too" + ) +end + +T["discover_with_owner_equal_to_host_prefix"] = function() + with_origin("https://example.com/gitlab/repo.git") + + eq( + discover({ hosts = { "example.com", "example.com/gitlab" } }), + { host = "example.com", owner = "gitlab", repo = "repo" }, + "no owner is left after the prefix" + ) + eq( + discover({ hosts = { "example.com/gitlab" } }), + { host = "example.com/gitlab", owner = "gitlab", repo = "repo" }, + "the only prefixed entry keeps the owner" + ) +end + +T["discover_with_host_prefix_of_other_host"] = function() + with_origin("https://gitlab.com/gitlab/group/repo.git") + + eq( + discover({ hosts = { "github.com", "gitlab.com", "example.com/gitlab", "gitlab.co/gitlab" } }), + { host = "gitlab.com", owner = "gitlab/group", repo = "repo" }, + "the entries of other hosts do not apply" + ) + eq(discover({ hosts = { "gitlabXcom/gitlab" } }).host, "gitlab.com", "a . in a host is not a pattern") +end + T["discover_octo_buffer"] = function() local octo_config = { values = { github_hostname = "" } } helpers.stub(package.loaded, "octo.config", octo_config) @@ -303,7 +452,7 @@ T["discover_inside_git_dir"] = function() end ---What core does for each completion request. ----@param opts? { remotes?: string[], enableRemoteUrlRewrites?: boolean, ssh_aliases?: table } +---@param opts? tests.DiscoverOptions ---@return cmp_git.RepositoryMetadata? local function request(opts) if not is_git_repo() then diff --git a/tests/test_core.lua b/tests/test_core.lua index 837bdcb..5ff088c 100644 --- a/tests/test_core.lua +++ b/tests/test_core.lua @@ -774,4 +774,30 @@ T["setup_and_a_declined_request_load_only_the_core_modules"] = function() }, "the request answers without the sources, the repository, or the trailer parsers") end +T["complete_passes_the_github_and_gitlab_hosts_to_discovery"] = function() + local core = Core.new({ + github = { hosts = { "github.example.com" } }, + gitlab = { hosts = { "example.com/gitlab" } }, + }) + local hosts + helpers.stub(repository, "is_git_repo", function(on_result) + on_result(true) + end) + helpers.stub(repository, "discover", function(_, opts) + hosts = opts.hosts + opts.on_complete({}) + end) + + local callback, lists = helpers.recorder() + core:complete(trigger_context("#"), callback) + + eq( + hosts, + { "github.example.com", "github.com", "example.com/gitlab", "gitlab.com" }, + "the GitHub hosts, then the GitLab hosts" + ) + eq(core.config.github.hosts, { "github.example.com", "github.com" }, "the GitHub hosts do not change") + expect_one_empty_list(lists, "no source handles a request without a repository") +end + return T From d2aa51e2654665b1807eee57f35c741e657e14ba Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 21:19:40 +1100 Subject: [PATCH 37/37] docs: describe GitLab hosts with a path prefix The docs said that a host entry must be the same as the host in the URL of the remote. A GitLab entry can now also have a path prefix. The section about hosted instances now shows an entry with a path prefix and the API URL that curl uses. It also tells how cmp-git selects the entry for an SSH remote. The README config comment for `gitlab.hosts` shows the format. --- README.md | 2 +- docs/configuration.md | 29 +++++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c5e8b4c..8091f09 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ require("cmp_git").setup({ }, }, gitlab = { - hosts = {}, -- list of private instances of gitlab + hosts = {}, -- list of private instances of gitlab, with the path prefix if any, such as "example.com/gitlab" issues = { limit = 100, state = "opened", -- opened, closed, all diff --git a/docs/configuration.md b/docs/configuration.md index e04ed6c..0fe5719 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -276,8 +276,33 @@ require("cmp_git").setup({ }) ``` -The host must be the same as the host in the URL of the remote. cmp-git always adds `github.com` and `gitlab.com`. -With curl, cmp-git uses `https:///api/v3` for GitHub Enterprise and `https:///api/v4` for GitLab. +Each entry must be the same as the host in the URL of the remote. A GitLab entry can also have a path prefix. +cmp-git always adds `github.com` and `gitlab.com`. With curl, cmp-git uses `https:///api/v3` for GitHub +Enterprise and `https:///api/v4` for GitLab. + +A GitLab instance can have a path prefix in its URL, for example `https://example.com/gitlab`. For this instance, add +the host and the path prefix to `gitlab.hosts`: + +```lua +require("cmp_git").setup({ + gitlab = { + hosts = { "example.com/gitlab" }, + }, +}) +``` + +With this setup, cmp-git reads the remote `https://example.com/gitlab/group/repo.git` as the project `group/repo` on +the instance `example.com/gitlab`. With curl, cmp-git uses `https://example.com/gitlab/api/v4`. A path prefix can +have more than one segment. If more than one entry matches the URL, cmp-git uses the entry with the longest path +prefix. + +The path of an SSH remote, such as `git@example.com:group/repo.git`, has no path prefix. cmp-git uses the entry with +the path prefix for this remote when these conditions are true: + +- `github.hosts` and `gitlab.hosts` have only one entry with a path prefix for `example.com`. +- `github.hosts` and `gitlab.hosts` do not have `example.com` without a path prefix. + +Path prefixes are only for GitLab. GitHub Enterprise does not use a path prefix. ## Troubleshooting