From 9be94b9f8a372d370d8881a2b97714e53a42f1de Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 21:50:37 +1100 Subject: [PATCH 1/6] test: keep the cache directory of the tests and benchmarks in a temporary directory The GitHub and GitLab items will go to a disk cache under `stdpath("cache")`, which follows `$XDG_CACHE_HOME`. The test cases and the benchmarks now set this variable to a temporary directory, so that they never read or write the cache of the user. --- benchmarks/fixtures.lua | 1 + tests/helpers.lua | 3 +++ 2 files changed, 4 insertions(+) diff --git a/benchmarks/fixtures.lua b/benchmarks/fixtures.lua index 0f71a6b..caa4625 100644 --- a/benchmarks/fixtures.lua +++ b/benchmarks/fixtures.lua @@ -296,6 +296,7 @@ function M.isolate_environment() vim.env.HOME = home vim.env.XDG_CONFIG_HOME = home .. "/.config" + vim.env.XDG_CACHE_HOME = home .. "/.cache" vim.env.GIT_CONFIG_GLOBAL = "/dev/null" vim.env.GIT_CONFIG_NOSYSTEM = "1" vim.env.GIT_CEILING_DIRECTORIES = root diff --git a/tests/helpers.lua b/tests/helpers.lua index 009d68d..73f348e 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -104,6 +104,7 @@ local ENV_NAMES = { "GIT_INDEX_FILE", "GIT_WORK_TREE", "HOME", + "XDG_CACHE_HOME", "XDG_CONFIG_HOME", } @@ -185,6 +186,8 @@ local function setup_case() end vim.fn.mkdir(temp_root .. "/home", "p") vim.env.HOME = temp_root .. "/home" + -- The disk cache of the GitHub and GitLab items goes to `stdpath("cache")` + vim.env.XDG_CACHE_HOME = temp_root .. "/cache" vim.env.GIT_CONFIG_GLOBAL = "/dev/null" vim.env.GIT_CONFIG_NOSYSTEM = "1" -- A temporary directory outside a repository must not resolve to a repository above it From 0f875b21dc3737004ffa8a152224a5eab7eec494 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 21:52:04 +1100 Subject: [PATCH 2/6] feat: add a disk cache for the items of each remote A new module `cmp_git.cache` keeps the raw items of one list of one remote, for example the open issues of github.com/owner/repo. The first request for an entry in a session reads its file, and a fetch writes the file. The files are under `stdpath("cache")/cmp-git`, in a directory for each host, owner, and repository. The name of a file has the kind of the list and a hash of the query fields of its config, so that another state, filter, limit, or fields value has its own entry. Only the user can read the files and the directories, because they can hold the issues of private repositories. A write goes to a temporary file and then a rename, so that another Neovim never reads a part of a file. A file with another version or shape is a miss. An entry can refresh its items in the background when they are older than a TTL. One refresh runs at a time, and a failed refresh keeps the items and waits one more TTL. A clear removes the entries and the files of one remote or of all remotes. An entry that a clear removed keeps no more items, so that a fetch that ends after the clear does not bring them back. The sources do not use the module yet. --- lua/cmp_git/cache.lua | 247 ++++++++++++++++++++++++++++++++++++++++++ tests/helpers.lua | 7 ++ tests/test_cache.lua | 203 ++++++++++++++++++++++++++++++++++ 3 files changed, 457 insertions(+) create mode 100644 lua/cmp_git/cache.lua create mode 100644 tests/test_cache.lua diff --git a/lua/cmp_git/cache.lua b/lua/cmp_git/cache.lua new file mode 100644 index 0000000..9e9a0fe --- /dev/null +++ b/lua/cmp_git/cache.lua @@ -0,0 +1,247 @@ +-- The cache of the GitHub and GitLab items. An entry holds the raw items of one list of one remote, and a file under +-- `stdpath("cache")/cmp-git` keeps them for the next sessions. The items are raw and not completion items, because the +-- formats are user functions, and the sort text depends on the time of the request. +local log = require("cmp_git.log") + +local M = {} + +-- A file with another version is a miss +local VERSION = 1 + +-- The cache can hold the issues of private repositories, so only the user can read it +local PRIVATE_DIR = tonumber("700", 8) +local PRIVATE_FILE = tonumber("600", 8) + +-- The config fields that change the query of a list. A list with other values has another entry. +local QUERY_FIELDS = { "state", "filter", "limit", "fields" } + +---The raw items of one list of one remote, in memory and in a file. +---@class cmp_git.CacheEntry +---@field path string The file of the entry. +---@field dir string The directory of the remote. +---@field items? table[] nil until a fetch or the file gives the items. +---@field fetched_at? integer When the items were fetched, in seconds since the epoch. +---@field failed_at? integer When the last refresh failed, in seconds since the epoch. +---@field loaded boolean The file was read. +---@field refreshing boolean A background refresh runs. +---@field dropped boolean A clear removed the entry, so it keeps no more items. +---@field fetch? cmp_git.Provider.MentionFetch The fetch in progress of the mentions. It is only in memory. +local Entry = {} +Entry.__index = Entry + +---@type table By path. +local entries = {} + +---The query hash of each capability config +---@type table +local query_hashes = setmetatable({}, { __mode = "k" }) + +---Encode a name as one path segment. A `/` in a GitLab group or in a host with a path prefix becomes `%2F`, and a `:` +---before a port becomes `%3A`. +---@param name string +---@return string +local function encode(name) + return ( + string.gsub(name, "[^%w._-]", function(char) + return string.format("%%%02X", string.byte(char)) + end) + ) +end + +---The directory of all cache files. +---@return string +function M.root() + return vim.fs.joinpath(vim.fn.stdpath("cache") --[[@as string]], "cmp-git") +end + +---@param git_info cmp_git.GitInfo +---@return string +local function remote_dir(git_info) + return vim.fs.joinpath(M.root(), encode(git_info.host), encode(git_info.owner), encode(git_info.repo)) +end + +---@param config table +---@return string +local function query_hash(config) + local hash = query_hashes[config] + if hash == nil then + local query = {} + for _, field in ipairs(QUERY_FIELDS) do + query[field] = config[field] + end + -- `vim.inspect` sorts the keys, so equal queries give equal text + hash = string.sub(vim.fn.sha256(vim.inspect(query)), 1, 16) + query_hashes[config] = hash + end + return hash +end + +---@param path string +---@return { fetched_at: integer, items: table[] }? +local function read(path) + local file = io.open(path, "rb") + if file == nil then + return nil + end + local content = file:read("*a") + file:close() + + local ok, data = pcall(vim.json.decode, content or "") + if + not ok + or type(data) ~= "table" + or data.version ~= VERSION + or type(data.fetched_at) ~= "number" + or type(data.items) ~= "table" + or not vim.islist(data.items) + then + log.fmt_debug("Ignoring the cache file %s", path) + return nil + end + return data +end + +---Write the file through a temporary file and a rename, so that a reader never gets a part of it. Between two Neovim +---processes, the last writer wins. +---@param path string +---@param dir string +---@param data table +local function write(path, dir, data) + -- `mkdir` gives the mode to each directory that it makes + if vim.fn.isdirectory(dir) == 0 then + pcall(vim.fn.mkdir, dir, "p", PRIVATE_DIR) + end + + local tmp = string.format("%s.tmp.%d", path, vim.uv.os_getpid()) + local fd, open_err = vim.uv.fs_open(tmp, "w", PRIVATE_FILE) + if fd == nil then + log.fmt_debug("Cannot write the cache file %s: %s", tmp, open_err) + return + end + local written, write_err = vim.uv.fs_write(fd, vim.json.encode(data)) + vim.uv.fs_close(fd) + + local renamed, rename_err = nil, nil + if written ~= nil then + renamed, rename_err = vim.uv.fs_rename(tmp, path) + end + if not renamed then + log.fmt_debug("Cannot write the cache file %s: %s", path, write_err or rename_err) + vim.uv.fs_unlink(tmp) + end +end + +---@param path string +local function remove(path) + local ok, err = pcall(vim.fs.rm, path, { recursive = true, force = true }) + if not ok then + log.fmt_debug("Cannot remove %s: %s", path, err) + end +end + +---The entry of a list of a remote. The same arguments give the same entry until a clear. +---@param git_info cmp_git.GitInfo The remote. It needs `host`, `owner`, and `repo`. +---@param kind string The list, such as `github-issues`. +---@param config table The capability config. Its query fields are part of the key. +---@return cmp_git.CacheEntry +function M.entry(git_info, kind, config) + local dir = remote_dir(git_info) + local path = vim.fs.joinpath(dir, string.format("%s-%s.json", kind, query_hash(config))) + local entry = entries[path] + if entry == nil then + entry = setmetatable({ path = path, dir = dir, loaded = false, refreshing = false, dropped = false }, Entry) + entries[path] = entry + end + return entry +end + +---The raw items, or nil on a miss. The first call reads the file. +---@return table[]? +function Entry:get() + if not self.loaded then + self.loaded = true + local data = read(self.path) + if data ~= nil then + self.items = data.items + self.fetched_at = data.fetched_at + end + end + return self.items +end + +---Keep the items in memory and in the file. A dropped entry keeps nothing, so that a fetch that started before a clear +---does not bring the items back. +---@param items table[] +function Entry:set(items) + if self.dropped then + return + end + + self.loaded = true + self.items = items + self.fetched_at = os.time() + self.failed_at = nil + write(self.path, self.dir, { version = VERSION, fetched_at = self.fetched_at, items = items }) +end + +---True when the items are older than `ttl` seconds, and no refresh failed in the last `ttl` seconds. +---@param ttl number +---@return boolean +function Entry:is_stale(ttl) + if self.items == nil or self.fetched_at == nil then + return false + end + + local now = os.time() + return now - self.fetched_at >= ttl and (self.failed_at == nil or now - self.failed_at >= ttl) +end + +---Start a background refresh when the items are stale and no refresh runs. A failure keeps the items. +---@param ttl number +---@param start fun(done: fun(items: table[]?)) Fetches the items. It calls `done` once with them, or with nil when the +---fetch fails. +function Entry:refresh(ttl, start) + if self.refreshing or self.dropped or not self:is_stale(ttl) then + return + end + + self.refreshing = true + start(function(items) + self.refreshing = false + if items ~= nil then + self:set(items) + else + self.failed_at = os.time() + end + end) +end + +---Remove the entries and the files of the remote. +---@param git_info cmp_git.GitInfo +function M.clear_remote(git_info) + local dir = remote_dir(git_info) + for path, entry in pairs(entries) do + if entry.dir == dir then + entry.dropped = true + entries[path] = nil + end + end + remove(dir) +end + +---Remove all entries and the cache directory. +function M.clear_all() + M.forget() + remove(M.root()) +end + +---Remove the entries from memory and keep the files. A fetch that runs keeps nothing, and the next request reads the +---files again, as in a new session. +function M.forget() + for _, entry in pairs(entries) do + entry.dropped = true + end + entries = {} +end + +return M diff --git a/tests/helpers.lua b/tests/helpers.lua index 73f348e..4635404 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -219,6 +219,13 @@ local function teardown_case() case.restores[i]() end + -- The entries are shared by all sources. Dropped entries keep nothing, so a fetch of this case that ends later + -- does not write into the next case. + local cache = package.loaded["cmp_git.cache"] + if cache then + cache.forget() + end + for _, name in ipairs(ENV_NAMES) do -- A nil value unsets the variable vim.env[name] = case.env[name] diff --git a/tests/test_cache.lua b/tests/test_cache.lua new file mode 100644 index 0000000..4b09f5c --- /dev/null +++ b/tests/test_cache.lua @@ -0,0 +1,203 @@ +local helpers = require("tests.helpers") +local cache = require("cmp_git.cache") + +local eq = helpers.eq +local neq = helpers.neq +local ok = helpers.ok + +local T = helpers.new_set() + +local remote = { host = "github.com", owner = "owner", repo = "repo" } +local config = { state = "open", limit = 100, fields = { "title", "number" } } + +---@param path string +---@return integer mode The permission bits. +local function mode(path) + return require("bit").band(helpers.not_nil(vim.uv.fs_stat(path), path .. " exists").mode, tonumber("777", 8)) +end + +---@param path string +---@param content string +local function write_file(path, content) + vim.fn.mkdir(vim.fs.dirname(path), "p") + local file = assert(io.open(path, "wb")) + file:write(content) + file:close() +end + +T["the_same_arguments_give_the_same_entry"] = function() + local entry = cache.entry(remote, "github-issues", config) + + ok(cache.entry(vim.deepcopy(remote), "github-issues", vim.deepcopy(config)) == entry, "equal arguments") + neq(cache.entry(remote, "github-pull_requests", config).path, entry.path, "another kind") + neq(cache.entry(remote, "github-issues", { state = "all", limit = 100 }).path, entry.path, "another state") + neq(cache.entry(remote, "github-issues", { state = "open", limit = 5 }).path, entry.path, "another limit") + eq( + cache.entry(remote, "github-issues", vim.tbl_extend("force", config, { format = {} })).path, + entry.path, + "a field that does not change the query" + ) +end + +T["each_remote_has_its_own_directory"] = function() + local function dir(host, owner, repo) + return cache.entry({ host = host, owner = owner, repo = repo }, "gitlab-issues", config).dir + end + + eq(dir("github.com", "owner", "repo"), cache.root() .. "/github.com/owner/repo", "a readable path") + neq(dir("example.com/gitlab", "a", "r"), dir("example.com", "gitlab/a", "r"), "a path prefix is not a group") + eq(dir("example.com:8443", "a/b", "r"), cache.root() .. "/example.com%3A8443/a%2Fb/r", "ports and subgroups") + eq(vim.fn.stdpath("cache") .. "/cmp-git", cache.root(), "the cache directory of Neovim") +end + +T["a_new_session_reads_the_items_from_the_file"] = function() + local items = { { number = 1, title = "an issue", body = vim.NIL, labels = { "bug" } } } + cache.entry(remote, "github-issues", config):set(items) + + cache.forget() + local entry = cache.entry(remote, "github-issues", config) + eq(entry:get(), items, "the items of the file, with null as vim.NIL") + ok(math.abs(helpers.not_nil(entry.fetched_at) - os.time()) <= 1, "the time of the fetch") +end + +T["only_the_user_can_read_the_files"] = function() + local entry = cache.entry({ host = "example.com/gitlab", owner = "a/b", repo = "r" }, "gitlab-issues", config) + entry:set({}) + + eq(mode(entry.path), tonumber("600", 8), "the file") + for _, dir in ipairs({ entry.dir, vim.fs.dirname(entry.dir), cache.root() }) do + eq(mode(dir), tonumber("700", 8), dir) + end + eq(vim.fn.glob(entry.dir .. "/*.tmp.*"), "", "no temporary file stays") +end + +T["a_file_that_is_not_an_entry_is_a_miss"] = function() + local files = { + ["invalid JSON"] = "{", + ["another version"] = vim.json.encode({ version = 2, fetched_at = 1, items = {} }), + ["no time"] = vim.json.encode({ version = 1, items = {} }), + ["no list"] = vim.json.encode({ version = 1, fetched_at = 1, items = { a = 1 } }), + ["not an object"] = "[]", + } + for name, content in pairs(files) do + cache.forget() + local entry = cache.entry(remote, "github-issues", config) + write_file(entry.path, content) + + eq(entry:get(), nil, name) + + entry:set({ { number = 2 } }) + cache.forget() + eq( + cache.entry(remote, "github-issues", config):get(), + { { number = 2 } }, + name .. ": the next fetch replaces it" + ) + end +end + +T["a_miss_reads_the_file_once"] = function() + local entry = cache.entry(remote, "github-issues", config) + eq(entry:get(), nil, "no file") + + write_file(entry.path, vim.json.encode({ version = 1, fetched_at = os.time(), items = { {} } })) + eq(entry:get(), nil, "the entry does not read the file again") +end + +T["stale_items_refresh_once_in_the_background"] = function() + local entry = cache.entry(remote, "github-issues", config) + local starts = {} + local function start(done) + table.insert(starts, done) + end + + entry:refresh(0, start) + eq(#starts, 0, "an entry without items does not refresh") + + entry:set({ { number = 1 } }) + entry:refresh(300, start) + eq(#starts, 0, "fresh items do not refresh") + + entry.fetched_at = os.time() - 300 + ok(entry:is_stale(300), "items of the age of the TTL are stale") + entry:refresh(300, start) + entry:refresh(300, start) + eq(#starts, 1, "one refresh runs at a time") + eq(entry:get(), { { number = 1 } }, "the stale items stay during the refresh") + + starts[1]({ { number = 2 } }) + eq(entry:get(), { { number = 2 } }, "the refresh stores its items") + ok(not entry:is_stale(300), "the new items are fresh") + cache.forget() + eq(cache.entry(remote, "github-issues", config):get(), { { number = 2 } }, "and writes them") + + entry:refresh(math.huge, start) + eq(#starts, 1, "math.huge never refreshes") +end + +T["a_failed_refresh_keeps_the_items_and_waits_a_ttl"] = function() + local entry = cache.entry(remote, "github-issues", config) + entry:set({ { number = 1 } }) + entry.fetched_at = os.time() - 1000 + local starts = 0 + local function fail(done) + starts = starts + 1 + done(nil) + end + + entry:refresh(300, fail) + eq(entry:get(), { { number = 1 } }, "the items stay") + entry:refresh(300, fail) + eq(starts, 1, "the next refresh waits") + + entry.failed_at = os.time() - 300 + entry:refresh(300, fail) + eq(starts, 2, "a refresh runs again after the TTL") +end + +T["a_clear_removes_one_remote"] = function() + local other = { host = "gitlab.com", owner = "group", repo = "project" } + local entry = cache.entry(remote, "github-issues", config) + local other_entry = cache.entry(other, "gitlab-issues", config) + entry:set({ { number = 1 } }) + other_entry:set({ { iid = 1 } }) + + cache.clear_remote(vim.deepcopy(remote)) + + eq(vim.fn.isdirectory(entry.dir), 0, "the directory of the remote is removed") + eq(vim.fn.filereadable(other_entry.path), 1, "the other remote keeps its file") + ok(cache.entry(remote, "github-issues", config) ~= entry, "a request after the clear gets a new entry") + eq(cache.entry(remote, "github-issues", config):get(), nil, "and no items") + ok(cache.entry(other, "gitlab-issues", config) == other_entry, "the other remote keeps its entry") + + -- A fetch that started before the clear + entry:set({ { number = 2 } }) + entry:refresh(0, function(done) + done({ { number = 3 } }) + end) + eq(vim.fn.isdirectory(entry.dir), 0, "a fetch that ends after the clear writes no file") +end + +T["a_clear_of_all_removes_the_cache_directory"] = function() + local entry = cache.entry(remote, "github-issues", config) + entry:set({ { number = 1 } }) + + cache.clear_all() + + eq(vim.fn.isdirectory(cache.root()), 0, "the directory is removed") + eq(cache.entry(remote, "github-issues", config):get(), nil, "no items") + entry:set({ { number = 2 } }) + eq(vim.fn.isdirectory(cache.root()), 0, "a fetch that ends after the clear writes no file") +end + +T["a_failed_write_keeps_the_items_in_memory"] = function() + -- A file where the cache directory must be + write_file(cache.root(), "") + local entry = cache.entry(remote, "github-issues", config) + + entry:set({ { number = 1 } }) + + eq(entry:get(), { { number = 1 } }, "the items stay in memory") +end + +return T From 6e72ad8279a6ec5129738c2033efd7d87af4f84f Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 21:57:59 +1100 Subject: [PATCH 3/6] feat: keep the GitHub and GitLab items of each remote across sessions The issues, pull requests, merge requests, and mentions were cached for each buffer and only in memory. Each commit message from `git commit` starts a new Neovim, and each new buffer has a new number, so each of them waited for the network again. The GitHub and GitLab sources now keep the items in the disk cache, keyed by the remote that discovery finds. All buffers and all source instances of one remote share the entries. A cache hit in a new session reads the file and starts no process, also not `gh repo set-default`. The capabilities cache the raw items and format them for each request, so that the format of the request and the current time give the labels and the sort text. A failure is still not cached. When the items are older than the new `cache.ttl` option (300 seconds by default), a request still gets them at once, and one refresh starts in the background. The refresh asks gh for the default repository again, and its items are for the next request. A refresh of the mentions gets all pages. A failed refresh keeps the items, and the next refresh waits one more TTL. --- lua/cmp_git/config.lua | 8 + lua/cmp_git/core.lua | 4 +- lua/cmp_git/format.lua | 13 ++ .../provider/capabilities/change_requests.lua | 11 +- lua/cmp_git/provider/capabilities/issues.lua | 70 +++++- .../provider/capabilities/mentions.lua | 148 +++++++------ lua/cmp_git/sources/github.lua | 154 +++++++------ lua/cmp_git/sources/gitlab.lua | 97 ++++---- .../capabilities/test_change_requests.lua | 96 +++++--- tests/provider/capabilities/test_issues.lua | 177 +++++++++++---- tests/provider/capabilities/test_mentions.lua | 207 ++++++++++++------ tests/sources/test_github.lua | 180 +++++++++++++-- tests/sources/test_gitlab.lua | 58 ++++- tests/test_config.lua | 5 + 14 files changed, 865 insertions(+), 363 deletions(-) diff --git a/lua/cmp_git/config.lua b/lua/cmp_git/config.lua index 9706077..ab20ad2 100644 --- a/lua/cmp_git/config.lua +++ b/lua/cmp_git/config.lua @@ -52,6 +52,14 @@ local defaults = { enableRemoteUrlRewrites = false, -- enable git url rewrites, see https://git-scm.com/docs/git-config#Documentation/git-config.txt-urlltbasegtinsteadOf ---@type table ssh_aliases = {}, + ---The disk cache of the GitHub and GitLab items + ---@class cmp_git.Config.Cache + cache = { + ---Seconds after which a request also refreshes the items in the background. 0 refreshes after each request, + ---and math.huge never refreshes. + ---@type number + ttl = 300, + }, ---@class cmp_git.Config.Git ---@field filter_fn? fun(trigger_char: string, item: cmp_git.Commit): string Compatibility alias for the commits format.filterText function. It does not apply to trailers. git = { diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index ce8840b..a0c03bb 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -215,7 +215,7 @@ local function lazy_sources(core_config) return nil end - local source = require(module).new(core_config[name]) + local source = require(module).new(core_config[name], core_config.cache) rawset(sources, name, source) return source end, @@ -314,7 +314,7 @@ 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 +---Copy the items, and give each copy a text edit that replaces the typed text. A legacy `action` can give the same ---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[] diff --git a/lua/cmp_git/format.lua b/lua/cmp_git/format.lua index 07ba26f..55b6947 100644 --- a/lua/cmp_git/format.lua +++ b/lua/cmp_git/format.lua @@ -260,4 +260,17 @@ function M.item(config, trigger_char, item) } end +---@generic TItem +---@param config { format: cmp_git.FormatConfig, sort_by: string | fun(item: TItem): string } +---@param trigger_char string? nil for pattern trigger actions without a trigger character +---@param items TItem[] +---@return cmp_git.CompletionItem[] +function M.items(config, trigger_char, items) + local result = {} + for index, item in ipairs(items) do + result[index] = M.item(config, trigger_char, item) + end + return result +end + return M diff --git a/lua/cmp_git/provider/capabilities/change_requests.lua b/lua/cmp_git/provider/capabilities/change_requests.lua index b434c8f..a2d0578 100644 --- a/lua/cmp_git/provider/capabilities/change_requests.lua +++ b/lua/cmp_git/provider/capabilities/change_requests.lua @@ -5,10 +5,7 @@ local M = {} ---@param args cmp_git.Provider.CapabilityArgs ---@return cmp_git.SystemJob? function M.job(args) - local bufnr = args.bufnr or vim.api.nvim_get_current_buf() - - if args.cache[bufnr] then - args.callback({ items = args.cache[bufnr], isIncomplete = false }) + if issues.from_cache(args) then return nil end @@ -24,4 +21,10 @@ function M.complete(args) return job end +---@param args cmp_git.Provider.RefreshArgs +---@param done fun(items: table[]?) +function M.refresh(args, done) + issues.fetch(args.runner, args.adapter.change_requests_request(args.git_info, args.trigger_char, args.config), done) +end + return M diff --git a/lua/cmp_git/provider/capabilities/issues.lua b/lua/cmp_git/provider/capabilities/issues.lua index a015e9f..45e6e0a 100644 --- a/lua/cmp_git/provider/capabilities/issues.lua +++ b/lua/cmp_git/provider/capabilities/issues.lua @@ -1,4 +1,5 @@ local command = require("cmp_git.command") +local format = require("cmp_git.format") local M = {} @@ -7,15 +8,46 @@ local M = {} ---@field handle_item fun(item: any): cmp_git.CompletionItem ---@field handle_parsed? fun(parsed: any): any[]? A result that is not a list is a failure. ----@class cmp_git.Provider.CapabilityArgs +---The arguments of a refresh. A refresh only fills the cache, so it has no callback. +---@class cmp_git.Provider.RefreshArgs ---@field adapter table ----@field cache table ----@field callback fun(list: cmp_git.CompletionList) ---@field config table ---@field git_info cmp_git.GitInfo ---@field trigger_char string ---@field runner? table ----@field bufnr? integer + +---@class cmp_git.Provider.CapabilityArgs: cmp_git.Provider.RefreshArgs +---@field cache cmp_git.CacheEntry +---@field callback fun(list: cmp_git.CompletionList) + +---A capability module, such as this one. +---@class cmp_git.Provider.Capability +---@field complete fun(args: cmp_git.Provider.CapabilityArgs): table? +---@field refresh fun(args: cmp_git.Provider.RefreshArgs, done: fun(items: table[]?)) + +---The raw items of the completion items. `format.item` keeps the raw item in `data`. +---@param items cmp_git.CompletionItem[] +---@return table[] +function M.raw_items(items) + local raw = {} + for index, item in ipairs(items) do + raw[index] = item.data + end + return raw +end + +---Give the cached items to the callback, formatted for this request. +---@param args cmp_git.Provider.CapabilityArgs +---@return boolean hit +function M.from_cache(args) + local items = args.cache:get() + if items == nil then + return false + end + + args.callback({ items = format.items(args.config, args.trigger_char, items), isIncomplete = false }) + return true +end ---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. @@ -23,7 +55,6 @@ local M = {} ---@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 @@ -31,7 +62,7 @@ function M.list_job(args, request) return end - args.cache[bufnr] = list.items + args.cache:set(M.raw_items(list.items)) args.callback(list) end, request.handle_item, request.handle_parsed) @@ -41,13 +72,26 @@ function M.list_job(args, request) return job end +---Start a list request for the cache only. +---@param runner? table +---@param request cmp_git.Provider.ListRequest +---@param done fun(items: table[]?) Gets the raw items, or nil when the request fails or cannot run. +function M.fetch(runner, request, done) + local job = (runner or command).build_fallback_list(request.commands, function(list, success) + done(success and M.raw_items(list.items) or nil) + end, request.handle_item, request.handle_parsed) + + if not job then + done(nil) + return + end + job:start() +end + ---@param args cmp_git.Provider.CapabilityArgs ---@return cmp_git.SystemJob? function M.job(args) - local bufnr = args.bufnr or vim.api.nvim_get_current_buf() - - if args.cache[bufnr] then - args.callback({ items = args.cache[bufnr], isIncomplete = false }) + if M.from_cache(args) then return nil end @@ -63,4 +107,10 @@ function M.complete(args) return job end +---@param args cmp_git.Provider.RefreshArgs +---@param done fun(items: table[]?) +function M.refresh(args, done) + M.fetch(args.runner, args.adapter.issues_request(args.git_info, args.trigger_char, args.config), done) +end + return M diff --git a/lua/cmp_git/provider/capabilities/mentions.lua b/lua/cmp_git/provider/capabilities/mentions.lua index 12519d7..c56f388 100644 --- a/lua/cmp_git/provider/capabilities/mentions.lua +++ b/lua/cmp_git/provider/capabilities/mentions.lua @@ -1,42 +1,45 @@ local command = require("cmp_git.command") +local format = require("cmp_git.format") +local issues = require("cmp_git.provider.capabilities.issues") 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. +---A fetch of the pages of mentions. The requests that wait for it share it. ---@class cmp_git.Provider.MentionFetch ----@field subscribers { callback: fun(list: cmp_git.CompletionList) }[] +---@field items table[] The raw users of the pages so far. +---@field subscribers cmp_git.Provider.MentionSubscriber[] ---@field active? cmp_git.SystemJob ---@field cancelled boolean - ----@class cmp_git.Provider.MentionCapabilityArgs: cmp_git.Provider.CapabilityArgs ----@field cache table - ----@param subscribers { callback: fun(list: cmp_git.CompletionList) }[] ----@param list cmp_git.CompletionList -local function notify(subscribers, list) +---@field done boolean + +---A request that waits for the pages of a fetch. Each request formats the users with its own config. +---@class cmp_git.Provider.MentionSubscriber +---@field callback fun(list: cmp_git.CompletionList) +---@field config table +---@field trigger_char string + +---@param subscribers cmp_git.Provider.MentionSubscriber[] +---@param items table[] The raw users. +---@param incomplete boolean +local function notify(subscribers, items, incomplete) -- A copy, because a callback can cancel its request for _, subscriber in ipairs(vim.list_extend({}, subscribers)) do - subscriber.callback(list) + subscriber.callback({ + items = format.items(subscriber.config, subscriber.trigger_char, items), + isIncomplete = incomplete, + }) 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 } +---Add a request to a fetch of the entry. The fetch stops when all its requests are cancelled. +---@param entry cmp_git.CacheEntry +---@param fetch cmp_git.Provider.MentionFetch +---@param args cmp_git.Provider.CapabilityArgs +local function subscribe(entry, fetch, args) + local subscriber = { callback = args.callback, config = args.config, trigger_char = args.trigger_char } table.insert(fetch.subscribers, subscriber) return { @@ -48,14 +51,14 @@ local function subscribe(cache, bufnr, entry, fetch, callback) break end end - if #fetch.subscribers > 0 or fetch.cancelled or not entry.in_progress then + if #fetch.subscribers > 0 or fetch.cancelled or fetch.done then return end - -- Otherwise the entry stays in progress, and later requests wait for pages that never come + -- Otherwise the entry keeps the fetch, and later requests wait for pages that never come fetch.cancelled = true - if cache[bufnr] == entry then - cache[bufnr] = nil + if entry.fetch == fetch then + entry.fetch = nil end if fetch.active and fetch.active.cancel then fetch.active:cancel() @@ -64,11 +67,12 @@ local function subscribe(cache, bufnr, entry, fetch, callback) } end ----@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) +---Get the pages of users into `fetch.items`. +---@param args cmp_git.Provider.RefreshArgs +---@param fetch cmp_git.Provider.MentionFetch +---@param on_page fun() Runs after each page before the last. +---@param on_done fun(success: boolean) Runs once after the last page or a failure, unless the fetch is cancelled. +local function start_fetch(args, fetch, on_page, on_done) 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 @@ -76,15 +80,9 @@ local function start_fetch(args, bufnr, entry, fetch) ---@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 }) + fetch.done = true + fetch.active = nil + on_done(success) end if limit < 1 then @@ -108,7 +106,7 @@ local function start_fetch(args, bufnr, entry, fetch) return end - local items = vim.list_extend(vim.list_extend({}, entry.items), list.items) + local items = vim.list_extend(vim.list_extend({}, fetch.items), issues.raw_items(list.items)) local has_more if args.adapter.mentions_has_more then has_more = args.adapter.mentions_has_more(list, items, args.config, context) @@ -122,14 +120,14 @@ local function start_fetch(args, bufnr, entry, fetch) items[i] = nil end end - entry.items = items + fetch.items = items if not has_more then finish(true) return end - notify(fetch.subscribers, { items = items, isIncomplete = true }) + on_page() fetch_page(page + 1, context) end, request.handle_item, request.handle_parsed) @@ -169,32 +167,58 @@ local function start_fetch(args, bufnr, entry, fetch) 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 cmp_git.Provider.MentionFetch +local function new_fetch() + return { items = {}, subscribers = {}, cancelled = false, done = false } +end + +---Give the mentions of the remote. 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. A request during a fetch joins +---it. +---@param args cmp_git.Provider.CapabilityArgs ---@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 }) + if issues.from_cache(args) then return nil end - if entry then - if #entry.items > 0 then - args.callback({ items = entry.items, isIncomplete = true }) + local entry = args.cache + local fetch = entry.fetch + if fetch then + if #fetch.items > 0 then + args.callback({ items = format.items(args.config, args.trigger_char, fetch.items), isIncomplete = true }) end - return subscribe(args.cache, bufnr, entry, entry.fetch --[[@as cmp_git.Provider.MentionFetch]], args.callback) + return subscribe(entry, fetch, args) 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) + fetch = new_fetch() + entry.fetch = fetch + local controller = subscribe(entry, fetch, args) + start_fetch(args, fetch, function() + notify(fetch.subscribers, fetch.items, true) + end, function(success) + if entry.fetch == fetch then + entry.fetch = nil + end + -- A failure is not cached, so that the next request tries again. The pages before it are still shown. + if success then + entry:set(fetch.items) + end + local subscribers = fetch.subscribers + fetch.subscribers = {} + notify(subscribers, fetch.items, false) + end) return controller end +---Get all pages for the cache only. +---@param args cmp_git.Provider.RefreshArgs +---@param done fun(items: table[]?) Gets the raw users, or nil when a page fails. +function M.refresh(args, done) + local fetch = new_fetch() + start_fetch(args, fetch, function() end, function(success) + done(success and fetch.items or nil) + end) +end + return M diff --git a/lua/cmp_git/sources/github.lua b/lua/cmp_git/sources/github.lua index 8f65917..efd1ef8 100644 --- a/lua/cmp_git/sources/github.lua +++ b/lua/cmp_git/sources/github.lua @@ -1,16 +1,11 @@ local adapter = require("cmp_git.provider.adapters.github") +local cache = require("cmp_git.cache") 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") local log = require("cmp_git.log") ----@class cmp_git.Source.GitHub.Cache ----@field issues table ----@field mentions table ----@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 @@ -20,26 +15,21 @@ local log = require("cmp_git.log") ---@class cmp_git.Source.GitHub ---@field config cmp_git.Config.GitHub ----@field cache cmp_git.Source.GitHub.Cache +---@field cache_config cmp_git.Config.Cache ---@field default_repos table By `host/owner/repo` of the remote. local GitHub = {} ---@param overrides cmp_git.Config.GitHub -function GitHub.new(overrides) +---@param cache_config? cmp_git.Config.Cache +function GitHub.new(overrides, cache_config) local self = setmetatable({}, { __index = GitHub, }) - local change_requests_cache = {} - self.cache = { - issues = {}, - mentions = {}, - change_requests = change_requests_cache, - pull_requests = change_requests_cache, - } self.default_repos = {} self.config = overrides or {} + self.cache_config = cache_config or require("cmp_git.config").cache return self end @@ -141,35 +131,62 @@ function GitHub:_with_repo(git_info, bufnr, cached, start) } end ----@param callback fun(list: cmp_git.CompletionList) +---The cache entry of a list of the remote. The key is the remote that discovery found and not the default repository, +---so that a hit needs no gh. A refresh asks gh again, so the entry follows a change of the default repository. +---@param kind "issues" | "pull_requests" | "mentions" +---@param git_info cmp_git.GitInfo +---@return cmp_git.CacheEntry +function GitHub:_entry(kind, git_info) + return cache.entry(git_info, "github-" .. kind, self.config[kind]) +end + +---Refresh the entry in the background when its items are stale. +---@param entry cmp_git.CacheEntry +---@param capability cmp_git.Provider.Capability +---@param kind "issues" | "pull_requests" | "mentions" ---@param git_info cmp_git.GitInfo ---@param trigger_char string ---@param bufnr integer -function GitHub:_issues_job(callback, git_info, trigger_char, bufnr) +function GitHub:_refresh(entry, capability, kind, git_info, trigger_char, bufnr) + entry:refresh(self.cache_config.ttl, function(done) + self:_with_repo(git_info, bufnr, false, function(repo_info) + capability.refresh({ + adapter = adapter, + config = self.config[kind], + git_info = repo_info, + trigger_char = trigger_char, + }, done) + end) + end) +end + +---@param callback fun(list: cmp_git.CompletionList) +---@param git_info cmp_git.GitInfo +---@param trigger_char string +---@param entry cmp_git.CacheEntry +function GitHub:_issues_job(callback, git_info, trigger_char, entry) return issues.job({ adapter = adapter, - cache = self.cache.issues, + cache = entry, callback = callback, 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 ----@param bufnr integer -function GitHub:_change_requests_job(callback, git_info, trigger_char, bufnr) +---@param entry cmp_git.CacheEntry +function GitHub:_change_requests_job(callback, git_info, trigger_char, entry) return change_requests.job({ adapter = adapter, - cache = self.cache.change_requests, + cache = entry, callback = callback, config = self.config.pull_requests, git_info = git_info, trigger_char = trigger_char, - bufnr = bufnr, }) end @@ -184,14 +201,16 @@ function GitHub:complete_issues(callback, git_info, trigger_char, bufnr) -- The buffer of the request, also when the lookup of the default repository ends in another buffer 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) - if job then - job:start() - end - return job - end) + local entry = self:_entry("issues", git_info) + local job = self:_with_repo(git_info, bufnr, entry:get() ~= nil, function(repo_info) + local request = self:_issues_job(callback, repo_info, trigger_char, entry) + if request then + request:start() + end + return request + end) + self:_refresh(entry, issues, "issues", git_info, trigger_char, bufnr) + return true, job end ---@param callback fun(list: cmp_git.CompletionList) @@ -204,21 +223,24 @@ function GitHub:complete_change_requests(callback, git_info, trigger_char, bufnr end 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) - if job then - job:start() - end - return job - end) + local entry = self:_entry("pull_requests", git_info) + local job = self:_with_repo(git_info, bufnr, entry:get() ~= nil, function(repo_info) + local request = self:_change_requests_job(callback, repo_info, trigger_char, entry) + if request then + request:start() + end + return request + end) + self:_refresh(entry, change_requests, "pull_requests", git_info, trigger_char, bufnr) + return true, job end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string ----@param bufnr integer -function GitHub:_issues_and_change_requests(callback, git_info, trigger_char, bufnr) +---@param issues_entry cmp_git.CacheEntry +---@param change_requests_entry cmp_git.CacheEntry +function GitHub:_issues_and_change_requests(callback, git_info, trigger_char, issues_entry, change_requests_entry) local results = {} local pending = 2 local cancelled = false @@ -237,15 +259,16 @@ function GitHub:_issues_and_change_requests(callback, git_info, trigger_char, bu return end - -- A new list, so that the issue cache does not get the pull requests - local items = vim.list_extend(vim.list_extend({}, results.issues), results.change_requests) + -- Each request gets new lists, because the cache keeps the raw items + local items = vim.list_extend(results.issues, results.change_requests) log.fmt_debug("Got %d issues and prs", #items) callback({ items = items, isIncomplete = false }) end end - 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) + local issues_job = self:_issues_job(collect("issues"), git_info, trigger_char, issues_entry) + local change_requests_job = + self:_change_requests_job(collect("change_requests"), git_info, trigger_char, change_requests_entry) -- 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() @@ -277,11 +300,15 @@ function GitHub:complete_issues_and_change_requests(callback, git_info, trigger_ end 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) - return self:_issues_and_change_requests(callback, repo_info, trigger_char, bufnr) - end) + local issues_entry = self:_entry("issues", git_info) + local change_requests_entry = self:_entry("pull_requests", git_info) + local cached = issues_entry:get() ~= nil and change_requests_entry:get() ~= nil + local job = self:_with_repo(git_info, bufnr, cached, function(repo_info) + return self:_issues_and_change_requests(callback, repo_info, trigger_char, issues_entry, change_requests_entry) + end) + self:_refresh(issues_entry, issues, "issues", git_info, trigger_char, bufnr) + self:_refresh(change_requests_entry, change_requests, "pull_requests", git_info, trigger_char, bufnr) + return true, job end ---@param callback fun(list: cmp_git.CompletionList) @@ -293,20 +320,21 @@ function GitHub:complete_mentions(callback, git_info, trigger_char, bufnr) return false end - -- A request during a fetch joins it, so it needs no repository either 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({ - adapter = adapter, - cache = self.cache.mentions, - callback = callback, - config = self.config.mentions, - git_info = repo_info, - trigger_char = trigger_char, - bufnr = bufnr, - }) - end) + local entry = self:_entry("mentions", git_info) + -- A request during a fetch joins it, so it needs no repository either + local job = self:_with_repo(git_info, bufnr, entry:get() ~= nil or entry.fetch ~= nil, function(repo_info) + return mentions.complete({ + adapter = adapter, + cache = entry, + callback = callback, + config = self.config.mentions, + git_info = repo_info, + trigger_char = trigger_char, + }) + end) + self:_refresh(entry, mentions, "mentions", git_info, trigger_char, bufnr) + return true, job end function GitHub:get_issues(callback, git_info, trigger_char) diff --git a/lua/cmp_git/sources/gitlab.lua b/lua/cmp_git/sources/gitlab.lua index a41bc32..4cd1647 100644 --- a/lua/cmp_git/sources/gitlab.lua +++ b/lua/cmp_git/sources/gitlab.lua @@ -1,34 +1,23 @@ local adapter = require("cmp_git.provider.adapters.gitlab") +local cache = require("cmp_git.cache") 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") ----@class cmp_git.Source.Gitlab.Cache ----@field issues table ----@field mentions table ----@field change_requests table ----@field merge_requests table The `change_requests` table. - ---@class cmp_git.Source.Gitlab ---@field config cmp_git.Config.Gitlab ----@field cache cmp_git.Source.Gitlab.Cache +---@field cache_config cmp_git.Config.Cache local GitLab = {} ---@param overrides cmp_git.Config.Gitlab -function GitLab.new(overrides) +---@param cache_config? cmp_git.Config.Cache +function GitLab.new(overrides, cache_config) local self = setmetatable({}, { __index = GitLab, }) - local change_requests_cache = {} - self.cache = { - issues = {}, - mentions = {}, - change_requests = change_requests_cache, - merge_requests = change_requests_cache, - } - self.config = overrides or {} + self.cache_config = cache_config or require("cmp_git.config").cache return self end @@ -46,70 +35,74 @@ function GitLab:is_valid_host(git_info) return true end +---@param kind "issues" | "merge_requests" | "mentions" +---@param git_info cmp_git.GitInfo +---@return cmp_git.CacheEntry +function GitLab:_entry(kind, git_info) + return cache.entry(git_info, "gitlab-" .. kind, self.config[kind]) +end + +---Start the request of a capability, then refresh its entry in the background when the items are stale. +---@param capability cmp_git.Provider.Capability +---@param kind "issues" | "merge_requests" | "mentions" ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string ----@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 - - local job = issues.complete({ +---@return table? job +function GitLab:_complete(capability, kind, callback, git_info, trigger_char) + local entry = self:_entry(kind, git_info) + local job = capability.complete({ adapter = adapter, - cache = self.cache.issues, + cache = entry, callback = callback, - config = self.config.issues, + config = self.config[kind], git_info = git_info, trigger_char = trigger_char, - bufnr = bufnr, }) - return true, job + entry:refresh(self.cache_config.ttl, function(done) + capability.refresh({ + adapter = adapter, + config = self.config[kind], + git_info = git_info, + trigger_char = trigger_char, + }, done) + end) + + return job end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string ----@param bufnr? integer The buffer of the request, by default the current one. -function GitLab:complete_mentions(callback, git_info, trigger_char, bufnr) +function GitLab:complete_issues(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, - bufnr = bufnr, - }) - - return true, job + return true, self:_complete(issues, "issues", callback, git_info, trigger_char) end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string ----@param bufnr? integer The buffer of the request, by default the current one. -function GitLab:complete_change_requests(callback, git_info, trigger_char, bufnr) +function GitLab:complete_mentions(callback, git_info, trigger_char) if not self:is_valid_host(git_info) then return false end - local job = change_requests.complete({ - adapter = adapter, - cache = self.cache.change_requests, - callback = callback, - config = self.config.merge_requests, - git_info = git_info, - trigger_char = trigger_char, - bufnr = bufnr, - }) + return true, self:_complete(mentions, "mentions", callback, git_info, trigger_char) +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) + if not self:is_valid_host(git_info) then + return false + end - return true, job + return true, self:_complete(change_requests, "merge_requests", callback, git_info, trigger_char) end function GitLab:get_issues(callback, git_info, trigger_char) diff --git a/tests/provider/capabilities/test_change_requests.lua b/tests/provider/capabilities/test_change_requests.lua index 7b640f3..2c5a63b 100644 --- a/tests/provider/capabilities/test_change_requests.lua +++ b/tests/provider/capabilities/test_change_requests.lua @@ -1,4 +1,7 @@ local helpers = require("tests.helpers") +local cache = require("cmp_git.cache") +local config = require("cmp_git.config") +local format = require("cmp_git.format") local change_request_capability = require("cmp_git.provider.capabilities.change_requests") local eq = helpers.eq @@ -6,42 +9,58 @@ local neq = helpers.neq local T = helpers.new_set() -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 }, true) - end) - end, - } - local adapter = { - change_requests_request = function() - return { - commands = { { exec = "fake", args = {} } }, - handle_item = function(item) - return { label = item.name } - end, - } - end, - } +local git_info = { host = "github.com", owner = "owner", repo = "repo" } + +---@return cmp_git.Config.GitHub.PullRequest +local function pr_config() + return config.normalize({}).github.pull_requests +end + +local pr = { number = 2, title = "a pull request", body = "", updatedAt = "2024-01-01T00:00:00Z", state = "open" } - local job = change_request_capability.complete({ +local adapter = { + change_requests_request = function(_git_info, trigger_char, capability_config) + return { + commands = { { exec = "fake", args = {} } }, + handle_item = function(item) + return format.item(capability_config, trigger_char, item) + end, + } + end, +} + +local runner = { + build_fallback_list = function(_commands, callback, handle_item) + return helpers.fake_job(function() + callback({ items = { handle_item(vim.deepcopy(pr)) }, isIncomplete = false }, true) + end) + end, +} + +T["change_request_capability_returns_job"] = function() + local capability_config = pr_config() + local callback, lists = helpers.recorder() + local args = { adapter = adapter, - cache = {}, - callback = function() end, - config = {}, - git_info = {}, + cache = cache.entry(git_info, "github-pull_requests", capability_config), + callback = callback, + config = capability_config, + git_info = git_info, trigger_char = "#", runner = runner, - bufnr = 45, - }) + } + + local job = change_request_capability.complete(args) + local cached_job = change_request_capability.complete(args) neq(job, nil, "change request capability returns started job") + eq(cached_job, nil, "a cache hit returns no job") + eq(lists[2].items[1].label, "#2: a pull request", "a hit formats the cached pull request") end T["failure_gives_an_empty_list_and_is_not_cached"] = function() local runs = 0 - local cache = {} + local capability_config = pr_config() local callback, lists = helpers.recorder() local args = { adapter = { @@ -49,10 +68,10 @@ T["failure_gives_an_empty_list_and_is_not_cached"] = function() return { commands = {}, handle_item = function() end } end, }, - cache = cache, + cache = cache.entry(git_info, "github-pull_requests", capability_config), callback = callback, - config = {}, - git_info = {}, + config = capability_config, + git_info = git_info, trigger_char = "#", runner = { build_fallback_list = function(_commands, list_callback) @@ -65,7 +84,6 @@ T["failure_gives_an_empty_list_and_is_not_cached"] = function() end) end, }, - bufnr = 46, } change_request_capability.complete(args) @@ -75,8 +93,24 @@ T["failure_gives_an_empty_list_and_is_not_cached"] = function() 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(args.cache:get(), nil, "a failure is not cached") eq(runs, 2, "the next request tries again") end +T["a_refresh_gives_the_raw_items"] = function() + local results = {} + + change_request_capability.refresh({ + adapter = adapter, + config = pr_config(), + git_info = git_info, + trigger_char = "#", + runner = runner, + }, function(items) + table.insert(results, items) + end) + + eq(results, { { pr } }, "the raw pull requests") +end + return T diff --git a/tests/provider/capabilities/test_issues.lua b/tests/provider/capabilities/test_issues.lua index 41d63bc..ed68e21 100644 --- a/tests/provider/capabilities/test_issues.lua +++ b/tests/provider/capabilities/test_issues.lua @@ -1,4 +1,7 @@ local helpers = require("tests.helpers") +local cache = require("cmp_git.cache") +local config = require("cmp_git.config") +local format = require("cmp_git.format") local issue_capability = require("cmp_git.provider.capabilities.issues") local eq = helpers.eq @@ -6,78 +9,133 @@ local neq = helpers.neq local T = helpers.new_set() -T["issue_capability_uses_cache_and_runner"] = function() - local callback_count = 0 - local last_list - local request_count = 0 - local runner_count = 0 - local adapter = { - issues_request = function() - request_count = request_count + 1 +local git_info = { host = "github.com", owner = "owner", repo = "repo" } + +---@param number integer +---@return cmp_git.GitHub.Issue +local function issue(number) + return { number = number, title = "issue " .. number, body = "", updatedAt = "2024-01-01T00:00:00Z", state = "open" } +end + +---An adapter that formats its items like the real adapters, and counts its requests. +---@return table adapter +---@return { count: integer } requests +local function counting_adapter() + local requests = { count = 0 } + return { + issues_request = function(_git_info, trigger_char, capability_config) + requests.count = requests.count + 1 return { commands = { { exec = "fake", args = { "issues" } } }, handle_item = function(item) - return { label = item.name } + return format.item(capability_config, trigger_char, item) end, } end, - } - local runner = { + }, + requests +end + +---A runner whose jobs give the issues at once. +---@param issues cmp_git.GitHub.Issue[] +---@return table runner +---@return { count: integer } runs +local function answering_runner(issues) + local runs = { count = 0 } + return { build_fallback_list = function(commands, callback, handle_item) - runner_count = runner_count + 1 + runs.count = runs.count + 1 eq(commands[1].exec, "fake", "issue capability forwards commands") return helpers.fake_job(function() - callback({ items = { handle_item({ name = "one" }) }, isIncomplete = false }, true) + callback({ items = vim.tbl_map(handle_item, vim.deepcopy(issues)), isIncomplete = false }, true) end) end, - } - local args = { - adapter = adapter, - cache = {}, - callback = function(list) - callback_count = callback_count + 1 - last_list = list - end, - config = {}, - git_info = {}, + }, + runs +end + +---@param overrides table +---@return cmp_git.Provider.CapabilityArgs args +---@return cmp_git.CompletionList[] lists +local function capability_args(overrides) + local callback, lists = helpers.recorder() + local issues_config = config.normalize({}).github.issues + return vim.tbl_extend("force", { + cache = cache.entry(git_info, "github-issues", issues_config), + callback = callback, + config = issues_config, + git_info = git_info, trigger_char = "#", - runner = runner, - bufnr = 42, - } + }, overrides), + lists +end + +T["issue_capability_uses_cache_and_runner"] = function() + local adapter, requests = counting_adapter() + local runner, runs = answering_runner({ issue(1) }) + local args, lists = capability_args({ adapter = adapter, runner = runner }) local job = issue_capability.complete(args) local cached_job = issue_capability.complete(args) - eq(request_count, 1, "issue capability requests once") - eq(runner_count, 1, "issue capability runs once") - eq(callback_count, 2, "issue capability callbacks on miss and hit") - eq(last_list.items[1].label, "one", "issue capability caches mapped items") + eq(requests.count, 1, "issue capability requests once") + eq(runs.count, 1, "issue capability runs once") + eq(#lists, 2, "issue capability callbacks on miss and hit") + eq(lists[2].items[1].label, "#1: issue 1", "a hit formats the cached issue") + eq(lists[2].items[1].data, issue(1), "a hit gives the raw issue in data") neq(job, nil, "issue capability returns started job") eq(cached_job, nil, "issue capability cache hit returns no job") end +T["a_hit_formats_the_items_for_its_request"] = function() + local adapter = counting_adapter() + local runner = answering_runner({ issue(1), issue(2) }) + local args, lists = capability_args({ adapter = adapter, runner = runner }) + issue_capability.complete(args) + + -- A new session, so that the items come from the file + cache.forget() + local custom = config.normalize({ + github = { + issues = { + format = { + label = function(_trigger_char, item) + return "custom " .. item.number + end, + }, + }, + }, + }).github.issues + args.config = custom + args.cache = cache.entry(git_info, "github-issues", custom) + args.trigger_char = "!" + issue_capability.complete(args) + + eq(#lists, 2, "a list for each request") + eq( + vim.tbl_map(function(item) + return item.label + end, lists[2].items), + { "custom 1", "custom 2" }, + "the format of the request" + ) + eq(lists[2].items[1].filterText, "! 1 issue 1", "the trigger character of the request") +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 { + return capability_args({ 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() @@ -95,7 +153,8 @@ T["failure_gives_an_empty_list_and_is_not_cached"] = function() 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(args.cache:get(), nil, "a failure is not cached") + eq(vim.fn.filereadable(args.cache.path), 0, "a failure writes no file") eq(runs, 2, "the next request tries again") end @@ -108,7 +167,41 @@ T["no_runnable_command_gives_an_empty_list"] = function() eq(issue_capability.complete(args), nil, "no job") eq(lists, { EMPTY }, "one empty list") - eq(args.cache[42], nil, "nothing is cached") + eq(args.cache:get(), nil, "nothing is cached") +end + +T["a_refresh_gives_the_raw_items"] = function() + local adapter = counting_adapter() + local runner = answering_runner({ issue(1) }) + local results = {} + + issue_capability.refresh({ + adapter = adapter, + config = config.normalize({}).github.issues, + git_info = git_info, + trigger_char = "#", + runner = runner, + }, function(items) + table.insert(results, items) + end) + + eq(results, { { issue(1) } }, "the raw issues") + + local failed = {} + issue_capability.refresh({ + adapter = adapter, + config = config.normalize({}).github.issues, + git_info = git_info, + trigger_char = "#", + runner = { + build_fallback_list = function() + return nil + end, + }, + }, function(items) + table.insert(failed, items == nil) + end) + eq(failed, { true }, "nil when no command can run") end return T diff --git a/tests/provider/capabilities/test_mentions.lua b/tests/provider/capabilities/test_mentions.lua index f953cd8..3455f03 100644 --- a/tests/provider/capabilities/test_mentions.lua +++ b/tests/provider/capabilities/test_mentions.lua @@ -1,10 +1,41 @@ local helpers = require("tests.helpers") +local cache = require("cmp_git.cache") +local config = require("cmp_git.config") +local format = require("cmp_git.format") local mention_capability = require("cmp_git.provider.capabilities.mentions") local eq = helpers.eq local T = helpers.new_set() +local git_info = { host = "github.com", owner = "owner", repo = "repo" } + +---The GitHub mentions config with a limit. +---@param limit number +---@return table +local function mentions_config(limit) + return config.normalize({ github = { mentions = { limit = limit } } }).github.mentions +end + +---@param capability_config table +---@return cmp_git.CacheEntry +local function entry_for(capability_config) + return cache.entry(git_info, "github-mentions", capability_config) +end + +---An adapter request that formats its users like the real adapters. +---@param opts table +---@param capability_config table +---@param trigger_char string +local function mentions_request(opts, capability_config, trigger_char) + return { + commands = { { exec = "mentions", args = {}, page = opts.page, page_size = opts.page_size } }, + handle_item = function(user) + return format.item(capability_config, trigger_char, user) + end, + } +end + T["mention_capability_preflight_and_pagination"] = function() local request_pages = {} local runner = { @@ -22,7 +53,7 @@ T["mention_capability_preflight_and_pagination"] = function() local count = page == 1 and 100 or 1 local items = {} for i = 1, count do - table.insert(items, handle_item({ name = page .. "-" .. i })) + table.insert(items, handle_item({ login = page .. "-" .. i })) end callback({ items = items, isIncomplete = false }, true) end) @@ -38,27 +69,22 @@ T["mention_capability_preflight_and_pagination"] = function() end, } end, - mentions_request = function(_git_info, _trigger_char, _config, opts) + mentions_request = function(_git_info, trigger_char, capability_config, opts) eq(opts.context.member_type, "collaborators", "mention context comes from preflight") - return { - commands = { { exec = "mentions", args = {}, page = opts.page } }, - handle_item = function(item) - return { label = item.name } - end, - } + return mentions_request(opts, capability_config, trigger_char) end, } local callback, callbacks = helpers.recorder() + local capability_config = mentions_config(200) mention_capability.complete({ adapter = adapter, - cache = {}, + cache = entry_for(capability_config), callback = callback, - config = { limit = 200 }, - git_info = {}, + config = capability_config, + git_info = git_info, trigger_char = "@", runner = runner, - bufnr = 43, }) eq(table.concat(request_pages, ","), "1,2", "mention capability paginates") @@ -74,7 +100,7 @@ end ---@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. +---A runner whose jobs wait until the case completes them, with an adapter that makes users from logins. ---@return table runner ---@return table adapter ---@return tests.MentionJob[] jobs The started jobs, in order. @@ -89,24 +115,22 @@ local function manual_provider() function job.cancel() job.cancelled = true end - function job.respond(users) - if not users then + function job.respond(logins) + if not logins then callback({ items = {}, isIncomplete = false }, false) return end + local users = vim.tbl_map(function(login) + return { login = login } + end, logins) 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, - } + mentions_request = function(_git_info, trigger_char, capability_config, opts) + return mentions_request(opts, capability_config, trigger_char) end, } return runner, adapter, jobs @@ -131,30 +155,40 @@ local function labels(list) end, list.items) end +---@param logins string[] +---@return string[] +local function mention_labels(logins) + return vim.tbl_map(function(login) + return "@" .. login + end, logins) +end + ---@param runner table ---@param adapter table ----@param cache table ----@param limit integer +---@param limit number +---@param capability_config? table The config of the request, by default the mentions config with the limit. ---@return cmp_git.CompletionList[] lists ---@return { cancel: fun() }? controller -local function request(runner, adapter, cache, limit) +---@return cmp_git.CacheEntry entry +local function request(runner, adapter, limit, capability_config) + capability_config = capability_config or mentions_config(limit) local callback, lists = helpers.recorder() + local entry = entry_for(capability_config) local controller = mention_capability.complete({ adapter = adapter, - cache = cache, + cache = entry, callback = callback, - config = { limit = limit }, - git_info = {}, + config = capability_config, + git_info = git_info, trigger_char = "@", runner = runner, - bufnr = 7, }) - return lists, controller + return lists, controller, entry end T["fewer_users_than_the_limit_need_one_page"] = function() local runner, adapter, jobs = manual_provider() - local lists = request(runner, adapter, {}, 100) + local lists = request(runner, adapter, 100) jobs[1].respond(users(30)) @@ -166,7 +200,7 @@ 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) + local lists = request(runner, adapter, 150) jobs[1].respond(users(100)) jobs[2].respond(users(100, 101)) @@ -176,51 +210,45 @@ T["the_page_size_is_constant_and_the_result_is_cut_to_the_limit"] = function() 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") + eq(labels(lists[2]), mention_labels(users(150)), "users 1 to 150") end -T["a_cancel_removes_the_entry"] = function() +T["a_cancel_removes_the_fetch"] = function() local runner, adapter, jobs = manual_provider() - local cache = {} - local lists, controller = request(runner, adapter, cache, 100) + local lists, controller, entry = request(runner, adapter, 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(entry.fetch, nil, "the entry has no fetch") + eq(entry:get(), nil, "the cancelled fetch is not cached") + eq(vim.fn.filereadable(entry.path), 0, "the cancelled fetch writes no file") eq(lists, {}, "a cancelled request gets no list") - local next_lists = request(runner, adapter, cache, 100) + local next_lists = request(runner, adapter, 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") + eq(#next_lists, 1, "one list") + eq(next_lists[1].isIncomplete, false, "the list is final") + eq(labels(next_lists[1]), mention_labels(users(3)), "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) + local lists, _, entry = request(runner, adapter, 100) jobs[1].respond(nil) eq(lists, { { items = {}, isIncomplete = false } }, "one empty list") - eq(cache[7], nil, "a failure is not cached") + eq(entry:get(), nil, "a failure is not cached") - request(runner, adapter, cache, 100) + request(runner, adapter, 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) + local lists, _, entry = request(runner, adapter, 200) jobs[1].respond(users(100)) jobs[2].respond(nil) @@ -228,11 +256,10 @@ T["a_failed_later_page_keeps_the_earlier_users"] = function() 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") + eq(entry:get(), 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 @@ -250,10 +277,10 @@ T["no_runnable_command_gives_an_empty_list"] = function() end, } - local lists = request(runner, adapter, cache, 100) + local lists, _, entry = request(runner, adapter, 100) eq(lists, { { items = {}, isIncomplete = false } }, "one empty list") - eq(cache[7], nil, "nothing is cached") + eq(entry:get(), nil, "nothing is cached") end T["a_failed_preflight_still_fetches"] = function() @@ -274,7 +301,7 @@ T["a_failed_preflight_still_fetches"] = function() } end - local lists = request(runner, adapter, {}, 100) + local lists = request(runner, adapter, 100) jobs[1].respond(users(2)) eq(preflight_results, { { "", false } }, "handle_result gets the failure") @@ -284,14 +311,14 @@ 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) + local capability_config = mentions_config(300) + local first, first_controller, entry = request(runner, adapter, 300, capability_config) + local second = request(runner, adapter, 300, capability_config) 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) + local third = request(runner, adapter, 300, capability_config) eq(#third, 1, "a request during the fetch gets the users so far") eq(third[1].isIncomplete, true, "and more come") @@ -307,11 +334,63 @@ T["requests_during_a_fetch_share_it"] = function() 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") + eq(#helpers.not_nil(entry:get()), 105, "the users are cached") - local cached = request(runner, adapter, cache, 300) - eq(#jobs, 2, "a cache hit does not fetch") + cache.forget() + local cached = request(runner, adapter, 300, capability_config) + eq(#jobs, 2, "a cache hit in a new session does not fetch") eq(cached[1].isIncomplete, false, "a cache hit is final") + eq(#cached[1].items, 105, "a cache hit gives the users of the file") +end + +T["each_request_formats_the_users_with_its_config"] = function() + local runner, adapter, jobs = manual_provider() + local capability_config = mentions_config(100) + local custom = vim.deepcopy(capability_config) + custom.format.label = function(_trigger_char, user) + return "custom " .. user.login + end + local first = request(runner, adapter, 100, capability_config) + local second = request(runner, adapter, 100, custom) + + jobs[1].respond(users(1)) + + eq(labels(first[1]), { "@user1" }, "the default format") + eq(labels(second[1]), { "custom user1" }, "the format of the second request") +end + +T["a_refresh_gets_all_pages"] = function() + local runner, adapter, jobs = manual_provider() + local results = {} + + mention_capability.refresh({ + adapter = adapter, + config = mentions_config(math.huge), + git_info = git_info, + trigger_char = "@", + runner = runner, + }, function(items) + table.insert(results, items) + end) + jobs[1].respond(users(100)) + eq(results, {}, "the refresh waits for the last page") + jobs[2].respond(users(1, 101)) + + eq(#results, 1, "one result") + eq(#results[1], 101, "the raw users of both pages") + eq(results[1][101], { login = "user101" }, "a raw user") + + mention_capability.refresh({ + adapter = adapter, + config = mentions_config(math.huge), + git_info = git_info, + trigger_char = "@", + runner = runner, + }, function(items) + table.insert(results, items == nil) + end) + jobs[3].respond(nil) + eq(results[2], true, "nil when a page fails") end return T diff --git a/tests/sources/test_github.lua b/tests/sources/test_github.lua index 06362ad..de03f4c 100644 --- a/tests/sources/test_github.lua +++ b/tests/sources/test_github.lua @@ -1,4 +1,5 @@ local helpers = require("tests.helpers") +local cache = require("cmp_git.cache") local command = require("cmp_git.command") local config = require("cmp_git.config") local repository = require("cmp_git.repository") @@ -29,11 +30,6 @@ T["github_instance_state_isolation"] = function() second:is_valid_host({ host = "github.com", owner = "owner", repo = "repo" }), "GitHub instance validates default host" ) - - first.cache.issues[1] = {} - eq(second.cache.issues[1], nil, "GitHub issue cache is instance-local") - ok(first.cache.mentions ~= second.cache.mentions, "GitHub mention cache table is instance-local") - ok(first.cache.pull_requests ~= second.cache.pull_requests, "GitHub PR cache table is instance-local") end local git_info = { host = "github.com", owner = "owner", repo = "repo" } @@ -93,14 +89,22 @@ local function labels(items) end, items) end +---@param items table[]? +---@return integer[] +local function numbers(items) + return vim.tbl_map(function(item) + return item.number + end, items or {}) +end + T["issues_and_change_requests_keep_the_caches_separate"] = function() local source, requests = fake_github() local callback, lists = helpers.recorder() - local bufnr = vim.api.nvim_get_current_buf() source:complete_issues_and_change_requests(callback, git_info, "#") eq(labels(lists[1].items), { "#1: an issue", "#2: a pull request" }, "issues, then pull requests") - eq(labels(source.cache.issues[bufnr]), { "#1: an issue" }, "the issue cache has no pull requests") + eq(numbers(source:_entry("issues", git_info):get()), { 1 }, "the issue cache has no pull requests") + eq(numbers(source:_entry("pull_requests", git_info):get()), { 2 }, "the pull request cache has no issues") source:complete_issues_and_change_requests(callback, git_info, "#") eq( @@ -184,6 +188,7 @@ end T["issues_and_change_requests_keep_the_items_of_a_request_that_succeeds"] = function() for _, failed in ipairs({ "issue", "pr" }) do + cache.clear_all() local source, jobs = manual_github() local callback, lists = helpers.recorder() @@ -288,8 +293,8 @@ T["the_default_repository_is_looked_up_once_without_blocking"] = function() 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")) + -- Without cached lists, the requests need the repository + cache.clear_all() source:complete_mentions(callback, remote(), "@") source:complete_issues_and_change_requests(callback, remote(), "#") @@ -323,7 +328,7 @@ T["without_a_default_repository_the_remote_is_used"] = function() helpers.wait_for(function() return #lists == 1 end, "the issues answer") - vim.api.nvim_set_current_buf(helpers.buffer({}, "markdown")) + cache.clear_all() source:complete_issues(callback, remote(), "#") eq(#lists, 2, "the second request answers at once") @@ -333,10 +338,9 @@ 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 } + for _, kind in ipairs({ "issues", "pull_requests", "mentions" }) do + source:_entry(kind, remote()):set({}) + end local callback, lists = helpers.recorder() for _, method in ipairs(vim.list_extend({ "complete_mentions" }, REQUESTS)) do @@ -353,7 +357,7 @@ T["without_gh_the_remote_is_used"] = function() local callback, lists = helpers.recorder() for _, method in ipairs(REQUESTS) do - vim.api.nvim_set_current_buf(helpers.buffer({}, "markdown")) + cache.clear_all() source[method](source, callback, remote(), "#") end @@ -389,24 +393,152 @@ 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() +T["requests_from_two_buffers_share_the_entries_of_the_remote"] = 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 first_bufnr = helpers.buffer({}, "markdown") + local second_bufnr = helpers.buffer({}, "markdown") local callback, lists = helpers.recorder() for _, request in ipairs(REQUESTS) do - source[request](source, callback, remote(), "#", request_bufnr) + source[request](source, callback, remote(), "#", first_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") + local requests = {} + helpers.stub(command, "build_fallback_list", function() + table.insert(requests, true) + end) + for _, request in ipairs(REQUESTS) do + source[request](source, callback, remote(), "#", second_bufnr) + end + + eq(#lists, #REQUESTS * 2, "the requests of the second buffer answer at once") + eq(#requests, 0, "from the cache") +end + +T["instances_share_the_entries_of_a_remote"] = function() + local first, requests = fake_github() + local second = GitHub.new(config.normalize({}).github) + local callback, lists = helpers.recorder() + + first:complete_issues_and_change_requests(callback, git_info, "#") + second:complete_issues_and_change_requests(callback, git_info, "#") + + eq(labels(lists[2].items), { "#1: an issue", "#2: a pull request" }, "the second instance gets the items") + eq(requests, { issue = 1, pr = 1 }, "each command runs once") +end + +T["a_new_session_answers_from_the_file_without_gh"] = function() + local source, repos, gh_calls = recording_github("upstream/project") + local callback, lists = helpers.recorder() + source:complete_issues_and_change_requests(callback, remote(), "#") + helpers.wait_for(function() + return #lists == 1 + end, "the first request answers") + + cache.forget() + local next_source = GitHub.new(config.normalize({}).github) + next_source:complete_issues_and_change_requests(callback, remote(), "#") + + eq(#lists, 2, "the request answers at once") + eq(#repos, 2, "no command runs for the second request") + eq(#gh_calls(), 1, "gh runs only for the first request") +end + +---Make the cached items of the entry older than the default TTL. +---@param entry cmp_git.CacheEntry +local function age(entry) + entry.fetched_at = os.time() - config.normalize({}).cache.ttl +end + +T["stale_items_answer_at_once_and_refresh_from_the_default_repository"] = function() + local source, repos, gh_calls = recording_github("upstream/project") + local entry = source:_entry("issues", remote()) + entry:set({ records.issue }) + age(entry) + local callback, lists = helpers.recorder() + + source:complete_issues(callback, remote(), "#") + eq(#lists, 1, "the request answers at once") + eq(labels(lists[1].items), { "#1: an issue" }, "with the stale items") + + source:complete_issues(callback, remote(), "#") + helpers.wait_for(function() + return #repos > 0 and not entry.refreshing + end, "the refresh ends") + helpers.flush() + + eq(#lists, 2, "the second request answers at once too") + eq(repos, { "upstream/project" }, "one refresh, from the default repository") + eq(#gh_calls(), 1, "gh looks up the default repository") + eq(entry:get(), {}, "the refresh stores its items") + ok(not entry:is_stale(config.normalize({}).cache.ttl), "the items are fresh") +end + +T["the_ttl_comes_from_the_config"] = function() + local _, repos = recording_github(nil) + local source = GitHub.new(config.normalize({}).github, { ttl = math.huge }) + local entry = source:_entry("issues", remote()) + entry:set({ records.issue }) + entry.fetched_at = 0 + local callback = helpers.recorder() + + source:complete_issues(callback, remote(), "#") + + eq(repos, {}, "math.huge never refreshes") +end + +T["a_failed_refresh_keeps_the_stale_items"] = function() + local source = recording_github(nil) + local runs = 0 + helpers.stub(command, "build_fallback_list", function(_commands, callback) + return helpers.fake_job(function() + runs = runs + 1 + callback({ items = {}, isIncomplete = false }, false) + end) + end) + local entry = source:_entry("issues", remote()) + entry:set({ records.issue }) + age(entry) + local callback, lists = helpers.recorder() + + source:complete_issues(callback, remote(), "#") + source:complete_issues(callback, remote(), "#") + + eq(runs, 1, "the second request waits a TTL before it refreshes again") + eq(labels(lists[2].items), { "#1: an issue" }, "the stale items stay") +end + +T["a_stale_combined_request_refreshes_both_lists"] = function() + local source, repos = recording_github(nil) + for _, kind in ipairs({ "issues", "pull_requests" }) do + local entry = source:_entry(kind, remote()) + entry:set({}) + age(entry) + end + local callback, lists = helpers.recorder() + + source:complete_issues_and_change_requests(callback, remote(), "#") + + eq(#lists, 1, "the request answers at once") + eq(repos, { "owner/repo", "owner/repo" }, "the issues and the pull requests refresh") +end + +T["stale_mentions_refresh_all_pages"] = function() + local source, repos = recording_github(nil) + local entry = source:_entry("mentions", remote()) + entry:set({ { login = "octocat" } }) + age(entry) + local callback, lists = helpers.recorder() + + source:complete_mentions(callback, remote(), "@") + + eq(labels(lists[1].items), { "@octocat" }, "the request answers with the stale users") + eq(repos, { "owner/repo", "owner/repo" }, "the refresh runs the preflight and the first page") + eq(entry:get(), {}, "the refresh stores the users") + eq(entry.fetch, nil, "the refresh does not block the requests") end return T diff --git a/tests/sources/test_gitlab.lua b/tests/sources/test_gitlab.lua index 1080028..c98779f 100644 --- a/tests/sources/test_gitlab.lua +++ b/tests/sources/test_gitlab.lua @@ -1,4 +1,5 @@ local helpers = require("tests.helpers") +local cache = require("cmp_git.cache") local config = require("cmp_git.config") local GitLab = require("cmp_git.sources.gitlab") @@ -27,11 +28,6 @@ T["gitlab_instance_state_isolation"] = function() second:is_valid_host({ host = "gitlab.com", owner = "owner", repo = "repo" }), "GitLab instance validates default host" ) - - first.cache.issues[1] = {} - eq(second.cache.issues[1], nil, "GitLab issue cache is instance-local") - ok(first.cache.mentions ~= second.cache.mentions, "GitLab mention cache table is instance-local") - 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" } @@ -70,14 +66,13 @@ 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(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") + eq(source:_entry("issues", git_info):get(), nil, "failed issues are not cached") + eq(source:_entry("merge_requests", git_info):get(), nil, "failed merge requests are not cached") + eq(source:_entry("mentions", git_info):get(), nil, "failed mentions are not cached") end T["missing_commands_give_an_empty_list"] = function() @@ -89,4 +84,49 @@ T["missing_commands_give_an_empty_list"] = function() eq(answers(source, "complete_mentions", "@"), { EMPTY }, "mentions") 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 + +T["glab_items_are_cached_for_the_next_session"] = function() + fake_commands({ + glab = [[printf '%s' '[{"iid":1,"title":"an issue","description":null,"updated_at":"2024-01-01T00:00:00Z"}]']], + }) + local source = GitLab.new(config.normalize({}).gitlab) + eq(labels(answers(source, "complete_issues", "#")[1]), { "#1: an issue" }, "the issues of glab") + + cache.forget() + fake_commands({ glab = "exit 1" }) + local callback, lists = helpers.recorder() + GitLab.new(config.normalize({}).gitlab):complete_issues(callback, git_info, "#") + + eq(#lists, 1, "a new session answers at once") + eq(labels(lists[1]), { "#1: an issue" }, "with the issues of the file") + eq(lists[1].items[1].data.description, "", "the null description of the API is still empty") +end + +T["stale_items_refresh_in_the_background"] = function() + fake_commands({ + glab = [[printf '%s' '[{"iid":2,"title":"a new issue","description":"","updated_at":"2024-01-01T00:00:00Z"}]']], + }) + local source = GitLab.new(config.normalize({}).gitlab, { ttl = 60 }) + local entry = source:_entry("issues", git_info) + entry:set({ { iid = 1, title = "an issue", description = "", updated_at = "2024-01-01T00:00:00Z" } }) + entry.fetched_at = os.time() - 60 + local callback, lists = helpers.recorder() + + source:complete_issues(callback, git_info, "#") + eq(labels(lists[1]), { "#1: an issue" }, "the request answers at once with the stale items") + + helpers.wait_for(function() + return not entry.refreshing + end, "the refresh ends") + source:complete_issues(callback, git_info, "#") + eq(labels(lists[2]), { "#2: a new issue" }, "the next request gets the new items") +end + return T diff --git a/tests/test_config.lua b/tests/test_config.lua index b301b4e..4e943bd 100644 --- a/tests/test_config.lua +++ b/tests/test_config.lua @@ -25,6 +25,11 @@ T["config_normalize_fills_defaults_and_hosts"] = function() ok(vim.tbl_contains(normalized.gitlab.hosts, "gitlab.com"), "normalized config adds gitlab host") end +T["config_normalize_fills_the_cache_ttl"] = function() + eq(config.normalize({}).cache.ttl, 300, "the default TTL is five minutes") + eq(config.normalize({ cache = { ttl = 0 } }).cache.ttl, 0, "a user TTL") +end + T["config_normalize_nested_filter_and_sort_overrides"] = function() local issue_filter = function() return "issue-filter" From 438b445d680792466b79da6e4b7e17ea500f2487 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 21:59:52 +1100 Subject: [PATCH 4/6] feat: add :CmpGitClearCache to clear the cache of a remote or of all remotes `:CmpGitClearCache` clears the GitHub and GitLab items of the remote of the current buffer, and the cached commits and trailers of the buffer. `:CmpGitClearCache!` clears the items of all remotes, deletes the cache directory, and drops the commits and trailers of all buffers. The next request fetches again. A message tells what the command cleared. The command finds the remote with the config of each core that is alive, because `ssh_aliases` or a host with a path prefix can change the remote of a URL. Without a core, it uses the default config. The cores and the git sources are in weak tables, so the command reaches them without keeping them alive. The new `plugin/cmp_git.lua` defines the command, so that blink.cmp users get it without `setup`. It loads the command module only when the command runs. --- Makefile | 2 +- lua/cmp_git/commands.lua | 82 +++++++++++++++++++ lua/cmp_git/core.lua | 30 +++++-- lua/cmp_git/sources/git.lua | 22 ++++++ plugin/cmp_git.lua | 12 +++ tests/test_commands.lua | 154 ++++++++++++++++++++++++++++++++++++ 6 files changed, 294 insertions(+), 8 deletions(-) create mode 100644 lua/cmp_git/commands.lua create mode 100644 plugin/cmp_git.lua create mode 100644 tests/test_commands.lua diff --git a/Makefile b/Makefile index 6822879..ba3ea6a 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ SELENE ?= selene LUA_LANGUAGE_SERVER ?= lua-language-server MINI_VERSION := v0.18.0 MINI_DIR := deps/mini.nvim -LUA_PATHS := lua tests benchmarks +LUA_PATHS := lua plugin tests benchmarks REF ?= main .PHONY: test-deps test test-file bench bench-compare format format-check lint typecheck ci diff --git a/lua/cmp_git/commands.lua b/lua/cmp_git/commands.lua new file mode 100644 index 0000000..a703c4d --- /dev/null +++ b/lua/cmp_git/commands.lua @@ -0,0 +1,82 @@ +-- The code of the user commands that `plugin/cmp_git.lua` defines +local M = {} + +---@param message string +local function notify(message) + vim.notify(message, vim.log.levels.INFO, { title = "cmp-git" }) +end + +---Drop the cached commits and trailers of the buffer, or of all buffers. A source module that is not loaded has no +---caches. +---@param bufnr? integer +local function clear_git_caches(bufnr) + local Git = package.loaded["cmp_git.sources.git"] + if Git then + Git.clear_caches(bufnr) + end +end + +---The remotes of the buffer, without repeats. Discovery runs with the config of each core, because the configs can +---give other hosts for one remote URL, for example with `ssh_aliases`. Without a core, it uses the default config. +---@param bufnr integer +---@param on_complete fun(remotes: cmp_git.GitInfo[]) +local function discover_remotes(bufnr, on_complete) + local Core = require("cmp_git.core") + local cores = Core.instances() + if #cores == 0 then + cores = { Core.new({}) } + end + + local remotes = {} + local found = {} + local pending = #cores + for _, core in ipairs(cores) do + core:discover_remote(bufnr, function(git_info) + if git_info.host ~= nil and git_info.owner ~= nil and git_info.repo ~= nil then + local name = string.format("%s/%s/%s", git_info.host, git_info.owner, git_info.repo) + if not found[name] then + found[name] = true + table.insert(remotes, git_info) + end + end + + pending = pending - 1 + if pending == 0 then + on_complete(remotes) + end + end) + end +end + +---Clear the cache of the remote of the buffer, or of all remotes. Both forms also drop the cached commits and +---trailers, of the buffer or of all buffers. +---@param all boolean +---@param bufnr? integer The buffer, by default the current one. +function M.clear_cache(all, bufnr) + local cache = require("cmp_git.cache") + if all then + cache.clear_all() + clear_git_caches(nil) + notify("Cleared the cache of all remotes.") + return + end + + bufnr = bufnr or vim.api.nvim_get_current_buf() + clear_git_caches(bufnr) + discover_remotes(bufnr, function(remotes) + if #remotes == 0 then + notify("Cleared the commits and trailers of the buffer. The buffer has no remote.") + return + end + + local names = {} + for _, git_info in ipairs(remotes) do + cache.clear_remote(git_info) + table.insert(names, string.format("%s/%s/%s", git_info.host, git_info.owner, git_info.repo)) + end + table.sort(names) + notify(string.format("Cleared the cache of %s.", table.concat(names, ", "))) + end) +end + +return M diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index a0c03bb..6f1513f 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -224,10 +224,14 @@ end local Core = {} +---The cores that are alive. `:CmpGitClearCache` discovers the remote with their configs. +local instances = setmetatable({}, { __mode = "k" }) + function Core.new(overrides) local self = setmetatable({}, { __index = Core, }) + instances[self] = true self.config = config.normalize(overrides) self.hosts = vim.list_extend(vim.list_extend({}, self.config.github.hosts), self.config.gitlab.hosts) @@ -280,6 +284,24 @@ function Core.new(overrides) return self end +---@return table[] cores The cores that are alive, in no order. +function Core.instances() + return vim.tbl_keys(instances) +end + +---Find the remote of the buffer with the remote options of the config. +---@param bufnr? integer The buffer, by default the current one. +---@param on_complete fun(git_info: cmp_git.GitInfo) +function Core:discover_remote(bufnr, on_complete) + require("cmp_git.repository").discover(self.config.remotes, { + enableRemoteUrlRewrites = self.config.enableRemoteUrlRewrites, + ssh_aliases = self.config.ssh_aliases, + hosts = self.hosts, + on_complete = on_complete, + bufnr = bufnr, + }) +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. @@ -471,13 +493,7 @@ function Core:_complete(context, callback, routes) return controller end - 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, - }) + self:discover_remote(context.bufnr, run) return controller end diff --git a/lua/cmp_git/sources/git.lua b/lua/cmp_git/sources/git.lua index 6ac3c86..1ef0e10 100644 --- a/lua/cmp_git/sources/git.lua +++ b/lua/cmp_git/sources/git.lua @@ -9,11 +9,18 @@ local trailers = require("cmp_git.provider.capabilities.trailers") ---@field cache_trailer_values table> local Git = {} +-- The fields of the caches, which are keyed by buffer number +local CACHE_FIELDS = { "cache_commits", "cache_trailer_keys", "cache_trailer_identities", "cache_trailer_values" } + +---The sources that are alive. `:CmpGitClearCache` drops their caches. +local instances = setmetatable({}, { __mode = "k" }) + ---@param overrides cmp_git.Config.Git function Git.new(overrides) local self = setmetatable({}, { __index = Git, }) + instances[self] = true self.cache_commits = {} self.cache_trailer_keys = {} @@ -25,6 +32,21 @@ function Git.new(overrides) return self end +---Drop the cached commits and trailers of the buffer in all sources, or of all buffers. +---@param bufnr? integer +function Git.clear_caches(bufnr) + for source in pairs(instances) do + for _, field in ipairs(CACHE_FIELDS) do + if bufnr then + source[field][bufnr] = nil + else + -- A new table, so that a git command that ends after the clear fills the old one + source[field] = {} + end + end + end +end + ---@param callback fun(commits: cmp_git.CompletionList) ---@param trigger_char string function Git:get_commits(callback, context, trigger_char) diff --git a/plugin/cmp_git.lua b/plugin/cmp_git.lua new file mode 100644 index 0000000..9cd2de9 --- /dev/null +++ b/plugin/cmp_git.lua @@ -0,0 +1,12 @@ +if vim.g.loaded_cmp_git then + return +end +vim.g.loaded_cmp_git = true + +-- The command loads its module when it runs, so that startup loads no cmp-git module +vim.api.nvim_create_user_command("CmpGitClearCache", function(opts) + require("cmp_git.commands").clear_cache(opts.bang) +end, { + bang = true, + desc = "Clear the cmp-git cache of the remote of the current buffer, or with ! of all remotes", +}) diff --git a/tests/test_commands.lua b/tests/test_commands.lua new file mode 100644 index 0000000..db999c5 --- /dev/null +++ b/tests/test_commands.lua @@ -0,0 +1,154 @@ +local helpers = require("tests.helpers") +local cache = require("cmp_git.cache") +local commands = require("cmp_git.commands") +local config = require("cmp_git.config") +local Core = require("cmp_git.core") +local Git = require("cmp_git.sources.git") + +local eq = helpers.eq +local ok = helpers.ok + +local T = helpers.new_set() + +local ROOT = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h") + +local remote = { host = "github.com", owner = "owner", repo = "repo" } +local other_remote = { host = "gitlab.com", owner = "group", repo = "project" } +local issues_config = config.normalize({}).github.issues + +---An entry of the remote with one item. +---@param git_info cmp_git.GitInfo +---@return cmp_git.CacheEntry +local function cached_entry(git_info) + local entry = cache.entry(git_info, "github-issues", issues_config) + entry:set({ { number = 1 } }) + return entry +end + +---@param git_info cmp_git.GitInfo +---@return boolean +local function is_cached(git_info) + cache.forget() + return cache.entry(git_info, "github-issues", issues_config):get() ~= nil +end + +---A git source with cached commits for each buffer. +---@param buffers integer[] +---@return cmp_git.Source.Git +local function git_source(buffers) + local source = Git.new(config.normalize({}).git) + for _, bufnr in ipairs(buffers) do + source.cache_commits[bufnr] = {} + source.cache_trailer_keys[bufnr] = {} + end + return source +end + +---The messages of the notifications so far. +---@return string[] +local function messages() + helpers.flush() + return vim.tbl_map(function(notification) + return notification.message + end, helpers.notifications) +end + +---A buffer of a file in a repository with the remote. The cores of earlier cases are collected first, so that only the +---cores of the case discover the remote. +---@param url string +---@return integer bufnr +local function repository_buffer(url) + collectgarbage("collect") + local repo = helpers.git_repo({ remotes = { { "origin", url } } }) + vim.cmd.edit(vim.fn.fnameescape(repo.dir .. "/COMMIT_EDITMSG")) + return vim.api.nvim_get_current_buf() +end + +T["the_bang_clears_all_remotes_and_buffers"] = function() + cached_entry(remote) + cached_entry(other_remote) + local source = git_source({ 1, 2 }) + + commands.clear_cache(true) + + eq(vim.fn.isdirectory(cache.root()), 0, "the cache directory is removed") + ok(not is_cached(remote) and not is_cached(other_remote), "no remote has items") + eq(source.cache_commits, {}, "no buffer has commits") + eq(source.cache_trailer_keys, {}, "no buffer has trailer keys") + eq(messages(), { "Cleared the cache of all remotes." }, "the user gets a message") +end + +T["without_a_bang_the_command_clears_the_remote_and_the_buffer"] = function() + local bufnr = repository_buffer("git@github.com:owner/repo.git") + local other_bufnr = helpers.buffer({}) + cached_entry(remote) + cached_entry(other_remote) + local source = git_source({ bufnr, other_bufnr }) + + commands.clear_cache(false) + helpers.wait_for(function() + return #messages() > 0 + end, "the command ends") + + ok(not is_cached(remote), "the remote of the buffer has no items") + ok(is_cached(other_remote), "the other remote keeps its items") + eq(source.cache_commits[bufnr], nil, "the buffer has no commits") + eq(source.cache_trailer_keys[bufnr], nil, "the buffer has no trailer keys") + eq(source.cache_commits[other_bufnr], {}, "the other buffer keeps its commits") + eq(messages(), { "Cleared the cache of github.com/owner/repo." }, "the message names the remote") +end + +T["the_remote_comes_from_the_config_of_each_core"] = function() + local bufnr = repository_buffer("git@work:owner/repo.git") + local core = Core.new({ ssh_aliases = { work = "github.com" } }) + cached_entry(remote) + + commands.clear_cache(false, bufnr) + helpers.wait_for(function() + return #messages() > 0 + end, "the command ends") + + ok(not is_cached(remote), "the SSH alias of the core gives the remote") + eq(messages(), { "Cleared the cache of github.com/owner/repo." }, "the message names the remote") + ok(core ~= nil, "the core is alive during the command") +end + +T["a_buffer_without_a_remote_clears_its_commits_and_trailers"] = function() + collectgarbage("collect") + -- Discovery uses the cwd for a buffer without a file. The case hooks restore it. + vim.fn.chdir(helpers.tempdir()) + local bufnr = helpers.buffer({}) + cached_entry(remote) + local source = git_source({ bufnr }) + + commands.clear_cache(false, bufnr) + helpers.wait_for(function() + return #messages() > 0 + end, "the command ends") + + eq(source.cache_commits[bufnr], nil, "the buffer has no commits") + ok(is_cached(remote), "the remote keeps its items") + eq( + messages(), + { "Cleared the commits and trailers of the buffer. The buffer has no remote." }, + "the message says that the buffer has no remote" + ) +end + +T["the_plugin_file_defines_the_command"] = function() + helpers.stub(vim.g, "loaded_cmp_git", nil) + local calls = {} + helpers.stub(commands, "clear_cache", function(all) + table.insert(calls, all) + end) + + dofile(ROOT .. "/plugin/cmp_git.lua") + vim.cmd("CmpGitClearCache") + vim.cmd("CmpGitClearCache!") + vim.api.nvim_del_user_command("CmpGitClearCache") + + eq(calls, { false, true }, "the bang clears all remotes") + eq(vim.g.loaded_cmp_git, true, "the guard is set") +end + +return T From fc192a7e94c79d3a5bcb825217b2cb79c5a58994 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 22:01:25 +1100 Subject: [PATCH 5/6] test: benchmark a request that reads the cache files of an earlier session The cache of the GitHub and GitLab items is now shared by all sources and kept on disk, so a new source alone no longer makes a request cold. The `cold` cases now also clear the cache. Revisions without the cache module have nothing to clear. The new `disk` cases for the GitHub issues and pull requests and for the mentions measure a new source with an empty memory and the files of the warm-up run, as in a new Neovim. CONTRIBUTING.md describes these cases and the `XDG_CACHE_HOME` of the test hooks. --- CONTRIBUTING.md | 4 +++- benchmarks/cases/e2e.lua | 35 +++++++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfec94e..e1d7b54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,6 +75,7 @@ Start each test file with `local T = helpers.new_set()`. The hooks of this set i read `helpers.notifications`. - They unset `GITHUB_API_TOKEN` and `GITLAB_TOKEN`. - They isolate git from your user config and your system config. +- They set `XDG_CACHE_HOME` to a temporary directory, so that the cache of the GitHub and GitLab items is empty. - They restore the cwd, the buffers, and the values that `helpers.stub` replaced. - They delete the directories from `helpers.tempdir` and `helpers.git_repo`. @@ -116,7 +117,8 @@ until the completion items show. Nothing in them uses the network. 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. +source and an empty cache for each request. `warm` cases repeat a request that the cache can answer. `disk` cases use a +new source and the cache files of an earlier request, as in a new Neovim. 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 diff --git a/benchmarks/cases/e2e.lua b/benchmarks/cases/e2e.lua index 64e67a9..b4f8624 100644 --- a/benchmarks/cases/e2e.lua +++ b/benchmarks/cases/e2e.lua @@ -1,7 +1,7 @@ ---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. +---network. `cold` cases use a new source and an empty cache for each request, and `warm` cases repeat a request that +---the cache can answer. `disk` cases use a new source and the cache files of an earlier request, as in a new session. local fixtures = require("benchmarks.fixtures") local COMMITS = 1000 @@ -26,13 +26,22 @@ local REQUESTS = { github_mentions = { row = 3, line = "See @", trigger_character = "@" }, } +---Remove the cached GitHub and GitLab items from memory and from disk. Revisions before the disk cache have no cache +---module, and a new source is cold there. +local function clear_cache() + local ok, cache = pcall(require, "cmp_git.cache") + if ok then + cache.clear_all() + end +end + ---@param name string A key of `REQUESTS`. ----@param cold boolean +---@param mode "cold" | "warm" | "disk" ---@return bench.AsyncCase -local function request_case(name, cold) +local function request_case(name, mode) local request = REQUESTS[name] return { - name = string.format("e2e/%s/%s", name, cold and "cold" or "warm"), + name = string.format("e2e/%s/%s", name, mode), async = true, setup = function() local dir = fixtures.repo(COMMITS) @@ -43,7 +52,13 @@ local function request_case(name, cold) end, before_each = function(state) vim.api.nvim_set_current_buf(state.bufnr) - if cold then + if mode == "cold" then + clear_cache() + elseif mode == "disk" then + -- The warm-up run writes the files + require("cmp_git.cache").forget() + end + if mode ~= "warm" then state.core = require("cmp_git.core").new({}) end end, @@ -77,8 +92,12 @@ for _, name in ipairs({ "github_issues_and_prs", "github_mentions", }) do - table.insert(cases, request_case(name, true)) - table.insert(cases, request_case(name, false)) + table.insert(cases, request_case(name, "cold")) + table.insert(cases, request_case(name, "warm")) +end +-- Only the GitHub and GitLab items have cache files +for _, name in ipairs({ "github_issues_and_prs", "github_mentions" }) do + table.insert(cases, request_case(name, "disk")) end return cases From effd1f2e587b57a13f370abf01f46815ac330c9b Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Thu, 22 Oct 2026 22:01:25 +1100 Subject: [PATCH 6/6] docs: describe the cache of the GitHub and GitLab items A new "Cache" section in docs/configuration.md tells where the cache is, what it holds, how `cache.ttl` refreshes it in the background, and how `:CmpGitClearCache` and `:CmpGitClearCache!` clear it. The section also says that the cache holds the text of private repositories, and that only the user can read it. The troubleshooting section no longer tells users to wipe the buffer to get new items. The README shows the `cache.ttl` default and links to the new section. --- README.md | 4 ++++ docs/configuration.md | 44 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8091f09..aaeef04 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,9 @@ require("cmp_git").setup({ remotes = { "upstream", "origin" }, -- in order of most to least prioritized enableRemoteUrlRewrites = false, -- enable git url rewrites, see https://git-scm.com/docs/git-config#Documentation/git-config.txt-urlltbasegtinsteadOf ssh_aliases = {}, -- SSH host aliases and their real hosts, such as { ["github-work"] = "github.com" } + cache = { + ttl = 300, -- seconds after which a request also gets the GitHub and GitLab items again in the background + }, git = { commits = { limit = 100, @@ -254,6 +257,7 @@ require("cmp_git").setup({ - [Remotes and SSH aliases](docs/configuration.md#remotes-and-ssh-aliases) - [GitHub CLI, GitLab CLI, and curl](docs/configuration.md#github-cli-gitlab-cli-and-curl) - [Hosted GitHub and GitLab instances](docs/configuration.md#hosted-github-and-gitlab-instances) +- [Cache](docs/configuration.md#cache), and the `:CmpGitClearCache` command - [Troubleshooting](docs/configuration.md#troubleshooting) ## Contributing diff --git a/docs/configuration.md b/docs/configuration.md index 0fe5719..932485b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -304,6 +304,46 @@ the path prefix for this remote when these conditions are true: Path prefixes are only for GitLab. GitHub Enterprise does not use a path prefix. +## Cache + +cmp-git keeps the GitHub and GitLab items in a cache on disk: the issues, the pull requests, the merge requests, and +the mentions. The cache is in `stdpath("cache")/cmp-git`, usually `~/.cache/nvim/cmp-git`, with a directory for each +remote, such as `github.com/owner/repo`. All buffers of a remote use the same items, also in a new Neovim. So the first +`#`, `!`, or `@` in a new commit message shows the items without a wait for the network. + +The remote is the first remote in `remotes` that has a supported URL. For GitHub, the items come from the repository +that `gh repo set-default` chose, if any. A change of the default repository shows after the next refresh. + +When the items are older than `cache.ttl` seconds, a request still shows them at once. The request also gets the items +again in the background, and the next request shows the new items. The default TTL is 300 seconds: + +```lua +require("cmp_git").setup({ + cache = { + ttl = 300, + }, +}) +``` + +With `ttl = 0`, each request gets the items again in the background. With `ttl = math.huge`, cmp-git never gets them +again. + +cmp-git does not cache the result of a failed command or an HTTP error, so the next request tries again. If a +background refresh fails, cmp-git keeps the old items and tries again after `cache.ttl` seconds. + +The cache holds the titles and the bodies of the issues and the pull requests, and the users of the repository, also +for private repositories. Only your user can read the cache files. + +To clear the cache, use these commands: + +- `:CmpGitClearCache` clears the items of the remote of the current buffer. It also clears the commits and the + trailers of the buffer. +- `:CmpGitClearCache!` clears the items of all remotes, and the commits and the trailers of all buffers. + +The commits and the trailers come from the local repository. cmp-git keeps them in memory for each buffer and does not +refresh them. To get new commits and trailers, run `:CmpGitClearCache`, or wipe the buffer with `:bwipeout` and open +the file again. + ## Troubleshooting If no items show, make sure that these conditions are true: @@ -316,6 +356,4 @@ If no items show, make sure that these conditions are true: To log missing executables, and the exit code and stderr of failed commands, set `vim.g.cmp_git_debug = true`. cmp-git 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. cmp-git does not cache the result of a failed command or an -HTTP error, so the next request tries again. +If the items are old or missing, run `:CmpGitClearCache`. The next request gets them again. See [Cache](#cache).