diff --git a/README.md b/README.md index a7f1239..245fed4 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,34 @@ 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`). + +Discovery runs in the background so it never blocks your editor. On the first +`@` 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. + +### 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 +91,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 +144,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..eb8d9e8 100644 --- a/doc/git-coauthors.nvim.txt +++ b/doc/git-coauthors.nvim.txt @@ -65,9 +65,37 @@ 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`). + + + +Discovery runs in the background so it never blocks your editor. On the first +`@` 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. + + +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 +112,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 +169,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/blink.lua b/lua/git-coauthors/blink.lua index 5ad3eed..af90c58 100644 --- a/lua/git-coauthors/blink.lua +++ b/lua/git-coauthors/blink.lua @@ -17,18 +17,40 @@ 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) + 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 = {} }) end + if handles.is_discovery_pending() then + handles.on_discovery_complete(function() + local updated_items = build_items(cursor_info) + if updated_items then + 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 + return function() end end diff --git a/lua/git-coauthors/discover.lua b/lua/git-coauthors/discover.lua new file mode 100644 index 0000000..287550a --- /dev/null +++ b/lua/git-coauthors/discover.lua @@ -0,0 +1,259 @@ +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 result = run_cmd({ 'gh', 'repo', 'view', '--json', 'owner,name', '--jq', '.owner.login + "/" + .name' }) + if result.code ~= 0 then + return nil + end + return vim.trim(result.stdout) +end + +local function get_current_gh_user() + local result = run_cmd({ 'gh', 'api', 'user', '--jq', '.login' }) + if result.code ~= 0 then + return nil + end + return vim.trim(result.stdout) +end + +local function parse_git_log() + 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 = {} + 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 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, result.stdout) + 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() + local rev_result = run_cmd({ 'git', 'rev-parse', '--show-toplevel' }) + if rev_result.code ~= 0 then + return {} + end + + 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 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 = {} + + 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 + +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 9a1c3a2..c2fb569 100644 --- a/lua/git-coauthors/handles.lua +++ b/lua/git-coauthors/handles.lua @@ -1,8 +1,10 @@ local M = {} local _cached_handles = nil +local _discovery_pending = false +local _discovery_listeners = {} -function M.load(config) +function M.load_fast(config) local handles = {} local json_path = vim.fn.expand(config.handles_path) @@ -11,7 +13,7 @@ function M.load(config) 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 @@ -19,7 +21,6 @@ function M.load(config) handles = vim.tbl_extend('force', handles, config.handles) end - _cached_handles = handles return handles end @@ -29,11 +30,38 @@ 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 + 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/lua/git-coauthors/init.lua b/lua/git-coauthors/init.lua index 54ca30e..4b71577 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 @@ -12,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 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) 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..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) @@ -73,4 +82,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