From ea57b77025d75a6e939cb9ec9743937b39d35fe7 Mon Sep 17 00:00:00 2001 From: Wassim Metallaoui Date: Fri, 20 Feb 2026 20:50:23 -0600 Subject: [PATCH 1/6] feat: auto-discover co-authors from GitHub and git log Query GitHub collaborators via gh CLI GraphQL API for real @handle completions. Fall back to git log authors when gh is unavailable. Cross-reference git log to resolve real emails over noreply addresses. Manual file and inline handles override discovered entries. --- README.md | 28 ++- doc/git-coauthors.nvim.txt | 36 ++- lua/git-coauthors/discover.lua | 220 ++++++++++++++++ lua/git-coauthors/handles.lua | 6 +- lua/git-coauthors/init.lua | 1 + tests/discover_spec.lua | 445 +++++++++++++++++++++++++++++++++ tests/handles_spec.lua | 95 +++++++ tests/helpers.lua | 33 +++ 8 files changed, 857 insertions(+), 7 deletions(-) create mode 100644 lua/git-coauthors/discover.lua create mode 100644 tests/discover_spec.lua diff --git a/README.md b/README.md index a7f1239..3f748a9 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,28 @@ Add `git_coauthors` to your nvim-cmp sources for the gitcommit filetype. ## 💻 Usage -### Step 1: Define Your Handles +### Auto-Discovery (Default) -Create a JSON file that maps GitHub handles to names and emails: +The plugin auto-discovers co-authors from your repository with zero +configuration: + +1. **GitHub collaborators** (preferred): If the + [`gh` CLI](https://cli.github.com/) is installed and authenticated, + collaborators are pulled from the GitHub API with real `@handle` completions + (e.g., `@molawson`). + +2. **Git log fallback**: When `gh` is unavailable, co-authors are read from + `git log` history using `@Full Name` format (e.g., `@Mo Lawson`). + +Your own name is automatically excluded from the list. Set `discover = false` in +[Configuration](#-configuration) to disable auto-discovery and use only manually +defined handles. + +### Manual Handles (Optional Overrides) + +For people not yet in the repo, or to override a discovered entry, you can +define handles manually. Create a JSON file that maps handles to names and +emails: ```json { @@ -66,8 +85,10 @@ inline through your plugin config instead of using a file (see The values are `Name ` strings matching the format git expects for [`Co-Authored-By` trailers](https://docs.github.com/en/pull-requests/committing-changes-to-your-project/creating-and-editing-commits/creating-a-commit-with-multiple-authors). +Manual handles are merged on top of discovered ones, so a manual `@alice` entry +overrides any `@alice` found via GitHub or git log. -### Step 2: Use in a Commit +### Use in a Commit The plugin provides two things in gitcommit buffers: @@ -117,6 +138,7 @@ require('git-coauthors').setup({ | Option | Default | Description | | --- | --- | --- | +| `discover` | `true` | Auto-discover co-authors from GitHub/git log. Set `false` to use only file/inline handles. | | `handles_path` | `~/.local/share/nvim/git-coauthors/handles.json` | Path to the JSON handles file | | `handles` | `nil` | Inline handles (merged over file, inline wins on duplicates) | diff --git a/doc/git-coauthors.nvim.txt b/doc/git-coauthors.nvim.txt index 6724794..da81bfa 100644 --- a/doc/git-coauthors.nvim.txt +++ b/doc/git-coauthors.nvim.txt @@ -65,9 +65,31 @@ Add `git_coauthors` to your nvim-cmp sources for the gitcommit filetype. П�� USAGE *git-coauthors.nvim-git-coauthors.nvim-💻-usage* -STEP 1: DEFINE YOUR HANDLES ~ +AUTO-DISCOVERY (DEFAULT) ~ -Create a JSON file that maps GitHub handles to names and emails: +The plugin auto-discovers co-authors from your repository with zero +configuration: + +1. **GitHub collaborators** (preferred): If the `gh` CLI +is installed and authenticated, collaborators are pulled from the GitHub API +with real `@handle` completions (e.g., `@molawson`). + + +2. **Git log fallback**: When `gh` is unavailable, co-authors are read from `git +log` history using `@Full Name` format (e.g., `@Mo Lawson`). + + + +Your own name is automatically excluded from the list. Set `discover = false` +in |git-coauthors.nvim-configuration| to disable auto-discovery and use only +manually defined handles. + + +MANUAL HANDLES (OPTIONAL OVERRIDES) ~ + +For people not yet in the repo, or to override a discovered entry, you can +define handles manually. Create a JSON file that maps handles to names and +emails: >json { @@ -84,9 +106,11 @@ handles inline through your plugin config instead of using a file (see The values are `Name ` strings matching the format git expects for `Co-Authored-By` trailers . +Manual handles are merged on top of discovered ones, so a manual `@alice` entry +overrides any `@alice` found via GitHub or git log. -STEP 2: USE IN A COMMIT ~ +USE IN A COMMIT ~ The plugin provides two things in gitcommit buffers: @@ -139,6 +163,12 @@ Pass options via blink.cmp’s provider `opts` or ------------------------------------------------------------------------------------------------ Option Default Description ----------------------- ------------------------------------------------ ----------------------- + discover true Auto-discover + co-authors from + GitHub/git log. Set + false to use only + file/inline handles. + handles_path ~/.local/share/nvim/git-coauthors/handles.json Path to the JSON handles file diff --git a/lua/git-coauthors/discover.lua b/lua/git-coauthors/discover.lua new file mode 100644 index 0000000..41f6c77 --- /dev/null +++ b/lua/git-coauthors/discover.lua @@ -0,0 +1,220 @@ +local M = {} + +local function is_gh_available() + return vim.fn.executable('gh') == 1 +end + +local function get_repo_info() + local output = vim.fn.system({ 'gh', 'repo', 'view', '--json', 'owner,name', '--jq', '.owner.login + "/" + .name' }) + if vim.v.shell_error ~= 0 then + return nil + end + return vim.trim(output) +end + +local function get_current_gh_user() + local output = vim.fn.system({ 'gh', 'api', 'user', '--jq', '.login' }) + if vim.v.shell_error ~= 0 then + return nil + end + return vim.trim(output) +end + +local function parse_git_log() + local output = vim.fn.system({ 'git', 'log', '--format=%aN <%aE>' }) + if vim.v.shell_error ~= 0 then + return {}, {} + end + + local email_to_names = {} + local name_to_emails = {} + local seen = {} + + for line in output:gmatch('[^\r\n]+') do + line = vim.trim(line) + if line ~= '' and not seen[line] then + seen[line] = true + local name, email = line:match('^(.+) <(.+)>$') + if name and email then + email_to_names[email] = name + if not name_to_emails[name] then + name_to_emails[name] = {} + end + table.insert(name_to_emails[name], email) + end + end + end + + return email_to_names, name_to_emails +end + +local function first_real_email(emails) + if not emails then + return nil + end + for _, email in ipairs(emails) do + if not email:find('noreply.github.com', 1, true) then + return email + end + end + return nil +end + +local function find_real_email(display_name, login, database_id, email_to_names, name_to_emails) + -- Try name match first (covers the common case where GitHub name == git config name) + local real = first_real_email(name_to_emails[display_name]) + if real then + return real + end + + -- Fall back to noreply pattern match (finds users who commit with their noreply email) + local noreply_patterns = { + string.format('%s+%s@users.noreply.github.com', database_id, login), + string.format('%s@users.noreply.github.com', login), + } + + for _, pattern in ipairs(noreply_patterns) do + local author_name = email_to_names[pattern] + if author_name then + real = first_real_email(name_to_emails[author_name]) + if real then + return real + end + end + end + + return nil +end + +local function discover_from_github() + if not is_gh_available() then + return nil + end + + local repo_info = get_repo_info() + if not repo_info then + return nil + end + + local owner, repo_name = repo_info:match('^(.+)/(.+)$') + if not owner or not repo_name then + return nil + end + + local current_user = get_current_gh_user() + local handles = {} + local has_next_page = true + local cursor = '' + + while has_next_page do + local after_clause = cursor ~= '' and string.format(', after: "%s"', cursor) or '' + local query = string.format( + '{ repository(owner: "%s", name: "%s") { collaborators(first: 100%s) { totalCount nodes { login name databaseId } pageInfo { hasNextPage endCursor } } } }', + owner, + repo_name, + after_clause + ) + + local output = vim.fn.system({ 'gh', 'api', 'graphql', '-f', 'query=' .. query }) + if vim.v.shell_error ~= 0 then + return nil + end + + local ok, decoded = pcall(vim.fn.json_decode, output) + if not ok or not decoded then + return nil + end + + if decoded.errors then + return nil + end + + local collaborators = decoded.data and decoded.data.repository and decoded.data.repository.collaborators + if not collaborators then + return nil + end + + for _, node in ipairs(collaborators.nodes or {}) do + local login = node.login + local is_self = current_user and login == current_user + local is_bot = login:find('%[bot%]') + + if not is_self and not is_bot then + local display_name = node.name + if display_name == nil or display_name == vim.NIL or display_name == '' then + display_name = login + end + local database_id = node.databaseId + local handle = '@' .. login + local email = string.format('%s+%s@users.noreply.github.com', database_id, login) + handles[handle] = string.format('%s <%s>', display_name, email) + end + end + + has_next_page = collaborators.pageInfo and collaborators.pageInfo.hasNextPage or false + cursor = collaborators.pageInfo and collaborators.pageInfo.endCursor or '' + end + + local email_to_names, name_to_emails = parse_git_log() + for handle, value in pairs(handles) do + local login = handle:sub(2) + local display_name = value:match('^(.+) <') + local db_id = value:match('<(%d+)+') + if display_name and db_id then + local real_email = find_real_email(display_name, login, db_id, email_to_names, name_to_emails) + if real_email then + handles[handle] = string.format('%s <%s>', display_name, real_email) + end + end + end + + return handles +end + +local function discover_from_git_log() + vim.fn.system({ 'git', 'rev-parse', '--show-toplevel' }) + if vim.v.shell_error ~= 0 then + return {} + end + + local current_name = vim.trim(vim.fn.system({ 'git', 'config', 'user.name' })) + local current_email = vim.trim(vim.fn.system({ 'git', 'config', 'user.email' })) + + local output = vim.fn.system({ 'git', 'log', '--format=%aN <%aE>' }) + if vim.v.shell_error ~= 0 then + return {} + end + + local handles = {} + local seen = {} + + for line in output:gmatch('[^\r\n]+') do + line = vim.trim(line) + if line ~= '' and not seen[line] then + seen[line] = true + local author_name, author_email = line:match('^(.+) <(.+)>$') + if author_name and author_email then + if author_name ~= current_name and author_email ~= current_email then + handles['@' .. author_name] = line + end + end + end + end + + return handles +end + +function M.discover(config) + if config.discover == false then + return {} + end + + local github_handles = discover_from_github() + if github_handles then + return github_handles + end + + return discover_from_git_log() +end + +return M diff --git a/lua/git-coauthors/handles.lua b/lua/git-coauthors/handles.lua index 9a1c3a2..4934aa1 100644 --- a/lua/git-coauthors/handles.lua +++ b/lua/git-coauthors/handles.lua @@ -5,13 +5,17 @@ local _cached_handles = nil function M.load(config) local handles = {} + if config.discover ~= false then + handles = require('git-coauthors.discover').discover(config) + end + local json_path = vim.fn.expand(config.handles_path) if vim.fn.filereadable(json_path) == 1 then local ok, decoded = pcall(function() return vim.fn.json_decode(vim.fn.readfile(json_path)) end) if ok and type(decoded) == 'table' then - handles = decoded + handles = vim.tbl_extend('force', handles, decoded) end end diff --git a/lua/git-coauthors/init.lua b/lua/git-coauthors/init.lua index 54ca30e..cec2109 100644 --- a/lua/git-coauthors/init.lua +++ b/lua/git-coauthors/init.lua @@ -3,6 +3,7 @@ local M = {} M._config = { handles_path = vim.fn.stdpath('data') .. '/git-coauthors/handles.json', handles = nil, + discover = true, } M._setup_called = false diff --git a/tests/discover_spec.lua b/tests/discover_spec.lua new file mode 100644 index 0000000..8c19b37 --- /dev/null +++ b/tests/discover_spec.lua @@ -0,0 +1,445 @@ +local helpers = require('helpers') +local discover = require('git-coauthors.discover') + +describe('discover', function() + before_each(function() + helpers.setup_mocks() + end) + + after_each(function() + helpers.teardown_mocks() + end) + + describe('from github', function() + local function setup_github_mocks(graphql_response) + helpers.executables = { gh = true } + helpers.system_responses = { + { 'gh repo view', 'owner/repo\n', exit_code = 0 }, + { 'gh api user', 'myuser\n', exit_code = 0 }, + { 'gh api graphql', graphql_response .. '\n', exit_code = 0 }, + } + end + + it('returns handles from GraphQL response', function() + local response = vim.fn.json_encode({ + data = { + repository = { + collaborators = { + totalCount = 2, + nodes = { + { login = 'alice', name = 'Alice Smith', databaseId = 12345 }, + { login = 'bob', name = 'Bob Jones', databaseId = 67890 }, + }, + pageInfo = { hasNextPage = false, endCursor = 'abc123' }, + }, + }, + }, + }) + setup_github_mocks(response) + + local result = discover.discover({ discover = true }) + + assert.equals('Alice Smith <12345+alice@users.noreply.github.com>', result['@alice']) + assert.equals('Bob Jones <67890+bob@users.noreply.github.com>', result['@bob']) + end) + + it('excludes the current user', function() + local response = vim.fn.json_encode({ + data = { + repository = { + collaborators = { + totalCount = 2, + nodes = { + { login = 'alice', name = 'Alice Smith', databaseId = 12345 }, + { login = 'myuser', name = 'My User', databaseId = 11111 }, + }, + pageInfo = { hasNextPage = false, endCursor = 'abc123' }, + }, + }, + }, + }) + setup_github_mocks(response) + + local result = discover.discover({ discover = true }) + + assert.equals('Alice Smith <12345+alice@users.noreply.github.com>', result['@alice']) + assert.is_nil(result['@myuser']) + end) + + it('excludes bot accounts', function() + local response = vim.fn.json_encode({ + data = { + repository = { + collaborators = { + totalCount = 2, + nodes = { + { login = 'alice', name = 'Alice Smith', databaseId = 12345 }, + { login = 'dependabot[bot]', databaseId = 99999 }, + }, + pageInfo = { hasNextPage = false }, + }, + }, + }, + }) + setup_github_mocks(response) + + local result = discover.discover({ discover = true }) + + assert.equals('Alice Smith <12345+alice@users.noreply.github.com>', result['@alice']) + assert.is_nil(result['@dependabot[bot]']) + end) + + it('uses login as name when name is null', function() + local response = vim.fn.json_encode({ + data = { + repository = { + collaborators = { + totalCount = 1, + nodes = { + { login = 'alice', databaseId = 12345 }, + }, + pageInfo = { hasNextPage = false }, + }, + }, + }, + }) + setup_github_mocks(response) + + local result = discover.discover({ discover = true }) + + assert.equals('alice <12345+alice@users.noreply.github.com>', result['@alice']) + end) + + it('handles pagination', function() + helpers.executables = { gh = true } + + local page1 = vim.fn.json_encode({ + data = { + repository = { + collaborators = { + totalCount = 2, + nodes = { + { login = 'alice', name = 'Alice Smith', databaseId = 12345 }, + }, + pageInfo = { hasNextPage = true, endCursor = 'cursor1' }, + }, + }, + }, + }) + + local page2 = vim.fn.json_encode({ + data = { + repository = { + collaborators = { + totalCount = 2, + nodes = { + { login = 'bob', name = 'Bob Jones', databaseId = 67890 }, + }, + pageInfo = { hasNextPage = false, endCursor = 'cursor2' }, + }, + }, + }, + }) + + helpers.system_responses = { + { 'gh repo view', 'owner/repo\n', exit_code = 0 }, + { 'gh api user', 'myuser\n', exit_code = 0 }, + { 'after:', page2 .. '\n', exit_code = 0 }, + { 'gh api graphql', page1 .. '\n', exit_code = 0 }, + } + + local result = discover.discover({ discover = true }) + + assert.equals('Alice Smith <12345+alice@users.noreply.github.com>', result['@alice']) + assert.equals('Bob Jones <67890+bob@users.noreply.github.com>', result['@bob']) + end) + + it('uses real email from git log when name matches', function() + local response = vim.fn.json_encode({ + data = { + repository = { + collaborators = { + totalCount = 2, + nodes = { + { login = 'alice', name = 'Alice Smith', databaseId = 12345 }, + { login = 'bob', name = 'Bob Jones', databaseId = 67890 }, + }, + pageInfo = { hasNextPage = false }, + }, + }, + }, + }) + + helpers.executables = { gh = true } + helpers.system_responses = { + { 'gh repo view', 'owner/repo\n', exit_code = 0 }, + { 'gh api user', 'myuser\n', exit_code = 0 }, + { 'gh api graphql', response .. '\n', exit_code = 0 }, + { 'git log', 'Alice Smith \nBob Jones \n', exit_code = 0 }, + } + + local result = discover.discover({ discover = true }) + + assert.equals('Alice Smith ', result['@alice']) + assert.equals('Bob Jones ', result['@bob']) + end) + + it('falls back to noreply pattern when name does not match', function() + local response = vim.fn.json_encode({ + data = { + repository = { + collaborators = { + totalCount = 1, + nodes = { + { login = 'alice', name = 'Alice Smith', databaseId = 12345 }, + }, + pageInfo = { hasNextPage = false }, + }, + }, + }, + }) + + helpers.executables = { gh = true } + helpers.system_responses = { + { 'gh repo view', 'owner/repo\n', exit_code = 0 }, + { 'gh api user', 'myuser\n', exit_code = 0 }, + { 'gh api graphql', response .. '\n', exit_code = 0 }, + { + 'git log', + 'A. Smith <12345+alice@users.noreply.github.com>\nA. Smith \n', + exit_code = 0, + }, + } + + local result = discover.discover({ discover = true }) + + assert.equals('Alice Smith ', result['@alice']) + end) + + it('keeps noreply when no name or noreply pattern matches in git log', function() + local response = vim.fn.json_encode({ + data = { + repository = { + collaborators = { + totalCount = 1, + nodes = { + { login = 'alice', name = 'Alice Smith', databaseId = 12345 }, + }, + pageInfo = { hasNextPage = false }, + }, + }, + }, + }) + + helpers.executables = { gh = true } + helpers.system_responses = { + { 'gh repo view', 'owner/repo\n', exit_code = 0 }, + { 'gh api user', 'myuser\n', exit_code = 0 }, + { 'gh api graphql', response .. '\n', exit_code = 0 }, + { 'git log', 'Someone Else \n', exit_code = 0 }, + } + + local result = discover.discover({ discover = true }) + + assert.equals('Alice Smith <12345+alice@users.noreply.github.com>', result['@alice']) + end) + + it('keeps noreply when git log only has noreply emails for that user', function() + local response = vim.fn.json_encode({ + data = { + repository = { + collaborators = { + totalCount = 1, + nodes = { + { login = 'alice', name = 'Alice Smith', databaseId = 12345 }, + }, + pageInfo = { hasNextPage = false }, + }, + }, + }, + }) + + helpers.executables = { gh = true } + helpers.system_responses = { + { 'gh repo view', 'owner/repo\n', exit_code = 0 }, + { 'gh api user', 'myuser\n', exit_code = 0 }, + { 'gh api graphql', response .. '\n', exit_code = 0 }, + { 'git log', 'Alice Smith <12345+alice@users.noreply.github.com>\n', exit_code = 0 }, + } + + local result = discover.discover({ discover = true }) + + assert.equals('Alice Smith <12345+alice@users.noreply.github.com>', result['@alice']) + end) + + it('returns empty when gh is not installed', function() + local result = discover.discover({ discover = true }) + + assert.same({}, result) + end) + + it('falls back to git log when GraphQL returns FORBIDDEN', function() + local response = vim.fn.json_encode({ + errors = { { type = 'FORBIDDEN', message = 'Resource not accessible' } }, + }) + + helpers.executables = { gh = true } + helpers.system_responses = { + { 'gh repo view', 'owner/repo\n', exit_code = 0 }, + { 'gh api user', 'myuser\n', exit_code = 0 }, + { 'gh api graphql', response .. '\n', exit_code = 0 }, + { 'git rev-parse', '/path/to/repo\n', exit_code = 0 }, + { 'git config user.name', 'My User\n', exit_code = 0 }, + { 'git config user.email', 'myuser@example.com\n', exit_code = 0 }, + { 'git log', 'Alice Smith \n', exit_code = 0 }, + } + + local result = discover.discover({ discover = true }) + + assert.equals('Alice Smith ', result['@Alice Smith']) + end) + + it('falls back to git log on malformed JSON', function() + helpers.executables = { gh = true } + helpers.system_responses = { + { 'gh repo view', 'owner/repo\n', exit_code = 0 }, + { 'gh api user', 'myuser\n', exit_code = 0 }, + { 'gh api graphql', 'not valid json{{', exit_code = 0 }, + { 'git rev-parse', '/path/to/repo\n', exit_code = 0 }, + { 'git config user.name', 'My User\n', exit_code = 0 }, + { 'git config user.email', 'myuser@example.com\n', exit_code = 0 }, + { 'git log', 'Alice Smith \n', exit_code = 0 }, + } + + local result = discover.discover({ discover = true }) + + assert.equals('Alice Smith ', result['@Alice Smith']) + end) + end) + + describe('from git log', function() + it('returns handles from git log output', function() + helpers.system_responses = { + { 'git rev-parse', '/path/to/repo\n', exit_code = 0 }, + { 'git config user.name', 'My User\n', exit_code = 0 }, + { 'git config user.email', 'myuser@example.com\n', exit_code = 0 }, + { 'git log', 'Alice Smith \nBob Jones \n', exit_code = 0 }, + } + + local result = discover.discover({ discover = true }) + + assert.equals('Alice Smith ', result['@Alice Smith']) + assert.equals('Bob Jones ', result['@Bob Jones']) + end) + + it('excludes the current user by name', function() + helpers.system_responses = { + { 'git rev-parse', '/path/to/repo\n', exit_code = 0 }, + { 'git config user.name', 'My User\n', exit_code = 0 }, + { 'git config user.email', 'myuser@example.com\n', exit_code = 0 }, + { 'git log', 'Alice Smith \nMy User \n', exit_code = 0 }, + } + + local result = discover.discover({ discover = true }) + + assert.equals('Alice Smith ', result['@Alice Smith']) + assert.is_nil(result['@My User']) + end) + + it('excludes the current user by email', function() + helpers.system_responses = { + { 'git rev-parse', '/path/to/repo\n', exit_code = 0 }, + { 'git config user.name', 'My User\n', exit_code = 0 }, + { 'git config user.email', 'myuser@example.com\n', exit_code = 0 }, + { 'git log', 'Alice Smith \nDifferent Name \n', exit_code = 0 }, + } + + local result = discover.discover({ discover = true }) + + assert.equals('Alice Smith ', result['@Alice Smith']) + assert.is_nil(result['@Different Name']) + end) + + it('deduplicates entries from git log', function() + helpers.system_responses = { + { 'git rev-parse', '/path/to/repo\n', exit_code = 0 }, + { 'git config user.name', 'My User\n', exit_code = 0 }, + { 'git config user.email', 'myuser@example.com\n', exit_code = 0 }, + { + 'git log', + 'Alice Smith \nAlice Smith \nBob Jones \n', + exit_code = 0, + }, + } + + local result = discover.discover({ discover = true }) + + assert.equals('Alice Smith ', result['@Alice Smith']) + assert.equals('Bob Jones ', result['@Bob Jones']) + end) + + it('keeps both entries when same name has different emails', function() + helpers.system_responses = { + { 'git rev-parse', '/path/to/repo\n', exit_code = 0 }, + { 'git config user.name', 'My User\n', exit_code = 0 }, + { 'git config user.email', 'myuser@example.com\n', exit_code = 0 }, + { + 'git log', + 'Alice Smith \nAlice Smith \n', + exit_code = 0, + }, + } + + local result = discover.discover({ discover = true }) + + -- Same key @Alice Smith, last one wins + assert.is_not_nil(result['@Alice Smith']) + end) + + it('returns empty when not in a git repo', function() + local result = discover.discover({ discover = true }) + + assert.same({}, result) + end) + + it('returns empty on empty git log', function() + helpers.system_responses = { + { 'git rev-parse', '/path/to/repo\n', exit_code = 0 }, + { 'git config user.name', 'My User\n', exit_code = 0 }, + { 'git config user.email', 'myuser@example.com\n', exit_code = 0 }, + { 'git log', '', exit_code = 0 }, + } + + local result = discover.discover({ discover = true }) + + assert.same({}, result) + end) + end) + + it('returns empty when discover is disabled', function() + helpers.executables = { gh = true } + helpers.system_responses = { + { 'gh repo view', 'owner/repo\n', exit_code = 0 }, + { 'gh api user', 'myuser\n', exit_code = 0 }, + { + 'gh api graphql', + vim.fn.json_encode({ + data = { + repository = { + collaborators = { + totalCount = 1, + nodes = { { login = 'alice', name = 'Alice Smith', databaseId = 12345 } }, + pageInfo = { hasNextPage = false }, + }, + }, + }, + }) .. '\n', + exit_code = 0, + }, + } + + local result = discover.discover({ discover = false }) + + assert.same({}, result) + end) +end) diff --git a/tests/handles_spec.lua b/tests/handles_spec.lua index 096d65a..7b84efe 100644 --- a/tests/handles_spec.lua +++ b/tests/handles_spec.lua @@ -73,4 +73,99 @@ describe('handles', function() assert.equals(result1, result2) end) + + describe('with discovery', function() + it('file handles override discovered handles on key collision', function() + local response = vim.fn.json_encode({ + data = { + repository = { + collaborators = { + totalCount = 2, + nodes = { + { login = 'alice', name = 'Alice GH', databaseId = 12345 }, + { login = 'carol', name = 'Carol Davis', databaseId = 33333 }, + }, + pageInfo = { hasNextPage = false }, + }, + }, + }, + }) + + helpers.executables = { gh = true } + helpers.system_responses = { + { 'gh repo view', 'owner/repo\n', exit_code = 0 }, + { 'gh api user', 'nobody\n', exit_code = 0 }, + { 'gh api graphql', response .. '\n', exit_code = 0 }, + } + + git_coauthors.setup() + local result = handles.get() + + assert.equals('Alice Smith ', result['@alice']) + assert.equals('Bob Jones ', result['@bob']) + assert.equals('Carol Davis <33333+carol@users.noreply.github.com>', result['@carol']) + end) + + it('inline handles override discovered handles on key collision', function() + local response = vim.fn.json_encode({ + data = { + repository = { + collaborators = { + totalCount = 1, + nodes = { + { login = 'carol', name = 'Carol Davis', databaseId = 33333 }, + }, + pageInfo = { hasNextPage = false }, + }, + }, + }, + }) + + helpers.executables = { gh = true } + helpers.system_responses = { + { 'gh repo view', 'owner/repo\n', exit_code = 0 }, + { 'gh api user', 'nobody\n', exit_code = 0 }, + { 'gh api graphql', response .. '\n', exit_code = 0 }, + } + + git_coauthors.setup({ + handles = { + ['@carol'] = 'Carol Override ', + }, + }) + local result = handles.get() + + assert.equals('Carol Override ', result['@carol']) + end) + + it('does not discover when disabled', function() + local response = vim.fn.json_encode({ + data = { + repository = { + collaborators = { + totalCount = 1, + nodes = { + { login = 'carol', name = 'Carol Davis', databaseId = 33333 }, + }, + pageInfo = { hasNextPage = false }, + }, + }, + }, + }) + + helpers.executables = { gh = true } + helpers.system_responses = { + { 'gh repo view', 'owner/repo\n', exit_code = 0 }, + { 'gh api user', 'nobody\n', exit_code = 0 }, + { 'gh api graphql', response .. '\n', exit_code = 0 }, + } + + git_coauthors.setup({ discover = false }) + local result = handles.get() + + assert.equals('Alice Smith ', result['@alice']) + assert.equals('Bob Jones ', result['@bob']) + assert.is_nil(result['@carol']) + end) + end) end) diff --git a/tests/helpers.lua b/tests/helpers.lua index f70b60c..40d9d4c 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -1,6 +1,7 @@ local M = {} local _originals = {} +local _real_system = nil M.test_handles = { ['@alice'] = 'Alice Smith ', @@ -13,14 +14,20 @@ M.test_handles_json = vim.fn.json_encode({ }) function M.setup_mocks() + _real_system = vim.fn.system + _originals = { filereadable = rawget(vim.fn, 'filereadable'), readfile = rawget(vim.fn, 'readfile'), expand = rawget(vim.fn, 'expand'), + system = rawget(vim.fn, 'system'), + executable = rawget(vim.fn, 'executable'), } M.file_exists = true M.file_content = M.test_handles_json + M.system_responses = {} + M.executables = {} vim.fn.filereadable = function(_) return M.file_exists and 1 or 0 @@ -33,13 +40,36 @@ function M.setup_mocks() vim.fn.expand = function(expr) return expr end + + vim.fn.system = function(cmd) + local cmd_str = type(cmd) == 'table' and table.concat(cmd, ' ') or cmd + for _, entry in ipairs(M.system_responses) do + if cmd_str:find(entry[1], 1, true) then + if (entry.exit_code or 0) ~= 0 then + _real_system('false') + else + _real_system('true') + end + return entry[2] or '' + end + end + _real_system('false') + return '' + end + + vim.fn.executable = function(name) + return M.executables[name] and 1 or 0 + end end function M.teardown_mocks() rawset(vim.fn, 'filereadable', _originals.filereadable) rawset(vim.fn, 'readfile', _originals.readfile) rawset(vim.fn, 'expand', _originals.expand) + rawset(vim.fn, 'system', _originals.system) + rawset(vim.fn, 'executable', _originals.executable) _originals = {} + _real_system = nil -- Reset module caches so each test starts fresh require('git-coauthors.handles')._reset_cache() @@ -47,10 +77,13 @@ function M.teardown_mocks() require('git-coauthors')._config = { handles_path = vim.fn.stdpath('data') .. '/git-coauthors/handles.json', handles = nil, + discover = true, } M.file_exists = true M.file_content = M.test_handles_json + M.system_responses = {} + M.executables = {} end return M From 82c37ca1b9cc1e1ec6b8ef43f54875ad5ca1be70 Mon Sep 17 00:00:00 2001 From: Wassim Metallaoui Date: Fri, 20 Feb 2026 21:22:14 -0600 Subject: [PATCH 2/6] refactor: lazy-load handles instead of eagerly loading during setup The handles.get() function already loads on first access when the cache is empty, so the explicit load() call in setup() was redundant. Removing it defers handle loading until actually needed. --- lua/git-coauthors/init.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/lua/git-coauthors/init.lua b/lua/git-coauthors/init.lua index cec2109..4b71577 100644 --- a/lua/git-coauthors/init.lua +++ b/lua/git-coauthors/init.lua @@ -13,7 +13,6 @@ function M.setup(opts) M._setup_called = true require('git-coauthors.handles')._reset_cache() - require('git-coauthors.handles').load(M._config) end return M From cc0228fee6e7cdcf3d3bbbab188bbf4d7b29cd3a Mon Sep 17 00:00:00 2001 From: Wassim Metallaoui Date: Fri, 20 Feb 2026 22:12:38 -0600 Subject: [PATCH 3/6] feat: async discovery to avoid blocking UI on first completion Return file/inline handles immediately on first @ trigger and run GitHub/git log discovery in a coroutine background. Discovered handles merge into the cache when complete and appear on the next keystroke. --- lua/git-coauthors/discover.lua | 73 ++++++++++++++++++++++++++-------- lua/git-coauthors/handles.lua | 23 +++++++---- tests/handles_spec.lua | 9 +++++ 3 files changed, 81 insertions(+), 24 deletions(-) diff --git a/lua/git-coauthors/discover.lua b/lua/git-coauthors/discover.lua index 41f6c77..287550a 100644 --- a/lua/git-coauthors/discover.lua +++ b/lua/git-coauthors/discover.lua @@ -1,30 +1,54 @@ local M = {} +local async_coroutines = setmetatable({}, { __mode = 'k' }) + +local function run_cmd(cmd) + local co = coroutine.running() + if not co or not async_coroutines[co] then + local output = vim.fn.system(cmd) + return { stdout = output, code = vim.v.shell_error } + end + + vim.system(cmd, { text = true }, function(result) + vim.schedule(function() + coroutine.resume(co, result) + end) + end) + return coroutine.yield() +end + +local function async_run(fn) + local co = coroutine.create(fn) + async_coroutines[co] = true + coroutine.resume(co) +end + local function is_gh_available() return vim.fn.executable('gh') == 1 end local function get_repo_info() - local output = vim.fn.system({ 'gh', 'repo', 'view', '--json', 'owner,name', '--jq', '.owner.login + "/" + .name' }) - if vim.v.shell_error ~= 0 then + local result = run_cmd({ 'gh', 'repo', 'view', '--json', 'owner,name', '--jq', '.owner.login + "/" + .name' }) + if result.code ~= 0 then return nil end - return vim.trim(output) + return vim.trim(result.stdout) end local function get_current_gh_user() - local output = vim.fn.system({ 'gh', 'api', 'user', '--jq', '.login' }) - if vim.v.shell_error ~= 0 then + local result = run_cmd({ 'gh', 'api', 'user', '--jq', '.login' }) + if result.code ~= 0 then return nil end - return vim.trim(output) + return vim.trim(result.stdout) end local function parse_git_log() - local output = vim.fn.system({ 'git', 'log', '--format=%aN <%aE>' }) - if vim.v.shell_error ~= 0 then + local result = run_cmd({ 'git', 'log', '--format=%aN <%aE>' }) + if result.code ~= 0 then return {}, {} end + local output = result.stdout local email_to_names = {} local name_to_emails = {} @@ -115,12 +139,12 @@ local function discover_from_github() after_clause ) - local output = vim.fn.system({ 'gh', 'api', 'graphql', '-f', 'query=' .. query }) - if vim.v.shell_error ~= 0 then + local result = run_cmd({ 'gh', 'api', 'graphql', '-f', 'query=' .. query }) + if result.code ~= 0 then return nil end - local ok, decoded = pcall(vim.fn.json_decode, output) + local ok, decoded = pcall(vim.fn.json_decode, result.stdout) if not ok or not decoded then return nil end @@ -172,18 +196,19 @@ local function discover_from_github() end local function discover_from_git_log() - vim.fn.system({ 'git', 'rev-parse', '--show-toplevel' }) - if vim.v.shell_error ~= 0 then + local rev_result = run_cmd({ 'git', 'rev-parse', '--show-toplevel' }) + if rev_result.code ~= 0 then return {} end - local current_name = vim.trim(vim.fn.system({ 'git', 'config', 'user.name' })) - local current_email = vim.trim(vim.fn.system({ 'git', 'config', 'user.email' })) + local current_name = vim.trim(run_cmd({ 'git', 'config', 'user.name' }).stdout) + local current_email = vim.trim(run_cmd({ 'git', 'config', 'user.email' }).stdout) - local output = vim.fn.system({ 'git', 'log', '--format=%aN <%aE>' }) - if vim.v.shell_error ~= 0 then + local log_result = run_cmd({ 'git', 'log', '--format=%aN <%aE>' }) + if log_result.code ~= 0 then return {} end + local output = log_result.stdout local handles = {} local seen = {} @@ -217,4 +242,18 @@ function M.discover(config) return discover_from_git_log() end +function M.discover_async(config, callback) + if config.discover == false then + return callback({}) + end + + async_run(function() + local github_handles = discover_from_github() + if github_handles then + return callback(github_handles) + end + callback(discover_from_git_log()) + end) +end + return M diff --git a/lua/git-coauthors/handles.lua b/lua/git-coauthors/handles.lua index 4934aa1..a4193c3 100644 --- a/lua/git-coauthors/handles.lua +++ b/lua/git-coauthors/handles.lua @@ -1,14 +1,11 @@ local M = {} local _cached_handles = nil +local _discovery_pending = false -function M.load(config) +function M.load_fast(config) local handles = {} - if config.discover ~= false then - handles = require('git-coauthors.discover').discover(config) - end - local json_path = vim.fn.expand(config.handles_path) if vim.fn.filereadable(json_path) == 1 then local ok, decoded = pcall(function() @@ -23,7 +20,6 @@ function M.load(config) handles = vim.tbl_extend('force', handles, config.handles) end - _cached_handles = handles return handles end @@ -33,11 +29,24 @@ function M.get() end local config = require('git-coauthors')._config - return M.load(config) + _cached_handles = M.load_fast(config) + + if config.discover ~= false and not _discovery_pending then + _discovery_pending = true + require('git-coauthors.discover').discover_async(config, function(discovered) + _discovery_pending = false + if discovered and not vim.tbl_isempty(discovered) then + _cached_handles = vim.tbl_extend('force', discovered, _cached_handles) + end + end) + end + + return _cached_handles end function M._reset_cache() _cached_handles = nil + _discovery_pending = false end return M diff --git a/tests/handles_spec.lua b/tests/handles_spec.lua index 7b84efe..c2fd24b 100644 --- a/tests/handles_spec.lua +++ b/tests/handles_spec.lua @@ -1,13 +1,22 @@ local helpers = require('helpers') local handles = require('git-coauthors.handles') +local discover = require('git-coauthors.discover') local git_coauthors = require('git-coauthors') describe('handles', function() + local _original_discover_async + before_each(function() helpers.setup_mocks() + _original_discover_async = discover.discover_async + -- Mock discover_async to call callback synchronously via sync discover() + discover.discover_async = function(config, callback) + callback(discover.discover(config)) + end end) after_each(function() + discover.discover_async = _original_discover_async helpers.teardown_mocks() end) From 76183a696b8b52809a49e17078b631770e1cc0a5 Mon Sep 17 00:00:00 2001 From: Wassim Metallaoui Date: Fri, 20 Feb 2026 22:13:48 -0600 Subject: [PATCH 4/6] docs: note that discovery runs in the background --- README.md | 10 +++++++--- doc/git-coauthors.nvim.txt | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3f748a9..6b1044d 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,13 @@ configuration: 2. **Git log fallback**: When `gh` is unavailable, co-authors are read from `git log` history using `@Full Name` format (e.g., `@Mo Lawson`). -Your own name is automatically excluded from the list. Set `discover = false` in -[Configuration](#-configuration) to disable auto-discovery and use only manually -defined handles. +Discovery runs in the background so it never blocks your editor. On the first +`@` trigger, you will see any file or inline handles right away. Discovered +handles appear a moment later on the next keystroke once the background lookup +finishes. Your own name is automatically excluded from results. + +Set `discover = false` in [Configuration](#-configuration) to disable +auto-discovery and use only manually defined handles. ### Manual Handles (Optional Overrides) diff --git a/doc/git-coauthors.nvim.txt b/doc/git-coauthors.nvim.txt index da81bfa..d9b0253 100644 --- a/doc/git-coauthors.nvim.txt +++ b/doc/git-coauthors.nvim.txt @@ -80,9 +80,13 @@ log` history using `@Full Name` format (e.g., `@Mo Lawson`). -Your own name is automatically excluded from the list. Set `discover = false` -in |git-coauthors.nvim-configuration| to disable auto-discovery and use only -manually defined handles. +Discovery runs in the background so it never blocks your editor. On the first +`@` trigger, you will see any file or inline handles right away. Discovered +handles appear a moment later on the next keystroke once the background lookup +finishes. Your own name is automatically excluded from results. + +Set `discover = false` in |git-coauthors.nvim-configuration| to disable +auto-discovery and use only manually defined handles. MANUAL HANDLES (OPTIONAL OVERRIDES) ~ From 1cd018bc4eafd0bf41147cd1c0d9b95fc5159e32 Mon Sep 17 00:00:00 2001 From: Wassim Metallaoui Date: Fri, 20 Feb 2026 23:05:45 -0600 Subject: [PATCH 5/6] feat: stream discovered handles into open completion menu Call blink.cmp's callback twice: once immediately with file/inline handles, then again when async discovery completes. This updates the open menu in-place without requiring the user to re-trigger completion. --- README.md | 8 +++++--- doc/git-coauthors.nvim.txt | 8 +++++--- lua/git-coauthors/blink.lua | 17 +++++++++++++++-- lua/git-coauthors/handles.lua | 15 +++++++++++++++ tests/blink_spec.lua | 8 ++++++++ 5 files changed, 48 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6b1044d..245fed4 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,11 @@ configuration: `git log` history using `@Full Name` format (e.g., `@Mo Lawson`). Discovery runs in the background so it never blocks your editor. On the first -`@` trigger, you will see any file or inline handles right away. Discovered -handles appear a moment later on the next keystroke once the background lookup -finishes. Your own name is automatically excluded from results. +`@` trigger in a session, there may be a brief delay while the plugin fetches +collaborators from the GitHub API or parses your git log. The completion menu +will update automatically once results are ready. Subsequent triggers are +instant since results are cached. Your own name is automatically excluded from +results. Set `discover = false` in [Configuration](#-configuration) to disable auto-discovery and use only manually defined handles. diff --git a/doc/git-coauthors.nvim.txt b/doc/git-coauthors.nvim.txt index d9b0253..eb8d9e8 100644 --- a/doc/git-coauthors.nvim.txt +++ b/doc/git-coauthors.nvim.txt @@ -81,9 +81,11 @@ log` history using `@Full Name` format (e.g., `@Mo Lawson`). Discovery runs in the background so it never blocks your editor. On the first -`@` trigger, you will see any file or inline handles right away. Discovered -handles appear a moment later on the next keystroke once the background lookup -finishes. Your own name is automatically excluded from results. +`@` trigger in a session, there may be a brief delay while the plugin fetches +collaborators from the GitHub API or parses your git log. The completion menu +will update automatically once results are ready. Subsequent triggers are +instant since results are cached. Your own name is automatically excluded from +results. Set `discover = false` in |git-coauthors.nvim-configuration| to disable auto-discovery and use only manually defined handles. diff --git a/lua/git-coauthors/blink.lua b/lua/git-coauthors/blink.lua index 5ad3eed..c9a689e 100644 --- a/lua/git-coauthors/blink.lua +++ b/lua/git-coauthors/blink.lua @@ -17,11 +17,15 @@ function source:get_trigger_characters() end function source:get_completions(context, callback) - local items = require('git-coauthors.items').build_items({ + local handles = require('git-coauthors.handles') + local build_items = require('git-coauthors.items').build_items + local cursor_info = { line_text = context.line, line_number = context.cursor[1] - 1, cursor_col = context.cursor[2], - }) + } + + local items = build_items(cursor_info) if items then callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = items }) @@ -29,6 +33,15 @@ function source:get_completions(context, callback) callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = {} }) end + if handles.is_discovery_pending() then + handles.on_discovery_complete(function() + local updated_items = build_items(cursor_info) + if updated_items then + callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = updated_items }) + end + end) + end + return function() end end diff --git a/lua/git-coauthors/handles.lua b/lua/git-coauthors/handles.lua index a4193c3..c2fb569 100644 --- a/lua/git-coauthors/handles.lua +++ b/lua/git-coauthors/handles.lua @@ -2,6 +2,7 @@ local M = {} local _cached_handles = nil local _discovery_pending = false +local _discovery_listeners = {} function M.load_fast(config) local handles = {} @@ -38,15 +39,29 @@ function M.get() if discovered and not vim.tbl_isempty(discovered) then _cached_handles = vim.tbl_extend('force', discovered, _cached_handles) end + local listeners = _discovery_listeners + _discovery_listeners = {} + for _, fn in ipairs(listeners) do + fn() + end end) end return _cached_handles end +function M.is_discovery_pending() + return _discovery_pending +end + +function M.on_discovery_complete(fn) + table.insert(_discovery_listeners, fn) +end + function M._reset_cache() _cached_handles = nil _discovery_pending = false + _discovery_listeners = {} end return M diff --git a/tests/blink_spec.lua b/tests/blink_spec.lua index 83d98b0..aa30cdf 100644 --- a/tests/blink_spec.lua +++ b/tests/blink_spec.lua @@ -1,11 +1,19 @@ local helpers = require('helpers') +local discover = require('git-coauthors.discover') describe('blink source', function() + local _original_discover_async + before_each(function() helpers.setup_mocks() + _original_discover_async = discover.discover_async + discover.discover_async = function(config, callback) + callback(discover.discover(config)) + end end) after_each(function() + discover.discover_async = _original_discover_async helpers.teardown_mocks() end) From 3ab0a2f9eb6773a029f264bb2af12932bdfb4c78 Mon Sep 17 00:00:00 2001 From: Wassim Metallaoui Date: Fri, 20 Feb 2026 23:10:42 -0600 Subject: [PATCH 6/6] fix: deduplicate items when streaming discovered handles Filter out items already sent in the first callback before appending discovered handles, preventing duplicates for entries that exist in both the local handles file and the discovery results. --- lua/git-coauthors/blink.lua | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lua/git-coauthors/blink.lua b/lua/git-coauthors/blink.lua index c9a689e..af90c58 100644 --- a/lua/git-coauthors/blink.lua +++ b/lua/git-coauthors/blink.lua @@ -26,8 +26,12 @@ function source:get_completions(context, callback) } local items = build_items(cursor_info) + local sent_labels = {} if items then + for _, item in ipairs(items) do + sent_labels[item.label] = true + end callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = items }) else callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = {} }) @@ -37,7 +41,12 @@ function source:get_completions(context, callback) handles.on_discovery_complete(function() local updated_items = build_items(cursor_info) if updated_items then - callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = updated_items }) + local new_items = vim.tbl_filter(function(item) + return not sent_labels[item.label] + end, updated_items) + if #new_items > 0 then + callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = new_items }) + end end end) end