From e7b8a341cf0143e3ca3fd31e42fcec306146adad Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Sun, 21 Dec 2025 12:39:59 +0800 Subject: [PATCH 01/17] feat(gitcommit): add remote, fetch, pull support --- .../_extensions/gitcommit/tools/git.lua | 118 ++++++++++++++++-- .../_extensions/gitcommit/tools/git_edit.lua | 104 ++++++++++++++- 2 files changed, 209 insertions(+), 13 deletions(-) diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git.lua b/lua/codecompanion/_extensions/gitcommit/tools/git.lua index cda725b..e3ffada 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git.lua @@ -515,15 +515,15 @@ function GitTool.push(remote, branch, force, set_upstream, tags, tag_name) -- Handle tag pushing - single tag takes priority over all tags if tag_name and vim.trim(tag_name) ~= "" then -- Push single tag: git push origin tag_name - if remote then - cmd = cmd .. " " .. vim.fn.shellescape(remote) - end + -- Default to "origin" if no remote specified to avoid git interpreting tag_name as remote + local push_remote = remote or "origin" + cmd = cmd .. " " .. vim.fn.shellescape(push_remote) cmd = cmd .. " " .. vim.fn.shellescape(tag_name) elseif tags then -- Push all tags: git push origin --tags - if remote then - cmd = cmd .. " " .. vim.fn.shellescape(remote) - end + -- Default to "origin" if no remote specified + local push_remote = remote or "origin" + cmd = cmd .. " " .. vim.fn.shellescape(push_remote) cmd = cmd .. " --tags" else -- Regular branch push: git push origin branch @@ -558,15 +558,13 @@ function GitTool.push_async(remote, branch, force, set_upstream, tags, tag_name, -- Handle tag pushing - single tag takes priority over all tags if tag_name and vim.trim(tag_name) ~= "" then -- Push single tag: git push origin tag_name - if remote then - table.insert(cmd, remote) - end + -- Default to "origin" if no remote specified to avoid git interpreting tag_name as remote + table.insert(cmd, remote or "origin") table.insert(cmd, tag_name) elseif tags then -- Push all tags: git push origin --tags - if remote then - table.insert(cmd, remote) - end + -- Default to "origin" if no remote specified + table.insert(cmd, remote or "origin") table.insert(cmd, "--tags") else -- Regular branch push with optional upstream setting @@ -944,5 +942,101 @@ function GitTool.generate_release_notes(from_tag, to_tag, format) return true, release_notes, user_msg, llm_msg end +---Add a new remote +---@param name string Remote name +---@param url string Remote URL +---@return boolean success, string output +function GitTool.add_remote(name, url) + if not name or vim.trim(name) == "" then + return false, "Remote name is required" + end + if not url or vim.trim(url) == "" then + return false, "Remote URL is required" + end + local cmd = "git remote add " .. vim.fn.shellescape(name) .. " " .. vim.fn.shellescape(url) + return execute_git_command(cmd) +end + +---Remove a remote +---@param name string Remote name +---@return boolean success, string output +function GitTool.remove_remote(name) + if not name or vim.trim(name) == "" then + return false, "Remote name is required" + end + local cmd = "git remote remove " .. vim.fn.shellescape(name) + return execute_git_command(cmd) +end + +---Rename a remote +---@param old_name string Current remote name +---@param new_name string New remote name +---@return boolean success, string output +function GitTool.rename_remote(old_name, new_name) + if not old_name or vim.trim(old_name) == "" then + return false, "Current remote name is required" + end + if not new_name or vim.trim(new_name) == "" then + return false, "New remote name is required" + end + local cmd = "git remote rename " .. vim.fn.shellescape(old_name) .. " " .. vim.fn.shellescape(new_name) + return execute_git_command(cmd) +end + +---Set remote URL +---@param name string Remote name +---@param url string New URL +---@return boolean success, string output +function GitTool.set_remote_url(name, url) + if not name or vim.trim(name) == "" then + return false, "Remote name is required" + end + if not url or vim.trim(url) == "" then + return false, "Remote URL is required" + end + local cmd = "git remote set-url " .. vim.fn.shellescape(name) .. " " .. vim.fn.shellescape(url) + return execute_git_command(cmd) +end + +---Fetch from remote +---@param remote? string Remote name (default: all remotes) +---@param branch? string Specific branch to fetch +---@param prune? boolean Remove remote-tracking references that no longer exist +---@return boolean success, string output +function GitTool.fetch(remote, branch, prune) + local cmd = "git fetch" + if prune then + cmd = cmd .. " --prune" + end + if remote then + cmd = cmd .. " " .. vim.fn.shellescape(remote) + if branch then + cmd = cmd .. " " .. vim.fn.shellescape(branch) + end + else + cmd = cmd .. " --all" + end + return execute_git_command(cmd) +end + +---Pull from remote +---@param remote? string Remote name (default: origin) +---@param branch? string Branch to pull (default: current branch) +---@param rebase? boolean Use rebase instead of merge +---@return boolean success, string output +function GitTool.pull(remote, branch, rebase) + local cmd = "git pull" + if rebase then + cmd = cmd .. " --rebase" + end + if remote then + cmd = cmd .. " " .. vim.fn.shellescape(remote) + if branch then + cmd = cmd .. " " .. vim.fn.shellescape(branch) + end + end + return execute_git_command(cmd) +end + M.GitTool = GitTool return M diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git_edit.lua b/lua/codecompanion/_extensions/gitcommit/tools/git_edit.lua index 70693d6..b35dd34 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git_edit.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git_edit.lua @@ -29,6 +29,12 @@ GitEdit.schema = { "gitignore_add", "gitignore_remove", "push", + "fetch", + "pull", + "add_remote", + "remove_remote", + "rename_remote", + "set_remote_url", "cherry_pick", "revert", "create_tag", @@ -140,6 +146,26 @@ GitEdit.schema = { type = "string", description = "An optional commit hash to tag", }, + remote_name = { + type = "string", + description = "Name of the remote", + }, + remote_url = { + type = "string", + description = "URL of the remote repository", + }, + new_remote_name = { + type = "string", + description = "New name for the remote (used in rename_remote)", + }, + prune = { + type = "boolean", + description = "Remove remote-tracking references that no longer exist (for fetch)", + }, + rebase = { + type = "boolean", + description = "Use rebase instead of merge (for pull)", + }, }, additionalProperties = false, }, @@ -176,7 +202,13 @@ GitEdit.system_prompt = [[# Git Edit Tool (`git_edit`) | `reset` | Reset to commit | commit_hash (required), mode? | | `gitignore_add` | Add .gitignore rules | gitignore_rules (required) | | `gitignore_remove` | Remove .gitignore rules | gitignore_rule (required) | -| `push` | Push to remote | remote?, branch?, set_upstream? | +| `push` | Push to remote | remote?, branch?, set_upstream?, tags?, single_tag_name? | +| `fetch` | Fetch from remote | remote?, branch?, prune? | +| `pull` | Pull from remote | remote?, branch?, rebase? | +| `add_remote` | Add new remote | remote_name (required), remote_url (required) | +| `remove_remote` | Remove remote | remote_name (required) | +| `rename_remote` | Rename remote | remote_name (required), new_remote_name (required) | +| `set_remote_url` | Change remote URL | remote_name (required), remote_url (required) | | `cherry_pick` | Apply commit | cherry_pick_commit_hash (required) | | `revert` | Revert commit | revert_commit_hash (required) | | `create_tag` | Create tag | tag_name (required), tag_message? | @@ -184,6 +216,11 @@ GitEdit.system_prompt = [[# Git Edit Tool (`git_edit`) | `merge` | Merge branch | branch (required) | | `help` | Show help | - | +## PUSH OPERATION NOTES +- To push a single tag: use `single_tag_name` parameter (remote defaults to "origin") +- To push all tags: use `tags: true` parameter +- Do NOT use `single_tag_name` as the `branch` parameter + ## SAFETY RESTRICTIONS - Never use force push without explicit user confirmation. - Always verify staged changes before committing. @@ -206,6 +243,12 @@ local VALID_OPERATIONS = { "gitignore_add", "gitignore_remove", "push", + "fetch", + "pull", + "add_remote", + "remove_remote", + "rename_remote", + "set_remote_url", "cherry_pick", "revert", "create_tag", @@ -245,6 +288,12 @@ Available write-access Git operations: • gitignore_add: Add rule to .gitignore • gitignore_remove: Remove rule from .gitignore • push: Push changes to a remote repository (WARNING: force push is dangerous) +• fetch: Fetch from remote (prune option available) +• pull: Pull from remote (rebase option available) +• add_remote: Add a new remote repository +• remove_remote: Remove a remote repository +• rename_remote: Rename a remote repository +• set_remote_url: Change URL of a remote repository • cherry_pick: Apply changes from existing commits • revert: Revert a commit • create_tag: Create a new tag @@ -425,6 +474,59 @@ Available write-access Git operations: return param_err end success, output = GitTool.merge(op_args.branch) + elseif operation == "fetch" then + param_err = validation.first_error({ + validation.optional_string(op_args.remote, "remote", TOOL_NAME), + validation.optional_string(op_args.branch, "branch", TOOL_NAME), + validation.optional_boolean(op_args.prune, "prune", TOOL_NAME), + }) + if param_err then + return param_err + end + success, output = GitTool.fetch(op_args.remote, op_args.branch, op_args.prune) + elseif operation == "pull" then + param_err = validation.first_error({ + validation.optional_string(op_args.remote, "remote", TOOL_NAME), + validation.optional_string(op_args.branch, "branch", TOOL_NAME), + validation.optional_boolean(op_args.rebase, "rebase", TOOL_NAME), + }) + if param_err then + return param_err + end + success, output = GitTool.pull(op_args.remote, op_args.branch, op_args.rebase) + elseif operation == "add_remote" then + param_err = validation.first_error({ + validation.require_string(op_args.remote_name, "remote_name", TOOL_NAME), + validation.require_string(op_args.remote_url, "remote_url", TOOL_NAME), + }) + if param_err then + return param_err + end + success, output = GitTool.add_remote(op_args.remote_name, op_args.remote_url) + elseif operation == "remove_remote" then + param_err = validation.require_string(op_args.remote_name, "remote_name", TOOL_NAME) + if param_err then + return param_err + end + success, output = GitTool.remove_remote(op_args.remote_name) + elseif operation == "rename_remote" then + param_err = validation.first_error({ + validation.require_string(op_args.remote_name, "remote_name", TOOL_NAME), + validation.require_string(op_args.new_remote_name, "new_remote_name", TOOL_NAME), + }) + if param_err then + return param_err + end + success, output = GitTool.rename_remote(op_args.remote_name, op_args.new_remote_name) + elseif operation == "set_remote_url" then + param_err = validation.first_error({ + validation.require_string(op_args.remote_name, "remote_name", TOOL_NAME), + validation.require_string(op_args.remote_url, "remote_url", TOOL_NAME), + }) + if param_err then + return param_err + end + success, output = GitTool.set_remote_url(op_args.remote_name, op_args.remote_url) else return validation.format_error(TOOL_NAME, "Unknown Git edit operation: " .. tostring(operation)) end From 5996fe8b1a8160a5e56eaa0938115e2ad686f8b9 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Sun, 21 Dec 2025 17:23:00 +0800 Subject: [PATCH 02/17] feat(gitcommit): add cherry-pick/merge abort/continue - Add cherry_pick_skip operation - Improve error messages with conflict resolution guidance --- .../_extensions/gitcommit/tools/git.lua | 139 +++++++++++++++++- .../_extensions/gitcommit/tools/git_edit.lua | 30 ++++ 2 files changed, 166 insertions(+), 3 deletions(-) diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git.lua b/lua/codecompanion/_extensions/gitcommit/tools/git.lua index e3ffada..90f9c01 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git.lua @@ -634,8 +634,94 @@ function GitTool.cherry_pick(commit_hash) if not commit_hash then return false, "Commit hash is required for cherry-pick" end + + if not is_git_repo() then + return false, "Not in a git repository" + end + local cmd = "git cherry-pick --no-edit " .. vim.fn.shellescape(commit_hash) - return execute_git_command(cmd) + local output = vim.fn.system(cmd) + local exit_code = vim.v.shell_error + + if exit_code == 0 then + return true, output + else + if output:match("CONFLICT") or output:match("conflict") then + return false, + "Cherry-pick conflict detected. Please resolve the conflicts manually.\n" + .. "Options:\n" + .. " • Use 'cherry_pick_continue' after resolving conflicts\n" + .. " • Use 'cherry_pick_abort' to cancel the cherry-pick\n" + .. " • Use 'cherry_pick_skip' to skip this commit" + else + return false, output + end + end +end + +---Abort cherry-pick operation +---@return boolean success, string output +function GitTool.cherry_pick_abort() + if not is_git_repo() then + return false, "Not in a git repository" + end + + local cmd = "git cherry-pick --abort" + local output = vim.fn.system(cmd) + local exit_code = vim.v.shell_error + + if exit_code == 0 then + return true, "Cherry-pick aborted successfully" + else + if output:match("no cherry%-pick") or output:match("not in progress") then + return false, "No cherry-pick in progress to abort" + end + return false, output + end +end + +---Continue cherry-pick after resolving conflicts +---@return boolean success, string output +function GitTool.cherry_pick_continue() + if not is_git_repo() then + return false, "Not in a git repository" + end + + local cmd = "git cherry-pick --continue" + local output = vim.fn.system(cmd) + local exit_code = vim.v.shell_error + + if exit_code == 0 then + return true, "Cherry-pick continued successfully" + else + if output:match("CONFLICT") or output:match("conflict") then + return false, "Conflicts still exist. Please resolve all conflicts before continuing." + elseif output:match("no cherry%-pick") or output:match("not in progress") then + return false, "No cherry-pick in progress to continue" + end + return false, output + end +end + +---Skip current commit in cherry-pick +---@return boolean success, string output +function GitTool.cherry_pick_skip() + if not is_git_repo() then + return false, "Not in a git repository" + end + + local cmd = "git cherry-pick --skip" + local output = vim.fn.system(cmd) + local exit_code = vim.v.shell_error + + if exit_code == 0 then + return true, "Current commit skipped successfully" + else + if output:match("no cherry%-pick") or output:match("not in progress") then + return false, "No cherry-pick in progress to skip" + end + return false, output + end end ---Revert a commit @@ -714,15 +800,62 @@ function GitTool.merge(branch) if exit_code == 0 then return true, output else - if output:match("CONFLICT") then + if output:match("CONFLICT") or output:match("conflict") then return false, - "Merge conflict detected. Please resolve the conflicts manually. You can use 'git merge --abort' to cancel." + "Merge conflict detected. Please resolve the conflicts manually.\n" + .. "Options:\n" + .. " • Use 'merge_continue' after resolving conflicts\n" + .. " • Use 'merge_abort' to cancel the merge" else return false, output end end end +---Abort merge operation +---@return boolean success, string output +function GitTool.merge_abort() + if not is_git_repo() then + return false, "Not in a git repository" + end + + local cmd = "git merge --abort" + local output = vim.fn.system(cmd) + local exit_code = vim.v.shell_error + + if exit_code == 0 then + return true, "Merge aborted successfully" + else + if output:match("not merging") or output:match("no merge") then + return false, "No merge in progress to abort" + end + return false, output + end +end + +---Continue merge after resolving conflicts +---@return boolean success, string output +function GitTool.merge_continue() + if not is_git_repo() then + return false, "Not in a git repository" + end + + local cmd = "git merge --continue" + local output = vim.fn.system(cmd) + local exit_code = vim.v.shell_error + + if exit_code == 0 then + return true, "Merge continued successfully" + else + if output:match("CONFLICT") or output:match("conflict") then + return false, "Conflicts still exist. Please resolve all conflicts before continuing." + elseif output:match("not merging") or output:match("no merge") then + return false, "No merge in progress to continue" + end + return false, output + end +end + --- Generate release notes between two tags ---@param from_tag string|nil Starting tag (if not provided, uses second latest tag) ---@param to_tag string|nil Ending tag (if not provided, uses latest tag) diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git_edit.lua b/lua/codecompanion/_extensions/gitcommit/tools/git_edit.lua index b35dd34..38d719a 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git_edit.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git_edit.lua @@ -36,10 +36,15 @@ GitEdit.schema = { "rename_remote", "set_remote_url", "cherry_pick", + "cherry_pick_abort", + "cherry_pick_continue", + "cherry_pick_skip", "revert", "create_tag", "delete_tag", "merge", + "merge_abort", + "merge_continue", "help", }, description = "The write-access Git operation to perform.", @@ -210,10 +215,15 @@ GitEdit.system_prompt = [[# Git Edit Tool (`git_edit`) | `rename_remote` | Rename remote | remote_name (required), new_remote_name (required) | | `set_remote_url` | Change remote URL | remote_name (required), remote_url (required) | | `cherry_pick` | Apply commit | cherry_pick_commit_hash (required) | +| `cherry_pick_abort` | Abort cherry-pick | - | +| `cherry_pick_continue` | Continue cherry-pick | - | +| `cherry_pick_skip` | Skip current commit | - | | `revert` | Revert commit | revert_commit_hash (required) | | `create_tag` | Create tag | tag_name (required), tag_message? | | `delete_tag` | Delete tag | tag_name (required) | | `merge` | Merge branch | branch (required) | +| `merge_abort` | Abort merge | - | +| `merge_continue` | Continue merge | - | | `help` | Show help | - | ## PUSH OPERATION NOTES @@ -250,10 +260,15 @@ local VALID_OPERATIONS = { "rename_remote", "set_remote_url", "cherry_pick", + "cherry_pick_abort", + "cherry_pick_continue", + "cherry_pick_skip", "revert", "create_tag", "delete_tag", "merge", + "merge_abort", + "merge_continue", "help", } local VALID_RESET_MODES = { "soft", "mixed", "hard" } @@ -295,10 +310,15 @@ Available write-access Git operations: • rename_remote: Rename a remote repository • set_remote_url: Change URL of a remote repository • cherry_pick: Apply changes from existing commits +• cherry_pick_abort: Abort cherry-pick in progress +• cherry_pick_continue: Continue cherry-pick after resolving conflicts +• cherry_pick_skip: Skip current commit in cherry-pick • revert: Revert a commit • create_tag: Create a new tag • delete_tag: Delete a tag • merge: Merge a branch into the current branch (requires branch parameter) +• merge_abort: Abort merge in progress +• merge_continue: Continue merge after resolving conflicts ]] return { status = "success", data = help_text } end @@ -443,6 +463,12 @@ Available write-access Git operations: return param_err end success, output = GitTool.cherry_pick(op_args.cherry_pick_commit_hash) + elseif operation == "cherry_pick_abort" then + success, output = GitTool.cherry_pick_abort() + elseif operation == "cherry_pick_continue" then + success, output = GitTool.cherry_pick_continue() + elseif operation == "cherry_pick_skip" then + success, output = GitTool.cherry_pick_skip() elseif operation == "revert" then param_err = validation.require_string(op_args.revert_commit_hash, "revert_commit_hash", TOOL_NAME) if param_err then @@ -474,6 +500,10 @@ Available write-access Git operations: return param_err end success, output = GitTool.merge(op_args.branch) + elseif operation == "merge_abort" then + success, output = GitTool.merge_abort() + elseif operation == "merge_continue" then + success, output = GitTool.merge_continue() elseif operation == "fetch" then param_err = validation.first_error({ validation.optional_string(op_args.remote, "remote", TOOL_NAME), From 60a1f202aada1445c465741ee66650dcbaad7e54 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Sun, 21 Dec 2025 17:45:37 +0800 Subject: [PATCH 03/17] feat(gitcommit): add merge conflict detection --- .../_extensions/gitcommit/tools/git.lua | 112 ++++++++++++++++++ .../_extensions/gitcommit/tools/git_read.lua | 16 ++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git.lua b/lua/codecompanion/_extensions/gitcommit/tools/git.lua index 90f9c01..448af68 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git.lua @@ -856,6 +856,118 @@ function GitTool.merge_continue() end end +---Get list of files with merge conflicts +---@return boolean success, string output, string user_msg, string llm_msg +function GitTool.get_conflict_status() + if not is_git_repo() then + local msg = "Not in a git repository" + return false, msg, "✗ " .. msg, "fail: " .. msg .. "" + end + + -- git diff --name-only --diff-filter=U lists unmerged (conflicted) files + local cmd = "git diff --name-only --diff-filter=U" + local output = vim.fn.system(cmd) + local exit_code = vim.v.shell_error + + if exit_code ~= 0 then + local msg = "Failed to get conflict status" + return false, msg, "✗ " .. msg, "fail: " .. msg .. "" + end + + local trimmed = vim.trim(output) + if trimmed == "" then + local msg = "No conflicts found" + return true, msg, "✓ " .. msg, "success: " .. msg .. "" + end + + local files = {} + for file in trimmed:gmatch("[^\r\n]+") do + if file ~= "" then + table.insert(files, file) + end + end + + local user_msg = string.format("⚠ %d file(s) with conflicts:\n", #files) + for _, file in ipairs(files) do + user_msg = user_msg .. " • " .. file .. "\n" + end + + local llm_msg = + string.format("success: %d conflicted file(s):\n%s", #files, trimmed) + + return true, trimmed, user_msg, llm_msg +end + +---Show conflict markers in a specific file +---@param file_path string Path to the file with conflicts +---@return boolean success, string output, string user_msg, string llm_msg +function GitTool.show_conflict(file_path) + if not is_git_repo() then + local msg = "Not in a git repository" + return false, msg, "✗ " .. msg, "fail: " .. msg .. "" + end + + if not file_path or vim.trim(file_path) == "" then + local msg = "File path is required" + return false, msg, "✗ " .. msg, "fail: " .. msg .. "" + end + + local stat = vim.uv.fs_stat(file_path) + if not stat then + local msg = "File not found: " .. file_path + return false, msg, "✗ " .. msg, "fail: " .. msg .. "" + end + + local fd = vim.uv.fs_open(file_path, "r", 438) + if not fd then + local msg = "Failed to open file: " .. file_path + return false, msg, "✗ " .. msg, "fail: " .. msg .. "" + end + + local content = vim.uv.fs_read(fd, stat.size, 0) + vim.uv.fs_close(fd) + + if not content then + local msg = "Failed to read file: " .. file_path + return false, msg, "✗ " .. msg, "fail: " .. msg .. "" + end + + if not content:match("<<<<<<< ") then + local msg = "No conflict markers found in: " .. file_path + return true, msg, "✓ " .. msg, "success: " .. msg .. "" + end + + local conflicts = {} + local conflict_num = 0 + + for conflict_block in content:gmatch("(<<<<<<<.->>>>>>>.-)\n?") do + conflict_num = conflict_num + 1 + table.insert(conflicts, string.format("--- Conflict #%d ---\n%s", conflict_num, conflict_block)) + end + + if #conflicts == 0 then + local msg = "No conflict markers found in: " .. file_path + return true, msg, "✓ " .. msg, "success: " .. msg .. "" + end + + local conflict_output = table.concat(conflicts, "\n\n") + local user_msg = string.format( + "⚠ Found %d conflict(s) in %s:\n\n```\n%s\n```\n\nResolve conflicts manually, then use 'stage' followed by 'cherry_pick_continue' or 'merge_continue'.", + #conflicts, + file_path, + conflict_output + ) + + local llm_msg = string.format( + "success: %d conflict(s) in %s:\n%s", + #conflicts, + file_path, + conflict_output + ) + + return true, conflict_output, user_msg, llm_msg +end + --- Generate release notes between two tags ---@param from_tag string|nil Starting tag (if not provided, uses second latest tag) ---@param to_tag string|nil Ending tag (if not provided, uses latest tag) diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git_read.lua b/lua/codecompanion/_extensions/gitcommit/tools/git_read.lua index 15658c5..cb06d2e 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git_read.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git_read.lua @@ -31,6 +31,8 @@ GitRead.schema = { "search_commits", "tags", "generate_release_notes", + "conflict_status", + "conflict_show", "help", "gitignore_get", "gitignore_check", @@ -140,6 +142,8 @@ GitRead.system_prompt = [[# Git Read Tool (`git_read`) | `search_commits` | Search commit messages | pattern (required) | | `tags` | List all tags | - | | `generate_release_notes` | Generate release notes | from_tag?, to_tag? | +| `conflict_status` | List files with conflicts | - | +| `conflict_show` | Show conflict markers in file | file_path (required) | | `gitignore_get` | Get .gitignore content | - | | `gitignore_check` | Check if file is ignored | gitignore_file (required) | | `help` | Show help information | - | @@ -164,6 +168,8 @@ local VALID_OPERATIONS = { "search_commits", "tags", "generate_release_notes", + "conflict_status", + "conflict_show", "help", "gitignore_get", "gitignore_check", @@ -191,7 +197,7 @@ GitRead.cmds = { if operation == "help" then local help_text = - "\\\nAvailable read-only Git operations:\n• status: Show repository status\n• log: Show commit history\n• diff: Show file differences\n• branch: List branches\n• remotes: Show remote repositories\n• show: Show commit details\n• blame: Show file blame info\n• stash_list: List stashes\n• diff_commits: Compare commits\n• contributors: Show contributors\n• search_commits: Search commit messages\n• tags: List all tags\n• generate_release_notes: Generate release notes between tags\n• gitignore_get: Get .gitignore content\n• gitignore_check: Check if a file is ignored\n " + "\\\nAvailable read-only Git operations:\n• status: Show repository status\n• log: Show commit history\n• diff: Show file differences\n• branch: List branches\n• remotes: Show remote repositories\n• show: Show commit details\n• blame: Show file blame info\n• stash_list: List stashes\n• diff_commits: Compare commits\n• contributors: Show contributors\n• search_commits: Search commit messages\n• tags: List all tags\n• generate_release_notes: Generate release notes between tags\n• conflict_status: List files with merge conflicts\n• conflict_show: Show conflict markers in a file\n• gitignore_get: Get .gitignore content\n• gitignore_check: Check if a file is ignored\n " return { status = "success", data = help_text } end @@ -285,6 +291,14 @@ GitRead.cmds = { end success, output, user_msg, llm_msg = GitTool.generate_release_notes(op_args.from_tag, op_args.to_tag, op_args.release_format) + elseif operation == "conflict_status" then + success, output, user_msg, llm_msg = GitTool.get_conflict_status() + elseif operation == "conflict_show" then + param_err = validation.require_string(op_args.file_path, "file_path", TOOL_NAME) + if param_err then + return param_err + end + success, output, user_msg, llm_msg = GitTool.show_conflict(op_args.file_path) elseif operation == "gitignore_get" then success, output, user_msg, llm_msg = GitTool.get_gitignore() elseif operation == "gitignore_check" then From 9a9335bba1f86c4c98ba0d067c957e7c63af8c3b Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Sun, 21 Dec 2025 19:46:18 +0800 Subject: [PATCH 04/17] refactor(gitcommit): flatten tool response structures - Remove nested output/user_msg/llm_msg wrapper objects - Remove setup handlers from tool definitions - Rename agent to tools in all handler signatures - Remove v17 compatibility code and comments --- .../gitcommit/tools/ai_release_notes.lua | 68 +++++------------ .../_extensions/gitcommit/tools/git_edit.lua | 50 ++++++------- .../_extensions/gitcommit/tools/git_read.lua | 75 ++++++++----------- 3 files changed, 72 insertions(+), 121 deletions(-) diff --git a/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua b/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua index 1cec675..dccd5e8 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua @@ -1,10 +1,9 @@ local prompts = require("codecompanion._extensions.gitcommit.prompts.release_notes") ----@class CodeCompanion.GitCommit.Tools.AIReleaseNotes +---@class CodeCompanion.GitCommit.Tools.AIReleaseNotes: CodeCompanion.Tools.Tool local AIReleaseNotes = {} AIReleaseNotes.name = "ai_release_notes" -AIReleaseNotes.description = "Generate comprehensive release notes using AI analysis of commit history" AIReleaseNotes.schema = { type = "function", @@ -194,77 +193,44 @@ AIReleaseNotes.cmds = { -- Get detailed commit history local commits, error_msg = get_detailed_commits(from_tag, to_tag) if not commits then - return { - status = "error", - data = { - output = error_msg, - user_msg = "✗ " .. error_msg, - llm_msg = "fail: " .. error_msg .. "", - }, - } + return { status = "error", data = error_msg } end if #commits == 0 then local msg = string.format("No commits found between %s and %s", from_tag, to_tag) - return { - status = "success", - data = { - output = msg, - user_msg = "ℹ " .. msg, - llm_msg = "success: " .. msg .. "", - }, - } + return { status = "success", data = msg } end local prompt = prompts.create_smart_prompt(commits, style, { from = from_tag, to = to_tag }) - local user_msg = - string.format("📝 Generating %s release notes: %s → %s (%d commits)", style, from_tag, to_tag, #commits) - - local llm_msg = string.format("\n%s\n", prompt) - - return { - status = "success", - data = { - output = prompt, - user_msg = user_msg, - llm_msg = llm_msg, - }, - } + return { status = "success", data = prompt } end, } AIReleaseNotes.handlers = { - setup = function(_self, _agent) - return true - end, - on_exit = function(_self, _agent) end, + on_exit = function(self, tools) end, } AIReleaseNotes.output = { - success = function(self, agent, _cmd, stdout) - local chat = agent.chat - local data = stdout[1] - local llm_msg = data and data.llm_msg or data.output - local user_msg = data and data.user_msg or data.output - return chat:add_tool_output(self, llm_msg, user_msg) + success = function(self, tools, cmd, stdout) + local chat = tools.chat + local output = stdout and #stdout > 0 and vim.iter(stdout):flatten():join("\n") or "" + local user_msg = "Release notes generated" + chat:add_tool_output(self, output, user_msg) end, - error = function(self, agent, _cmd, stderr, stdout) - local chat = agent.chat - local data = stderr[1] or stdout[1] - local llm_msg = data and data.llm_msg or (type(data) == "string" and data or "AI release notes generation failed") - local user_msg = data and data.user_msg or "AI release notes generation failed" - return chat:add_tool_output(self, llm_msg, user_msg) + error = function(self, tools, cmd, stderr, stdout) + local chat = tools.chat + local errors = stderr and #stderr > 0 and vim.iter(stderr):flatten():join("\n") or "Unknown error" + local user_msg = "Release notes generation failed" + chat:add_tool_output(self, errors, user_msg) end, } AIReleaseNotes.opts = { - -- v18+ uses require_approval_before - require_approval_before = function(_self, _agent) + require_approval_before = function(self, tools) return false end, - -- COMPAT(v17): Remove when dropping v17 support - requires_approval = function(_self, _agent) + requires_approval = function(self, tools) return false end, } diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git_edit.lua b/lua/codecompanion/_extensions/gitcommit/tools/git_edit.lua index 38d719a..cd979ec 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git_edit.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git_edit.lua @@ -1,11 +1,10 @@ local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool local validation = require("codecompanion._extensions.gitcommit.tools.validation") ----@class CodeCompanion.GitCommit.Tools.GitEdit +---@class CodeCompanion.GitCommit.Tools.GitEdit: CodeCompanion.Tools.Tool local GitEdit = {} GitEdit.name = "git_edit" -GitEdit.description = "Tool for write-access Git operations like stage, unstage, branch creation, etc." GitEdit.schema = { type = "function", @@ -587,14 +586,11 @@ Available write-access Git operations: } GitEdit.handlers = { - setup = function(self, agent) - return true - end, - on_exit = function(self, agent) end, + on_exit = function(self, tools) end, } GitEdit.output = { - prompt = function(self, _tools) + prompt = function(self, tools) local operation = self.args and self.args.operation or "unknown" local details = "" if operation == "stage" or operation == "unstage" then @@ -612,36 +608,40 @@ GitEdit.output = { return string.format("Execute git %s%s?", operation, details) end, - success = function(self, agent, _cmd, stdout) - local chat = agent.chat - local operation = self.args.operation - local user_msg = string.format("Git edit operation [%s] executed successfully", operation) - return chat:add_tool_output(self, stdout[1], user_msg) + success = function(self, tools, cmd, stdout) + local chat = tools.chat + local operation = self.args and self.args.operation or "unknown" + local output = stdout and #stdout > 0 and vim.iter(stdout):flatten():join("\n") or "" + local user_msg = string.format("Git %s completed", operation) + chat:add_tool_output(self, output, user_msg) end, - error = function(self, agent, _cmd, stderr, stdout) - local chat = agent.chat - local operation = self.args.operation - local error_msg = stderr and stderr[1] or ("Git edit operation [%s] failed"):format(operation) - local user_msg = string.format("Git edit operation [%s] failed", operation) - return chat:add_tool_output(self, error_msg, user_msg) + error = function(self, tools, cmd, stderr, stdout) + local chat = tools.chat + local operation = self.args and self.args.operation or "unknown" + local errors = stderr and #stderr > 0 and vim.iter(stderr):flatten():join("\n") or "Unknown error" + local user_msg = string.format("Git %s failed", operation) + chat:add_tool_output(self, errors, user_msg) end, - rejected = function(self, tools, _cmd, _opts) - local chat = tools.chat + rejected = function(self, tools, cmd, opts) local operation = self.args and self.args.operation or "unknown" local message = string.format("User rejected the git %s operation", operation) - return chat:add_tool_output(self, message, message) + opts = vim.tbl_extend("force", { message = message }, opts or {}) + local ok, helpers = pcall(require, "codecompanion.interactions.chat.tools.builtin.helpers") + if ok and helpers and helpers.rejected then + helpers.rejected(self, tools, cmd, opts) + else + tools.chat:add_tool_output(self, message) + end end, } GitEdit.opts = { - -- v18+ uses require_approval_before - require_approval_before = function(self, agent) + require_approval_before = function(self, tools) return true end, - -- COMPAT(v17): Remove when dropping v17 support - requires_approval = function(self, agent) + requires_approval = function(self, tools) return true end, } diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git_read.lua b/lua/codecompanion/_extensions/gitcommit/tools/git_read.lua index cb06d2e..464ee98 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git_read.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git_read.lua @@ -1,11 +1,10 @@ local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool local validation = require("codecompanion._extensions.gitcommit.tools.validation") ----@class CodeCompanion.GitCommit.Tools.GitRead +---@class CodeCompanion.GitCommit.Tools.GitRead: CodeCompanion.Tools.Tool local GitRead = {} GitRead.name = "git_read" -GitRead.description = "Tool for read-only Git operations like status, log, diff, etc." GitRead.schema = { type = "function", @@ -315,77 +314,63 @@ GitRead.cmds = { -- Handle unexpected execution errors if not ok then local error_msg = "Git read operation failed unexpectedly: " .. tostring(result) - return { - status = "error", - data = { - output = error_msg, - user_msg = error_msg, - llm_msg = "fail: " .. error_msg .. "", - }, - } + return { status = "error", data = error_msg } end - local success, output, user_msg, llm_msg = result.success, result.output, result.user_msg, result.llm_msg + local op_success, output = result.success, result.output - -- Ensure proper response format even if operation fails - if success then - return { status = "success", data = { output = output, user_msg = user_msg, llm_msg = llm_msg } } + if op_success then + return { status = "success", data = output or "Operation completed" } else - -- Ensure consistent error message format - local formatted_output = { - output = output or "Git read operation failed", - user_msg = user_msg or "Git read operation failed", - llm_msg = llm_msg or "fail: Git read operation failed", - } - return { status = "error", data = formatted_output } + return { status = "error", data = output or "Git read operation failed" } end end, } GitRead.handlers = { - setup = function(_self, _agent) - return true - end, - on_exit = function(_self, _agent) end, + on_exit = function(self, tools) end, } GitRead.output = { - prompt = function(self, _tools) + prompt = function(self, tools) local operation = self.args and self.args.operation or "unknown" return string.format("Execute git %s?", operation) end, - success = function(self, agent, _cmd, stdout) - local chat = agent.chat - local data = stdout[1] - local llm_msg = data and data.llm_msg or data.output - local user_msg = data and data.user_msg or data.output - return chat:add_tool_output(self, llm_msg, user_msg) + success = function(self, tools, cmd, stdout) + local chat = tools.chat + local operation = self.args and self.args.operation or "unknown" + local output = stdout and #stdout > 0 and vim.iter(stdout):flatten():join("\n") or "" + local user_msg = string.format("Git %s completed", operation) + chat:add_tool_output(self, output, user_msg) end, - error = function(self, agent, _cmd, stderr, stdout) - local chat = agent.chat - local data = stderr[1] or stdout[1] - local llm_msg = data and data.llm_msg or (type(data) == "string" and data or "Git read operation failed") - local user_msg = data and data.user_msg or "Git read operation failed" - return chat:add_tool_output(self, llm_msg, user_msg) + error = function(self, tools, cmd, stderr, stdout) + local chat = tools.chat + local operation = self.args and self.args.operation or "unknown" + local errors = stderr and #stderr > 0 and vim.iter(stderr):flatten():join("\n") or "Unknown error" + local user_msg = string.format("Git %s failed", operation) + chat:add_tool_output(self, errors, user_msg) end, - rejected = function(self, tools, _cmd, _opts) - local chat = tools.chat + rejected = function(self, tools, cmd, opts) local operation = self.args and self.args.operation or "unknown" local message = string.format("User rejected the git %s operation", operation) - return chat:add_tool_output(self, message, message) + opts = vim.tbl_extend("force", { message = message }, opts or {}) + local ok, helpers = pcall(require, "codecompanion.interactions.chat.tools.builtin.helpers") + if ok and helpers and helpers.rejected then + helpers.rejected(self, tools, cmd, opts) + else + tools.chat:add_tool_output(self, message) + end end, } GitRead.opts = { - -- v18+ uses require_approval_before - require_approval_before = function(_self, _agent) + require_approval_before = function(self, tools) return false end, - -- COMPAT(v17): Remove when dropping v17 support - requires_approval = function(_self, _agent) + requires_approval = function(self, tools) return false end, } From daf9886cd8be8120bb9a1ca6f26b1c8755f96f84 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Sun, 21 Dec 2025 22:23:37 +0800 Subject: [PATCH 05/17] test(gitcommit): add comprehensive test suite --- Makefile | 20 +- deps/mini.nvim | 1 + .../gitcommit/tools/ai_release_notes.lua | 22 +- .../_extensions/gitcommit/tools/git.lua | 22 +- .../_extensions/gitcommit/tools/git_read.lua | 5 + .../gitcommit/tools/validation.lua | 12 +- tests/helpers.lua | 16 ++ tests/minimal_init.lua | 4 + tests/test_git_edit.lua | 234 +++++++++++++++++ tests/test_git_read.lua | 175 +++++++++++++ tests/test_validation.lua | 238 ++++++++++++++++++ 11 files changed, 743 insertions(+), 6 deletions(-) create mode 160000 deps/mini.nvim create mode 100644 tests/helpers.lua create mode 100644 tests/minimal_init.lua create mode 100644 tests/test_git_edit.lua create mode 100644 tests/test_git_read.lua create mode 100644 tests/test_validation.lua diff --git a/Makefile b/Makefile index 506c7b6..bd8d09f 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,25 @@ -.PHONY: doc +.PHONY: doc test test-file lint fmt deps OS := $(shell uname -s 2>/dev/null || echo Windows_NT) +NVIM ?= nvim + +deps: + @mkdir -p deps + @test -d deps/mini.nvim || git clone --depth 1 https://github.com/echasnovski/mini.nvim deps/mini.nvim + +test: deps + @$(NVIM) --headless -u tests/minimal_init.lua -c "lua MiniTest.run()" + +test-file: deps + @$(NVIM) --headless -u tests/minimal_init.lua -c "lua MiniTest.run_file('$(FILE)')" + +lint: + @stylua --check . + +fmt: + @stylua . + # Default output path for the downloaded doc DOC_OUT := codecompanion.txt diff --git a/deps/mini.nvim b/deps/mini.nvim new file mode 160000 index 0000000..6acb626 --- /dev/null +++ b/deps/mini.nvim @@ -0,0 +1 @@ +Subproject commit 6acb62618e2e7f1bf4f2e96e77a562ec80696f5e diff --git a/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua b/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua index dccd5e8..b2370ab 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua @@ -1,5 +1,24 @@ local prompts = require("codecompanion._extensions.gitcommit.prompts.release_notes") +--- Check if running on Windows +---@return boolean +local function is_windows() + return vim.loop.os_uname().sysname == "Windows_NT" +end + +--- Quote a string for shell command (Windows uses double quotes, Unix uses single quotes) +---@param str string The string to quote +---@return string +local function shell_quote(str) + if is_windows() then + -- Windows CMD: use double quotes, escape internal double quotes with \" + return '"' .. str:gsub('"', '\\"') .. '"' + else + -- Unix: use single quotes, escape internal single quotes + return "'" .. str:gsub("'", "'\\''") .. "'" + end +end + ---@class CodeCompanion.GitCommit.Tools.AIReleaseNotes: CodeCompanion.Tools.Tool local AIReleaseNotes = {} @@ -57,7 +76,8 @@ local function get_detailed_commits(from_ref, to_ref) local escaped_range = vim.fn.shellescape(range) local separator = "---COMMIT_SEPARATOR---" - local commit_cmd = string.format("git log --pretty=format:'%%H||%%s||%%an||%%b%s' %s", separator, escaped_range) + local format_str = shell_quote("%H||%s||%an||%b" .. separator) + local commit_cmd = string.format("git log --pretty=format:%s %s", format_str, escaped_range) local success, output = pcall(vim.fn.system, commit_cmd) if not success or vim.v.shell_error ~= 0 then diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git.lua b/lua/codecompanion/_extensions/gitcommit/tools/git.lua index 448af68..11f64e0 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git.lua @@ -2,6 +2,25 @@ local Git = require("codecompanion._extensions.gitcommit.git") local M = {} +--- Check if running on Windows +---@return boolean +local function is_windows() + return vim.loop.os_uname().sysname == "Windows_NT" +end + +--- Quote a string for shell command (Windows uses double quotes, Unix uses single quotes) +---@param str string The string to quote +---@return string +local function shell_quote(str) + if is_windows() then + -- Windows CMD: use double quotes, escape internal double quotes with \" + return '"' .. str:gsub('"', '\\"') .. '"' + else + -- Unix: use single quotes, escape internal single quotes + return "'" .. str:gsub("'", "'\\''") .. "'" + end +end + ---Git tool for CodeCompanion GitCommit extension ---Provides git operations like status, diff, log, branch management etc. ---@class CodeCompanion.GitCommit.Tools.Git @@ -1027,7 +1046,8 @@ function GitTool.generate_release_notes(from_tag, to_tag, format) local llm_msg = "fail: " .. msg .. "" return false, msg, user_msg, llm_msg end - local commit_cmd = "git log --pretty=format:'%h\x01%s\x01%an\x01%ad' --date=short " .. escaped_range + local format_str = shell_quote("%h\x01%s\x01%an\x01%ad") + local commit_cmd = "git log --pretty=format:" .. format_str .. " --date=short " .. escaped_range local success_commits, commits_output = pcall(vim.fn.system, commit_cmd) if not success_commits or vim.v.shell_error ~= 0 then diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git_read.lua b/lua/codecompanion/_extensions/gitcommit/tools/git_read.lua index 464ee98..df88ddd 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git_read.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git_read.lua @@ -317,6 +317,11 @@ GitRead.cmds = { return { status = "error", data = error_msg } end + -- Check if this is an early return case (validation error) + if result.status then + return result + end + local op_success, output = result.success, result.output if op_success then diff --git a/lua/codecompanion/_extensions/gitcommit/tools/validation.lua b/lua/codecompanion/_extensions/gitcommit/tools/validation.lua index 169e47c..36ff745 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/validation.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/validation.lua @@ -149,9 +149,15 @@ end ---@param validations table Array of validation results (nil or error table) ---@return table|nil error_response Returns first error or nil if all valid function M.first_error(validations) - for _, result in ipairs(validations) do - if result then - return result + local max_idx = 0 + for k in pairs(validations) do + if type(k) == "number" and k > max_idx then + max_idx = k + end + end + for i = 1, max_idx do + if validations[i] then + return validations[i] end end return nil diff --git a/tests/helpers.lua b/tests/helpers.lua new file mode 100644 index 0000000..a54f512 --- /dev/null +++ b/tests/helpers.lua @@ -0,0 +1,16 @@ +local H = {} + +H.eq = MiniTest.expect.equality +H.not_eq = MiniTest.expect.no_equality + +H.expect_match = MiniTest.new_expectation("string matching", function(str, pattern) + return str:find(pattern) ~= nil +end, function(str, pattern) + return string.format("Pattern: %s\nObserved string: %s", vim.inspect(pattern), str) +end) + +H.child_start = function(child) + child.restart({ "-u", "tests/minimal_init.lua" }) +end + +return H diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..a550481 --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,4 @@ +vim.cmd([[let &rtp.=','.getcwd()]]) +vim.cmd("set rtp+=deps/mini.nvim") + +require("mini.test").setup() diff --git a/tests/test_git_edit.lua b/tests/test_git_edit.lua new file mode 100644 index 0000000..26da7b8 --- /dev/null +++ b/tests/test_git_edit.lua @@ -0,0 +1,234 @@ +local h = require("tests.helpers") +local new_set = MiniTest.new_set + +local child = MiniTest.new_child_neovim() + +local T = new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_once = child.stop, + }, +}) + +T["schema"] = new_set() + +T["schema"]["has correct name"] = function() + local name = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + return GitEdit.name + ]]) + h.eq("git_edit", name) +end + +T["schema"]["has function type and strict mode"] = function() + local result = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + return { + type = GitEdit.schema.type, + func_name = GitEdit.schema["function"].name, + strict = GitEdit.schema["function"].strict, + } + ]]) + h.eq("function", result.type) + h.eq("git_edit", result.func_name) + h.eq(true, result.strict) +end + +T["schema"]["contains all valid operations"] = function() + local count = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + return #GitEdit.schema["function"].parameters.properties.operation.enum + ]]) + h.eq(28, count) +end + +T["cmds"] = new_set() + +T["cmds"]["returns error for nil args"] = function() + local result = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + local cmd_fn = GitEdit.cmds[1] + local result = cmd_fn({}, nil, nil, function() end) + return { + status = result.status, + has_msg = result.data.output:find("Invalid arguments") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["returns error for invalid operation"] = function() + local result = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + local cmd_fn = GitEdit.cmds[1] + local result = cmd_fn({}, { operation = "invalid_op" }, nil, function() end) + return { + status = result.status, + has_msg = result.data.output:find("operation must be one of") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["returns error for missing operation"] = function() + local result = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + local cmd_fn = GitEdit.cmds[1] + local result = cmd_fn({}, {}, nil, function() end) + return { + status = result.status, + has_msg = result.data.output:find("operation is required") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["help operation returns success"] = function() + local result = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + local cmd_fn = GitEdit.cmds[1] + local result = cmd_fn({}, { operation = "help" }, nil, function() end) + return { + status = result.status, + has_stage = result.data:find("stage") ~= nil, + } + ]]) + h.eq("success", result.status) + h.eq(true, result.has_stage) +end + +T["cmds"]["stage requires files array"] = function() + local result = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + local cmd_fn = GitEdit.cmds[1] + local result = cmd_fn({}, { operation = "stage", args = {} }, nil, function() end) + return { + status = result.status, + has_msg = result.data.output:find("files is required") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["stage requires non-empty files array"] = function() + local result = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + local cmd_fn = GitEdit.cmds[1] + local result = cmd_fn({}, { operation = "stage", args = { files = {} } }, nil, function() end) + return { + status = result.status, + has_msg = result.data.output:find("files cannot be empty") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["create_branch requires branch_name"] = function() + local result = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + local cmd_fn = GitEdit.cmds[1] + local result = cmd_fn({}, { operation = "create_branch", args = {} }, nil, function() end) + return { + status = result.status, + has_msg = result.data.output:find("branch_name is required") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["checkout requires target"] = function() + local result = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + local cmd_fn = GitEdit.cmds[1] + local result = cmd_fn({}, { operation = "checkout", args = {} }, nil, function() end) + return { + status = result.status, + has_msg = result.data.output:find("target is required") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["reset requires commit_hash"] = function() + local result = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + local cmd_fn = GitEdit.cmds[1] + local result = cmd_fn({}, { operation = "reset", args = {} }, nil, function() end) + return { + status = result.status, + has_msg = result.data.output:find("commit_hash is required") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["reset validates mode enum"] = function() + local result = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + local cmd_fn = GitEdit.cmds[1] + local result = cmd_fn({}, { + operation = "reset", + args = { commit_hash = "abc123", mode = "invalid" }, + }, nil, function() end) + return { + status = result.status, + has_msg = result.data.output:find("mode must be one of") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["merge requires branch"] = function() + local result = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + local cmd_fn = GitEdit.cmds[1] + local result = cmd_fn({}, { operation = "merge", args = {} }, nil, function() end) + return { + status = result.status, + has_msg = result.data.output:find("branch is required") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["gitignore_add requires rules"] = function() + local result = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + local cmd_fn = GitEdit.cmds[1] + local result = cmd_fn({}, { operation = "gitignore_add", args = {} }, nil, function() end) + return { + status = result.status, + has_msg = result.data.output:find("gitignore_rules or gitignore_rule is required") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["opts"] = new_set() + +T["opts"]["requires approval for write operations"] = function() + local result = child.lua([[ + local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") + return { + v18 = GitEdit.opts.require_approval_before({}, {}), + v17 = GitEdit.opts.requires_approval({}, {}), + } + ]]) + h.eq(true, result.v18) + h.eq(true, result.v17) +end + +return T diff --git a/tests/test_git_read.lua b/tests/test_git_read.lua new file mode 100644 index 0000000..5cc6714 --- /dev/null +++ b/tests/test_git_read.lua @@ -0,0 +1,175 @@ +local h = require("tests.helpers") +local new_set = MiniTest.new_set + +local child = MiniTest.new_child_neovim() + +local T = new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_once = child.stop, + }, +}) + +T["schema"] = new_set() + +T["schema"]["has correct name"] = function() + local name = child.lua([[ + local GitRead = require("codecompanion._extensions.gitcommit.tools.git_read") + return GitRead.name + ]]) + h.eq("git_read", name) +end + +T["schema"]["has function type and strict mode"] = function() + local result = child.lua([[ + local GitRead = require("codecompanion._extensions.gitcommit.tools.git_read") + return { + type = GitRead.schema.type, + func_name = GitRead.schema["function"].name, + strict = GitRead.schema["function"].strict, + } + ]]) + h.eq("function", result.type) + h.eq("git_read", result.func_name) + h.eq(true, result.strict) +end + +T["schema"]["contains all valid operations"] = function() + local count = child.lua([[ + local GitRead = require("codecompanion._extensions.gitcommit.tools.git_read") + return #GitRead.schema["function"].parameters.properties.operation.enum + ]]) + h.eq(18, count) +end + +T["cmds"] = new_set() + +T["cmds"]["returns error for nil args"] = function() + local result = child.lua([[ + local GitRead = require("codecompanion._extensions.gitcommit.tools.git_read") + local cmd_fn = GitRead.cmds[1] + local result = cmd_fn({}, nil, nil) + return { + status = result.status, + has_msg = result.data.output:find("Invalid arguments") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["returns error for invalid operation"] = function() + local result = child.lua([[ + local GitRead = require("codecompanion._extensions.gitcommit.tools.git_read") + local cmd_fn = GitRead.cmds[1] + local result = cmd_fn({}, { operation = "invalid_op" }, nil) + return { + status = result.status, + has_msg = result.data.output:find("operation must be one of") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["returns error for missing operation"] = function() + local result = child.lua([[ + local GitRead = require("codecompanion._extensions.gitcommit.tools.git_read") + local cmd_fn = GitRead.cmds[1] + local result = cmd_fn({}, {}, nil) + return { + status = result.status, + has_msg = result.data.output:find("operation is required") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["help operation returns success"] = function() + local result = child.lua([[ + local GitRead = require("codecompanion._extensions.gitcommit.tools.git_read") + local cmd_fn = GitRead.cmds[1] + local result = cmd_fn({}, { operation = "help" }, nil) + return { + status = result.status, + has_status = result.data:find("status") ~= nil, + } + ]]) + h.eq("success", result.status) + h.eq(true, result.has_status) +end + +T["cmds"]["blame requires file_path"] = function() + local result = child.lua([[ + local GitRead = require("codecompanion._extensions.gitcommit.tools.git_read") + local cmd_fn = GitRead.cmds[1] + local result = cmd_fn({}, { operation = "blame", args = {} }, nil) + return { + status = result.status, + has_msg = result.data.output:find("file_path is required") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["diff_commits requires commit1"] = function() + local result = child.lua([[ + local GitRead = require("codecompanion._extensions.gitcommit.tools.git_read") + local cmd_fn = GitRead.cmds[1] + local result = cmd_fn({}, { operation = "diff_commits", args = {} }, nil) + return { + status = result.status, + has_msg = result.data.output:find("commit1 is required") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["validates log count range"] = function() + local result = child.lua([[ + local GitRead = require("codecompanion._extensions.gitcommit.tools.git_read") + local cmd_fn = GitRead.cmds[1] + local result = cmd_fn({}, { operation = "log", args = { count = 10000 } }, nil) + return { + status = result.status, + has_msg = result.data.output:find("count must be at most 1000") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["cmds"]["validates log format enum"] = function() + local result = child.lua([[ + local GitRead = require("codecompanion._extensions.gitcommit.tools.git_read") + local cmd_fn = GitRead.cmds[1] + local result = cmd_fn({}, { operation = "log", args = { format = "invalid" } }, nil) + return { + status = result.status, + has_msg = result.data.output:find("format must be one of") ~= nil, + } + ]]) + h.eq("error", result.status) + h.eq(true, result.has_msg) +end + +T["opts"] = new_set() + +T["opts"]["does not require approval"] = function() + local result = child.lua([[ + local GitRead = require("codecompanion._extensions.gitcommit.tools.git_read") + return { + v18 = GitRead.opts.require_approval_before({}, {}), + v17 = GitRead.opts.requires_approval({}, {}), + } + ]]) + h.eq(false, result.v18) + h.eq(false, result.v17) +end + +return T diff --git a/tests/test_validation.lua b/tests/test_validation.lua new file mode 100644 index 0000000..9595681 --- /dev/null +++ b/tests/test_validation.lua @@ -0,0 +1,238 @@ +local h = require("tests.helpers") +local new_set = MiniTest.new_set + +local child = MiniTest.new_child_neovim() + +local T = new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_once = child.stop, + }, +}) + +T["format_error"] = new_set() + +T["format_error"]["returns correct structure"] = function() + local result = child.lua([[ + local validation = require("codecompanion._extensions.gitcommit.tools.validation") + local result = validation.format_error("myTool", "Something went wrong") + return { + status = result.status, + output = result.data.output, + user_msg = result.data.user_msg, + llm_msg = result.data.llm_msg, + } + ]]) + h.eq("error", result.status) + h.eq("Something went wrong", result.output) + h.eq("✗ Something went wrong", result.user_msg) + h.eq("fail: Something went wrong", result.llm_msg) +end + +T["require_string"] = new_set() + +T["require_string"]["returns nil for valid string"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + return v.require_string("hello", "param", "test") == nil + ]]) + h.eq(true, result) +end + +T["require_string"]["returns error for nil"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local err = v.require_string(nil, "myParam", "test") + return err.data.output + ]]) + h.eq("myParam is required", result) +end + +T["require_string"]["returns error for non-string type"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local err = v.require_string(123, "myParam", "test") + return err.data.output + ]]) + h.eq("myParam must be a string, got number", result) +end + +T["require_string"]["returns error for empty string"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local err = v.require_string("", "myParam", "test") + return err.data.output + ]]) + h.eq("myParam cannot be empty", result) +end + +T["optional_string"] = new_set() + +T["optional_string"]["returns nil for nil value"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + return v.optional_string(nil, "param", "test") == nil + ]]) + h.eq(true, result) +end + +T["optional_string"]["returns error for non-string type"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local err = v.optional_string(42, "myParam", "test") + return err.data.output + ]]) + h.eq("myParam must be a string, got number", result) +end + +T["require_array"] = new_set() + +T["require_array"]["returns nil for non-empty array"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + return v.require_array({"a", "b"}, "param", "test") == nil + ]]) + h.eq(true, result) +end + +T["require_array"]["returns error for nil"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local err = v.require_array(nil, "myParam", "test") + return err.data.output + ]]) + h.eq("myParam is required", result) +end + +T["require_array"]["returns error for empty array"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local err = v.require_array({}, "myParam", "test") + return err.data.output + ]]) + h.eq("myParam cannot be empty", result) +end + +T["optional_integer"] = new_set() + +T["optional_integer"]["returns nil for nil value"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + return v.optional_integer(nil, "param", "test") == nil + ]]) + h.eq(true, result) +end + +T["optional_integer"]["returns nil for valid integer"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + return v.optional_integer(42, "param", "test") == nil + ]]) + h.eq(true, result) +end + +T["optional_integer"]["returns error for float"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local err = v.optional_integer(3.14, "myParam", "test") + return err.data.output + ]]) + h.eq("myParam must be an integer, got 3.14", result) +end + +T["optional_integer"]["returns error when below min"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local err = v.optional_integer(0, "myParam", "test", 1, 10) + return err.data.output + ]]) + h.eq("myParam must be at least 1, got 0", result) +end + +T["optional_integer"]["returns error when above max"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local err = v.optional_integer(15, "myParam", "test", 1, 10) + return err.data.output + ]]) + h.eq("myParam must be at most 10, got 15", result) +end + +T["optional_boolean"] = new_set() + +T["optional_boolean"]["returns nil for nil value"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + return v.optional_boolean(nil, "param", "test") == nil + ]]) + h.eq(true, result) +end + +T["optional_boolean"]["returns error for non-boolean type"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local err = v.optional_boolean("true", "myParam", "test") + return err.data.output + ]]) + h.eq("myParam must be a boolean, got string", result) +end + +T["require_enum"] = new_set() + +T["require_enum"]["returns nil for valid enum value"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + return v.require_enum("two", "param", {"one", "two", "three"}, "test") == nil + ]]) + h.eq(true, result) +end + +T["require_enum"]["returns error for nil"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local err = v.require_enum(nil, "myParam", {"one", "two"}, "test") + return err.data.output + ]]) + h.eq("myParam is required", result) +end + +T["require_enum"]["returns error for invalid value"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local err = v.require_enum("four", "myParam", {"one", "two", "three"}, "test") + return err.data.output + ]]) + h.eq("myParam must be one of: one, two, three, got 'four'", result) +end + +T["first_error"] = new_set() + +T["first_error"]["returns nil when all validations pass"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + return v.first_error({nil, nil, nil}) == nil + ]]) + h.eq(true, result) +end + +T["first_error"]["returns first error"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local err1 = v.format_error("test", "First error") + local err2 = v.format_error("test", "Second error") + local validations = {} + validations[1] = nil + validations[2] = err1 + validations[3] = err2 + local first = v.first_error(validations) + if first and first.data then + return first.data.output + end + return "no error found" + ]]) + h.eq("First error", result) +end + +return T From e985f22658385f75089ba387c847b966a86a3764 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Sun, 21 Dec 2025 22:56:58 +0800 Subject: [PATCH 06/17] chore: add mise.toml with development tasks --- .styluaignore | 2 ++ mise.toml | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 .styluaignore create mode 100644 mise.toml diff --git a/.styluaignore b/.styluaignore new file mode 100644 index 0000000..f69caa5 --- /dev/null +++ b/.styluaignore @@ -0,0 +1,2 @@ +# Dependencies +deps/ diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..ab0c95d --- /dev/null +++ b/mise.toml @@ -0,0 +1,40 @@ +# mise configuration for codecompanion-gitcommit.nvim +# Run tasks with: mise run +# List tasks with: mise tasks + +[tools] +# stylua for code formatting +"cargo:stylua" = "latest" + +[tasks.deps] +description = "Install test dependencies (mini.nvim)" +shell = "pwsh -NoProfile -Command" +run = """ +if (-not (Test-Path deps)) { New-Item -ItemType Directory -Path deps | Out-Null } +if (-not (Test-Path deps/mini.nvim)) { git clone --depth 1 https://github.com/echasnovski/mini.nvim deps/mini.nvim } +""" + +[tasks.test] +description = "Run all unit tests" +depends = ["deps"] +shell = "pwsh -NoProfile -Command" +run = "nvim --headless -u tests/minimal_init.lua -c 'lua MiniTest.run()'" + +[tasks."test:file"] +description = "Run tests for a specific file" +depends = ["deps"] +shell = "pwsh -NoProfile -Command" +run = "nvim --headless -u tests/minimal_init.lua -c 'lua MiniTest.run_file(\"{{arg(name=\"file\")}}\")'" + +[tasks.lint] +description = "Check code formatting with stylua" +run = "stylua --check ." + +[tasks.fmt] +description = "Format code with stylua" +run = "stylua ." + +[tasks.doc] +description = "Download latest CodeCompanion documentation" +shell = "pwsh -NoProfile -Command" +run = "Invoke-WebRequest -Uri 'https://github.com/olimorris/codecompanion.nvim/raw/refs/heads/main/doc/codecompanion.txt' -OutFile 'codecompanion.txt'" From 6e42609c4893efbb2f7a25b405e2e5120e300af6 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Sun, 21 Dec 2025 22:57:55 +0800 Subject: [PATCH 07/17] chore: remove Makefile Tasks have been migrated to mise.toml --- Makefile | 50 -------------------------------------------------- 1 file changed, 50 deletions(-) delete mode 100644 Makefile diff --git a/Makefile b/Makefile deleted file mode 100644 index bd8d09f..0000000 --- a/Makefile +++ /dev/null @@ -1,50 +0,0 @@ -.PHONY: doc test test-file lint fmt deps - -OS := $(shell uname -s 2>/dev/null || echo Windows_NT) - -NVIM ?= nvim - -deps: - @mkdir -p deps - @test -d deps/mini.nvim || git clone --depth 1 https://github.com/echasnovski/mini.nvim deps/mini.nvim - -test: deps - @$(NVIM) --headless -u tests/minimal_init.lua -c "lua MiniTest.run()" - -test-file: deps - @$(NVIM) --headless -u tests/minimal_init.lua -c "lua MiniTest.run_file('$(FILE)')" - -lint: - @stylua --check . - -fmt: - @stylua . - -# Default output path for the downloaded doc -DOC_OUT := codecompanion.txt - -# Windows (PowerShell 7) target -ifeq ($(OS),Windows_NT) -# Prefer pwsh if available, fallback to powershell -POWERSHELL := $(if $(shell where pwsh 2> NUL),pwsh,powershell) - -doc: - $(POWERSHELL) -NoProfile -ExecutionPolicy Bypass -File scripts/download_codecompanion.ps1 -OutFile $(DOC_OUT) - -else -# Non-Windows target uses curl or wget -CURL := $(shell command -v curl 2>/dev/null) -WGET := $(shell command -v wget 2>/dev/null) -URL := https://github.com/olimorris/codecompanion.nvim/raw/refs/heads/main/doc/codecompanion.txt - -doc: -ifeq ($(CURL),) -ifeq ($(WGET),) - @echo "Error: need curl or wget to download on non-Windows" && exit 1 -else - @$(WGET) -O $(DOC_OUT) $(URL) -endif -else - @$(CURL) -fsSL -o $(DOC_OUT) $(URL) -endif -endif From 93cb3d80ff060dd41aaed86cecdf35467ba5f451 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Sun, 21 Dec 2025 23:14:40 +0800 Subject: [PATCH 08/17] fix(mise): add cross-platform support with run_windows --- AGENTS.md | 18 +++++++++++++----- mise.toml | 15 +++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4572fb3..5291868 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -241,15 +241,23 @@ Same pattern as read operations, but in `git_edit.lua`. Remember: ## Testing & Development -### Running Style Checks +### Available Mise Tasks ```bash -stylua --check . # Check formatting (used in CI) -stylua . # Auto-fix formatting +mise run deps # Install test dependencies (mini.nvim) +mise run test # Run all unit tests +mise run test:file file=tests/test_validation.lua # Run specific test file +mise run lint # Check code formatting with stylua +mise run fmt # Format code with stylua +mise run doc # Download latest CodeCompanion documentation ``` -### Available Make Commands +### Running Style Checks ```bash -make doc # Download latest CodeCompanion documentation +stylua --check . # Check formatting (used in CI) +stylua . # Auto-fix formatting +# Or via mise: +mise run lint # Check formatting +mise run fmt # Auto-fix formatting ``` ### Development Setup diff --git a/mise.toml b/mise.toml index ab0c95d..2aa6a07 100644 --- a/mise.toml +++ b/mise.toml @@ -8,8 +8,11 @@ [tasks.deps] description = "Install test dependencies (mini.nvim)" -shell = "pwsh -NoProfile -Command" run = """ +mkdir -p deps +test -d deps/mini.nvim || git clone --depth 1 https://github.com/echasnovski/mini.nvim deps/mini.nvim +""" +run_windows = """ if (-not (Test-Path deps)) { New-Item -ItemType Directory -Path deps | Out-Null } if (-not (Test-Path deps/mini.nvim)) { git clone --depth 1 https://github.com/echasnovski/mini.nvim deps/mini.nvim } """ @@ -17,14 +20,14 @@ if (-not (Test-Path deps/mini.nvim)) { git clone --depth 1 https://github.com/ec [tasks.test] description = "Run all unit tests" depends = ["deps"] -shell = "pwsh -NoProfile -Command" run = "nvim --headless -u tests/minimal_init.lua -c 'lua MiniTest.run()'" +run_windows = "nvim --headless -u tests/minimal_init.lua -c \"lua MiniTest.run()\"" [tasks."test:file"] description = "Run tests for a specific file" depends = ["deps"] -shell = "pwsh -NoProfile -Command" -run = "nvim --headless -u tests/minimal_init.lua -c 'lua MiniTest.run_file(\"{{arg(name=\"file\")}}\")'" +run = 'nvim --headless -u tests/minimal_init.lua -c "lua MiniTest.run_file(\"{{arg(name=\"file\")}}\")"' +run_windows = 'nvim --headless -u tests/minimal_init.lua -c "lua MiniTest.run_file([[{{arg(name=\"file\")}}]])"' [tasks.lint] description = "Check code formatting with stylua" @@ -36,5 +39,5 @@ run = "stylua ." [tasks.doc] description = "Download latest CodeCompanion documentation" -shell = "pwsh -NoProfile -Command" -run = "Invoke-WebRequest -Uri 'https://github.com/olimorris/codecompanion.nvim/raw/refs/heads/main/doc/codecompanion.txt' -OutFile 'codecompanion.txt'" +run = "curl -fsSL -o codecompanion.txt https://github.com/olimorris/codecompanion.nvim/raw/refs/heads/main/doc/codecompanion.txt" +run_windows = "Invoke-WebRequest -Uri 'https://github.com/olimorris/codecompanion.nvim/raw/refs/heads/main/doc/codecompanion.txt' -OutFile 'codecompanion.txt'" From 540cf27464a5032b71a19e409b72e035da876961 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Sun, 21 Dec 2025 23:20:17 +0800 Subject: [PATCH 09/17] fix(mise): use pwsh for Windows shell commands --- mise.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mise.toml b/mise.toml index 2aa6a07..2ee0148 100644 --- a/mise.toml +++ b/mise.toml @@ -6,6 +6,10 @@ # stylua for code formatting "cargo:stylua" = "latest" +[settings] +# Use PowerShell 7 (pwsh) on Windows +windows_default_inline_shell_args = "pwsh -NoProfile -Command" + [tasks.deps] description = "Install test dependencies (mini.nvim)" run = """ @@ -21,7 +25,7 @@ if (-not (Test-Path deps/mini.nvim)) { git clone --depth 1 https://github.com/ec description = "Run all unit tests" depends = ["deps"] run = "nvim --headless -u tests/minimal_init.lua -c 'lua MiniTest.run()'" -run_windows = "nvim --headless -u tests/minimal_init.lua -c \"lua MiniTest.run()\"" +run_windows = 'nvim --headless -u tests/minimal_init.lua -c "lua MiniTest.run()"' [tasks."test:file"] description = "Run tests for a specific file" From ef9156bd182653aa3f1421d6edfdc0574c61e3b2 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Mon, 22 Dec 2025 00:19:51 +0800 Subject: [PATCH 10/17] refactor(gitcommit): extract utility functions --- .../_extensions/gitcommit/generator.lua | 73 +- .../_extensions/gitcommit/git.lua | 175 +--- .../_extensions/gitcommit/git_utils.lua | 311 +++++++ .../gitcommit/tools/ai_release_notes.lua | 20 +- .../_extensions/gitcommit/tools/command.lua | 671 +++++++++++++++ .../_extensions/gitcommit/tools/git.lua | 740 ++++++----------- tests/test_ai_release_notes.lua | 170 ++++ tests/test_command_builder.lua | 765 ++++++++++++++++++ tests/test_generator.lua | 192 +++++ tests/test_git_utils.lua | 504 ++++++++++++ tests/test_langs.lua | 116 +++ 11 files changed, 2980 insertions(+), 757 deletions(-) create mode 100644 lua/codecompanion/_extensions/gitcommit/git_utils.lua create mode 100644 lua/codecompanion/_extensions/gitcommit/tools/command.lua create mode 100644 tests/test_ai_release_notes.lua create mode 100644 tests/test_command_builder.lua create mode 100644 tests/test_generator.lua create mode 100644 tests/test_git_utils.lua create mode 100644 tests/test_langs.lua diff --git a/lua/codecompanion/_extensions/gitcommit/generator.lua b/lua/codecompanion/_extensions/gitcommit/generator.lua index c932c9c..fb49410 100644 --- a/lua/codecompanion/_extensions/gitcommit/generator.lua +++ b/lua/codecompanion/_extensions/gitcommit/generator.lua @@ -1,5 +1,6 @@ local codecompanion_adapter = require("codecompanion.adapters") local codecompanion_schema = require("codecompanion.schema") +local git_utils = require("codecompanion._extensions.gitcommit.git_utils") ---@class CodeCompanion.GitCommit.Generator local Generator = {} @@ -161,18 +162,7 @@ end ---@param message string Raw message from LLM ---@return string cleaned_message The cleaned commit message function Generator._clean_commit_message(message) - local cleaned = vim.trim(message) - - -- Remove markdown code blocks (```...``` or ````...````) - -- Match opening code fence with optional language identifier - cleaned = cleaned:gsub("^```+%w*\n", "") - -- Match closing code fence - cleaned = cleaned:gsub("\n```+$", "") - - -- Trim again after removing code blocks - cleaned = vim.trim(cleaned) - - return cleaned + return git_utils.clean_commit_message(message) end ---@param commit_history? string[] Array of recent commit messages for context (optional) @@ -236,64 +226,7 @@ end ---@param diff string The git diff to include in prompt ---@param commit_history? string[] Recent commit messages for context (optional) function Generator._create_prompt(diff, lang, commit_history) - -- Build history context section - local history_context = "" - if commit_history and #commit_history > 0 then - history_context = "\nRECENT COMMIT HISTORY (for style reference):\n" - for i, commit_msg in ipairs(commit_history) do - history_context = history_context .. string.format("%d. %s\n", i, commit_msg) - end - history_context = history_context - .. "\nAnalyze commit history to understand project style, tone, and format patterns. Use this for consistency.\n" - end - - return string.format( - [[You are a commit message generator. Generate exactly ONE Conventional Commit message for the provided git diff.%s - -FORMAT: -type(scope): specific description of WHAT changed - -[Optional body - only for non-obvious changes] - -Allowed types: feat, fix, docs, style, refactor, perf, test, chore -Language: %s - -CRITICAL RULES: -1. Respond with ONLY the commit message - no markdown blocks, no explanations -2. Description must state WHAT was done, not WHY or the effect -3. AVOID vague verbs: "update", "improve", "clarify", "adjust", "enhance", "fix issues" - USE specific verbs: "add", "remove", "rename", "move", "replace", "extract", "inline" -4. Subject line under 50 chars, body lines under 72 chars -5. Body is OPTIONAL - omit if subject is self-explanatory - -BAD (vague): -- refactor(api): improve error handling -- fix(auth): update login logic -- chore(deps): update dependencies - -GOOD (specific): -- refactor(api): replace try-catch with Result type -- fix(auth): check token expiry before API call -- chore(deps): bump axios from 0.21 to 1.6 - -EXAMPLES: - -docs(readme): add installation section - -refactor(api): rename getUserData to fetchUser - -feat(auth): add OAuth2 token refresh flow - -- Store refresh token in secure storage -- Auto-refresh 5 min before expiry - -```diff -%s -```]], - history_context, - lang or "English", - diff - ) + return git_utils.build_commit_prompt(diff, lang, commit_history) end return Generator diff --git a/lua/codecompanion/_extensions/gitcommit/git.lua b/lua/codecompanion/_extensions/gitcommit/git.lua index 6f7dbbf..339e426 100644 --- a/lua/codecompanion/_extensions/gitcommit/git.lua +++ b/lua/codecompanion/_extensions/gitcommit/git.lua @@ -1,10 +1,10 @@ +local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + ---@class CodeCompanion.GitCommit.Git local Git = {} --- Store configuration local config = {} ----Setup Git module with configuration ---@param opts? table Configuration options function Git.setup(opts) config = vim.tbl_deep_extend("force", { @@ -14,136 +14,19 @@ function Git.setup(opts) }, opts or {}) end --- Trim whitespace from string ----@param s string The string to trim ----@return string trimmed_string -local function trim(s) - return (s:gsub("^%s*(.-)%s*$", "%1")) -end - ----Filter diff content to exclude file patterns ---@param diff_content string The original diff content ---@return string filtered_diff The filtered diff content function Git._filter_diff(diff_content) - if not config.exclude_files or #config.exclude_files == 0 then - return diff_content - end - - local lines = vim.split(diff_content, "\n") - local filtered_lines = {} - local all_files = {} - local excluded_files = {} - local current_file = nil - local skip_current_file = false - - for _, line in ipairs(lines) do - local file_match = line:match("^diff %-%-git a/(.*) b/") - if file_match then - current_file = file_match - table.insert(all_files, current_file) - skip_current_file = Git._should_exclude_file(current_file) - if skip_current_file then - table.insert(excluded_files, current_file) - end - end - - local plus_file = line:match("^%+%+%+ b/(.*)") - local minus_file = line:match("^%-%-%-a/(.*)") - if plus_file then - current_file = plus_file - table.insert(all_files, current_file) - skip_current_file = Git._should_exclude_file(current_file) - if skip_current_file then - table.insert(excluded_files, current_file) - end - elseif minus_file then - current_file = minus_file - table.insert(all_files, current_file) - skip_current_file = Git._should_exclude_file(current_file) - if skip_current_file then - table.insert(excluded_files, current_file) - end - end - - if not skip_current_file then - table.insert(filtered_lines, line) - end - end - - -- If all files are excluded, return original diff to avoid empty output - if #all_files > 0 and #excluded_files >= #all_files then - return diff_content - end - - return table.concat(filtered_lines, "\n") + return GitUtils.filter_diff(diff_content, config.exclude_files) end ----Convert a glob pattern to a Lua pattern ----Handles: * (any non-slash), ** (any including slash), ? (single char), escapes special chars ----@param glob string The glob pattern (e.g., "*.lua", "**/*.js", "dist/*") ----@return string lua_pattern The converted Lua pattern -local function glob_to_lua_pattern(glob) - local escaped = glob:gsub("([%.%+%-%^%$%(%)%[%]%%])", "%%%1") - escaped = escaped:gsub("%*%*", "\0DOUBLESTAR\0") - escaped = escaped:gsub("%*", "[^/]*") - escaped = escaped:gsub("%?", "[^/]") - escaped = escaped:gsub("\0DOUBLESTAR\0", ".*") - - if escaped:sub(1, 1) == "/" then - escaped = "^" .. escaped:sub(2) - end - - if not glob:match("/") then - escaped = escaped .. "$" - else - escaped = escaped .. "$" - end - - return escaped -end - ----Check if filepath matches a glob pattern (handles basename matching for patterns without /) ----@param filepath string The file path to check ----@param pattern string The glob pattern ----@return boolean matches True if filepath matches -local function matches_glob(filepath, pattern) - local lua_pattern = glob_to_lua_pattern(pattern) - - if filepath:match(lua_pattern) then - return true - end - - if not pattern:match("/") then - local basename = filepath:match("[^/]+$") or filepath - if basename:match(lua_pattern) then - return true - end - end - - return false -end - ----Check if file should be excluded by patterns ---@param filepath string The file path to check ---@return boolean should_exclude True if file should be excluded function Git._should_exclude_file(filepath) - if not config.exclude_files then - return false - end - - local normalized_path = filepath:gsub("\\", "/") - - for _, pattern in ipairs(config.exclude_files) do - if matches_glob(normalized_path, pattern) then - return true - end - end - - return false + return GitUtils.should_exclude_file(filepath, config.exclude_files) end function Git.is_repository() - -- Check for .git directory in current and parent directories local function check_git_dir(path) local sep = package.config:sub(1, 1) local git_path = path .. sep .. ".git" @@ -151,17 +34,14 @@ function Git.is_repository() return stat ~= nil end - -- Search from current directory up local current_dir = vim.fn.getcwd() while current_dir do if check_git_dir(current_dir) then return true end - -- Move to parent local parent = vim.fn.fnamemodify(current_dir, ":h") if parent == current_dir then - -- Reached root break end current_dir = parent @@ -174,20 +54,16 @@ function Git.is_repository() end function Git.is_amending() - -- Safe git operations local ok, result = pcall(function() if not Git.is_repository() then return false end - -- Check for amend scenario by examining COMMIT_EDITMSG - -- During amend, git pre-populates COMMIT_EDITMSG with previous commit local git_dir = vim.trim(vim.fn.system("git rev-parse --git-dir")) if vim.v.shell_error ~= 0 then return false end - -- Use platform-appropriate separator local path_sep = package.config:sub(1, 1) local commit_editmsg = git_dir .. path_sep .. "COMMIT_EDITMSG" local stat = vim.uv.fs_stat(commit_editmsg) @@ -195,15 +71,12 @@ function Git.is_amending() return false end - -- Verify HEAD commit exists (not initial commit) local redirect = (vim.uv.os_uname().sysname == "Windows_NT") and " 2>nul" or " 2>/dev/null" vim.fn.system("git rev-parse --verify HEAD" .. redirect) if vim.v.shell_error ~= 0 then return false end - -- Read COMMIT_EDITMSG content - -- During amend, git pre-populates this file with previous commit local fd = vim.uv.fs_open(commit_editmsg, "r", 438) if not fd then return false @@ -216,20 +89,16 @@ function Git.is_amending() return false end - -- Check for non-comment content in COMMIT_EDITMSG - -- During amend, this indicates editing existing commit local lines = vim.split(content, "\n") local has_existing_message = false for _, line in ipairs(lines) do local trimmed = vim.trim(line) - -- Skip empty lines and comments (starting with #) if trimmed ~= "" and not trimmed:match("^#") then has_existing_message = true break end end - -- If COMMIT_EDITMSG has content and HEAD exists, likely amend return has_existing_message end) @@ -237,26 +106,22 @@ function Git.is_amending() end function Git.get_staged_diff() - -- Safe git operations local ok, result = pcall(function() if not Git.is_repository() then return nil end - -- Try to get staged changes local staged_diff = vim.fn.system("git diff --no-ext-diff --staged") if vim.v.shell_error == 0 and vim.trim(staged_diff) ~= "" then return Git._filter_diff(staged_diff) end - -- If no staged changes and in amend mode, get last commit changes if Git.is_amending() then local last_commit_diff = vim.fn.system("git diff --no-ext-diff HEAD~1") if vim.v.shell_error == 0 and vim.trim(last_commit_diff) ~= "" then return Git._filter_diff(last_commit_diff) end - -- Fallback for initial commit: show all files local show_diff = vim.fn.system("git show --no-ext-diff --format= HEAD") if vim.v.shell_error == 0 and vim.trim(show_diff) ~= "" then return Git._filter_diff(show_diff) @@ -269,51 +134,46 @@ function Git.get_staged_diff() return ok and result or nil end ----Get contextual diff based on current git state ---@return string|nil diff The diff content or nil if no changes ---@return string|nil context The context describing the diff type function Git.get_contextual_diff() - -- Safe git operations local ok, result = pcall(function() if not Git.is_repository() then return nil, "not_in_repo" end - -- Check for staged changes local staged_diff = vim.fn.system("git diff --no-ext-diff --staged") - if vim.v.shell_error == 0 and trim(staged_diff) ~= "" then + if vim.v.shell_error == 0 and GitUtils.trim(staged_diff) ~= "" then local filtered_diff = Git._filter_diff(staged_diff) - if trim(filtered_diff) ~= "" then + if GitUtils.trim(filtered_diff) ~= "" then return filtered_diff, "staged" else return nil, "no_changes_after_filter" end end - -- Check if we're amending if Git.is_amending() then local last_commit_diff = vim.fn.system("git diff --no-ext-diff HEAD~1") - if vim.v.shell_error == 0 and trim(last_commit_diff) ~= "" then + if vim.v.shell_error == 0 and GitUtils.trim(last_commit_diff) ~= "" then local filtered_diff = Git._filter_diff(last_commit_diff) - if trim(filtered_diff) ~= "" then + if GitUtils.trim(filtered_diff) ~= "" then return filtered_diff, "amend_with_parent" end end - -- Fallback for initial commit: show all files local show_diff = vim.fn.system("git show --no-ext-diff --format= HEAD") - if vim.v.shell_error == 0 and trim(show_diff) ~= "" then + if vim.v.shell_error == 0 and GitUtils.trim(show_diff) ~= "" then local filtered_diff = Git._filter_diff(show_diff) - if trim(filtered_diff) ~= "" then + if GitUtils.trim(filtered_diff) ~= "" then return filtered_diff, "amend_initial" end end end local all_local_diff = vim.fn.system("git diff --no-ext-diff HEAD") - if vim.v.shell_error == 0 and trim(all_local_diff) ~= "" then + if vim.v.shell_error == 0 and GitUtils.trim(all_local_diff) ~= "" then local filtered_diff = Git._filter_diff(all_local_diff) - if trim(filtered_diff) ~= "" then + if GitUtils.trim(filtered_diff) ~= "" then return filtered_diff, "unstaged_or_all_local" else return nil, "no_changes_after_filter" @@ -331,14 +191,12 @@ function Git.get_contextual_diff() end function Git.commit_changes(message) - -- Safe git operations local ok, success = pcall(function() if not Git.is_repository() then vim.notify("Not in a git repository", vim.log.levels.ERROR) return false end - -- Check for changes to commit local diff, context = Git.get_contextual_diff() if not diff then if context == "no_changes" then @@ -356,7 +214,6 @@ function Git.commit_changes(message) return false end - -- Pass commit message via stdin local cmd if Git.is_amending() then cmd = "git commit --amend -F -" @@ -389,20 +246,16 @@ function Git.commit_changes(message) return success end ----Get recent commit messages for context ---@param count? number Number of recent commits to retrieve (default: 10) ---@return string[]|nil commit_messages Array of commit messages or nil on error function Git.get_commit_history(count) count = count or 10 - -- Safe git operations local ok, result = pcall(function() if not Git.is_repository() then return nil end - -- Get recent commit messages with git log - -- Use --pretty=format to get just commit messages local cmd = string.format("git log --pretty=format:%%s --no-merges -%d", count) local output = vim.fn.system(cmd) @@ -410,12 +263,11 @@ function Git.get_commit_history(count) return nil end - -- Split output into lines and filter empty lines local lines = vim.split(output, "\n") local commit_messages = {} for _, line in ipairs(lines) do - local trimmed = trim(line) + local trimmed = GitUtils.trim(line) if trimmed ~= "" then table.insert(commit_messages, trimmed) end @@ -431,7 +283,6 @@ function Git.get_commit_history(count) return result end ----Get current configuration ---@return table config Current configuration function Git.get_config() return vim.deepcopy(config) diff --git a/lua/codecompanion/_extensions/gitcommit/git_utils.lua b/lua/codecompanion/_extensions/gitcommit/git_utils.lua new file mode 100644 index 0000000..f88006c --- /dev/null +++ b/lua/codecompanion/_extensions/gitcommit/git_utils.lua @@ -0,0 +1,311 @@ +---@class CodeCompanion.GitCommit.GitUtils +---Pure utility functions for git operations. +---These are testable without requiring a git repository. + +local M = {} + +---Trim whitespace from string +---@param s string The string to trim +---@return string trimmed_string +function M.trim(s) + return (s:gsub("^%s*(.-)%s*$", "%1")) +end + +---Convert a glob pattern to a Lua pattern +---Handles: * (any non-slash), ** (any including slash), ? (single char), escapes special chars +---@param glob string The glob pattern (e.g., "*.lua", "**/*.js", "dist/*") +---@return string lua_pattern The converted Lua pattern +function M.glob_to_lua_pattern(glob) + local escaped = glob + escaped = escaped:gsub("%%", "%%%%") + escaped = escaped:gsub("%.", "%%%.") + escaped = escaped:gsub("%-", "%%%-") + escaped = escaped:gsub("%^", "%%%^") + escaped = escaped:gsub("%$", "%%%$") + escaped = escaped:gsub("%(", "%%%(") + escaped = escaped:gsub("%)", "%%%)") + escaped = escaped:gsub("%+", "%%%+") + + local placeholder = "\001DOUBLESTAR\001" + escaped = escaped:gsub("%*%*", placeholder) + escaped = escaped:gsub("%*", "[^/]*") + escaped = escaped:gsub("%?", "[^/]") + escaped = escaped:gsub(placeholder, ".*") + + if escaped:sub(1, 1) == "/" then + escaped = "^" .. escaped:sub(2) + end + + escaped = escaped .. "$" + + return escaped +end + +---Check if filepath matches a glob pattern (handles basename matching for patterns without /) +---@param filepath string The file path to check +---@param pattern string The glob pattern +---@return boolean matches True if filepath matches +function M.matches_glob(filepath, pattern) + local lua_pattern = M.glob_to_lua_pattern(pattern) + + if filepath:match(lua_pattern) then + return true + end + + if not pattern:match("/") then + local basename = filepath:match("[^/]+$") or filepath + if basename:match(lua_pattern) then + return true + end + end + + if pattern:match("/%*$") then + local dir_prefix = pattern:gsub("/%*$", "/") + if filepath:sub(1, #dir_prefix) == dir_prefix then + return true + end + end + + return false +end + +---Check if file should be excluded by patterns +---@param filepath string The file path to check +---@param exclude_patterns string[]|nil List of glob patterns to exclude +---@return boolean should_exclude True if file should be excluded +function M.should_exclude_file(filepath, exclude_patterns) + if not exclude_patterns or #exclude_patterns == 0 then + return false + end + + local normalized_path = filepath:gsub("\\", "/") + + for _, pattern in ipairs(exclude_patterns) do + if M.matches_glob(normalized_path, pattern) then + return true + end + end + + return false +end + +---Filter diff content to exclude file patterns +---@param diff_content string The original diff content +---@param exclude_patterns string[]|nil List of glob patterns to exclude +---@return string filtered_diff The filtered diff content +function M.filter_diff(diff_content, exclude_patterns) + if not exclude_patterns or #exclude_patterns == 0 then + return diff_content + end + + local lines = vim.split(diff_content, "\n") + local filtered_lines = {} + local all_files = {} + local excluded_files = {} + local current_file = nil + local skip_current_file = false + + for _, line in ipairs(lines) do + local file_match = line:match("^diff %-%-git a/(.*) b/") + if file_match then + current_file = file_match + table.insert(all_files, current_file) + skip_current_file = M.should_exclude_file(current_file, exclude_patterns) + if skip_current_file then + table.insert(excluded_files, current_file) + end + end + + local plus_file = line:match("^%+%+%+ b/(.*)") + local minus_file = line:match("^%-%-%-a/(.*)") + if plus_file then + current_file = plus_file + table.insert(all_files, current_file) + skip_current_file = M.should_exclude_file(current_file, exclude_patterns) + if skip_current_file then + table.insert(excluded_files, current_file) + end + elseif minus_file then + current_file = minus_file + table.insert(all_files, current_file) + skip_current_file = M.should_exclude_file(current_file, exclude_patterns) + if skip_current_file then + table.insert(excluded_files, current_file) + end + end + + if not skip_current_file then + table.insert(filtered_lines, line) + end + end + + if #all_files > 0 and #excluded_files >= #all_files then + return diff_content + end + + return table.concat(filtered_lines, "\n") +end + +---Parse commit line from git log output +---@param line string Git log output line (format: hash subject) +---@return table|nil commit Parsed commit {hash, subject} or nil +function M.parse_commit_line(line) + local trimmed = M.trim(line) + if trimmed == "" then + return nil + end + local hash, subject = trimmed:match("^(%S+)%s+(.*)$") + if hash and subject then + return { hash = hash, subject = subject } + end + return nil +end + +---Extract file paths from diff header lines +---@param diff_content string The diff content +---@return string[] files List of file paths mentioned in diff +function M.extract_diff_files(diff_content) + local files = {} + local seen = {} + + for line in diff_content:gmatch("[^\r\n]+") do + local file_match = line:match("^diff %-%-git a/(.*) b/") + if file_match and not seen[file_match] then + table.insert(files, file_match) + seen[file_match] = true + end + end + + return files +end + +---Validate conventional commit message format +---@param message string The commit message +---@return boolean valid True if message follows conventional commits +---@return string|nil type The commit type (feat, fix, etc.) or nil if invalid +function M.parse_conventional_commit(message) + local type_match = message:match("^(%w+)%(.*%):") or message:match("^(%w+):") + if type_match then + return true, type_match + end + return false, nil +end + +---Group commits by conventional commit type +---@param commits table[] Array of commits with subject field +---@return table groups Table with keys: features, fixes, others +function M.group_commits_by_type(commits) + local features = {} + local fixes = {} + local others = {} + + for _, commit in ipairs(commits) do + local _, type_match = M.parse_conventional_commit(commit.subject or "") + if type_match then + if type_match == "feat" then + table.insert(features, commit) + elseif type_match == "fix" then + table.insert(fixes, commit) + else + table.insert(others, commit) + end + else + table.insert(others, commit) + end + end + + return { + features = features, + fixes = fixes, + others = others, + } +end + +---Check if running on Windows +---@return boolean +function M.is_windows() + return vim.loop.os_uname().sysname == "Windows_NT" +end + +---Quote a string for shell command (cross-platform) +---@param str string The string to quote +---@param force_windows? boolean Force Windows quoting style (for testing) +---@return string quoted The quoted string +function M.shell_quote(str, force_windows) + local is_win = force_windows or M.is_windows() + if is_win then + return '"' .. str:gsub('"', '\\"') .. '"' + else + return "'" .. str:gsub("'", "'\\''") .. "'" + end +end + +---Quote a string for Unix shell +---@param str string The string to quote +---@return string quoted The quoted string +function M.shell_quote_unix(str) + return "'" .. str:gsub("'", "'\\''") .. "'" +end + +---Quote a string for Windows CMD +---@param str string The string to quote +---@return string quoted The quoted string +function M.shell_quote_windows(str) + return '"' .. str:gsub('"', '\\"') .. '"' +end + +---Clean commit message by removing markdown code blocks and extra formatting +---@param message string Raw message from LLM +---@return string cleaned_message The cleaned commit message +function M.clean_commit_message(message) + local cleaned = vim.trim(message) + cleaned = cleaned:gsub("^```+%w*\n?", "") + cleaned = cleaned:gsub("\n?```+$", "") + cleaned = vim.trim(cleaned) + return cleaned +end + +---Build commit message prompt with optional history context +---@param diff string The git diff content +---@param lang string The target language for the commit message +---@param commit_history? string[] Recent commit messages for context +---@return string prompt The formatted prompt +function M.build_commit_prompt(diff, lang, commit_history) + local history_context = "" + if commit_history and #commit_history > 0 then + history_context = "\nRECENT COMMIT HISTORY (for style reference):\n" + for i, commit_msg in ipairs(commit_history) do + history_context = history_context .. string.format("%d. %s\n", i, commit_msg) + end + history_context = history_context + .. "\nAnalyze commit history to understand project style, tone, and format patterns. Use this for consistency.\n" + end + + return string.format( + [[You are a commit message generator. Generate exactly ONE Conventional Commit message for the provided git diff.%s + +FORMAT: +type(scope): specific description of WHAT changed + +[Optional body - only for non-obvious changes] + +Allowed types: feat, fix, docs, style, refactor, perf, test, chore +Language: %s + +CRITICAL RULES: +1. Respond with ONLY the commit message - no markdown blocks, no explanations +2. Description must state WHAT was done, not WHY or the effect +3. AVOID vague verbs: "update", "improve", "clarify", "adjust", "enhance", "fix issues" + USE specific verbs: "add", "remove", "rename", "move", "replace", "extract", "inline" +4. Subject line under 50 chars, body lines under 72 chars +5. Body is OPTIONAL - omit if subject is self-explanatory + +DIFF: +%s]], + history_context, + lang or "English", + diff + ) +end + +return M diff --git a/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua b/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua index b2370ab..577a748 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua @@ -1,23 +1,7 @@ local prompts = require("codecompanion._extensions.gitcommit.prompts.release_notes") +local git_utils = require("codecompanion._extensions.gitcommit.git_utils") ---- Check if running on Windows ----@return boolean -local function is_windows() - return vim.loop.os_uname().sysname == "Windows_NT" -end - ---- Quote a string for shell command (Windows uses double quotes, Unix uses single quotes) ----@param str string The string to quote ----@return string -local function shell_quote(str) - if is_windows() then - -- Windows CMD: use double quotes, escape internal double quotes with \" - return '"' .. str:gsub('"', '\\"') .. '"' - else - -- Unix: use single quotes, escape internal single quotes - return "'" .. str:gsub("'", "'\\''") .. "'" - end -end +local shell_quote = git_utils.shell_quote ---@class CodeCompanion.GitCommit.Tools.AIReleaseNotes: CodeCompanion.Tools.Tool local AIReleaseNotes = {} diff --git a/lua/codecompanion/_extensions/gitcommit/tools/command.lua b/lua/codecompanion/_extensions/gitcommit/tools/command.lua new file mode 100644 index 0000000..5404227 --- /dev/null +++ b/lua/codecompanion/_extensions/gitcommit/tools/command.lua @@ -0,0 +1,671 @@ +---@class CodeCompanion.GitCommit.Tools.Command +---Command building and execution utilities for git operations. +---Separates pure command generation (testable) from side-effectful execution. + +local M = {} + +--- Check if running on Windows +---@return boolean +local function is_windows() + return vim.loop.os_uname().sysname == "Windows_NT" +end + +--- Quote a string for shell command (Windows uses double quotes, Unix uses single quotes) +---@param str string The string to quote +---@return string +local function shell_quote(str) + if is_windows() then + -- Windows CMD: use double quotes, escape internal double quotes with \" + return '"' .. str:gsub('"', '\\"') .. '"' + else + -- Unix: use single quotes, escape internal single quotes + return "'" .. str:gsub("'", "'\\''") .. "'" + end +end + +-------------------------------------------------------------------------------- +-- CommandBuilder: Pure functions for generating git command strings +-- These are easily testable without requiring a git repository +-------------------------------------------------------------------------------- + +---@class CodeCompanion.GitCommit.Tools.CommandBuilder +local CommandBuilder = {} + +-- Log format mapping +CommandBuilder.LOG_FORMATS = { + oneline = "--oneline", + short = "--pretty=short", + medium = "--pretty=medium", + full = "--pretty=full", + fuller = "--pretty=fuller", + format = "--pretty=format", +} + +-- Reset mode mapping +CommandBuilder.RESET_MODES = { + soft = "--soft", + mixed = "--mixed", + hard = "--hard", +} + +---Build git status command +---@return string command +function CommandBuilder.status() + return "git status --porcelain" +end + +---Build git log command +---@param count? number Number of commits (default: 10) +---@param format? string Log format (default: "oneline") +---@return string command +function CommandBuilder.log(count, format) + count = count or 10 + format = format or "oneline" + local format_option = CommandBuilder.LOG_FORMATS[format] or "--oneline" + return string.format("git log -%d %s", count, format_option) +end + +---Build git diff command +---@param staged? boolean Show staged changes +---@param file? string Specific file path +---@return string command +function CommandBuilder.diff(staged, file) + local parts = { "git", "diff" } + if staged then + table.insert(parts, "--cached") + end + if file then + table.insert(parts, vim.fn.shellescape(file)) + end + return table.concat(parts, " ") +end + +---Build git branch command (show current) +---@return string command +function CommandBuilder.current_branch() + return "git branch --show-current" +end + +---Build git branch list command +---@param remote_only? boolean Show only remote branches +---@return string command +function CommandBuilder.branches(remote_only) + return remote_only and "git branch -r" or "git branch -a" +end + +---Build git add command +---@param files string|string[] Files to stage +---@return string command +function CommandBuilder.stage(files) + if type(files) == "string" then + files = { files } + end + local escaped_files = {} + for _, file in ipairs(files) do + table.insert(escaped_files, vim.fn.shellescape(file)) + end + return "git add " .. table.concat(escaped_files, " ") +end + +---Build git reset (unstage) command +---@param files string|string[] Files to unstage +---@return string command +function CommandBuilder.unstage(files) + if type(files) == "string" then + files = { files } + end + local escaped_files = {} + for _, file in ipairs(files) do + table.insert(escaped_files, vim.fn.shellescape(file)) + end + return "git reset HEAD " .. table.concat(escaped_files, " ") +end + +---Build git commit command +---@param message string Commit message +---@param amend? boolean Amend the last commit +---@return string command +function CommandBuilder.commit(message, amend) + local parts = { "git", "commit" } + if amend then + table.insert(parts, "--amend") + end + table.insert(parts, "-m") + table.insert(parts, vim.fn.shellescape(message)) + return table.concat(parts, " ") +end + +---Build git create branch command +---@param branch_name string Name of the new branch +---@param checkout? boolean Whether to checkout the new branch (default: true) +---@return string command +function CommandBuilder.create_branch(branch_name, checkout) + checkout = checkout ~= false + local cmd = checkout and "git checkout -b " or "git branch " + return cmd .. vim.fn.shellescape(branch_name) +end + +---Build git checkout command +---@param target string Branch name or commit hash +---@return string command +function CommandBuilder.checkout(target) + return "git checkout " .. vim.fn.shellescape(target) +end + +---Build git remote command +---@return string command +function CommandBuilder.remotes() + return "git remote -v" +end + +---Build git show command +---@param commit_hash? string Commit hash (default: HEAD) +---@return string command +function CommandBuilder.show(commit_hash) + commit_hash = commit_hash or "HEAD" + return "git show " .. vim.fn.shellescape(commit_hash) +end + +---Build git blame command +---@param file_path string Path to the file +---@param line_start? number Start line number +---@param line_end? number End line number +---@return string command +function CommandBuilder.blame(file_path, line_start, line_end) + local parts = { "git", "blame", vim.fn.shellescape(file_path) } + if line_start and line_end then + table.insert(parts, "-L") + table.insert(parts, line_start .. "," .. line_end) + elseif line_start then + table.insert(parts, "-L") + table.insert(parts, line_start .. ",+10") + end + return table.concat(parts, " ") +end + +---Build git stash command +---@param message? string Stash message +---@param include_untracked? boolean Include untracked files +---@return string command +function CommandBuilder.stash(message, include_untracked) + local parts = { "git", "stash" } + if include_untracked then + table.insert(parts, "-u") + end + if message then + table.insert(parts, "-m") + table.insert(parts, vim.fn.shellescape(message)) + end + return table.concat(parts, " ") +end + +---Build git stash list command +---@return string command +function CommandBuilder.stash_list() + return "git stash list" +end + +---Build git stash apply command +---@param stash_ref? string Stash reference (default: stash@{0}) +---@return string command +function CommandBuilder.stash_apply(stash_ref) + stash_ref = stash_ref or "stash@{0}" + return "git stash apply " .. vim.fn.shellescape(stash_ref) +end + +---Build git reset command +---@param commit_hash string Commit hash or reference +---@param mode? string Reset mode (soft, mixed, hard) +---@return string command +function CommandBuilder.reset(commit_hash, mode) + mode = mode or "mixed" + local mode_flag = CommandBuilder.RESET_MODES[mode] or "--mixed" + return string.format("git reset %s %s", mode_flag, vim.fn.shellescape(commit_hash)) +end + +---Build git diff between commits command +---@param commit1 string First commit +---@param commit2? string Second commit (default: HEAD) +---@param file_path? string Specific file path +---@return string command +function CommandBuilder.diff_commits(commit1, commit2, file_path) + commit2 = commit2 or "HEAD" + local parts = { + "git", + "diff", + vim.fn.shellescape(commit1), + vim.fn.shellescape(commit2), + } + if file_path then + table.insert(parts, "--") + table.insert(parts, vim.fn.shellescape(file_path)) + end + return table.concat(parts, " ") +end + +---Build git shortlog (contributors) command +---@param count? number Number of top contributors +---@return string command +function CommandBuilder.contributors(count) + count = count or 10 + if is_windows() then + return string.format("git shortlog -sn | Select-Object -First %d", count) + else + return string.format("git shortlog -sn | head -%d", count) + end +end + +---Build git log search command +---@param pattern string Search pattern +---@param count? number Maximum number of results +---@return string command +function CommandBuilder.search_commits(pattern, count) + count = count or 20 + return string.format("git log --grep=%s --oneline -%d", vim.fn.shellescape(pattern), count) +end + +---Build git push command +---@param remote? string Remote name +---@param branch? string Branch name +---@param force? boolean Force push +---@param set_upstream? boolean Set upstream +---@param tags? boolean Push all tags +---@param tag_name? string Single tag to push +---@return string command +function CommandBuilder.push(remote, branch, force, set_upstream, tags, tag_name) + local parts = { "git", "push" } + if force then + table.insert(parts, "--force") + end + if set_upstream then + table.insert(parts, "--set-upstream") + end + + -- Handle tag pushing - single tag takes priority over all tags + if tag_name and vim.trim(tag_name) ~= "" then + local push_remote = remote or "origin" + table.insert(parts, vim.fn.shellescape(push_remote)) + table.insert(parts, vim.fn.shellescape(tag_name)) + elseif tags then + local push_remote = remote or "origin" + table.insert(parts, vim.fn.shellescape(push_remote)) + table.insert(parts, "--tags") + else + if remote then + table.insert(parts, vim.fn.shellescape(remote)) + end + if branch then + table.insert(parts, vim.fn.shellescape(branch)) + end + end + return table.concat(parts, " ") +end + +---Build git push command as array (for async jobstart) +---@param remote? string Remote name +---@param branch? string Branch name +---@param force? boolean Force push +---@param set_upstream? boolean Set upstream +---@param tags? boolean Push all tags +---@param tag_name? string Single tag to push +---@return string[] command array +function CommandBuilder.push_array(remote, branch, force, set_upstream, tags, tag_name) + local cmd = { "git", "push" } + if force then + table.insert(cmd, "--force") + end + if set_upstream then + table.insert(cmd, "--set-upstream") + end + + if tag_name and vim.trim(tag_name) ~= "" then + table.insert(cmd, remote or "origin") + table.insert(cmd, tag_name) + elseif tags then + table.insert(cmd, remote or "origin") + table.insert(cmd, "--tags") + else + if remote then + table.insert(cmd, remote) + end + if branch then + table.insert(cmd, branch) + end + end + return cmd +end + +---Build git rebase command +---@param onto? string Branch to rebase onto +---@param base? string Upstream branch +---@param interactive? boolean Interactive rebase +---@return string command +function CommandBuilder.rebase(onto, base, interactive) + local parts = { "git", "rebase" } + if interactive then + table.insert(parts, "--interactive") + end + if onto then + table.insert(parts, "--onto") + table.insert(parts, vim.fn.shellescape(onto)) + end + if base then + table.insert(parts, vim.fn.shellescape(base)) + end + return table.concat(parts, " ") +end + +---Build git cherry-pick command +---@param commit_hash string Commit hash +---@return string command +function CommandBuilder.cherry_pick(commit_hash) + return "git cherry-pick --no-edit " .. vim.fn.shellescape(commit_hash) +end + +---Build git cherry-pick abort command +---@return string command +function CommandBuilder.cherry_pick_abort() + return "git cherry-pick --abort" +end + +---Build git cherry-pick continue command +---@return string command +function CommandBuilder.cherry_pick_continue() + return "git cherry-pick --continue" +end + +---Build git cherry-pick skip command +---@return string command +function CommandBuilder.cherry_pick_skip() + return "git cherry-pick --skip" +end + +---Build git revert command +---@param commit_hash string Commit hash +---@return string command +function CommandBuilder.revert(commit_hash) + return "git revert --no-edit " .. vim.fn.shellescape(commit_hash) +end + +---Build git tag list command +---@return string command +function CommandBuilder.tags() + return "git tag" +end + +---Build git tag sorted command +---@return string command +function CommandBuilder.tags_sorted() + return "git tag --sort=-version:refname" +end + +---Build git create tag command +---@param tag_name string Tag name +---@param message? string Annotated tag message +---@param commit_hash? string Commit to tag +---@return string command +function CommandBuilder.create_tag(tag_name, message, commit_hash) + local parts = { "git", "tag" } + if message then + table.insert(parts, "-a") + table.insert(parts, vim.fn.shellescape(tag_name)) + table.insert(parts, "-m") + table.insert(parts, vim.fn.shellescape(message)) + else + table.insert(parts, vim.fn.shellescape(tag_name)) + end + if commit_hash then + table.insert(parts, vim.fn.shellescape(commit_hash)) + end + return table.concat(parts, " ") +end + +---Build git delete tag command +---@param tag_name string Tag name +---@param remote? string Remote to delete from +---@return string command +function CommandBuilder.delete_tag(tag_name, remote) + if remote then + return "git push --delete " .. vim.fn.shellescape(remote) .. " " .. vim.fn.shellescape(tag_name) + else + return "git tag -d " .. vim.fn.shellescape(tag_name) + end +end + +---Build git merge command +---@param branch string Branch to merge +---@return string command +function CommandBuilder.merge(branch) + return "git merge " .. vim.fn.shellescape(branch) .. " --no-edit" +end + +---Build git merge abort command +---@return string command +function CommandBuilder.merge_abort() + return "git merge --abort" +end + +---Build git merge continue command +---@return string command +function CommandBuilder.merge_continue() + return "git merge --continue" +end + +---Build git diff conflict status command +---@return string command +function CommandBuilder.conflict_status() + return "git diff --name-only --diff-filter=U" +end + +---Build git log for release notes command +---@param from_tag string Starting tag +---@param to_tag string Ending tag +---@return string command +function CommandBuilder.release_notes_log(from_tag, to_tag) + local range = from_tag .. "^.." .. to_tag + local escaped_range = vim.fn.shellescape(range) + local format_str = shell_quote("%h\x01%s\x01%an\x01%ad") + return "git log --pretty=format:" .. format_str .. " --date=short " .. escaped_range +end + +---Build git remote add command +---@param name string Remote name +---@param url string Remote URL +---@return string command +function CommandBuilder.add_remote(name, url) + return "git remote add " .. vim.fn.shellescape(name) .. " " .. vim.fn.shellescape(url) +end + +---Build git remote remove command +---@param name string Remote name +---@return string command +function CommandBuilder.remove_remote(name) + return "git remote remove " .. vim.fn.shellescape(name) +end + +---Build git remote rename command +---@param old_name string Current name +---@param new_name string New name +---@return string command +function CommandBuilder.rename_remote(old_name, new_name) + return "git remote rename " .. vim.fn.shellescape(old_name) .. " " .. vim.fn.shellescape(new_name) +end + +---Build git remote set-url command +---@param name string Remote name +---@param url string New URL +---@return string command +function CommandBuilder.set_remote_url(name, url) + return "git remote set-url " .. vim.fn.shellescape(name) .. " " .. vim.fn.shellescape(url) +end + +---Build git fetch command +---@param remote? string Remote name +---@param branch? string Branch name +---@param prune? boolean Prune deleted branches +---@return string command +function CommandBuilder.fetch(remote, branch, prune) + local parts = { "git", "fetch" } + if prune then + table.insert(parts, "--prune") + end + if remote then + table.insert(parts, vim.fn.shellescape(remote)) + if branch then + table.insert(parts, vim.fn.shellescape(branch)) + end + else + table.insert(parts, "--all") + end + return table.concat(parts, " ") +end + +---Build git pull command +---@param remote? string Remote name +---@param branch? string Branch name +---@param rebase? boolean Use rebase +---@return string command +function CommandBuilder.pull(remote, branch, rebase) + local parts = { "git", "pull" } + if rebase then + table.insert(parts, "--rebase") + end + if remote then + table.insert(parts, vim.fn.shellescape(remote)) + if branch then + table.insert(parts, vim.fn.shellescape(branch)) + end + end + return table.concat(parts, " ") +end + +---Build git rev-parse command (check repo) +---@return string command +function CommandBuilder.is_inside_work_tree() + local redirect = is_windows() and " 2>nul" or " 2>/dev/null" + return "git rev-parse --is-inside-work-tree" .. redirect +end + +---Build git rev-parse git-dir command +---@return string command +function CommandBuilder.git_dir() + return "git rev-parse --git-dir" +end + +---Build git rev-parse show-toplevel command +---@return string command +function CommandBuilder.repo_root() + return "git rev-parse --show-toplevel" +end + +---Build git check-ignore command +---@param file string File to check +---@return string[] command array for system call +function CommandBuilder.check_ignore(file) + return { "git", "check-ignore", file } +end + +-------------------------------------------------------------------------------- +-- CommandExecutor: Handles actual command execution with proper error handling +-------------------------------------------------------------------------------- + +---@class CodeCompanion.GitCommit.Tools.CommandExecutor +local CommandExecutor = {} + +---Execute a git command string +---@param cmd string Command to execute +---@return boolean success +---@return string output +function CommandExecutor.run(cmd) + local ok, output = pcall(vim.fn.system, cmd) + if not ok then + return false, "Command execution failed: " .. tostring(output) + end + + local exit_code = vim.v.shell_error + if exit_code ~= 0 or (output and output:match("fatal: ")) then + return false, output or "Git command failed" + end + + return true, output or "" +end + +---Execute a git command array (for better escaping) +---@param cmd string[] Command array +---@return boolean success +---@return string output +function CommandExecutor.run_array(cmd) + local ok, output = pcall(vim.fn.system, cmd) + if not ok then + return false, "Command execution failed: " .. tostring(output) + end + + local exit_code = vim.v.shell_error + if exit_code ~= 0 then + return false, output or "Git command failed" + end + + return true, output or "" +end + +---Execute a git command asynchronously +---@param cmd string[] Command array +---@param on_exit function Callback function(result: {status: string, data: string}) +function CommandExecutor.run_async(cmd, on_exit) + local stdout_lines = {} + local stderr_lines = {} + + vim.fn.jobstart(cmd, { + on_stdout = function(_, data) + if data then + for _, line in ipairs(data) do + if line ~= "" then + table.insert(stdout_lines, line) + end + end + end + end, + on_stderr = function(_, data) + if data then + for _, line in ipairs(data) do + if line ~= "" then + table.insert(stderr_lines, line) + end + end + end + end, + on_exit = function(_, code) + if code == 0 then + on_exit({ status = "success", data = table.concat(stdout_lines, "\n") }) + else + on_exit({ status = "error", data = table.concat(stderr_lines, "\n") }) + end + end, + }) +end + +---Check if currently in a git repository +---@return boolean +function CommandExecutor.is_git_repo() + local cmd = CommandBuilder.is_inside_work_tree() + local ok, output = pcall(vim.fn.system, cmd) + return ok and vim.v.shell_error == 0 and vim.trim(output) == "true" +end + +---Execute a git command with repo check +---@param cmd string Command to execute +---@return boolean success +---@return string output +function CommandExecutor.run_in_repo(cmd) + if not CommandExecutor.is_git_repo() then + return false, "Not in a git repository" + end + return CommandExecutor.run(cmd) +end + +M.CommandBuilder = CommandBuilder +M.CommandExecutor = CommandExecutor +M.is_windows = is_windows +M.shell_quote = shell_quote + +return M diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git.lua b/lua/codecompanion/_extensions/gitcommit/tools/git.lua index 11f64e0..dd3d7f6 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git.lua @@ -1,45 +1,30 @@ local Git = require("codecompanion._extensions.gitcommit.git") +local Command = require("codecompanion._extensions.gitcommit.tools.command") -local M = {} - ---- Check if running on Windows ----@return boolean -local function is_windows() - return vim.loop.os_uname().sysname == "Windows_NT" -end +local CommandBuilder = Command.CommandBuilder +local CommandExecutor = Command.CommandExecutor ---- Quote a string for shell command (Windows uses double quotes, Unix uses single quotes) ----@param str string The string to quote ----@return string -local function shell_quote(str) - if is_windows() then - -- Windows CMD: use double quotes, escape internal double quotes with \" - return '"' .. str:gsub('"', '\\"') .. '"' - else - -- Unix: use single quotes, escape internal single quotes - return "'" .. str:gsub("'", "'\\''") .. "'" - end -end +local M = {} ----Git tool for CodeCompanion GitCommit extension ----Provides git operations like status, diff, log, branch management etc. ---@class CodeCompanion.GitCommit.Tools.Git local GitTool = { name = "git_operations", description = "Execute git operations and commands", } ---- Get the path to the .gitignore file in the current git repo root local function get_gitignore_path() - local git_dir = vim.fn.system("git rev-parse --show-toplevel"):gsub("\n", "") - if vim.v.shell_error ~= 0 or not git_dir or git_dir == "" then + local success, output = CommandExecutor.run(CommandBuilder.repo_root()) + if not success then + return nil + end + local git_dir = output:gsub("\n", "") + if not git_dir or git_dir == "" then return nil end local sep = package.config:sub(1, 1) return git_dir .. sep .. ".gitignore" end ---- Read .gitignore content function GitTool.get_gitignore() local path = get_gitignore_path() if not path then @@ -50,7 +35,7 @@ function GitTool.get_gitignore() end local stat = vim.uv.fs_stat(path) if not stat then - local msg = "" -- treat as empty if not exists + local msg = "" local user_msg = "ℹ .gitignore file does not exist (repository has no ignore rules)" local llm_msg = "success: .gitignore is empty" return true, msg, user_msg, llm_msg @@ -65,7 +50,7 @@ function GitTool.get_gitignore() local data = vim.uv.fs_read(fd, stat.size, 0) vim.uv.fs_close(fd) local msg = data or "" - -- Format the output nicely for users + local user_msg if data and vim.trim(data) ~= "" then user_msg = "✓ .gitignore content:\n\n```gitignore\n" .. data .. "\n```" else @@ -75,7 +60,6 @@ function GitTool.get_gitignore() return true, msg, user_msg, llm_msg end ---- Add rule(s) to .gitignore (no duplicates) function GitTool.add_gitignore_rule(rule) local path = get_gitignore_path() if not path then @@ -120,7 +104,6 @@ function GitTool.add_gitignore_rule(rule) return true, "Added rule(s): " .. table.concat(added, ", ") end ---- Remove rule(s) from .gitignore function GitTool.remove_gitignore_rule(rule) local path = get_gitignore_path() if not path then @@ -165,7 +148,6 @@ function GitTool.remove_gitignore_rule(rule) return true, "Removed rule(s): " .. table.concat(removed, ", ") end ---- Check if a file is ignored by .gitignore function GitTool.is_ignored(file) if not file or file == "" then local msg = "No file specified" @@ -173,10 +155,9 @@ function GitTool.is_ignored(file) local llm_msg = "fail: " .. msg .. "" return false, msg, user_msg, llm_msg end - local ok, result = pcall(function() - return vim.fn.system({ "git", "check-ignore", file }) - end) - if not ok or vim.v.shell_error ~= 0 then + local cmd = CommandBuilder.check_ignore(file) + local success, result = CommandExecutor.run_array(cmd) + if not success then local msg = "File is not ignored or not in a git repo" local user_msg = string.format("ℹ File '%s' is NOT ignored by .gitignore", file) local llm_msg = "fail: " .. msg .. "" @@ -192,51 +173,23 @@ local function is_git_repo() return Git.is_repository() end -local function execute_git_command(cmd) - local ok, success, output = pcall(function() - if not is_git_repo() then - return false, "Not in a git repository" - end - - local cmd_output = vim.fn.system(cmd) - local exit_code = vim.v.shell_error - - if exit_code ~= 0 or (cmd_output and cmd_output:match("fatal: ")) then - return false, cmd_output or "Git command failed" - end - return true, cmd_output or "" - end) - - if not ok then - return false, "Git command execution failed: " .. tostring(success) - end - - return success, output -end - --- Helper function to format git tool responses consistently local function format_git_response(tool_name, success, output, empty_msg) local user_msg, llm_msg local tag = "git" .. tool_name:gsub("^%l", string.upper) .. "Tool" if success then if output and vim.trim(output) ~= "" then - -- Format user message with better visual structure local formatted_output = vim.trim(output) - -- Add icons and better formatting for user messages local icon = "✓" user_msg = string.format("%s Git %s executed successfully:\n\n```\n%s\n```", icon, tool_name, formatted_output) - -- Keep llm_msg simple and structured llm_msg = string.format("<%s>success:\n%s", tag, formatted_output, tag) else - -- Handle empty results with more descriptive messaging local icon = "ℹ" local empty_text = empty_msg or ("No " .. tool_name .. " data available") user_msg = string.format("%s Git %s: %s", icon, tool_name, empty_text) llm_msg = string.format("<%s>success: %s", tag, empty_text, tag) end else - -- Format error messages clearly local icon = "✗" local error_text = output or "Unknown error occurred" user_msg = string.format("%s Git %s failed:\n%s", icon, tool_name, error_text) @@ -247,59 +200,68 @@ local function format_git_response(tool_name, success, output, empty_msg) end function GitTool.get_status() - local success, output = execute_git_command("git status --porcelain") + if not is_git_repo() then + return false, + "Not in a git repository", + "✗ Not in a git repository", + "fail: Not in a git repository" + end + local cmd = CommandBuilder.status() + local success, output = CommandExecutor.run(cmd) local user_msg, llm_msg = format_git_response("status", success, output, "no changes found") return success, output, user_msg, llm_msg end function GitTool.get_log(count, format) - count = count or 10 - format = format or "oneline" - local format_map = { - oneline = "--oneline", - short = "--pretty=short", - medium = "--pretty=medium", - full = "--pretty=full", - fuller = "--pretty=fuller", - format = "--pretty=format", - } - local format_option = format_map[format] or "--oneline" - local cmd = string.format("git log -%d %s", count, format_option) - local success, output = execute_git_command(cmd) + if not is_git_repo() then + return false, + "Not in a git repository", + "✗ Not in a git repository", + "fail: Not in a git repository" + end + local cmd = CommandBuilder.log(count, format) + local success, output = CommandExecutor.run(cmd) local user_msg, llm_msg = format_git_response("log", success, output, "no commits found") return success, output, user_msg, llm_msg end function GitTool.get_diff(staged, file) - local cmd = "git diff" - if staged then - cmd = cmd .. " --cached" - end - if file then - cmd = cmd .. " " .. vim.fn.shellescape(file) + if not is_git_repo() then + return false, + "Not in a git repository", + "✗ Not in a git repository", + "fail: Not in a git repository" end - local success, output = execute_git_command(cmd) + local cmd = CommandBuilder.diff(staged, file) + local success, output = CommandExecutor.run(cmd) local diff_type = staged and "staged" or "unstaged" local empty_msg = "no " .. diff_type .. " changes found" local user_msg, llm_msg = format_git_response("diff", success, output, empty_msg) return success, output, user_msg, llm_msg end ----Get current branch name ----@return boolean success, string branch_name - function GitTool.get_current_branch() - local success, output = execute_git_command("git branch --show-current") + if not is_git_repo() then + return false, + "Not in a git repository", + "✗ Not in a git repository", + "fail: Not in a git repository" + end + local cmd = CommandBuilder.current_branch() + local success, output = CommandExecutor.run(cmd) local user_msg, llm_msg = format_git_response("branch", success, output, "no current branch (possibly detached HEAD)") return success, output, user_msg, llm_msg end ----Get all branches (local and remote) ----@param remote_only? boolean Show only remote branches ----@return boolean success, string output, string user_msg, string llm_msg function GitTool.get_branches(remote_only) - local cmd = remote_only and "git branch -r" or "git branch -a" - local success, output = execute_git_command(cmd) + if not is_git_repo() then + return false, + "Not in a git repository", + "✗ Not in a git repository", + "fail: Not in a git repository" + end + local cmd = CommandBuilder.branches(remote_only) + local success, output = CommandExecutor.run(cmd) local branch_type = remote_only and "remote branches" or "branches" local empty_msg = "no " .. branch_type .. " found" local user_msg, llm_msg = format_git_response("branch", success, output, empty_msg) @@ -307,358 +269,214 @@ function GitTool.get_branches(remote_only) end function GitTool.stage_files(files) - local ok, success, output = pcall(function() + if not is_git_repo() then + return false, "Not in a git repository" + end + local ok, result = pcall(function() if type(files) == "string" then files = { files } end - - local escaped_files = {} - for _, file in ipairs(files) do - table.insert(escaped_files, vim.fn.shellescape(file)) - end - - local cmd = "git add " .. table.concat(escaped_files, " ") - return execute_git_command(cmd) + local cmd = CommandBuilder.stage(files) + return CommandExecutor.run(cmd) end) - if not ok then - return false, "Failed to stage files: " .. tostring(success) + return false, "Failed to stage files: " .. tostring(result) end - - return success, output + return result end function GitTool.unstage_files(files) - local ok, success, output = pcall(function() + if not is_git_repo() then + return false, "Not in a git repository" + end + local ok, result = pcall(function() if type(files) == "string" then files = { files } end - - local escaped_files = {} - for _, file in ipairs(files) do - table.insert(escaped_files, vim.fn.shellescape(file)) - end - - local cmd = "git reset HEAD " .. table.concat(escaped_files, " ") - return execute_git_command(cmd) + local cmd = CommandBuilder.unstage(files) + return CommandExecutor.run(cmd) end) - if not ok then - return false, "Failed to unstage files: " .. tostring(success) + return false, "Failed to unstage files: " .. tostring(result) end - - return success, output + return result end ----Commit staged changes ----Commit staged changes ----@param message string Commit message ----@param amend? boolean Whether to amend the last commit (default: false) ----@return boolean success, string output + function GitTool.commit(message, amend) + if not is_git_repo() then + return false, "Not in a git repository" + end if not message or vim.trim(message) == "" then return false, "Commit message is required" end - - local cmd = "git commit" - if amend then - cmd = cmd .. " --amend" - end - cmd = cmd .. " -m " .. vim.fn.shellescape(message) - - return execute_git_command(cmd) + local cmd = CommandBuilder.commit(message, amend) + return CommandExecutor.run(cmd) end ----Create a new branch ----@param branch_name string Name of the new branch ----@param checkout? boolean Whether to checkout the new branch (default: true) ----@return boolean success, string output function GitTool.create_branch(branch_name, checkout) - checkout = checkout ~= false -- default to true - - local cmd = checkout and "git checkout -b " or "git branch " - cmd = cmd .. vim.fn.shellescape(branch_name) - - return execute_git_command(cmd) + if not is_git_repo() then + return false, "Not in a git repository" + end + local cmd = CommandBuilder.create_branch(branch_name, checkout) + return CommandExecutor.run(cmd) end ----Checkout branch or commit ----@param target string Branch name or commit hash ----@return boolean success, string output function GitTool.checkout(target) - local cmd = "git checkout " .. vim.fn.shellescape(target) - return execute_git_command(cmd) + if not is_git_repo() then + return false, "Not in a git repository" + end + local cmd = CommandBuilder.checkout(target) + return CommandExecutor.run(cmd) end ----Get remote information ----@return boolean success, string output, string user_msg, string llm_msg function GitTool.get_remotes() - local success, output = execute_git_command("git remote -v") + if not is_git_repo() then + return false, + "Not in a git repository", + "✗ Not in a git repository", + "fail: Not in a git repository" + end + local cmd = CommandBuilder.remotes() + local success, output = CommandExecutor.run(cmd) local user_msg, llm_msg = format_git_response("remote", success, output) return success, output, user_msg, llm_msg end ----Show commit details ----@param commit_hash? string Commit hash (default: HEAD) ----@return boolean success, string output, string user_msg, string llm_msg function GitTool.show_commit(commit_hash) - commit_hash = commit_hash or "HEAD" - local cmd = "git show " .. vim.fn.shellescape(commit_hash) - local success, output = execute_git_command(cmd) + if not is_git_repo() then + return false, + "Not in a git repository", + "✗ Not in a git repository", + "fail: Not in a git repository" + end + local cmd = CommandBuilder.show(commit_hash) + local success, output = CommandExecutor.run(cmd) local user_msg, llm_msg = format_git_response("show", success, output) return success, output, user_msg, llm_msg end ----Get blame information for a file ----@param file_path string Path to the file ----@param line_start? number Start line number ----@param line_end? number End line number ----@return boolean success, string output, string user_msg, string llm_msg function GitTool.get_blame(file_path, line_start, line_end) - local cmd = "git blame " .. vim.fn.shellescape(file_path) - if line_start and line_end then - cmd = cmd .. " -L " .. line_start .. "," .. line_end - elseif line_start then - cmd = cmd .. " -L " .. line_start .. ",+10" + if not is_git_repo() then + return false, + "Not in a git repository", + "✗ Not in a git repository", + "fail: Not in a git repository" end - local success, output = execute_git_command(cmd) + local cmd = CommandBuilder.blame(file_path, line_start, line_end) + local success, output = CommandExecutor.run(cmd) local user_msg, llm_msg = format_git_response("blame", success, output) return success, output, user_msg, llm_msg end ----Stash changes ----@param message? string Stash message ----@param include_untracked? boolean Include untracked files ----@return boolean success, string output function GitTool.stash(message, include_untracked) - local cmd = "git stash" - - if include_untracked then - cmd = cmd .. " -u" - end - - if message then - cmd = cmd .. " -m " .. vim.fn.shellescape(message) + if not is_git_repo() then + return false, "Not in a git repository" end - - return execute_git_command(cmd) + local cmd = CommandBuilder.stash(message, include_untracked) + return CommandExecutor.run(cmd) end ----List stashes ----@return boolean success, string output, string user_msg, string llm_msg function GitTool.list_stashes() - local success, output = execute_git_command("git stash list") + if not is_git_repo() then + return false, + "Not in a git repository", + "✗ Not in a git repository", + "fail: Not in a git repository" + end + local cmd = CommandBuilder.stash_list() + local success, output = CommandExecutor.run(cmd) local user_msg, llm_msg = format_git_response("stash", success, output) return success, output, user_msg, llm_msg end ----Apply stash ----@param stash_ref? string Stash reference (default: stash@{0}) ----@return boolean success, string output function GitTool.apply_stash(stash_ref) - stash_ref = stash_ref or "stash@{0}" - local cmd = "git stash apply " .. vim.fn.shellescape(stash_ref) - return execute_git_command(cmd) + if not is_git_repo() then + return false, "Not in a git repository" + end + local cmd = CommandBuilder.stash_apply(stash_ref) + return CommandExecutor.run(cmd) end ----Reset to a specific commit ----@param commit_hash string Commit hash or reference ----@param mode? string Reset mode (soft, mixed, hard) ----@return boolean success, string output function GitTool.reset(commit_hash, mode) - mode = mode or "mixed" - local cmd = string.format("git reset --%s %s", mode, vim.fn.shellescape(commit_hash)) - return execute_git_command(cmd) + if not is_git_repo() then + return false, "Not in a git repository" + end + local cmd = CommandBuilder.reset(commit_hash, mode) + return CommandExecutor.run(cmd) end ----Get file changes between commits ----@param commit1 string First commit ----@param commit2? string Second commit (default: HEAD) ----@param file_path? string Specific file path ----@return boolean success, string output, string user_msg, string llm_msg function GitTool.diff_commits(commit1, commit2, file_path) - commit2 = commit2 or "HEAD" - local cmd = string.format("git diff %s %s", vim.fn.shellescape(commit1), vim.fn.shellescape(commit2)) - if file_path then - cmd = cmd .. " -- " .. vim.fn.shellescape(file_path) + if not is_git_repo() then + return false, + "Not in a git repository", + "✗ Not in a git repository", + "fail: Not in a git repository" end - local success, output = execute_git_command(cmd) + local cmd = CommandBuilder.diff_commits(commit1, commit2, file_path) + local success, output = CommandExecutor.run(cmd) local user_msg, llm_msg = format_git_response("diff_commits", success, output) return success, output, user_msg, llm_msg end ----Get contributors/authors ----@param count? number Number of top contributors to show ----@return boolean success, string output, string user_msg, string llm_msg function GitTool.get_contributors(count) - count = count or 10 - local head_cmd - if vim.loop.os_uname().sysname == "Windows_NT" then - head_cmd = string.format("git shortlog -sn | Select-Object -First %d", count) - else - head_cmd = string.format("git shortlog -sn | head -%d", count) + if not is_git_repo() then + return false, + "Not in a git repository", + "✗ Not in a git repository", + "fail: Not in a git repository" end - local success, output = execute_git_command(head_cmd) + local cmd = CommandBuilder.contributors(count) + local success, output = CommandExecutor.run(cmd) local user_msg, llm_msg = format_git_response("contributors", success, output) return success, output, user_msg, llm_msg end ----Search commits by message ----@param pattern string Search pattern ----@param count? number Maximum number of results ----@return boolean success, string output, string user_msg, string llm_msg function GitTool.search_commits(pattern, count) - count = count or 20 - local cmd = string.format("git log --grep=%s --oneline -%d", vim.fn.shellescape(pattern), count) - local success, output = execute_git_command(cmd) + if not is_git_repo() then + return false, + "Not in a git repository", + "✗ Not in a git repository", + "fail: Not in a git repository" + end + local cmd = CommandBuilder.search_commits(pattern, count) + local success, output = CommandExecutor.run(cmd) local user_msg, llm_msg = format_git_response("search_commits", success, output) return success, output, user_msg, llm_msg end ----Push changes to a remote repository ----@param remote? string The name of the remote to push to (e.g., origin) ----@param branch? string The name of the branch to push (defaults to current branch) ----@param force? boolean Force push (DANGEROUS: overwrites remote history) ----@param set_upstream? boolean Set the upstream branch for the current local branch ----@param tags? boolean Push all tags ----@param tag_name? string The name of a single tag to push (takes priority over tags parameter) ----@return boolean success, string output function GitTool.push(remote, branch, force, set_upstream, tags, tag_name) - local cmd = "git push" - if force then - cmd = cmd .. " --force" - end - if set_upstream then - cmd = cmd .. " --set-upstream" - end - - -- Handle tag pushing - single tag takes priority over all tags - if tag_name and vim.trim(tag_name) ~= "" then - -- Push single tag: git push origin tag_name - -- Default to "origin" if no remote specified to avoid git interpreting tag_name as remote - local push_remote = remote or "origin" - cmd = cmd .. " " .. vim.fn.shellescape(push_remote) - cmd = cmd .. " " .. vim.fn.shellescape(tag_name) - elseif tags then - -- Push all tags: git push origin --tags - -- Default to "origin" if no remote specified - local push_remote = remote or "origin" - cmd = cmd .. " " .. vim.fn.shellescape(push_remote) - cmd = cmd .. " --tags" - else - -- Regular branch push: git push origin branch - if remote then - cmd = cmd .. " " .. vim.fn.shellescape(remote) - end - if branch then - cmd = cmd .. " " .. vim.fn.shellescape(branch) - end + if not is_git_repo() then + return false, "Not in a git repository" end - - return execute_git_command(cmd) + local cmd = CommandBuilder.push(remote, branch, force, set_upstream, tags, tag_name) + return CommandExecutor.run(cmd) end ----Push changes to a remote repository asynchronously ----@param remote? string The name of the remote to push to (e.g., origin) ----@param branch? string The name of the branch to push (defaults to current branch) ----@param force? boolean Force push (DANGEROUS: overwrites remote history) ----@param set_upstream? boolean Set the upstream branch ----@param tags? boolean Push all tags ----@param tag_name? string The name of a single tag to push ----@param on_exit function The callback function to execute on completion function GitTool.push_async(remote, branch, force, set_upstream, tags, tag_name, on_exit) - local cmd = { "git", "push" } - if force then - table.insert(cmd, "--force") - end - if set_upstream then - table.insert(cmd, "--set-upstream") - end - - -- Handle tag pushing - single tag takes priority over all tags - if tag_name and vim.trim(tag_name) ~= "" then - -- Push single tag: git push origin tag_name - -- Default to "origin" if no remote specified to avoid git interpreting tag_name as remote - table.insert(cmd, remote or "origin") - table.insert(cmd, tag_name) - elseif tags then - -- Push all tags: git push origin --tags - -- Default to "origin" if no remote specified - table.insert(cmd, remote or "origin") - table.insert(cmd, "--tags") - else - -- Regular branch push with optional upstream setting - if remote then - table.insert(cmd, remote) - end - if branch then - table.insert(cmd, branch) - end + if not is_git_repo() then + on_exit({ status = "error", data = "Not in a git repository" }) + return end - - local stdout_lines = {} - local stderr_lines = {} - - vim.fn.jobstart(cmd, { - on_stdout = function(_, data) - if data then - for _, line in ipairs(data) do - if line ~= "" then - table.insert(stdout_lines, line) - end - end - end - end, - on_stderr = function(_, data) - if data then - for _, line in ipairs(data) do - if line ~= "" then - table.insert(stderr_lines, line) - end - end - end - end, - on_exit = function(_, code) - if code == 0 then - on_exit({ status = "success", data = table.concat(stdout_lines, "\n") }) - else - on_exit({ status = "error", data = table.concat(stderr_lines, "\n") }) - end - end, - }) + local cmd = CommandBuilder.push_array(remote, branch, force, set_upstream, tags, tag_name) + CommandExecutor.run_async(cmd, on_exit) end ----Perform a git rebase operation ----@param onto? string The branch to rebase onto ----@param base? string The upstream branch to rebase from ----@param interactive? boolean Whether to perform an interactive rebase (DANGEROUS: opens an editor, not suitable for automated environments) ----@return boolean success, string output function GitTool.rebase(onto, base, interactive) - local cmd = "git rebase" - if interactive then - cmd = cmd .. " --interactive" - end - if onto then - cmd = cmd .. " --onto " .. vim.fn.shellescape(onto) - end - if base then - cmd = cmd .. " " .. vim.fn.shellescape(base) + if not is_git_repo() then + return false, "Not in a git repository" end - return execute_git_command(cmd) + local cmd = CommandBuilder.rebase(onto, base, interactive) + return CommandExecutor.run(cmd) end ----Apply the changes introduced by some existing commits ----@param commit_hash string The commit hash to cherry-pick ----@return boolean success, string output function GitTool.cherry_pick(commit_hash) if not commit_hash then return false, "Commit hash is required for cherry-pick" end - if not is_git_repo() then return false, "Not in a git repository" end - - local cmd = "git cherry-pick --no-edit " .. vim.fn.shellescape(commit_hash) + local cmd = CommandBuilder.cherry_pick(commit_hash) local output = vim.fn.system(cmd) local exit_code = vim.v.shell_error @@ -678,14 +496,11 @@ function GitTool.cherry_pick(commit_hash) end end ----Abort cherry-pick operation ----@return boolean success, string output function GitTool.cherry_pick_abort() if not is_git_repo() then return false, "Not in a git repository" end - - local cmd = "git cherry-pick --abort" + local cmd = CommandBuilder.cherry_pick_abort() local output = vim.fn.system(cmd) local exit_code = vim.v.shell_error @@ -699,14 +514,11 @@ function GitTool.cherry_pick_abort() end end ----Continue cherry-pick after resolving conflicts ----@return boolean success, string output function GitTool.cherry_pick_continue() if not is_git_repo() then return false, "Not in a git repository" end - - local cmd = "git cherry-pick --continue" + local cmd = CommandBuilder.cherry_pick_continue() local output = vim.fn.system(cmd) local exit_code = vim.v.shell_error @@ -722,14 +534,11 @@ function GitTool.cherry_pick_continue() end end ----Skip current commit in cherry-pick ----@return boolean success, string output function GitTool.cherry_pick_skip() if not is_git_repo() then return false, "Not in a git repository" end - - local cmd = "git cherry-pick --skip" + local cmd = CommandBuilder.cherry_pick_skip() local output = vim.fn.system(cmd) local exit_code = vim.v.shell_error @@ -743,76 +552,60 @@ function GitTool.cherry_pick_skip() end end ----Revert a commit ----@param commit_hash string The commit hash to revert ----@return boolean success, string output function GitTool.revert(commit_hash) if not commit_hash then return false, "Commit hash is required for revert" end - local cmd = "git revert --no-edit " .. vim.fn.shellescape(commit_hash) - return execute_git_command(cmd) + if not is_git_repo() then + return false, "Not in a git repository" + end + local cmd = CommandBuilder.revert(commit_hash) + return CommandExecutor.run(cmd) end ----Get all tags ----@return boolean success, string output, string user_msg, string llm_msg function GitTool.get_tags() - local success, output = execute_git_command("git tag") + if not is_git_repo() then + return false, + "Not in a git repository", + "✗ Not in a git repository", + "fail: Not in a git repository" + end + local cmd = CommandBuilder.tags() + local success, output = CommandExecutor.run(cmd) local user_msg, llm_msg = format_git_response("tag", success, output) return success, output, user_msg, llm_msg end ----Create a new tag ----@param tag_name string The name of the tag ----@param message? string An optional message for an annotated tag ----@param commit_hash? string An optional commit hash to tag ----@return boolean success, string output function GitTool.create_tag(tag_name, message, commit_hash) if not tag_name then return false, "Tag name is required" end - local cmd = "git tag " - if message then - cmd = cmd .. "-a " .. vim.fn.shellescape(tag_name) .. " -m " .. vim.fn.shellescape(message) - else - cmd = cmd .. vim.fn.shellescape(tag_name) - end - if commit_hash then - cmd = cmd .. " " .. vim.fn.shellescape(commit_hash) + if not is_git_repo() then + return false, "Not in a git repository" end - return execute_git_command(cmd) + local cmd = CommandBuilder.create_tag(tag_name, message, commit_hash) + return CommandExecutor.run(cmd) end ----Delete a tag ----@param tag_name string The name of the tag to delete ----@param remote? string The name of the remote to delete from ----@return boolean success, string output function GitTool.delete_tag(tag_name, remote) if not tag_name then return false, "Tag name is required for deletion" end - local cmd - if remote then - cmd = "git push --delete " .. vim.fn.shellescape(remote) .. " " .. vim.fn.shellescape(tag_name) - else - cmd = "git tag -d " .. vim.fn.shellescape(tag_name) + if not is_git_repo() then + return false, "Not in a git repository" end - return execute_git_command(cmd) + local cmd = CommandBuilder.delete_tag(tag_name, remote) + return CommandExecutor.run(cmd) end ----Merge a branch into the current branch ----@param branch string The name of the branch to merge ----@return boolean success, string output function GitTool.merge(branch) if not branch or vim.trim(branch) == "" then return false, "Branch name is required for merge" end - if not is_git_repo() then return false, "Not in a git repository" end - - local cmd = "git merge " .. vim.fn.shellescape(branch) .. " --no-edit" + local cmd = CommandBuilder.merge(branch) local output = vim.fn.system(cmd) local exit_code = vim.v.shell_error @@ -831,14 +624,11 @@ function GitTool.merge(branch) end end ----Abort merge operation ----@return boolean success, string output function GitTool.merge_abort() if not is_git_repo() then return false, "Not in a git repository" end - - local cmd = "git merge --abort" + local cmd = CommandBuilder.merge_abort() local output = vim.fn.system(cmd) local exit_code = vim.v.shell_error @@ -852,14 +642,11 @@ function GitTool.merge_abort() end end ----Continue merge after resolving conflicts ----@return boolean success, string output function GitTool.merge_continue() if not is_git_repo() then return false, "Not in a git repository" end - - local cmd = "git merge --continue" + local cmd = CommandBuilder.merge_continue() local output = vim.fn.system(cmd) local exit_code = vim.v.shell_error @@ -875,16 +662,13 @@ function GitTool.merge_continue() end end ----Get list of files with merge conflicts ----@return boolean success, string output, string user_msg, string llm_msg function GitTool.get_conflict_status() if not is_git_repo() then local msg = "Not in a git repository" return false, msg, "✗ " .. msg, "fail: " .. msg .. "" end - -- git diff --name-only --diff-filter=U lists unmerged (conflicted) files - local cmd = "git diff --name-only --diff-filter=U" + local cmd = CommandBuilder.conflict_status() local output = vim.fn.system(cmd) local exit_code = vim.v.shell_error @@ -917,9 +701,6 @@ function GitTool.get_conflict_status() return true, trimmed, user_msg, llm_msg end ----Show conflict markers in a specific file ----@param file_path string Path to the file with conflicts ----@return boolean success, string output, string user_msg, string llm_msg function GitTool.show_conflict(file_path) if not is_git_repo() then local msg = "Not in a git repository" @@ -987,20 +768,12 @@ function GitTool.show_conflict(file_path) return true, conflict_output, user_msg, llm_msg end ---- Generate release notes between two tags ----@param from_tag string|nil Starting tag (if not provided, uses second latest tag) ----@param to_tag string|nil Ending tag (if not provided, uses latest tag) ----@param format string|nil Format (markdown, plain, json) ----@return boolean success ----@return string output ----@return string user_msg ----@return string llm_msg function GitTool.generate_release_notes(from_tag, to_tag, format) format = format or "markdown" - -- Get all tags sorted by version - local success, tags_output = pcall(vim.fn.system, "git tag --sort=-version:refname") - if not success or vim.v.shell_error ~= 0 then + local tags_cmd = CommandBuilder.tags_sorted() + local success_tags, tags_output = CommandExecutor.run(tags_cmd) + if not success_tags then local msg = "Failed to get git tags: " .. (tags_output or "unknown error") local user_msg = "✗ " .. msg local llm_msg = "fail: " .. msg .. "" @@ -1021,9 +794,8 @@ function GitTool.generate_release_notes(from_tag, to_tag, format) return false, msg, user_msg, llm_msg end - -- Determine tag range if not to_tag then - to_tag = tags[1] -- Latest tag + to_tag = tags[1] end if not from_tag then @@ -1033,24 +805,13 @@ function GitTool.generate_release_notes(from_tag, to_tag, format) local llm_msg = "fail: " .. msg .. "" return false, msg, user_msg, llm_msg end - from_tag = tags[2] -- Second latest tag + from_tag = tags[2] end - -- Get commit range between tags - -- Use ^.. to get commits AFTER from_tag (excluding from_tag itself) - local range = from_tag .. "^.." .. to_tag - local escaped_range = vim.fn.shellescape(range) - if not escaped_range or escaped_range == "" then - local msg = "Failed to escape tag range: " .. range - local user_msg = "✗ " .. msg - local llm_msg = "fail: " .. msg .. "" - return false, msg, user_msg, llm_msg - end - local format_str = shell_quote("%h\x01%s\x01%an\x01%ad") - local commit_cmd = "git log --pretty=format:" .. format_str .. " --date=short " .. escaped_range - local success_commits, commits_output = pcall(vim.fn.system, commit_cmd) + local commit_cmd = CommandBuilder.release_notes_log(from_tag, to_tag) + local success_commits, commits_output = CommandExecutor.run(commit_cmd) - if not success_commits or vim.v.shell_error ~= 0 then + if not success_commits then local msg = "Failed to get commits between " .. from_tag .. " and " @@ -1062,7 +823,6 @@ function GitTool.generate_release_notes(from_tag, to_tag, format) return false, msg, user_msg, llm_msg end - -- Parse commits local commits = {} for line in commits_output:gmatch("[^\r\n]+") do local parts = vim.split(line, "\x01") @@ -1083,7 +843,6 @@ function GitTool.generate_release_notes(from_tag, to_tag, format) return true, msg, user_msg, llm_msg end - -- Generate release notes based on format local release_notes = "" local user_msg = "" local llm_msg = "" @@ -1092,7 +851,6 @@ function GitTool.generate_release_notes(from_tag, to_tag, format) local parts = { "# Release Notes: " .. from_tag .. " → " .. to_tag .. "\n\n" } table.insert(parts, "## Changes (" .. #commits .. " commits)\n\n") - -- Group commits by type (conventional commits) local features = {} local fixes = {} local others = {} @@ -1112,7 +870,6 @@ function GitTool.generate_release_notes(from_tag, to_tag, format) end end - -- Add features if #features > 0 then table.insert(parts, "### ✨ New Features\n\n") for _, commit in ipairs(features) do @@ -1121,7 +878,6 @@ function GitTool.generate_release_notes(from_tag, to_tag, format) table.insert(parts, "\n") end - -- Add fixes if #fixes > 0 then table.insert(parts, "### 🐛 Bug Fixes\n\n") for _, commit in ipairs(fixes) do @@ -1130,7 +886,6 @@ function GitTool.generate_release_notes(from_tag, to_tag, format) table.insert(parts, "\n") end - -- Add other changes if #others > 0 then table.insert(parts, "### 📝 Other Changes\n\n") for _, commit in ipairs(others) do @@ -1139,7 +894,6 @@ function GitTool.generate_release_notes(from_tag, to_tag, format) table.insert(parts, "\n") end - -- Add contributors local contributors = {} for _, commit in ipairs(commits) do if not contributors[commit.author] then @@ -1180,12 +934,11 @@ function GitTool.generate_release_notes(from_tag, to_tag, format) release_notes = vim.fn.json_encode(json_data) else local msg = "Unsupported format: " .. format .. ". Supported formats: markdown, plain, json" - local user_msg = "✗ " .. msg - local llm_msg = "fail: " .. msg .. "" + user_msg = "✗ " .. msg + llm_msg = "fail: " .. msg .. "" return false, msg, user_msg, llm_msg end - -- Create formatted user message with the release notes user_msg = string.format( "✓ Generated release notes for %s → %s (%d commits)\n\n```%s\n%s\n```", from_tag, @@ -1207,10 +960,6 @@ function GitTool.generate_release_notes(from_tag, to_tag, format) return true, release_notes, user_msg, llm_msg end ----Add a new remote ----@param name string Remote name ----@param url string Remote URL ----@return boolean success, string output function GitTool.add_remote(name, url) if not name or vim.trim(name) == "" then return false, "Remote name is required" @@ -1218,25 +967,24 @@ function GitTool.add_remote(name, url) if not url or vim.trim(url) == "" then return false, "Remote URL is required" end - local cmd = "git remote add " .. vim.fn.shellescape(name) .. " " .. vim.fn.shellescape(url) - return execute_git_command(cmd) + if not is_git_repo() then + return false, "Not in a git repository" + end + local cmd = CommandBuilder.add_remote(name, url) + return CommandExecutor.run(cmd) end ----Remove a remote ----@param name string Remote name ----@return boolean success, string output function GitTool.remove_remote(name) if not name or vim.trim(name) == "" then return false, "Remote name is required" end - local cmd = "git remote remove " .. vim.fn.shellescape(name) - return execute_git_command(cmd) + if not is_git_repo() then + return false, "Not in a git repository" + end + local cmd = CommandBuilder.remove_remote(name) + return CommandExecutor.run(cmd) end ----Rename a remote ----@param old_name string Current remote name ----@param new_name string New remote name ----@return boolean success, string output function GitTool.rename_remote(old_name, new_name) if not old_name or vim.trim(old_name) == "" then return false, "Current remote name is required" @@ -1244,14 +992,13 @@ function GitTool.rename_remote(old_name, new_name) if not new_name or vim.trim(new_name) == "" then return false, "New remote name is required" end - local cmd = "git remote rename " .. vim.fn.shellescape(old_name) .. " " .. vim.fn.shellescape(new_name) - return execute_git_command(cmd) + if not is_git_repo() then + return false, "Not in a git repository" + end + local cmd = CommandBuilder.rename_remote(old_name, new_name) + return CommandExecutor.run(cmd) end ----Set remote URL ----@param name string Remote name ----@param url string New URL ----@return boolean success, string output function GitTool.set_remote_url(name, url) if not name or vim.trim(name) == "" then return false, "Remote name is required" @@ -1259,48 +1006,27 @@ function GitTool.set_remote_url(name, url) if not url or vim.trim(url) == "" then return false, "Remote URL is required" end - local cmd = "git remote set-url " .. vim.fn.shellescape(name) .. " " .. vim.fn.shellescape(url) - return execute_git_command(cmd) + if not is_git_repo() then + return false, "Not in a git repository" + end + local cmd = CommandBuilder.set_remote_url(name, url) + return CommandExecutor.run(cmd) end ----Fetch from remote ----@param remote? string Remote name (default: all remotes) ----@param branch? string Specific branch to fetch ----@param prune? boolean Remove remote-tracking references that no longer exist ----@return boolean success, string output function GitTool.fetch(remote, branch, prune) - local cmd = "git fetch" - if prune then - cmd = cmd .. " --prune" - end - if remote then - cmd = cmd .. " " .. vim.fn.shellescape(remote) - if branch then - cmd = cmd .. " " .. vim.fn.shellescape(branch) - end - else - cmd = cmd .. " --all" + if not is_git_repo() then + return false, "Not in a git repository" end - return execute_git_command(cmd) + local cmd = CommandBuilder.fetch(remote, branch, prune) + return CommandExecutor.run(cmd) end ----Pull from remote ----@param remote? string Remote name (default: origin) ----@param branch? string Branch to pull (default: current branch) ----@param rebase? boolean Use rebase instead of merge ----@return boolean success, string output function GitTool.pull(remote, branch, rebase) - local cmd = "git pull" - if rebase then - cmd = cmd .. " --rebase" - end - if remote then - cmd = cmd .. " " .. vim.fn.shellescape(remote) - if branch then - cmd = cmd .. " " .. vim.fn.shellescape(branch) - end + if not is_git_repo() then + return false, "Not in a git repository" end - return execute_git_command(cmd) + local cmd = CommandBuilder.pull(remote, branch, rebase) + return CommandExecutor.run(cmd) end M.GitTool = GitTool diff --git a/tests/test_ai_release_notes.lua b/tests/test_ai_release_notes.lua new file mode 100644 index 0000000..2df0aba --- /dev/null +++ b/tests/test_ai_release_notes.lua @@ -0,0 +1,170 @@ +local h = require("tests.helpers") +local new_set = MiniTest.new_set + +local child = MiniTest.new_child_neovim() + +local T = new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_once = child.stop, + }, +}) + +T["schema"] = new_set() + +T["schema"]["has correct name"] = function() + local name = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + return AIReleaseNotes.name + ]]) + h.eq("ai_release_notes", name) +end + +T["schema"]["has function type"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + return AIReleaseNotes.schema.type + ]]) + h.eq("function", result) +end + +T["schema"]["has strict mode enabled"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + return AIReleaseNotes.schema["function"].strict + ]]) + h.eq(true, result) +end + +T["schema"]["has correct function name"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + return AIReleaseNotes.schema["function"].name + ]]) + h.eq("ai_release_notes", result) +end + +T["schema"]["has description"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + return AIReleaseNotes.schema["function"].description ~= nil + ]]) + h.eq(true, result) +end + +T["schema"]["parameters has object type"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + return AIReleaseNotes.schema["function"].parameters.type + ]]) + h.eq("object", result) +end + +T["schema"]["has from_tag property"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + local props = AIReleaseNotes.schema["function"].parameters.properties + return props.from_tag ~= nil and props.from_tag.type == "string" + ]]) + h.eq(true, result) +end + +T["schema"]["has to_tag property"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + local props = AIReleaseNotes.schema["function"].parameters.properties + return props.to_tag ~= nil and props.to_tag.type == "string" + ]]) + h.eq(true, result) +end + +T["schema"]["has style property with enum"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + local props = AIReleaseNotes.schema["function"].parameters.properties + return props.style ~= nil and props.style.type == "string" and props.style.enum ~= nil + ]]) + h.eq(true, result) +end + +T["schema"]["style enum has all valid values"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + local enum = AIReleaseNotes.schema["function"].parameters.properties.style.enum + local expected = { "detailed", "concise", "changelog", "marketing" } + if #enum ~= #expected then return false end + for i, v in ipairs(expected) do + if enum[i] ~= v then return false end + end + return true + ]]) + h.eq(true, result) +end + +T["schema"]["disallows additional properties"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + return AIReleaseNotes.schema["function"].parameters.additionalProperties + ]]) + h.eq(false, result) +end + +T["system_prompt"] = new_set() + +T["system_prompt"]["exists and is string"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + return type(AIReleaseNotes.system_prompt) == "string" + ]]) + h.eq(true, result) +end + +T["system_prompt"]["mentions output styles"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + local prompt = AIReleaseNotes.system_prompt + return prompt:find("detailed") ~= nil + and prompt:find("concise") ~= nil + and prompt:find("changelog") ~= nil + and prompt:find("marketing") ~= nil + ]]) + h.eq(true, result) +end + +T["handlers"] = new_set() + +T["handlers"]["has on_exit handler"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + return AIReleaseNotes.handlers ~= nil and AIReleaseNotes.handlers.on_exit ~= nil + ]]) + h.eq(true, result) +end + +T["output"] = new_set() + +T["output"]["has required fields"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + local output = AIReleaseNotes.output + return output ~= nil + and output.success ~= nil + and output.error ~= nil + ]]) + h.eq(true, result) +end + +T["opts"] = new_set() + +T["opts"]["requires approval"] = function() + local result = child.lua([[ + local AIReleaseNotes = require("codecompanion._extensions.gitcommit.tools.ai_release_notes") + local opts = AIReleaseNotes.opts + return opts ~= nil and (opts.require_approval_before ~= nil or opts.requires_approval ~= nil) + ]]) + h.eq(true, result) +end + +return T diff --git a/tests/test_command_builder.lua b/tests/test_command_builder.lua new file mode 100644 index 0000000..b0a5bb4 --- /dev/null +++ b/tests/test_command_builder.lua @@ -0,0 +1,765 @@ +local h = require("tests.helpers") +local new_set = MiniTest.new_set + +local child = MiniTest.new_child_neovim() + +local T = new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_once = child.stop, + }, +}) + +T["status"] = new_set() + +T["status"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.status() + ]]) + h.eq("git status --porcelain", result) +end + +T["log"] = new_set() + +T["log"]["returns default command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.log() + ]]) + h.eq("git log -10 --oneline", result) +end + +T["log"]["respects count parameter"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.log(5) + ]]) + h.eq("git log -5 --oneline", result) +end + +T["log"]["respects format parameter"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.log(10, "short") + ]]) + h.eq("git log -10 --pretty=short", result) +end + +T["log"]["handles all format types"] = function() + local formats = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CB = Command.CommandBuilder + return { + oneline = CB.log(1, "oneline"), + short = CB.log(1, "short"), + medium = CB.log(1, "medium"), + full = CB.log(1, "full"), + fuller = CB.log(1, "fuller"), + } + ]]) + h.eq("git log -1 --oneline", formats.oneline) + h.eq("git log -1 --pretty=short", formats.short) + h.eq("git log -1 --pretty=medium", formats.medium) + h.eq("git log -1 --pretty=full", formats.full) + h.eq("git log -1 --pretty=fuller", formats.fuller) +end + +T["diff"] = new_set() + +T["diff"]["returns basic command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.diff() + ]]) + h.eq("git diff", result) +end + +T["diff"]["adds cached flag for staged"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.diff(true) + ]]) + h.eq("git diff --cached", result) +end + +T["diff"]["adds file path"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.diff(false, "test.lua") + ]]) + h.eq(true, result:find("test.lua") ~= nil) +end + +T["diff"]["adds both staged and file"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.diff(true, "test.lua") + ]]) + h.eq(true, result:find("--cached") ~= nil) + h.eq(true, result:find("test.lua") ~= nil) +end + +T["branch"] = new_set() + +T["branch"]["current_branch returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.current_branch() + ]]) + h.eq("git branch --show-current", result) +end + +T["branch"]["branches returns all branches by default"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.branches() + ]]) + h.eq("git branch -a", result) +end + +T["branch"]["branches returns remote only when specified"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.branches(true) + ]]) + h.eq("git branch -r", result) +end + +T["stage"] = new_set() + +T["stage"]["handles single file as string"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.stage("test.lua") + ]]) + h.eq(true, result:find("git add") ~= nil) + h.eq(true, result:find("test.lua") ~= nil) +end + +T["stage"]["handles multiple files"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.stage({"a.lua", "b.lua"}) + ]]) + h.eq(true, result:find("git add") ~= nil) + h.eq(true, result:find("a.lua") ~= nil) + h.eq(true, result:find("b.lua") ~= nil) +end + +T["unstage"] = new_set() + +T["unstage"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.unstage("test.lua") + ]]) + h.eq(true, result:find("git reset HEAD") ~= nil) + h.eq(true, result:find("test.lua") ~= nil) +end + +T["commit"] = new_set() + +T["commit"]["returns basic commit command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.commit("test message") + ]]) + h.eq(true, result:find("git commit") ~= nil) + h.eq(true, result:find("-m") ~= nil) + h.eq(true, result:find("test message") ~= nil) +end + +T["commit"]["adds amend flag"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.commit("test message", true) + ]]) + h.eq(true, result:find("--amend") ~= nil) +end + +T["create_branch"] = new_set() + +T["create_branch"]["creates and checks out by default"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.create_branch("feature/test") + ]]) + h.eq(true, result:find("git checkout %-b") ~= nil) + h.eq(true, result:find("feature") ~= nil) +end + +T["create_branch"]["creates without checkout when specified"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.create_branch("feature/test", false) + ]]) + h.eq(true, result:find("git branch ") ~= nil) + h.eq(true, result:find("feature/test") ~= nil) +end + +T["checkout"] = new_set() + +T["checkout"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.checkout("main") + ]]) + h.eq(true, result:find("git checkout") ~= nil) + h.eq(true, result:find("main") ~= nil) +end + +T["remotes"] = new_set() + +T["remotes"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.remotes() + ]]) + h.eq("git remote -v", result) +end + +T["show"] = new_set() + +T["show"]["defaults to HEAD"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.show() + ]]) + h.eq(true, result:find("git show") ~= nil) + h.eq(true, result:find("HEAD") ~= nil) +end + +T["show"]["accepts commit hash"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.show("abc123") + ]]) + h.eq(true, result:find("abc123") ~= nil) +end + +T["blame"] = new_set() + +T["blame"]["returns basic command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.blame("test.lua") + ]]) + h.eq(true, result:find("git blame") ~= nil) + h.eq(true, result:find("test.lua") ~= nil) +end + +T["blame"]["adds line range"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.blame("test.lua", 10, 20) + ]]) + h.eq(true, result:find("-L") ~= nil) + h.eq(true, result:find("10,20") ~= nil) +end + +T["blame"]["adds line start with default range"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.blame("test.lua", 10) + ]]) + h.eq(true, result:find("%-L") ~= nil) + h.eq(true, result:find("10,%+10") ~= nil) +end + +T["stash"] = new_set() + +T["stash"]["returns basic command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.stash() + ]]) + h.eq("git stash", result) +end + +T["stash"]["adds untracked flag"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.stash(nil, true) + ]]) + h.eq(true, result:find("-u") ~= nil) +end + +T["stash"]["adds message"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.stash("WIP", false) + ]]) + h.eq(true, result:find("-m") ~= nil) + h.eq(true, result:find("WIP") ~= nil) +end + +T["stash_list"] = new_set() + +T["stash_list"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.stash_list() + ]]) + h.eq("git stash list", result) +end + +T["stash_apply"] = new_set() + +T["stash_apply"]["defaults to stash@{0}"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.stash_apply() + ]]) + h.eq(true, result:find("git stash apply") ~= nil) + h.eq(true, result:find("stash@{0}") ~= nil) +end + +T["stash_apply"]["accepts custom ref"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.stash_apply("stash@{2}") + ]]) + h.eq(true, result:find("stash@{2}") ~= nil) +end + +T["reset"] = new_set() + +T["reset"]["defaults to mixed mode"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.reset("HEAD~1") + ]]) + h.eq(true, result:find("git reset") ~= nil) + h.eq(true, result:find("--mixed") ~= nil) + h.eq(true, result:find("HEAD~1") ~= nil) +end + +T["reset"]["handles all modes"] = function() + local modes = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CB = Command.CommandBuilder + return { + soft = CB.reset("HEAD~1", "soft"), + mixed = CB.reset("HEAD~1", "mixed"), + hard = CB.reset("HEAD~1", "hard"), + } + ]]) + h.eq(true, modes.soft:find("--soft") ~= nil) + h.eq(true, modes.mixed:find("--mixed") ~= nil) + h.eq(true, modes.hard:find("--hard") ~= nil) +end + +T["diff_commits"] = new_set() + +T["diff_commits"]["compares two commits"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.diff_commits("abc123", "def456") + ]]) + h.eq(true, result:find("git diff") ~= nil) + h.eq(true, result:find("abc123") ~= nil) + h.eq(true, result:find("def456") ~= nil) +end + +T["diff_commits"]["defaults second commit to HEAD"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.diff_commits("abc123") + ]]) + h.eq(true, result:find("HEAD") ~= nil) +end + +T["diff_commits"]["adds file path"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.diff_commits("abc123", "def456", "test.lua") + ]]) + h.eq(true, result:find("--") ~= nil) + h.eq(true, result:find("test.lua") ~= nil) +end + +T["push"] = new_set() + +T["push"]["returns basic command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.push() + ]]) + h.eq("git push", result) +end + +T["push"]["adds remote and branch"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.push("origin", "main") + ]]) + h.eq(true, result:find("origin") ~= nil) + h.eq(true, result:find("main") ~= nil) +end + +T["push"]["adds force flag"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.push("origin", "main", true) + ]]) + h.eq(true, result:find("--force") ~= nil) +end + +T["push"]["adds set-upstream flag"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.push("origin", "main", false, true) + ]]) + h.eq(true, result:find("%-%-set%-upstream") ~= nil) +end + +T["push"]["handles tags flag"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.push(nil, nil, false, false, true) + ]]) + h.eq(true, result:find("--tags") ~= nil) + h.eq(true, result:find("origin") ~= nil) +end + +T["push"]["handles single tag"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.push(nil, nil, false, false, false, "v1.0.0") + ]]) + h.eq(true, result:find("v1.0.0") ~= nil) + h.eq(true, result:find("origin") ~= nil) +end + +T["push_array"] = new_set() + +T["push_array"]["returns array format"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local cmd = Command.CommandBuilder.push_array("origin", "main") + return type(cmd) == "table" and cmd[1] == "git" and cmd[2] == "push" + ]]) + h.eq(true, result) +end + +T["cherry_pick"] = new_set() + +T["cherry_pick"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.cherry_pick("abc123") + ]]) + h.eq(true, result:find("git cherry%-pick") ~= nil) + h.eq(true, result:find("%-%-no%-edit") ~= nil) + h.eq(true, result:find("abc123") ~= nil) +end + +T["cherry_pick_abort"] = new_set() + +T["cherry_pick_abort"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.cherry_pick_abort() + ]]) + h.eq("git cherry-pick --abort", result) +end + +T["cherry_pick_continue"] = new_set() + +T["cherry_pick_continue"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.cherry_pick_continue() + ]]) + h.eq("git cherry-pick --continue", result) +end + +T["cherry_pick_skip"] = new_set() + +T["cherry_pick_skip"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.cherry_pick_skip() + ]]) + h.eq("git cherry-pick --skip", result) +end + +T["revert"] = new_set() + +T["revert"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.revert("abc123") + ]]) + h.eq(true, result:find("git revert") ~= nil) + h.eq(true, result:find("%-%-no%-edit") ~= nil) + h.eq(true, result:find("abc123") ~= nil) +end + +T["tags"] = new_set() + +T["tags"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.tags() + ]]) + h.eq("git tag", result) +end + +T["tags_sorted"] = new_set() + +T["tags_sorted"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.tags_sorted() + ]]) + h.eq("git tag --sort=-version:refname", result) +end + +T["create_tag"] = new_set() + +T["create_tag"]["creates lightweight tag"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.create_tag("v1.0.0") + ]]) + h.eq(true, result:find("git tag") ~= nil) + h.eq(true, result:find("v1.0.0") ~= nil) +end + +T["create_tag"]["creates annotated tag"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.create_tag("v1.0.0", "Release 1.0") + ]]) + h.eq(true, result:find("-a") ~= nil) + h.eq(true, result:find("-m") ~= nil) + h.eq(true, result:find("Release 1.0") ~= nil) +end + +T["create_tag"]["tags specific commit"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.create_tag("v1.0.0", nil, "abc123") + ]]) + h.eq(true, result:find("abc123") ~= nil) +end + +T["delete_tag"] = new_set() + +T["delete_tag"]["deletes local tag"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.delete_tag("v1.0.0") + ]]) + h.eq(true, result:find("git tag %-d") ~= nil) + h.eq(true, result:find("v1%.0%.0") ~= nil) +end + +T["delete_tag"]["deletes remote tag"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.delete_tag("v1.0.0", "origin") + ]]) + h.eq(true, result:find("git push %-%-delete") ~= nil) + h.eq(true, result:find("origin") ~= nil) +end + +T["merge"] = new_set() + +T["merge"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.merge("feature/test") + ]]) + h.eq(true, result:find("git merge") ~= nil) + h.eq(true, result:find("%-%-no%-edit") ~= nil) + h.eq(true, result:find("feature") ~= nil) +end + +T["merge_abort"] = new_set() + +T["merge_abort"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.merge_abort() + ]]) + h.eq("git merge --abort", result) +end + +T["merge_continue"] = new_set() + +T["merge_continue"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.merge_continue() + ]]) + h.eq("git merge --continue", result) +end + +T["conflict_status"] = new_set() + +T["conflict_status"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.conflict_status() + ]]) + h.eq("git diff --name-only --diff-filter=U", result) +end + +T["remote_operations"] = new_set() + +T["remote_operations"]["add_remote"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.add_remote("upstream", "https://github.com/test/repo.git") + ]]) + h.eq(true, result:find("git remote add") ~= nil) + h.eq(true, result:find("upstream") ~= nil) +end + +T["remote_operations"]["remove_remote"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.remove_remote("upstream") + ]]) + h.eq(true, result:find("git remote remove") ~= nil) + h.eq(true, result:find("upstream") ~= nil) +end + +T["remote_operations"]["rename_remote"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.rename_remote("origin", "upstream") + ]]) + h.eq(true, result:find("git remote rename") ~= nil) +end + +T["remote_operations"]["set_remote_url"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.set_remote_url("origin", "https://new-url.git") + ]]) + h.eq(true, result:find("git remote set%-url") ~= nil) +end + +T["fetch"] = new_set() + +T["fetch"]["fetches all by default"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.fetch() + ]]) + h.eq(true, result:find("git fetch") ~= nil) + h.eq(true, result:find("--all") ~= nil) +end + +T["fetch"]["fetches specific remote"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.fetch("origin") + ]]) + h.eq(true, result:find("origin") ~= nil) +end + +T["fetch"]["adds prune flag"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.fetch(nil, nil, true) + ]]) + h.eq(true, result:find("--prune") ~= nil) +end + +T["pull"] = new_set() + +T["pull"]["returns basic command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.pull() + ]]) + h.eq("git pull", result) +end + +T["pull"]["adds remote and branch"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.pull("origin", "main") + ]]) + h.eq(true, result:find("origin") ~= nil) + h.eq(true, result:find("main") ~= nil) +end + +T["pull"]["adds rebase flag"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.pull("origin", "main", true) + ]]) + h.eq(true, result:find("--rebase") ~= nil) +end + +T["utility"] = new_set() + +T["utility"]["is_inside_work_tree"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.is_inside_work_tree() + ]]) + h.eq(true, result:find("git rev%-parse %-%-is%-inside%-work%-tree") ~= nil) +end + +T["utility"]["git_dir"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.git_dir() + ]]) + h.eq("git rev-parse --git-dir", result) +end + +T["utility"]["repo_root"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.repo_root() + ]]) + h.eq("git rev-parse --show-toplevel", result) +end + +T["utility"]["check_ignore returns array"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local cmd = Command.CommandBuilder.check_ignore("test.lua") + return type(cmd) == "table" and cmd[1] == "git" and cmd[2] == "check-ignore" and cmd[3] == "test.lua" + ]]) + h.eq(true, result) +end + +T["rebase"] = new_set() + +T["rebase"]["returns basic command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.rebase() + ]]) + h.eq("git rebase", result) +end + +T["rebase"]["adds onto flag"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.rebase("main") + ]]) + h.eq(true, result:find("--onto") ~= nil) + h.eq(true, result:find("main") ~= nil) +end + +T["rebase"]["adds interactive flag"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.rebase(nil, nil, true) + ]]) + h.eq(true, result:find("--interactive") ~= nil) +end + +return T diff --git a/tests/test_generator.lua b/tests/test_generator.lua new file mode 100644 index 0000000..3651ab5 --- /dev/null +++ b/tests/test_generator.lua @@ -0,0 +1,192 @@ +local h = require("tests.helpers") +local new_set = MiniTest.new_set + +local child = MiniTest.new_child_neovim() + +local T = new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_once = child.stop, + }, +}) + +T["clean_commit_message"] = new_set() + +T["clean_commit_message"]["returns trimmed message"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.clean_commit_message(" feat: add feature ") + ]]) + h.eq("feat: add feature", result) +end + +T["clean_commit_message"]["removes markdown code block"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.clean_commit_message("```\nfeat: add feature\n```") + ]]) + h.eq("feat: add feature", result) +end + +T["clean_commit_message"]["removes markdown code block with language identifier"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.clean_commit_message("```text\nfeat: add feature\n```") + ]]) + h.eq("feat: add feature", result) +end + +T["clean_commit_message"]["removes quadruple backtick code blocks"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.clean_commit_message("````\nfeat: add feature\n````") + ]]) + h.eq("feat: add feature", result) +end + +T["clean_commit_message"]["handles multiline commit message"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local input = "```\nfeat: add feature\n\nThis is the body.\n```" + return GitUtils.clean_commit_message(input) + ]]) + h.eq("feat: add feature\n\nThis is the body.", result) +end + +T["clean_commit_message"]["preserves message without code blocks"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.clean_commit_message("feat: simple message") + ]]) + h.eq("feat: simple message", result) +end + +T["clean_commit_message"]["handles empty string"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.clean_commit_message("") + ]]) + h.eq("", result) +end + +T["clean_commit_message"]["handles only whitespace"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.clean_commit_message(" \n ") + ]]) + h.eq("", result) +end + +T["clean_commit_message"]["handles code block with only backticks"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.clean_commit_message("```\n```") + ]]) + h.eq("", result) +end + +T["clean_commit_message"]["does not remove inline backticks"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.clean_commit_message("feat: add `config` option") + ]]) + h.eq("feat: add `config` option", result) +end + +T["build_commit_prompt"] = new_set() + +T["build_commit_prompt"]["returns string"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local prompt = GitUtils.build_commit_prompt("diff content", "English", nil) + return type(prompt) == "string" + ]]) + h.eq(true, result) +end + +T["build_commit_prompt"]["includes diff content"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local prompt = GitUtils.build_commit_prompt("my-unique-diff-content", "English", nil) + return prompt:find("my%-unique%-diff%-content") ~= nil + ]]) + h.eq(true, result) +end + +T["build_commit_prompt"]["includes language"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local prompt = GitUtils.build_commit_prompt("diff", "Japanese", nil) + return prompt:find("Japanese") ~= nil + ]]) + h.eq(true, result) +end + +T["build_commit_prompt"]["includes commit history when provided"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local history = { "feat: first commit", "fix: second commit" } + local prompt = GitUtils.build_commit_prompt("diff", "English", history) + return prompt:find("RECENT COMMIT HISTORY") ~= nil + ]]) + h.eq(true, result) +end + +T["build_commit_prompt"]["includes all history entries"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local history = { "feat: first commit", "fix: second commit" } + local prompt = GitUtils.build_commit_prompt("diff", "English", history) + return prompt:find("feat: first commit") ~= nil and prompt:find("fix: second commit") ~= nil + ]]) + h.eq(true, result) +end + +T["build_commit_prompt"]["excludes history section when nil"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local prompt = GitUtils.build_commit_prompt("diff", "English", nil) + return prompt:find("RECENT COMMIT HISTORY") == nil + ]]) + h.eq(true, result) +end + +T["build_commit_prompt"]["excludes history section when empty"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local prompt = GitUtils.build_commit_prompt("diff", "English", {}) + return prompt:find("RECENT COMMIT HISTORY") == nil + ]]) + h.eq(true, result) +end + +T["build_commit_prompt"]["mentions conventional commit types"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local prompt = GitUtils.build_commit_prompt("diff", "English", nil) + return prompt:find("feat") ~= nil and prompt:find("fix") ~= nil and prompt:find("refactor") ~= nil + ]]) + h.eq(true, result) +end + +T["build_commit_prompt"]["includes formatting rules"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local prompt = GitUtils.build_commit_prompt("diff", "English", nil) + return prompt:find("50 char") ~= nil or prompt:find("72 char") ~= nil + ]]) + h.eq(true, result) +end + +T["build_commit_prompt"]["defaults to English when lang is nil"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local prompt = GitUtils.build_commit_prompt("diff", nil, nil) + return prompt:find("English") ~= nil + ]]) + h.eq(true, result) +end + +return T diff --git a/tests/test_git_utils.lua b/tests/test_git_utils.lua new file mode 100644 index 0000000..e35a3b4 --- /dev/null +++ b/tests/test_git_utils.lua @@ -0,0 +1,504 @@ +local h = require("tests.helpers") +local new_set = MiniTest.new_set + +local child = MiniTest.new_child_neovim() + +local T = new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_once = child.stop, + }, +}) + +T["trim"] = new_set() + +T["trim"]["removes leading whitespace"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.trim(" hello") + ]]) + h.eq("hello", result) +end + +T["trim"]["removes trailing whitespace"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.trim("hello ") + ]]) + h.eq("hello", result) +end + +T["trim"]["removes both leading and trailing whitespace"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.trim(" hello world ") + ]]) + h.eq("hello world", result) +end + +T["trim"]["handles empty string"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.trim("") + ]]) + h.eq("", result) +end + +T["glob_to_lua_pattern"] = new_set() + +T["glob_to_lua_pattern"]["converts simple extension pattern"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local pattern = GitUtils.glob_to_lua_pattern("*.lua") + return ("test.lua"):match(pattern) ~= nil + ]]) + h.eq(true, result) +end + +T["glob_to_lua_pattern"]["does not match wrong extension"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local pattern = GitUtils.glob_to_lua_pattern("*.lua") + return ("test.js"):match(pattern) ~= nil + ]]) + h.eq(false, result) +end + +T["glob_to_lua_pattern"]["handles double star pattern"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local pattern = GitUtils.glob_to_lua_pattern("**/*.js") + return ("src/components/Button.js"):match(pattern) ~= nil + ]]) + h.eq(true, result) +end + +T["glob_to_lua_pattern"]["handles directory pattern"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local pattern = GitUtils.glob_to_lua_pattern("dist/*") + return ("dist/bundle.js"):match(pattern) ~= nil + ]]) + h.eq(true, result) +end + +T["glob_to_lua_pattern"]["escapes special regex characters"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local pattern = GitUtils.glob_to_lua_pattern("*.min.js") + return ("app.min.js"):match(pattern) ~= nil + ]]) + h.eq(true, result) +end + +T["matches_glob"] = new_set() + +T["matches_glob"]["matches simple file extension"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.matches_glob("test.lua", "*.lua") + ]]) + h.eq(true, result) +end + +T["matches_glob"]["matches file in subdirectory with basename pattern"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.matches_glob("src/test.lua", "*.lua") + ]]) + h.eq(true, result) +end + +T["matches_glob"]["matches deep nested path with double star"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.matches_glob("src/components/ui/Button.tsx", "**/*.tsx") + ]]) + h.eq(true, result) +end + +T["matches_glob"]["matches directory prefix"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.matches_glob("node_modules/lodash/index.js", "node_modules/*") + ]]) + h.eq(true, result) +end + +T["matches_glob"]["does not match unrelated path"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.matches_glob("src/main.lua", "*.js") + ]]) + h.eq(false, result) +end + +T["matches_glob"]["handles package-lock.json specifically"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.matches_glob("package-lock.json", "package-lock.json") + ]]) + h.eq(true, result) +end + +T["matches_glob"]["handles windows path separators"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.matches_glob("src\\test.lua", "*.lua") + ]]) + h.eq(true, result) +end + +T["should_exclude_file"] = new_set() + +T["should_exclude_file"]["returns false when no patterns"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.should_exclude_file("test.lua", nil) + ]]) + h.eq(false, result) +end + +T["should_exclude_file"]["returns false when empty patterns"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.should_exclude_file("test.lua", {}) + ]]) + h.eq(false, result) +end + +T["should_exclude_file"]["excludes matching file"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.should_exclude_file("package-lock.json", {"package-lock.json", "yarn.lock"}) + ]]) + h.eq(true, result) +end + +T["should_exclude_file"]["excludes file matching extension pattern"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.should_exclude_file("app.min.js", {"*.min.js", "*.min.css"}) + ]]) + h.eq(true, result) +end + +T["should_exclude_file"]["excludes file in excluded directory"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.should_exclude_file("node_modules/lodash/index.js", {"node_modules/*"}) + ]]) + h.eq(true, result) +end + +T["should_exclude_file"]["does not exclude unmatched file"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.should_exclude_file("src/main.lua", {"*.js", "*.min.css", "node_modules/*"}) + ]]) + h.eq(false, result) +end + +T["should_exclude_file"]["normalizes windows paths"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.should_exclude_file("src\\app.min.js", {"*.min.js"}) + ]]) + h.eq(true, result) +end + +T["filter_diff"] = new_set() + +T["filter_diff"]["returns original when no patterns"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local diff = "diff --git a/test.lua b/test.lua\n+hello" + return GitUtils.filter_diff(diff, nil) == diff + ]]) + h.eq(true, result) +end + +T["filter_diff"]["returns original when empty patterns"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local diff = "diff --git a/test.lua b/test.lua\n+hello" + return GitUtils.filter_diff(diff, {}) == diff + ]]) + h.eq(true, result) +end + +T["filter_diff"]["filters out excluded file"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local diff = "diff --git a/test.lua b/test.lua\n+hello\ndiff --git a/package-lock.json b/package-lock.json\n+lots of deps" + local filtered = GitUtils.filter_diff(diff, {"package-lock.json"}) + return filtered:find("package%-lock") == nil and filtered:find("test%.lua") ~= nil + ]]) + h.eq(true, result) +end + +T["filter_diff"]["keeps non-excluded files"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local diff = "diff --git a/src/main.lua b/src/main.lua\n+code\ndiff --git a/src/utils.lua b/src/utils.lua\n+more code" + local filtered = GitUtils.filter_diff(diff, {"*.js"}) + return filtered:find("main%.lua") ~= nil and filtered:find("utils%.lua") ~= nil + ]]) + h.eq(true, result) +end + +T["filter_diff"]["returns original when all files excluded"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local diff = "diff --git a/package-lock.json b/package-lock.json\n+deps" + local filtered = GitUtils.filter_diff(diff, {"package-lock.json"}) + return filtered == diff + ]]) + h.eq(true, result) +end + +T["parse_conventional_commit"] = new_set() + +T["parse_conventional_commit"]["parses feat type"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local valid, type_match = GitUtils.parse_conventional_commit("feat(api): add new endpoint") + return valid and type_match == "feat" + ]]) + h.eq(true, result) +end + +T["parse_conventional_commit"]["parses fix type"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local valid, type_match = GitUtils.parse_conventional_commit("fix: resolve bug") + return valid and type_match == "fix" + ]]) + h.eq(true, result) +end + +T["parse_conventional_commit"]["parses type with scope"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local valid, type_match = GitUtils.parse_conventional_commit("chore(deps): update dependencies") + return valid and type_match == "chore" + ]]) + h.eq(true, result) +end + +T["parse_conventional_commit"]["returns false for non-conventional"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local valid, type_match = GitUtils.parse_conventional_commit("Update readme file") + return not valid and type_match == nil + ]]) + h.eq(true, result) +end + +T["group_commits_by_type"] = new_set() + +T["group_commits_by_type"]["groups features correctly"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local commits = { + { subject = "feat(api): add endpoint" }, + { subject = "fix: bug fix" }, + { subject = "feat: another feature" }, + } + local groups = GitUtils.group_commits_by_type(commits) + return #groups.features == 2 + ]]) + h.eq(true, result) +end + +T["group_commits_by_type"]["groups fixes correctly"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local commits = { + { subject = "fix(ui): button color" }, + { subject = "fix: another fix" }, + { subject = "feat: feature" }, + } + local groups = GitUtils.group_commits_by_type(commits) + return #groups.fixes == 2 + ]]) + h.eq(true, result) +end + +T["group_commits_by_type"]["groups others correctly"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local commits = { + { subject = "chore: cleanup" }, + { subject = "docs: update readme" }, + { subject = "Update something" }, + } + local groups = GitUtils.group_commits_by_type(commits) + return #groups.others == 3 and #groups.features == 0 and #groups.fixes == 0 + ]]) + h.eq(true, result) +end + +T["extract_diff_files"] = new_set() + +T["extract_diff_files"]["extracts single file"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local diff = "diff --git a/test.lua b/test.lua\n+hello" + local files = GitUtils.extract_diff_files(diff) + return #files == 1 and files[1] == "test.lua" + ]]) + h.eq(true, result) +end + +T["extract_diff_files"]["extracts multiple files"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local diff = "diff --git a/src/main.lua b/src/main.lua\n+code\ndiff --git a/src/utils.lua b/src/utils.lua\n+more" + local files = GitUtils.extract_diff_files(diff) + return #files == 2 + ]]) + h.eq(true, result) +end + +T["extract_diff_files"]["deduplicates files"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local diff = "diff --git a/test.lua b/test.lua\n+line1\ndiff --git a/test.lua b/test.lua\n+line2" + local files = GitUtils.extract_diff_files(diff) + return #files == 1 + ]]) + h.eq(true, result) +end + +T["extract_diff_files"]["returns empty for no diff headers"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local diff = "+hello\n-world" + local files = GitUtils.extract_diff_files(diff) + return #files == 0 + ]]) + h.eq(true, result) +end + +T["shell_quote_unix"] = new_set() + +T["shell_quote_unix"]["quotes simple string"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.shell_quote_unix("hello") + ]]) + h.eq("'hello'", result) +end + +T["shell_quote_unix"]["escapes single quotes"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.shell_quote_unix("it's") + ]]) + h.eq("'it'\\''s'", result) +end + +T["shell_quote_unix"]["handles multiple single quotes"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.shell_quote_unix("'quoted'") + ]]) + h.eq("''\\''quoted'\\'''", result) +end + +T["shell_quote_unix"]["handles empty string"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.shell_quote_unix("") + ]]) + h.eq("''", result) +end + +T["shell_quote_unix"]["preserves double quotes"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.shell_quote_unix('say "hello"') + ]]) + h.eq("'say \"hello\"'", result) +end + +T["shell_quote_unix"]["handles spaces"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.shell_quote_unix("hello world") + ]]) + h.eq("'hello world'", result) +end + +T["shell_quote_unix"]["handles special characters"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.shell_quote_unix("$PATH && rm -rf /") + ]]) + h.eq("'$PATH && rm -rf /'", result) +end + +T["shell_quote_windows"] = new_set() + +T["shell_quote_windows"]["quotes simple string"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.shell_quote_windows("hello") + ]]) + h.eq('"hello"', result) +end + +T["shell_quote_windows"]["escapes double quotes"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.shell_quote_windows('say "hello"') + ]]) + h.eq('"say \\"hello\\""', result) +end + +T["shell_quote_windows"]["handles multiple double quotes"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.shell_quote_windows('"quoted"') + ]]) + h.eq('"\\"quoted\\""', result) +end + +T["shell_quote_windows"]["handles empty string"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.shell_quote_windows("") + ]]) + h.eq('""', result) +end + +T["shell_quote_windows"]["preserves single quotes"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.shell_quote_windows("it's") + ]]) + h.eq('"it\'s"', result) +end + +T["shell_quote_windows"]["handles spaces"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.shell_quote_windows("hello world") + ]]) + h.eq('"hello world"', result) +end + +T["is_windows"] = new_set() + +T["is_windows"]["returns boolean"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return type(GitUtils.is_windows()) == "boolean" + ]]) + h.eq(true, result) +end + +return T diff --git a/tests/test_langs.lua b/tests/test_langs.lua new file mode 100644 index 0000000..94f1c69 --- /dev/null +++ b/tests/test_langs.lua @@ -0,0 +1,116 @@ +local h = require("tests.helpers") +local new_set = MiniTest.new_set + +local child = MiniTest.new_child_neovim() + +local T = new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_once = child.stop, + }, +}) + +T["setup"] = new_set() + +T["setup"]["accepts valid array of languages"] = function() + local result = child.lua([[ + local Langs = require("codecompanion._extensions.gitcommit.langs") + Langs.setup({ "English", "Chinese", "Japanese" }) + return true + ]]) + h.eq(true, result) +end + +T["setup"]["accepts empty array"] = function() + local result = child.lua([[ + local Langs = require("codecompanion._extensions.gitcommit.langs") + Langs.setup({}) + return true + ]]) + h.eq(true, result) +end + +T["setup"]["accepts single language"] = function() + local result = child.lua([[ + local Langs = require("codecompanion._extensions.gitcommit.langs") + Langs.setup({ "English" }) + return true + ]]) + h.eq(true, result) +end + +T["setup"]["throws error for non-table input"] = function() + local result = child.lua([[ + local Langs = require("codecompanion._extensions.gitcommit.langs") + local ok, err = pcall(function() + Langs.setup("not a table") + end) + return not ok and err:find("must be a array") ~= nil + ]]) + h.eq(true, result) +end + +T["setup"]["throws error for number input"] = function() + local result = child.lua([[ + local Langs = require("codecompanion._extensions.gitcommit.langs") + local ok, err = pcall(function() + Langs.setup(123) + end) + return not ok and err:find("must be a array") ~= nil + ]]) + h.eq(true, result) +end + +T["setup"]["accepts nil and defaults to empty"] = function() + local result = child.lua([[ + local Langs = require("codecompanion._extensions.gitcommit.langs") + Langs.setup(nil) + return true + ]]) + h.eq(true, result) +end + +T["select_lang"] = new_set() + +T["select_lang"]["returns nil when no languages configured"] = function() + local result = child.lua([[ + local Langs = require("codecompanion._extensions.gitcommit.langs") + Langs.setup({}) + local selected = nil + Langs.select_lang(function(choice) + selected = choice + end) + return selected + ]]) + h.eq(vim.NIL, result) +end + +T["select_lang"]["returns single language directly without prompt"] = function() + local result = child.lua([[ + local Langs = require("codecompanion._extensions.gitcommit.langs") + Langs.setup({ "English" }) + local selected = nil + Langs.select_lang(function(choice) + selected = choice + end) + return selected + ]]) + h.eq("English", result) +end + +T["select_lang"]["returns nil when setup with nil"] = function() + local result = child.lua([[ + local Langs = require("codecompanion._extensions.gitcommit.langs") + Langs.setup(nil) + local selected = nil + Langs.select_lang(function(choice) + selected = choice + end) + return selected + ]]) + h.eq(vim.NIL, result) +end + +return T From c547a8e09dbfdca47690255c1e17663493a5f7d5 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Mon, 22 Dec 2025 00:52:50 +0800 Subject: [PATCH 11/17] test(git,ui): add unit tests for Git and UI modules --- .../_extensions/gitcommit/git_utils.lua | 14 +- tests/test_git.lua | 336 ++++++++++++++++++ tests/test_git_utils.lua | 4 +- tests/test_ui.lua | 158 ++++++++ 4 files changed, 506 insertions(+), 6 deletions(-) create mode 100644 tests/test_git.lua create mode 100644 tests/test_ui.lua diff --git a/lua/codecompanion/_extensions/gitcommit/git_utils.lua b/lua/codecompanion/_extensions/gitcommit/git_utils.lua index f88006c..f17050e 100644 --- a/lua/codecompanion/_extensions/gitcommit/git_utils.lua +++ b/lua/codecompanion/_extensions/gitcommit/git_utils.lua @@ -66,6 +66,16 @@ function M.matches_glob(filepath, pattern) end end + -- Handle **/ at start of pattern - should match any depth including root + if pattern:match("^%*%*/") then + local suffix_pattern = pattern:gsub("^%*%*/", "") + local suffix_lua_pattern = M.glob_to_lua_pattern(suffix_pattern) + -- Try matching from root (no directory prefix) + if filepath:match(suffix_lua_pattern) then + return true + end + end + return false end @@ -139,10 +149,6 @@ function M.filter_diff(diff_content, exclude_patterns) end end - if #all_files > 0 and #excluded_files >= #all_files then - return diff_content - end - return table.concat(filtered_lines, "\n") end diff --git a/tests/test_git.lua b/tests/test_git.lua new file mode 100644 index 0000000..89394e5 --- /dev/null +++ b/tests/test_git.lua @@ -0,0 +1,336 @@ +local h = require("tests.helpers") +local new_set = MiniTest.new_set + +local child = MiniTest.new_child_neovim() + +local T = new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_once = child.stop, + }, +}) + +-- ============================================================================= +-- Git.setup and Git.get_config +-- ============================================================================= + +T["setup"] = new_set() + +T["setup"]["sets default config when no opts provided"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup() + local config = Git.get_config() + return { + has_exclude_files = type(config.exclude_files) == "table", + use_commit_history = config.use_commit_history, + commit_history_count = config.commit_history_count, + } + ]]) + h.eq(true, result.has_exclude_files) + h.eq(true, result.use_commit_history) + h.eq(10, result.commit_history_count) +end + +T["setup"]["merges custom options"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ + exclude_files = { "*.log", "*.tmp" }, + commit_history_count = 20, + }) + local config = Git.get_config() + return { + exclude_files = config.exclude_files, + commit_history_count = config.commit_history_count, + } + ]]) + h.eq({ "*.log", "*.tmp" }, result.exclude_files) + h.eq(20, result.commit_history_count) +end + +T["setup"]["preserves defaults for unspecified options"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ commit_history_count = 5 }) + local config = Git.get_config() + return config.use_commit_history + ]]) + h.eq(true, result) +end + +T["get_config"] = new_set() + +T["get_config"]["returns deep copy"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = { "*.log" } }) + local config1 = Git.get_config() + config1.exclude_files[1] = "modified" + local config2 = Git.get_config() + return config2.exclude_files[1] + ]]) + h.eq("*.log", result) +end + +-- ============================================================================= +-- Git._should_exclude_file +-- ============================================================================= + +T["_should_exclude_file"] = new_set() + +T["_should_exclude_file"]["returns false when no exclusions configured"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = {} }) + return Git._should_exclude_file("test.lua") + ]]) + h.eq(false, result) +end + +T["_should_exclude_file"]["matches simple extension pattern"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = { "*.log" } }) + return Git._should_exclude_file("debug.log") + ]]) + h.eq(true, result) +end + +T["_should_exclude_file"]["does not match non-matching extension"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = { "*.log" } }) + return Git._should_exclude_file("main.lua") + ]]) + h.eq(false, result) +end + +T["_should_exclude_file"]["matches directory pattern"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = { "node_modules/*" } }) + return Git._should_exclude_file("node_modules/package/index.js") + ]]) + h.eq(true, result) +end + +T["_should_exclude_file"]["matches exact filename"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = { "package-lock.json" } }) + return Git._should_exclude_file("package-lock.json") + ]]) + h.eq(true, result) +end + +T["_should_exclude_file"]["matches multiple patterns"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = { "*.log", "*.tmp", "dist/*" } }) + return { + log = Git._should_exclude_file("error.log"), + tmp = Git._should_exclude_file("cache.tmp"), + dist = Git._should_exclude_file("dist/bundle.js"), + lua = Git._should_exclude_file("main.lua"), + } + ]]) + h.eq(true, result.log) + h.eq(true, result.tmp) + h.eq(true, result.dist) + h.eq(false, result.lua) +end + +T["_should_exclude_file"]["matches double star pattern"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = { "**/*.min.js" } }) + return { + root = Git._should_exclude_file("app.min.js"), + nested = Git._should_exclude_file("src/lib/utils.min.js"), + non_min = Git._should_exclude_file("src/app.js"), + } + ]]) + h.eq(true, result.root) + h.eq(true, result.nested) + h.eq(false, result.non_min) +end + +-- ============================================================================= +-- Git._filter_diff +-- ============================================================================= + +T["_filter_diff"] = new_set() + +T["_filter_diff"]["returns original diff when no exclusions"] = function() + local result = child.lua([=[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = {} }) + local diff = [[ +diff --git a/main.lua b/main.lua +index 123..456 789 +--- a/main.lua ++++ b/main.lua +@@ -1,3 +1,4 @@ ++local M = {} + return M +]] + return Git._filter_diff(diff) == diff + ]=]) + h.eq(true, result) +end + +T["_filter_diff"]["filters out excluded files"] = function() + local result = child.lua([=[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = { "*.log" } }) + local diff = [[ +diff --git a/main.lua b/main.lua +--- a/main.lua ++++ b/main.lua +@@ -1 +1 @@ +-old ++new +diff --git a/debug.log b/debug.log +--- a/debug.log ++++ b/debug.log +@@ -1 +1 @@ +-log1 ++log2 +]] + local filtered = Git._filter_diff(diff) + return { + has_main = filtered:find("main.lua") ~= nil, + has_log = filtered:find("debug.log") ~= nil, + } + ]=]) + h.eq(true, result.has_main) + h.eq(false, result.has_log) +end + +T["_filter_diff"]["handles empty diff"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = { "*.log" } }) + return Git._filter_diff("") + ]]) + h.eq("", result) +end + +T["_filter_diff"]["filters multiple excluded patterns"] = function() + local result = child.lua([=[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = { "*.log", "*.min.js", "package-lock.json" } }) + local diff = [[ +diff --git a/src/app.lua b/src/app.lua +--- a/src/app.lua ++++ b/src/app.lua +@@ -1 +1 @@ +-a ++b +diff --git a/error.log b/error.log +--- a/error.log ++++ b/error.log +@@ -1 +1 @@ +-x ++y +diff --git a/dist/bundle.min.js b/dist/bundle.min.js +--- a/dist/bundle.min.js ++++ b/dist/bundle.min.js +@@ -1 +1 @@ +-old ++new +diff --git a/package-lock.json b/package-lock.json +--- a/package-lock.json ++++ b/package-lock.json +@@ -1 +1 @@ +-{} ++{"a":1} +]] + local filtered = Git._filter_diff(diff) + return { + has_app = filtered:find("app.lua") ~= nil, + has_log = filtered:find("error.log") ~= nil, + has_min = filtered:find("bundle.min.js") ~= nil, + has_lock = filtered:find("package%-lock.json") ~= nil, + } + ]=]) + h.eq(true, result.has_app) + h.eq(false, result.has_log) + h.eq(false, result.has_min) + h.eq(false, result.has_lock) +end + +T["_filter_diff"]["preserves diff format"] = function() + local result = child.lua([=[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = { "*.log" } }) + local diff = [[ +diff --git a/main.lua b/main.lua +index abc123..def456 100644 +--- a/main.lua ++++ b/main.lua +@@ -1,5 +1,6 @@ + local M = {} ++M.version = "1.0" + return M +]] + local filtered = Git._filter_diff(diff) + return { + has_diff_header = filtered:find("diff %-%-git") ~= nil, + has_index = filtered:find("index") ~= nil, + has_hunk = filtered:find("@@") ~= nil, + has_addition = filtered:find("%+M.version") ~= nil, + } + ]=]) + h.eq(true, result.has_diff_header) + h.eq(true, result.has_index) + h.eq(true, result.has_hunk) + h.eq(true, result.has_addition) +end + +T["_filter_diff"]["handles path with spaces"] = function() + local result = child.lua([=[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = {} }) + local diff = [[ +diff --git a/path with spaces/file.lua b/path with spaces/file.lua +--- a/path with spaces/file.lua ++++ b/path with spaces/file.lua +@@ -1 +1 @@ +-old ++new +]] + local filtered = Git._filter_diff(diff) + return filtered:find("path with spaces") ~= nil + ]=]) + h.eq(true, result) +end + +T["_filter_diff"]["returns empty when all files excluded"] = function() + local result = child.lua([=[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.setup({ exclude_files = { "*.log", "*.tmp" } }) + local diff = [[ +diff --git a/error.log b/error.log +--- a/error.log ++++ b/error.log +@@ -1 +1 @@ +-a ++b +diff --git a/cache.tmp b/cache.tmp +--- a/cache.tmp ++++ b/cache.tmp +@@ -1 +1 @@ +-x ++y +]] + local filtered = Git._filter_diff(diff) + return vim.trim(filtered) + ]=]) + h.eq("", result) +end + +return T diff --git a/tests/test_git_utils.lua b/tests/test_git_utils.lua index e35a3b4..65ccb7e 100644 --- a/tests/test_git_utils.lua +++ b/tests/test_git_utils.lua @@ -249,12 +249,12 @@ T["filter_diff"]["keeps non-excluded files"] = function() h.eq(true, result) end -T["filter_diff"]["returns original when all files excluded"] = function() +T["filter_diff"]["returns empty when all files excluded"] = function() local result = child.lua([[ local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") local diff = "diff --git a/package-lock.json b/package-lock.json\n+deps" local filtered = GitUtils.filter_diff(diff, {"package-lock.json"}) - return filtered == diff + return vim.trim(filtered) == "" ]]) h.eq(true, result) end diff --git a/tests/test_ui.lua b/tests/test_ui.lua new file mode 100644 index 0000000..206021d --- /dev/null +++ b/tests/test_ui.lua @@ -0,0 +1,158 @@ +local h = require("tests.helpers") +local new_set = MiniTest.new_set + +local child = MiniTest.new_child_neovim() + +local T = new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_once = child.stop, + }, +}) + +T["_prepare_content"] = new_set() + +T["_prepare_content"]["returns table with header"] = function() + local result = child.lua([[ + local UI = require("codecompanion._extensions.gitcommit.ui") + local content = UI._prepare_content("test message") + return content[1] + ]]) + h.eq("# Generated Commit Message", result) +end + +T["_prepare_content"]["includes commit message in code block"] = function() + local result = child.lua([[ + local UI = require("codecompanion._extensions.gitcommit.ui") + local content = UI._prepare_content("feat: add new feature") + -- content[3] is "```", content[4] is the message, content[5] is "```" + return content[4] + ]]) + h.eq("feat: add new feature", result) +end + +T["_prepare_content"]["handles multi-line commit message"] = function() + local result = child.lua([[ + local UI = require("codecompanion._extensions.gitcommit.ui") + local content = UI._prepare_content("feat: add feature\n\nThis is description") + -- Find the message lines between code blocks + local lines = {} + local in_block = false + for i, line in ipairs(content) do + if line == "```" then + if in_block then break end + in_block = true + elseif in_block then + table.insert(lines, line) + end + end + return lines + ]]) + h.eq({ "feat: add feature", "", "This is description" }, result) +end + +T["_prepare_content"]["includes action instructions"] = function() + local result = child.lua([[ + local UI = require("codecompanion._extensions.gitcommit.ui") + local content = UI._prepare_content("test") + local has_copy = false + local has_submit = false + local has_close = false + for _, line in ipairs(content) do + if line:match("%[c%].*Copy to clipboard") then has_copy = true end + if line:match("%[s%].*Submit") then has_submit = true end + if line:match("%[q/Esc%].*Close") then has_close = true end + end + return { has_copy = has_copy, has_submit = has_submit, has_close = has_close } + ]]) + h.eq(true, result.has_copy) + h.eq(true, result.has_submit) + h.eq(true, result.has_close) +end + +T["_prepare_content"]["returns correct structure"] = function() + local result = child.lua([[ + local UI = require("codecompanion._extensions.gitcommit.ui") + local content = UI._prepare_content("msg") + return { + is_table = type(content) == "table", + has_elements = #content > 0, + first_is_header = content[1] == "# Generated Commit Message", + } + ]]) + h.eq(true, result.is_table) + h.eq(true, result.has_elements) + h.eq(true, result.first_is_header) +end + +T["_calculate_dimensions"] = new_set() + +T["_calculate_dimensions"]["returns width and height"] = function() + local result = child.lua([[ + local UI = require("codecompanion._extensions.gitcommit.ui") + local content = { "line 1", "line 2", "line 3" } + local width, height = UI._calculate_dimensions(content) + return { width = width, height = height } + ]]) + h.eq("number", type(result.width)) + h.eq("number", type(result.height)) +end + +T["_calculate_dimensions"]["minimum width is 50"] = function() + local result = child.lua([[ + local UI = require("codecompanion._extensions.gitcommit.ui") + local content = { "a", "b" } + local width, _ = UI._calculate_dimensions(content) + return width + ]]) + h.eq(true, result >= 50) +end + +T["_calculate_dimensions"]["width increases with long lines"] = function() + local result = child.lua([[ + local UI = require("codecompanion._extensions.gitcommit.ui") + local short_content = { "short" } + local long_content = { string.rep("x", 80) } + local short_width, _ = UI._calculate_dimensions(short_content) + local long_width, _ = UI._calculate_dimensions(long_content) + return long_width > short_width + ]]) + h.eq(true, result) +end + +T["_calculate_dimensions"]["maximum width is 120"] = function() + local result = child.lua([[ + local UI = require("codecompanion._extensions.gitcommit.ui") + local content = { string.rep("x", 200) } + local width, _ = UI._calculate_dimensions(content) + return width + ]]) + h.eq(true, result <= 120) +end + +T["_calculate_dimensions"]["height based on content lines"] = function() + local result = child.lua([[ + local UI = require("codecompanion._extensions.gitcommit.ui") + local small = { "a" } + local large = { "a", "b", "c", "d", "e", "f", "g", "h", "i", "j" } + local _, h1 = UI._calculate_dimensions(small) + local _, h2 = UI._calculate_dimensions(large) + return h2 > h1 + ]]) + h.eq(true, result) +end + +T["_calculate_dimensions"]["height includes padding"] = function() + local result = child.lua([[ + local UI = require("codecompanion._extensions.gitcommit.ui") + local content = { "line1", "line2", "line3" } + local _, height = UI._calculate_dimensions(content) + -- height should be at least content lines + 4 (padding) + return height >= #content + ]]) + h.eq(true, result) +end + +return T From 88cb9cb13455728b78d6c386a5cb089d262c36a5 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Mon, 22 Dec 2025 01:06:08 +0800 Subject: [PATCH 12/17] test(release-notes): add unit tests for prompts/release_notes.lua --- tests/test_release_notes.lua | 546 +++++++++++++++++++++++++++++++++++ 1 file changed, 546 insertions(+) create mode 100644 tests/test_release_notes.lua diff --git a/tests/test_release_notes.lua b/tests/test_release_notes.lua new file mode 100644 index 0000000..b9eebeb --- /dev/null +++ b/tests/test_release_notes.lua @@ -0,0 +1,546 @@ +local h = require("tests.helpers") +local new_set = MiniTest.new_set + +local child = MiniTest.new_child_neovim() + +local T = new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_once = child.stop, + }, +}) + +-- ============================================================================= +-- style_guides +-- ============================================================================= + +T["style_guides"] = new_set() + +T["style_guides"]["has detailed style"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + return ReleaseNotes.style_guides.detailed ~= nil + ]]) + h.eq(true, result) +end + +T["style_guides"]["has concise style"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + return ReleaseNotes.style_guides.concise ~= nil + ]]) + h.eq(true, result) +end + +T["style_guides"]["has changelog style"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + return ReleaseNotes.style_guides.changelog ~= nil + ]]) + h.eq(true, result) +end + +T["style_guides"]["has marketing style"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + return ReleaseNotes.style_guides.marketing ~= nil + ]]) + h.eq(true, result) +end + +T["style_guides"]["detailed style mentions technical"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + return ReleaseNotes.style_guides.detailed:find("technical") ~= nil + ]]) + h.eq(true, result) +end + +T["style_guides"]["concise style mentions brief"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + return ReleaseNotes.style_guides.concise:find("brief") ~= nil + ]]) + h.eq(true, result) +end + +-- ============================================================================= +-- base_instructions +-- ============================================================================= + +T["base_instructions"] = new_set() + +T["base_instructions"]["exists and is not empty"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + return ReleaseNotes.base_instructions ~= nil and #ReleaseNotes.base_instructions > 0 + ]]) + h.eq(true, result) +end + +T["base_instructions"]["contains critical rules"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + return ReleaseNotes.base_instructions:find("CRITICAL") ~= nil + ]]) + h.eq(true, result) +end + +-- ============================================================================= +-- analyze_commits +-- ============================================================================= + +T["analyze_commits"] = new_set() + +T["analyze_commits"]["returns table with expected categories"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local analysis = ReleaseNotes.analyze_commits({}) + return { + has_features = analysis.features ~= nil, + has_fixes = analysis.fixes ~= nil, + has_breaking = analysis.breaking_changes ~= nil, + has_performance = analysis.performance ~= nil, + has_documentation = analysis.documentation ~= nil, + has_refactoring = analysis.refactoring ~= nil, + has_tests = analysis.tests ~= nil, + has_chore = analysis.chore ~= nil, + has_other = analysis.other ~= nil, + has_contributors = analysis.contributors ~= nil, + } + ]]) + h.eq(true, result.has_features) + h.eq(true, result.has_fixes) + h.eq(true, result.has_breaking) + h.eq(true, result.has_performance) + h.eq(true, result.has_documentation) + h.eq(true, result.has_refactoring) + h.eq(true, result.has_tests) + h.eq(true, result.has_chore) + h.eq(true, result.has_other) + h.eq(true, result.has_contributors) +end + +T["analyze_commits"]["categorizes feat commits"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: add new feature", type = "feat", author = "user1" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return #analysis.features + ]]) + h.eq(1, result) +end + +T["analyze_commits"]["categorizes fix commits"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "fix: resolve bug", type = "fix", author = "user1" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return #analysis.fixes + ]]) + h.eq(1, result) +end + +T["analyze_commits"]["categorizes breaking changes with exclamation mark"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat!: breaking change", type = "feat", author = "user1" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return #analysis.breaking_changes + ]]) + h.eq(1, result) +end + +T["analyze_commits"]["categorizes breaking changes with scope and exclamation"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat(api)!: breaking api change", type = "feat", author = "user1" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return #analysis.breaking_changes + ]]) + h.eq(1, result) +end + +T["analyze_commits"]["categorizes breaking changes with BREAKING keyword"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: BREAKING CHANGE in api", type = "feat", author = "user1" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return #analysis.breaking_changes + ]]) + h.eq(1, result) +end + +T["analyze_commits"]["categorizes perf commits"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "perf: improve speed", type = "perf", author = "user1" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return #analysis.performance + ]]) + h.eq(1, result) +end + +T["analyze_commits"]["categorizes docs commits"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "docs: update readme", type = "docs", author = "user1" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return #analysis.documentation + ]]) + h.eq(1, result) +end + +T["analyze_commits"]["categorizes refactor commits"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "refactor: clean up code", type = "refactor", author = "user1" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return #analysis.refactoring + ]]) + h.eq(1, result) +end + +T["analyze_commits"]["categorizes test commits"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "test: add unit tests", type = "test", author = "user1" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return #analysis.tests + ]]) + h.eq(1, result) +end + +T["analyze_commits"]["categorizes chore commits"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "chore: update deps", type = "chore", author = "user1" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return #analysis.chore + ]]) + h.eq(1, result) +end + +T["analyze_commits"]["categorizes build commits as chore"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "build: update config", type = "build", author = "user1" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return #analysis.chore + ]]) + h.eq(1, result) +end + +T["analyze_commits"]["categorizes ci commits as chore"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "ci: add workflow", type = "ci", author = "user1" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return #analysis.chore + ]]) + h.eq(1, result) +end + +T["analyze_commits"]["categorizes unknown type as other"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "misc: random change", type = "misc", author = "user1" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return #analysis.other + ]]) + h.eq(1, result) +end + +T["analyze_commits"]["tracks contributors"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: feature 1", type = "feat", author = "alice" }, + { subject = "fix: fix 1", type = "fix", author = "bob" }, + { subject = "feat: feature 2", type = "feat", author = "alice" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return { + alice_count = analysis.contributors["alice"], + bob_count = analysis.contributors["bob"], + } + ]]) + h.eq(2, result.alice_count) + h.eq(1, result.bob_count) +end + +T["analyze_commits"]["handles multiple commit types"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: new feature", type = "feat", author = "user1" }, + { subject = "fix: bug fix", type = "fix", author = "user2" }, + { subject = "docs: update docs", type = "docs", author = "user1" }, + { subject = "test: add tests", type = "test", author = "user3" }, + } + local analysis = ReleaseNotes.analyze_commits(commits) + return { + features = #analysis.features, + fixes = #analysis.fixes, + docs = #analysis.documentation, + tests = #analysis.tests, + } + ]]) + h.eq(1, result.features) + h.eq(1, result.fixes) + h.eq(1, result.docs) + h.eq(1, result.tests) +end + +-- ============================================================================= +-- create_smart_prompt +-- ============================================================================= + +T["create_smart_prompt"] = new_set() + +T["create_smart_prompt"]["returns string"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: new feature", type = "feat", author = "user1" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "detailed", { from = "v1.0", to = "v1.1" }) + return type(prompt) + ]]) + h.eq("string", result) +end + +T["create_smart_prompt"]["includes version info"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: new feature", type = "feat", author = "user1" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "detailed", { from = "v1.0.0", to = "v2.0.0" }) + return { + has_from = prompt:find("v1.0.0") ~= nil, + has_to = prompt:find("v2.0.0") ~= nil, + } + ]]) + h.eq(true, result.has_from) + h.eq(true, result.has_to) +end + +T["create_smart_prompt"]["includes commit count"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: feature 1", type = "feat", author = "user1" }, + { subject = "feat: feature 2", type = "feat", author = "user2" }, + { subject = "fix: fix 1", type = "fix", author = "user1" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "detailed", { from = "v1.0", to = "v1.1" }) + return prompt:find("Total commits: 3") ~= nil + ]]) + h.eq(true, result) +end + +T["create_smart_prompt"]["includes style guide for detailed"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: new feature", type = "feat", author = "user1" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "detailed", { from = "v1.0", to = "v1.1" }) + return prompt:find("comprehensive") ~= nil + ]]) + h.eq(true, result) +end + +T["create_smart_prompt"]["includes style guide for concise"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: new feature", type = "feat", author = "user1" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "concise", { from = "v1.0", to = "v1.1" }) + return prompt:find("brief") ~= nil + ]]) + h.eq(true, result) +end + +T["create_smart_prompt"]["includes style guide for changelog"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: new feature", type = "feat", author = "user1" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "changelog", { from = "v1.0", to = "v1.1" }) + return prompt:find("CHANGELOG") ~= nil + ]]) + h.eq(true, result) +end + +T["create_smart_prompt"]["includes style guide for marketing"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: new feature", type = "feat", author = "user1" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "marketing", { from = "v1.0", to = "v1.1" }) + return prompt:find("user%-friendly") ~= nil + ]]) + h.eq(true, result) +end + +T["create_smart_prompt"]["falls back to detailed for unknown style"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: new feature", type = "feat", author = "user1" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "unknown_style", { from = "v1.0", to = "v1.1" }) + return prompt:find("comprehensive") ~= nil + ]]) + h.eq(true, result) +end + +T["create_smart_prompt"]["includes contributors"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: feature 1", type = "feat", author = "alice" }, + { subject = "fix: fix 1", type = "fix", author = "bob" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "detailed", { from = "v1.0", to = "v1.1" }) + return { + has_alice = prompt:find("alice") ~= nil, + has_bob = prompt:find("bob") ~= nil, + } + ]]) + h.eq(true, result.has_alice) + h.eq(true, result.has_bob) +end + +T["create_smart_prompt"]["includes Features section for feat commits"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: new feature", type = "feat", author = "user1" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "detailed", { from = "v1.0", to = "v1.1" }) + return prompt:find("### Features") ~= nil + ]]) + h.eq(true, result) +end + +T["create_smart_prompt"]["includes Bug Fixes section for fix commits"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "fix: bug fix", type = "fix", author = "user1" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "detailed", { from = "v1.0", to = "v1.1" }) + return prompt:find("### Bug Fixes") ~= nil + ]]) + h.eq(true, result) +end + +T["create_smart_prompt"]["includes Breaking Changes section"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat!: breaking change", type = "feat", author = "user1" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "detailed", { from = "v1.0", to = "v1.1" }) + return prompt:find("Breaking Changes") ~= nil + ]]) + h.eq(true, result) +end + +T["create_smart_prompt"]["includes commit hash for detailed style"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: new feature", type = "feat", author = "user1", hash = "abc1234567890" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "detailed", { from = "v1.0", to = "v1.1" }) + return prompt:find("abc1234") ~= nil + ]]) + h.eq(true, result) +end + +T["create_smart_prompt"]["includes commit hash for changelog style"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: new feature", type = "feat", author = "user1", hash = "def5678901234" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "changelog", { from = "v1.0", to = "v1.1" }) + return prompt:find("def5678") ~= nil + ]]) + h.eq(true, result) +end + +T["create_smart_prompt"]["excludes commit hash for concise style"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: new feature", type = "feat", author = "user1", hash = "xyz9876543210" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "concise", { from = "v1.0", to = "v1.1" }) + return prompt:find("xyz9876") == nil + ]]) + h.eq(true, result) +end + +T["create_smart_prompt"]["handles empty commits"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = {} + local prompt = ReleaseNotes.create_smart_prompt(commits, "detailed", { from = "v1.0", to = "v1.1" }) + return { + is_string = type(prompt) == "string", + has_no_categorized = prompt:find("No categorized commits") ~= nil, + } + ]]) + h.eq(true, result.is_string) + h.eq(true, result.has_no_categorized) +end + +T["create_smart_prompt"]["includes markdown code block instruction"] = function() + local result = child.lua([[ + local ReleaseNotes = require("codecompanion._extensions.gitcommit.prompts.release_notes") + local commits = { + { subject = "feat: new feature", type = "feat", author = "user1" }, + } + local prompt = ReleaseNotes.create_smart_prompt(commits, "detailed", { from = "v1.0", to = "v1.1" }) + return prompt:find("```markdown") ~= nil + ]]) + h.eq(true, result) +end + +return T From 804ecc8e85ddd767430fb814e9389137bf6fc504 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Mon, 22 Dec 2025 01:18:59 +0800 Subject: [PATCH 13/17] feat(config): add configuration validation with helpful error messages --- README.md | 51 ++ README_zh.md | 51 ++ .../gitcommit/config_validation.lua | 307 ++++++++ .../_extensions/gitcommit/init.lua | 6 + tests/helpers.lua | 6 +- tests/test_config.lua | 660 ++++++++++++++++++ 6 files changed, 1078 insertions(+), 3 deletions(-) create mode 100644 lua/codecompanion/_extensions/gitcommit/config_validation.lua create mode 100644 tests/test_config.lua diff --git a/README.md b/README.md index bd8310c..f8f6e3e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A Neovim plugin extension for CodeCompanion that generates AI-powered Git commit - 📚 **Commit History Context** - Use recent commit history to maintain consistent styling and patterns - 🔌 **Programmatic API** - Full API for external integrations and custom workflows - ⚡ **Async Operations** - Non-blocking Git operations with proper error handling +- 🛡️ **Config Validation** - Automatic validation of configuration options with helpful error messages ## 📦 Installation @@ -278,6 +279,56 @@ The AI will analyze your commits to: - **Repository validation** ensures operations in valid Git repositories - **Comprehensive error handling** with helpful error messages +## 🛡️ Configuration Validation + +The extension automatically validates your configuration options at startup and provides helpful feedback: + +### Validation Types + +| Type | Behavior | +|------|----------| +| **Type errors** | Warns when a config option has the wrong type (e.g., string instead of boolean) | +| **Unknown options** | Warns about typos or unsupported configuration options | +| **Nested validation** | Validates nested options like `buffer.enabled`, `buffer.keymap`, etc. | + +### Example Output + +If you have configuration errors, you'll see helpful messages: + +``` +[codecompanion-gitcommit] config.adapter: expected string (optional), got number +[codecompanion-gitcommit] config.languges: unknown configuration option +[codecompanion-gitcommit] config.buffer.enabled: expected boolean (optional), got string +``` + +### Validation Behavior + +- **Non-blocking**: Invalid config produces warnings but doesn't prevent the extension from loading +- **Helpful messages**: Each message includes the field path and expected type +- **Typo detection**: Unknown fields are flagged as warnings to help catch typos + +### Programmatic Validation + +You can also validate configuration programmatically: + +```lua +local ConfigValidation = require("codecompanion._extensions.gitcommit.config_validation") + +-- Validate custom options +local result = ConfigValidation.validate({ + adapter = "openai", + buffer = { enabled = true }, +}) + +if result.valid then + print("Config is valid!") +else + for _, issue in ipairs(result.issues) do + print(issue.field .. ": " .. issue.message) + end +end +``` + ## 📄 License MIT License diff --git a/README_zh.md b/README_zh.md index 93c4d9a..e532258 100644 --- a/README_zh.md +++ b/README_zh.md @@ -16,6 +16,7 @@ - 📚 **提交历史上下文** - 使用最近的提交历史来保持一致的风格和模式 - 🔌 **编程 API** - 为外部集成和自定义工作流提供完整的 API - ⚡ **异步操作** - 非阻塞的 Git 操作,具有适当的错误处理 +- 🛡️ **配置验证** - 自动验证配置选项,并提供有用的错误信息 ## 📦 安装 @@ -278,6 +279,56 @@ AI 将分析你的提交以: - **仓库验证**确保操作在有效的 Git 仓库中进行 - **全面的错误处理**提供有用的错误信息 +## 🛡️ 配置验证 + +扩展会在启动时自动验证配置选项,并提供有用的反馈: + +### 验证类型 + +| 类型 | 行为 | +|------|----------| +| **类型错误** | 当配置选项类型错误时警告(例如,应为布尔值却传了字符串) | +| **未知选项** | 警告拼写错误或不支持的配置选项 | +| **嵌套验证** | 验证嵌套选项如 `buffer.enabled`、`buffer.keymap` 等 | + +### 输出示例 + +如果配置有错误,你会看到有用的提示信息: + +``` +[codecompanion-gitcommit] config.adapter: expected string (optional), got number +[codecompanion-gitcommit] config.languges: unknown configuration option +[codecompanion-gitcommit] config.buffer.enabled: expected boolean (optional), got string +``` + +### 验证行为 + +- **非阻塞**:无效配置会产生警告,但不会阻止扩展加载 +- **有用的信息**:每条信息包含字段路径和预期类型 +- **拼写检测**:未知字段会被标记为警告,帮助发现拼写错误 + +### 编程式验证 + +你也可以通过编程方式验证配置: + +```lua +local ConfigValidation = require("codecompanion._extensions.gitcommit.config_validation") + +-- 验证自定义选项 +local result = ConfigValidation.validate({ + adapter = "openai", + buffer = { enabled = true }, +}) + +if result.valid then + print("配置有效!") +else + for _, issue in ipairs(result.issues) do + print(issue.field .. ": " .. issue.message) + end +end +``` + ## 📄 许可证 MIT 许可证 \ No newline at end of file diff --git a/lua/codecompanion/_extensions/gitcommit/config_validation.lua b/lua/codecompanion/_extensions/gitcommit/config_validation.lua new file mode 100644 index 0000000..4fe8256 --- /dev/null +++ b/lua/codecompanion/_extensions/gitcommit/config_validation.lua @@ -0,0 +1,307 @@ +---@class CodeCompanion.GitCommit.ConfigValidation +local M = {} + +local fmt = string.format + +---@class ConfigValidationIssue +---@field field string The field path (e.g., "buffer.enabled") +---@field message string The error message +---@field severity "error"|"warning" Issue severity + +---@class ConfigValidationResult +---@field valid boolean Whether config is valid +---@field issues ConfigValidationIssue[] List of validation issues + +---@alias ConfigTypeSpec string|string[]|fun(value: any): boolean, string? + +---Config field type specifications +---Format: { field_name = type_spec } where type_spec can be: +--- - "string", "boolean", "number", "table", "function" +--- - { "string", "nil" } for optional string +--- - { type = "array", items = "string" } for string array +--- - { type = "table", fields = { ... } } for nested table +--- - function(value) -> boolean, error_msg for custom validation +---@type table +M.schema = { + adapter = { "string", "nil" }, + model = { "string", "nil" }, + languages = { type = "array", items = "string" }, + exclude_files = { type = "array", items = "string" }, + buffer = { + type = "table", + fields = { + enabled = { "boolean", "nil" }, + keymap = { "string", "nil" }, + auto_generate = { "boolean", "nil" }, + auto_generate_delay = { "number", "nil" }, + skip_auto_generate_on_amend = { "boolean", "nil" }, + }, + }, + add_slash_command = { "boolean", "nil" }, + add_git_tool = { "boolean", "nil" }, + enable_git_read = { "boolean", "nil" }, + enable_git_edit = { "boolean", "nil" }, + enable_git_bot = { "boolean", "nil" }, + add_git_commands = { "boolean", "nil" }, + git_tool_auto_submit_errors = { "boolean", "nil" }, + git_tool_auto_submit_success = { "boolean", "nil" }, + gitcommit_select_count = { "number", "nil" }, + use_commit_history = { "boolean", "nil" }, + commit_history_count = { "number", "nil" }, +} + +---Check if value matches a simple type +---@param value any +---@param expected_type string +---@return boolean +local function is_type(value, expected_type) + if expected_type == "nil" then + return value == nil + end + return type(value) == expected_type +end + +---Check if value matches any of the allowed types +---@param value any +---@param allowed_types string[] +---@return boolean +local function matches_types(value, allowed_types) + for _, t in ipairs(allowed_types) do + if is_type(value, t) then + return true + end + end + return false +end + +---Format type list for error messages +---@param types string[] +---@return string +local function format_types(types) + if #types == 1 then + return types[1] + end + local filtered = {} + for _, t in ipairs(types) do + if t ~= "nil" then + table.insert(filtered, t) + end + end + if #filtered == 1 then + return filtered[1] .. " (optional)" + end + return table.concat(filtered, " or ") .. " (optional)" +end + +---Validate a single field +---@param value any The value to validate +---@param spec ConfigTypeSpec|table The type specification +---@param field_path string The field path for error messages +---@param issues ConfigValidationIssue[] Issues collector +local function validate_field(value, spec, field_path, issues) + -- Handle nil values + if value == nil then + return -- nil is handled by type checking below + end + + -- Handle simple type string + if type(spec) == "string" then + if not is_type(value, spec) then + table.insert(issues, { + field = field_path, + message = fmt("expected %s, got %s", spec, type(value)), + severity = "error", + }) + end + return + end + + -- Handle array of allowed types (e.g., { "string", "nil" }) + if type(spec) == "table" and spec[1] ~= nil then + if not matches_types(value, spec) then + table.insert(issues, { + field = field_path, + message = fmt("expected %s, got %s", format_types(spec), type(value)), + severity = "error", + }) + end + return + end + + -- Handle complex type specifications + if type(spec) == "table" and spec.type then + if spec.type == "array" then + -- Validate array type + if type(value) ~= "table" then + table.insert(issues, { + field = field_path, + message = fmt("expected array, got %s", type(value)), + severity = "error", + }) + return + end + -- Validate array items + if spec.items then + for i, item in ipairs(value) do + if not is_type(item, spec.items) then + table.insert(issues, { + field = fmt("%s[%d]", field_path, i), + message = fmt("expected %s, got %s", spec.items, type(item)), + severity = "error", + }) + end + end + end + return + end + + if spec.type == "table" then + -- Validate nested table + if type(value) ~= "table" then + table.insert(issues, { + field = field_path, + message = fmt("expected table, got %s", type(value)), + severity = "error", + }) + return + end + -- Validate nested fields + if spec.fields then + for nested_field, nested_spec in pairs(spec.fields) do + validate_field(value[nested_field], nested_spec, field_path .. "." .. nested_field, issues) + end + -- Warn about unknown fields in nested table + for key in pairs(value) do + if not spec.fields[key] then + table.insert(issues, { + field = field_path .. "." .. key, + message = "unknown configuration option", + severity = "warning", + }) + end + end + end + return + end + end + + -- Handle custom validation function + if type(spec) == "function" then + local valid, err = spec(value) + if not valid then + table.insert(issues, { + field = field_path, + message = err or "invalid value", + severity = "error", + }) + end + return + end +end + +---Validate configuration options +---@param opts table User-provided configuration options +---@param schema? table Schema to validate against (defaults to M.schema) +---@return ConfigValidationResult +function M.validate(opts, schema) + schema = schema or M.schema + local issues = {} + + if type(opts) ~= "table" then + return { + valid = false, + issues = { + { + field = "opts", + message = fmt("expected table, got %s", type(opts)), + severity = "error", + }, + }, + } + end + + -- Validate known fields + for field, spec in pairs(schema) do + validate_field(opts[field], spec, field, issues) + end + + -- Warn about unknown top-level fields + for key in pairs(opts) do + if not schema[key] then + table.insert(issues, { + field = key, + message = "unknown configuration option", + severity = "warning", + }) + end + end + + -- Determine overall validity (only errors make it invalid, warnings are ok) + local valid = true + for _, issue in ipairs(issues) do + if issue.severity == "error" then + valid = false + break + end + end + + return { + valid = valid, + issues = issues, + } +end + +---Report validation issues to user via vim.notify +---@param result ConfigValidationResult +---@param prefix? string Prefix for messages (default: "codecompanion-gitcommit") +function M.report(result, prefix) + prefix = prefix or "codecompanion-gitcommit" + + if #result.issues == 0 then + return + end + + for _, issue in ipairs(result.issues) do + local level = issue.severity == "error" and vim.log.levels.ERROR or vim.log.levels.WARN + local msg = fmt("[%s] config.%s: %s", prefix, issue.field, issue.message) + vim.notify(msg, level) + end +end + +---Validate and report issues (convenience function) +---@param opts table User-provided configuration options +---@param prefix? string Prefix for messages +---@return boolean valid Whether config is valid +function M.validate_and_report(opts, prefix) + local result = M.validate(opts) + M.report(result, prefix) + return result.valid +end + +---Get only errors from validation result +---@param result ConfigValidationResult +---@return ConfigValidationIssue[] +function M.get_errors(result) + local errors = {} + for _, issue in ipairs(result.issues) do + if issue.severity == "error" then + table.insert(errors, issue) + end + end + return errors +end + +---Get only warnings from validation result +---@param result ConfigValidationResult +---@return ConfigValidationIssue[] +function M.get_warnings(result) + local warnings = {} + for _, issue in ipairs(result.issues) do + if issue.severity == "warning" then + table.insert(warnings, issue) + end + end + return warnings +end + +return M diff --git a/lua/codecompanion/_extensions/gitcommit/init.lua b/lua/codecompanion/_extensions/gitcommit/init.lua index c73b736..dff81f6 100644 --- a/lua/codecompanion/_extensions/gitcommit/init.lua +++ b/lua/codecompanion/_extensions/gitcommit/init.lua @@ -6,6 +6,7 @@ local Langs = require("codecompanion._extensions.gitcommit.langs") local GitRead = require("codecompanion._extensions.gitcommit.tools.git_read") local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") local Config = require("codecompanion._extensions.gitcommit.config") +local ConfigValidation = require("codecompanion._extensions.gitcommit.config_validation") local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool local M = {} @@ -336,6 +337,11 @@ return { --- @param opts CodeCompanion.GitCommit.ExtensionOpts setup = function(opts) + -- Validate user config before merging with defaults + if opts then + ConfigValidation.validate_and_report(opts, "codecompanion-gitcommit") + end + opts = vim.tbl_deep_extend("force", Config.default_opts, opts or {}) Git.setup({ diff --git a/tests/helpers.lua b/tests/helpers.lua index a54f512..14fc25b 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -3,9 +3,9 @@ local H = {} H.eq = MiniTest.expect.equality H.not_eq = MiniTest.expect.no_equality -H.expect_match = MiniTest.new_expectation("string matching", function(str, pattern) - return str:find(pattern) ~= nil -end, function(str, pattern) +H.expect_match = MiniTest.new_expectation("string matching", function(pattern, str) + return str:find(pattern, 1, true) ~= nil -- plain text match +end, function(pattern, str) return string.format("Pattern: %s\nObserved string: %s", vim.inspect(pattern), str) end) diff --git a/tests/test_config.lua b/tests/test_config.lua new file mode 100644 index 0000000..a4bcc03 --- /dev/null +++ b/tests/test_config.lua @@ -0,0 +1,660 @@ +local h = require("tests.helpers") +local new_set = MiniTest.new_set + +local child = MiniTest.new_child_neovim() + +local T = new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_once = child.stop, + }, +}) + +-- ============================================================================ +-- validate() - Basic validation +-- ============================================================================ + +T["validate"] = new_set() + +T["validate"]["returns valid for empty opts (all optional)"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({}) + return { valid = result.valid, issue_count = #result.issues } + ]]) + h.eq(true, result.valid) + h.eq(0, result.issue_count) +end + +T["validate"]["returns valid for correct types"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ + adapter = "openai", + model = "gpt-4", + languages = { "English", "Chinese" }, + exclude_files = { "*.log" }, + add_slash_command = true, + gitcommit_select_count = 100, + use_commit_history = true, + commit_history_count = 10, + }) + return { valid = result.valid, issue_count = #result.issues } + ]]) + h.eq(true, result.valid) + h.eq(0, result.issue_count) +end + +T["validate"]["returns error for non-table opts"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate("not a table") + return { valid = result.valid, field = result.issues[1].field, message = result.issues[1].message } + ]]) + h.eq(false, result.valid) + h.eq("opts", result.field) + h.expect_match("expected table", result.message) +end + +T["validate"]["returns warning for unknown fields"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ unknown_option = true }) + return { + valid = result.valid, + field = result.issues[1].field, + severity = result.issues[1].severity, + message = result.issues[1].message, + } + ]]) + -- Unknown fields produce warnings, not errors, so still valid + h.eq(true, result.valid) + h.eq("unknown_option", result.field) + h.eq("warning", result.severity) + h.expect_match("unknown configuration option", result.message) +end + +-- ============================================================================ +-- validate() - String type validation +-- ============================================================================ + +T["validate"]["string types"] = new_set() + +T["validate"]["string types"]["accepts nil for optional string"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ adapter = nil }) + return result.valid + ]]) + h.eq(true, result) +end + +T["validate"]["string types"]["accepts valid string"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ adapter = "openai" }) + return result.valid + ]]) + h.eq(true, result) +end + +T["validate"]["string types"]["rejects number for string field"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ adapter = 123 }) + return { + valid = result.valid, + field = result.issues[1].field, + message = result.issues[1].message, + } + ]]) + h.eq(false, result.valid) + h.eq("adapter", result.field) + h.expect_match("expected string", result.message) + h.expect_match("got number", result.message) +end + +T["validate"]["string types"]["rejects boolean for string field"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ model = true }) + return { + valid = result.valid, + field = result.issues[1].field, + message = result.issues[1].message, + } + ]]) + h.eq(false, result.valid) + h.eq("model", result.field) + h.expect_match("expected string", result.message) +end + +-- ============================================================================ +-- validate() - Boolean type validation +-- ============================================================================ + +T["validate"]["boolean types"] = new_set() + +T["validate"]["boolean types"]["accepts true"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ add_slash_command = true }) + return result.valid + ]]) + h.eq(true, result) +end + +T["validate"]["boolean types"]["accepts false"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ add_slash_command = false }) + return result.valid + ]]) + h.eq(true, result) +end + +T["validate"]["boolean types"]["accepts nil for optional boolean"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ enable_git_read = nil }) + return result.valid + ]]) + h.eq(true, result) +end + +T["validate"]["boolean types"]["rejects string for boolean field"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ add_git_tool = "yes" }) + return { + valid = result.valid, + field = result.issues[1].field, + message = result.issues[1].message, + } + ]]) + h.eq(false, result.valid) + h.eq("add_git_tool", result.field) + h.expect_match("expected boolean", result.message) +end + +T["validate"]["boolean types"]["rejects number for boolean field"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ enable_git_edit = 1 }) + return { + valid = result.valid, + field = result.issues[1].field, + } + ]]) + h.eq(false, result.valid) + h.eq("enable_git_edit", result.field) +end + +-- ============================================================================ +-- validate() - Number type validation +-- ============================================================================ + +T["validate"]["number types"] = new_set() + +T["validate"]["number types"]["accepts valid number"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ gitcommit_select_count = 50 }) + return result.valid + ]]) + h.eq(true, result) +end + +T["validate"]["number types"]["accepts zero"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ commit_history_count = 0 }) + return result.valid + ]]) + h.eq(true, result) +end + +T["validate"]["number types"]["accepts nil for optional number"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ gitcommit_select_count = nil }) + return result.valid + ]]) + h.eq(true, result) +end + +T["validate"]["number types"]["rejects string for number field"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ gitcommit_select_count = "100" }) + return { + valid = result.valid, + field = result.issues[1].field, + message = result.issues[1].message, + } + ]]) + h.eq(false, result.valid) + h.eq("gitcommit_select_count", result.field) + h.expect_match("expected number", result.message) +end + +-- ============================================================================ +-- validate() - Array type validation +-- ============================================================================ + +T["validate"]["array types"] = new_set() + +T["validate"]["array types"]["accepts valid string array"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ languages = { "English", "Chinese", "Japanese" } }) + return result.valid + ]]) + h.eq(true, result) +end + +T["validate"]["array types"]["accepts empty array"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ exclude_files = {} }) + return result.valid + ]]) + h.eq(true, result) +end + +T["validate"]["array types"]["rejects string for array field"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ languages = "English" }) + return { + valid = result.valid, + field = result.issues[1].field, + message = result.issues[1].message, + } + ]]) + h.eq(false, result.valid) + h.eq("languages", result.field) + h.expect_match("expected array", result.message) +end + +T["validate"]["array types"]["rejects number for array field"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ exclude_files = 123 }) + return { + valid = result.valid, + field = result.issues[1].field, + } + ]]) + h.eq(false, result.valid) + h.eq("exclude_files", result.field) +end + +T["validate"]["array types"]["rejects wrong item type in array"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ languages = { "English", 123, "Chinese" } }) + return { + valid = result.valid, + field = result.issues[1].field, + message = result.issues[1].message, + } + ]]) + h.eq(false, result.valid) + h.eq("languages[2]", result.field) + h.expect_match("expected string", result.message) + h.expect_match("got number", result.message) +end + +T["validate"]["array types"]["reports all invalid items"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ languages = { 1, 2, 3 } }) + return { valid = result.valid, issue_count = #result.issues } + ]]) + h.eq(false, result.valid) + h.eq(3, result.issue_count) +end + +-- ============================================================================ +-- validate() - Nested table (buffer config) validation +-- ============================================================================ + +T["validate"]["nested table"] = new_set() + +T["validate"]["nested table"]["accepts valid buffer config"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ + buffer = { + enabled = true, + keymap = "gc", + auto_generate = false, + auto_generate_delay = 300, + skip_auto_generate_on_amend = true, + } + }) + return { valid = result.valid, issue_count = #result.issues } + ]]) + h.eq(true, result.valid) + h.eq(0, result.issue_count) +end + +T["validate"]["nested table"]["accepts empty buffer config"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ buffer = {} }) + return result.valid + ]]) + h.eq(true, result) +end + +T["validate"]["nested table"]["accepts partial buffer config"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ + buffer = { enabled = true } + }) + return result.valid + ]]) + h.eq(true, result) +end + +T["validate"]["nested table"]["rejects non-table buffer"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ buffer = "enabled" }) + return { + valid = result.valid, + field = result.issues[1].field, + message = result.issues[1].message, + } + ]]) + h.eq(false, result.valid) + h.eq("buffer", result.field) + h.expect_match("expected table", result.message) +end + +T["validate"]["nested table"]["validates nested field types"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ + buffer = { enabled = "yes" } + }) + return { + valid = result.valid, + field = result.issues[1].field, + message = result.issues[1].message, + } + ]]) + h.eq(false, result.valid) + h.eq("buffer.enabled", result.field) + h.expect_match("expected boolean", result.message) +end + +T["validate"]["nested table"]["validates keymap type"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ + buffer = { keymap = 123 } + }) + return { + valid = result.valid, + field = result.issues[1].field, + } + ]]) + h.eq(false, result.valid) + h.eq("buffer.keymap", result.field) +end + +T["validate"]["nested table"]["validates auto_generate_delay type"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ + buffer = { auto_generate_delay = "200" } + }) + return { + valid = result.valid, + field = result.issues[1].field, + } + ]]) + h.eq(false, result.valid) + h.eq("buffer.auto_generate_delay", result.field) +end + +T["validate"]["nested table"]["warns about unknown nested fields"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ + buffer = { enabled = true, unknown_nested = "value" } + }) + return { + valid = result.valid, + field = result.issues[1].field, + severity = result.issues[1].severity, + } + ]]) + h.eq(true, result.valid) -- warnings don't invalidate + h.eq("buffer.unknown_nested", result.field) + h.eq("warning", result.severity) +end + +T["validate"]["nested table"]["reports multiple nested errors"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ + buffer = { + enabled = "yes", + keymap = 123, + auto_generate = "true", + } + }) + return { valid = result.valid, issue_count = #result.issues } + ]]) + h.eq(false, result.valid) + h.eq(3, result.issue_count) +end + +-- ============================================================================ +-- validate() - Multiple errors +-- ============================================================================ + +T["validate"]["multiple errors"] = new_set() + +T["validate"]["multiple errors"]["collects all errors"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ + adapter = 123, + model = true, + languages = "English", + add_slash_command = "yes", + }) + return { valid = result.valid, issue_count = #result.issues } + ]]) + h.eq(false, result.valid) + h.eq(4, result.issue_count) +end + +T["validate"]["multiple errors"]["mixes errors and warnings"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ + adapter = 123, -- error + unknown_field = true, -- warning + }) + local errors = 0 + local warnings = 0 + for _, issue in ipairs(result.issues) do + if issue.severity == "error" then errors = errors + 1 + else warnings = warnings + 1 end + end + return { valid = result.valid, errors = errors, warnings = warnings } + ]]) + h.eq(false, result.valid) + h.eq(1, result.errors) + h.eq(1, result.warnings) +end + +-- ============================================================================ +-- get_errors() and get_warnings() +-- ============================================================================ + +T["get_errors"] = new_set() + +T["get_errors"]["returns only errors"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local validation_result = cv.validate({ + adapter = 123, -- error + unknown_field = true, -- warning + }) + local errors = cv.get_errors(validation_result) + return { count = #errors, field = errors[1].field } + ]]) + h.eq(1, result.count) + h.eq("adapter", result.field) +end + +T["get_errors"]["returns empty for valid config"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local validation_result = cv.validate({ adapter = "openai" }) + local errors = cv.get_errors(validation_result) + return #errors + ]]) + h.eq(0, result) +end + +T["get_warnings"] = new_set() + +T["get_warnings"]["returns only warnings"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local validation_result = cv.validate({ + adapter = 123, -- error + unknown_field = true, -- warning + }) + local warnings = cv.get_warnings(validation_result) + return { count = #warnings, field = warnings[1].field } + ]]) + h.eq(1, result.count) + h.eq("unknown_field", result.field) +end + +T["get_warnings"]["returns empty when no warnings"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local validation_result = cv.validate({ adapter = 123 }) + local warnings = cv.get_warnings(validation_result) + return #warnings + ]]) + h.eq(0, result) +end + +-- ============================================================================ +-- Custom schema validation +-- ============================================================================ + +T["custom schema"] = new_set() + +T["custom schema"]["validates against custom schema"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local custom_schema = { + name = "string", + count = { "number", "nil" }, + } + local result = cv.validate({ name = "test", count = 5 }, custom_schema) + return result.valid + ]]) + h.eq(true, result) +end + +T["custom schema"]["rejects invalid value with custom schema"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local custom_schema = { + name = "string", + } + local result = cv.validate({ name = 123 }, custom_schema) + return { valid = result.valid, field = result.issues[1].field } + ]]) + h.eq(false, result.valid) + h.eq("name", result.field) +end + +-- ============================================================================ +-- Real-world config scenarios +-- ============================================================================ + +T["real-world scenarios"] = new_set() + +T["real-world scenarios"]["validates typical user config"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ + adapter = "anthropic", + model = "claude-3-5-sonnet-20241022", + languages = { "English", "Chinese" }, + exclude_files = { "*.log", "node_modules/*", "dist/*" }, + buffer = { + enabled = true, + keymap = "gc", + auto_generate = true, + auto_generate_delay = 200, + }, + add_slash_command = true, + add_git_tool = true, + enable_git_read = true, + enable_git_edit = true, + enable_git_bot = true, + use_commit_history = true, + commit_history_count = 15, + }) + return { valid = result.valid, issue_count = #result.issues } + ]]) + h.eq(true, result.valid) + h.eq(0, result.issue_count) +end + +T["real-world scenarios"]["detects common mistakes"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + -- Common mistakes: string instead of array, string instead of boolean + local result = cv.validate({ + languages = "English", -- should be array + add_slash_command = "true", -- should be boolean + buffer = { enabled = 1 }, -- should be boolean + }) + return { valid = result.valid, issue_count = #result.issues } + ]]) + h.eq(false, result.valid) + h.eq(3, result.issue_count) +end + +T["real-world scenarios"]["warns about typos in option names"] = function() + local result = child.lua([[ + local cv = require("codecompanion._extensions.gitcommit.config_validation") + local result = cv.validate({ + adaptor = "openai", -- typo: should be "adapter" + languges = { "English" }, -- typo: should be "languages" + }) + local warnings = cv.get_warnings(result) + return { + valid = result.valid, + warning_count = #warnings, + fields = { warnings[1].field, warnings[2].field }, + } + ]]) + h.eq(true, result.valid) -- typos are warnings, config still "valid" + h.eq(2, result.warning_count) + -- Order may vary, just check both are present + local fields = result.fields + h.eq(true, fields[1] == "adaptor" or fields[2] == "adaptor") + h.eq(true, fields[1] == "languges" or fields[2] == "languges") +end + +return T From ba78aed7743c53f9bc0f7bcc330341bf8dfde203 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Mon, 22 Dec 2025 02:04:20 +0800 Subject: [PATCH 14/17] fix(gitcommit): resolve bugs found by Gemini code review - Fix show_conflict multiline pattern matching (. doesn't match newlines) - Fix glob_to_lua_pattern missing [ and ] character escaping - Fix stage_files/unstage_files losing second return value from pcall - Replace hardcoded operation counts in tests with dynamic checks - Add tests for square bracket escaping in glob patterns - Add tests for sparse array handling in first_error --- .../_extensions/gitcommit/git_utils.lua | 2 + .../_extensions/gitcommit/tools/git.lua | 52 +++++++++++-------- tests/test_git_edit.lua | 18 +++++-- tests/test_git_read.lua | 18 +++++-- tests/test_git_utils.lua | 20 +++++++ tests/test_validation.lua | 25 +++++++++ 6 files changed, 104 insertions(+), 31 deletions(-) diff --git a/lua/codecompanion/_extensions/gitcommit/git_utils.lua b/lua/codecompanion/_extensions/gitcommit/git_utils.lua index f17050e..14e7d3c 100644 --- a/lua/codecompanion/_extensions/gitcommit/git_utils.lua +++ b/lua/codecompanion/_extensions/gitcommit/git_utils.lua @@ -25,6 +25,8 @@ function M.glob_to_lua_pattern(glob) escaped = escaped:gsub("%(", "%%%(") escaped = escaped:gsub("%)", "%%%)") escaped = escaped:gsub("%+", "%%%+") + escaped = escaped:gsub("%[", "%%%[") + escaped = escaped:gsub("%]", "%%%]") local placeholder = "\001DOUBLESTAR\001" escaped = escaped:gsub("%*%*", placeholder) diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git.lua b/lua/codecompanion/_extensions/gitcommit/tools/git.lua index dd3d7f6..d9904c3 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git.lua @@ -272,34 +272,22 @@ function GitTool.stage_files(files) if not is_git_repo() then return false, "Not in a git repository" end - local ok, result = pcall(function() - if type(files) == "string" then - files = { files } - end - local cmd = CommandBuilder.stage(files) - return CommandExecutor.run(cmd) - end) - if not ok then - return false, "Failed to stage files: " .. tostring(result) + if type(files) == "string" then + files = { files } end - return result + local cmd = CommandBuilder.stage(files) + return CommandExecutor.run(cmd) end function GitTool.unstage_files(files) if not is_git_repo() then return false, "Not in a git repository" end - local ok, result = pcall(function() - if type(files) == "string" then - files = { files } - end - local cmd = CommandBuilder.unstage(files) - return CommandExecutor.run(cmd) - end) - if not ok then - return false, "Failed to unstage files: " .. tostring(result) + if type(files) == "string" then + files = { files } end - return result + local cmd = CommandBuilder.unstage(files) + return CommandExecutor.run(cmd) end function GitTool.commit(message, amend) @@ -740,9 +728,27 @@ function GitTool.show_conflict(file_path) local conflicts = {} local conflict_num = 0 - for conflict_block in content:gmatch("(<<<<<<<.->>>>>>>.-)\n?") do - conflict_num = conflict_num + 1 - table.insert(conflicts, string.format("--- Conflict #%d ---\n%s", conflict_num, conflict_block)) + -- Parse conflicts line by line since Lua's . doesn't match newlines + local lines = vim.split(content, "\n") + local in_conflict = false + local current_block = {} + + for _, line in ipairs(lines) do + if line:match("^<<<<<<< ") then + in_conflict = true + current_block = { line } + elseif in_conflict then + table.insert(current_block, line) + if line:match("^>>>>>>> ") then + conflict_num = conflict_num + 1 + table.insert( + conflicts, + string.format("--- Conflict #%d ---\n%s", conflict_num, table.concat(current_block, "\n")) + ) + in_conflict = false + current_block = {} + end + end end if #conflicts == 0 then diff --git a/tests/test_git_edit.lua b/tests/test_git_edit.lua index 26da7b8..e2e2d9f 100644 --- a/tests/test_git_edit.lua +++ b/tests/test_git_edit.lua @@ -36,12 +36,22 @@ T["schema"]["has function type and strict mode"] = function() h.eq(true, result.strict) end -T["schema"]["contains all valid operations"] = function() - local count = child.lua([[ +T["schema"]["contains valid operations enum"] = function() + local result = child.lua([[ local GitEdit = require("codecompanion._extensions.gitcommit.tools.git_edit") - return #GitEdit.schema["function"].parameters.properties.operation.enum + local enum = GitEdit.schema["function"].parameters.properties.operation.enum + local has_stage = vim.tbl_contains(enum, "stage") + local has_commit = vim.tbl_contains(enum, "commit") + local has_help = vim.tbl_contains(enum, "help") + return { + count = #enum, + has_required = has_stage and has_commit and has_help, + is_non_empty = #enum > 0, + } ]]) - h.eq(28, count) + h.eq(true, result.has_required) + h.eq(true, result.is_non_empty) + h.eq(true, result.count >= 20) end T["cmds"] = new_set() diff --git a/tests/test_git_read.lua b/tests/test_git_read.lua index 5cc6714..9ddda66 100644 --- a/tests/test_git_read.lua +++ b/tests/test_git_read.lua @@ -36,12 +36,22 @@ T["schema"]["has function type and strict mode"] = function() h.eq(true, result.strict) end -T["schema"]["contains all valid operations"] = function() - local count = child.lua([[ +T["schema"]["contains valid operations enum"] = function() + local result = child.lua([[ local GitRead = require("codecompanion._extensions.gitcommit.tools.git_read") - return #GitRead.schema["function"].parameters.properties.operation.enum + local enum = GitRead.schema["function"].parameters.properties.operation.enum + local has_status = vim.tbl_contains(enum, "status") + local has_log = vim.tbl_contains(enum, "log") + local has_help = vim.tbl_contains(enum, "help") + return { + count = #enum, + has_required = has_status and has_log and has_help, + is_non_empty = #enum > 0, + } ]]) - h.eq(18, count) + h.eq(true, result.has_required) + h.eq(true, result.is_non_empty) + h.eq(true, result.count >= 15) end T["cmds"] = new_set() diff --git a/tests/test_git_utils.lua b/tests/test_git_utils.lua index 65ccb7e..6155b96 100644 --- a/tests/test_git_utils.lua +++ b/tests/test_git_utils.lua @@ -93,6 +93,26 @@ T["glob_to_lua_pattern"]["escapes special regex characters"] = function() h.eq(true, result) end +T["glob_to_lua_pattern"]["escapes square brackets"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local pattern = GitUtils.glob_to_lua_pattern("test[1].lua") + return ("test[1].lua"):match(pattern) ~= nil + ]]) + h.eq(true, result) +end + +T["glob_to_lua_pattern"]["escapes square brackets in complex pattern"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local pattern = GitUtils.glob_to_lua_pattern("files[0-9].txt") + local matches_literal = ("files[0-9].txt"):match(pattern) ~= nil + local no_match_digit = ("files5.txt"):match(pattern) == nil + return matches_literal and no_match_digit + ]]) + h.eq(true, result) +end + T["matches_glob"] = new_set() T["matches_glob"]["matches simple file extension"] = function() diff --git a/tests/test_validation.lua b/tests/test_validation.lua index 9595681..3c601f0 100644 --- a/tests/test_validation.lua +++ b/tests/test_validation.lua @@ -235,4 +235,29 @@ T["first_error"]["returns first error"] = function() h.eq("First error", result) end +T["first_error"]["handles sparse array with nil values"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local err = v.format_error("test", "The error") + local validations = {} + validations[1] = nil + validations[2] = nil + validations[3] = err + local first = v.first_error(validations) + if first and first.data then + return first.data.output + end + return "no error found" + ]]) + h.eq("The error", result) +end + +T["first_error"]["returns nil for empty array"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + return v.first_error({}) == nil + ]]) + h.eq(true, result) +end + return T From f7e17e96cec7e7ef009ee8383ad8d1edd1894e69 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Mon, 22 Dec 2025 02:16:26 +0800 Subject: [PATCH 15/17] test(robustness): add comprehensive edge case and property tests Add 37 new robustness tests covering: - glob_to_lua_pattern: empty string, unicode, special chars, long input - parse_conflicts: single/multiple/multiline conflicts, malformed input - has_conflicts: basic detection - filter_diff: empty diff, special filenames, unicode, malformed headers - shell_quote_unix/windows: newlines, tabs, shell injection, metacharacters - validation functions: various types without crash, special error messages Extract conflict parsing to git_utils.lua for better testability. --- .../_extensions/gitcommit/git_utils.lua | 33 ++ .../_extensions/gitcommit/tools/git.lua | 29 +- tests/test_git_utils.lua | 282 ++++++++++++++++++ tests/test_validation.lua | 158 ++++++++++ 4 files changed, 478 insertions(+), 24 deletions(-) diff --git a/lua/codecompanion/_extensions/gitcommit/git_utils.lua b/lua/codecompanion/_extensions/gitcommit/git_utils.lua index 14e7d3c..fbdc765 100644 --- a/lua/codecompanion/_extensions/gitcommit/git_utils.lua +++ b/lua/codecompanion/_extensions/gitcommit/git_utils.lua @@ -316,4 +316,37 @@ DIFF: ) end +---Parse git conflict markers from file content +---@param content string The file content with potential conflicts +---@return table[] conflicts Array of conflict blocks, each with {ours, theirs, marker_start, marker_end} +function M.parse_conflicts(content) + local conflicts = {} + local lines = vim.split(content, "\n") + local in_conflict = false + local current_block = {} + + for _, line in ipairs(lines) do + if line:match("^<<<<<<< ") then + in_conflict = true + current_block = { line } + elseif in_conflict then + table.insert(current_block, line) + if line:match("^>>>>>>> ") then + table.insert(conflicts, table.concat(current_block, "\n")) + in_conflict = false + current_block = {} + end + end + end + + return conflicts +end + +---Check if content has conflict markers +---@param content string The file content to check +---@return boolean has_conflicts True if conflict markers found +function M.has_conflicts(content) + return content:match("<<<<<<< ") ~= nil +end + return M diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git.lua b/lua/codecompanion/_extensions/gitcommit/tools/git.lua index d9904c3..9e5cf13 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git.lua @@ -1,4 +1,5 @@ local Git = require("codecompanion._extensions.gitcommit.git") +local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") local Command = require("codecompanion._extensions.gitcommit.tools.command") local CommandBuilder = Command.CommandBuilder @@ -720,35 +721,15 @@ function GitTool.show_conflict(file_path) return false, msg, "✗ " .. msg, "fail: " .. msg .. "" end - if not content:match("<<<<<<< ") then + if not GitUtils.has_conflicts(content) then local msg = "No conflict markers found in: " .. file_path return true, msg, "✓ " .. msg, "success: " .. msg .. "" end + local raw_conflicts = GitUtils.parse_conflicts(content) local conflicts = {} - local conflict_num = 0 - - -- Parse conflicts line by line since Lua's . doesn't match newlines - local lines = vim.split(content, "\n") - local in_conflict = false - local current_block = {} - - for _, line in ipairs(lines) do - if line:match("^<<<<<<< ") then - in_conflict = true - current_block = { line } - elseif in_conflict then - table.insert(current_block, line) - if line:match("^>>>>>>> ") then - conflict_num = conflict_num + 1 - table.insert( - conflicts, - string.format("--- Conflict #%d ---\n%s", conflict_num, table.concat(current_block, "\n")) - ) - in_conflict = false - current_block = {} - end - end + for i, block in ipairs(raw_conflicts) do + table.insert(conflicts, string.format("--- Conflict #%d ---\n%s", i, block)) end if #conflicts == 0 then diff --git a/tests/test_git_utils.lua b/tests/test_git_utils.lua index 6155b96..249d1e6 100644 --- a/tests/test_git_utils.lua +++ b/tests/test_git_utils.lua @@ -113,6 +113,87 @@ T["glob_to_lua_pattern"]["escapes square brackets in complex pattern"] = functio h.eq(true, result) end +T["glob_to_lua_pattern"]["robustness"] = new_set() + +T["glob_to_lua_pattern"]["robustness"]["handles empty string"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local pattern = GitUtils.glob_to_lua_pattern("") + return pattern == "$" + ]]) + h.eq(true, result) +end + +T["glob_to_lua_pattern"]["robustness"]["handles all lua pattern special chars"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local special = "test.file-name^start$end(group)+more[bracket]%percent" + local pattern = GitUtils.glob_to_lua_pattern(special) + return special:match(pattern) ~= nil + ]]) + h.eq(true, result) +end + +T["glob_to_lua_pattern"]["robustness"]["handles consecutive special chars"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local input = "..--^^$$" + local pattern = GitUtils.glob_to_lua_pattern(input) + return input:match(pattern) ~= nil + ]]) + h.eq(true, result) +end + +T["glob_to_lua_pattern"]["robustness"]["handles unicode characters"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local pattern = GitUtils.glob_to_lua_pattern("文件*.txt") + return ("文件test.txt"):match(pattern) ~= nil + ]]) + h.eq(true, result) +end + +T["glob_to_lua_pattern"]["robustness"]["handles path with spaces"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local pattern = GitUtils.glob_to_lua_pattern("my files/*.txt") + return ("my files/doc.txt"):match(pattern) ~= nil + ]]) + h.eq(true, result) +end + +T["glob_to_lua_pattern"]["robustness"]["handles multiple wildcards"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local pattern = GitUtils.glob_to_lua_pattern("*test*.lua") + local match1 = ("my_test_file.lua"):match(pattern) ~= nil + local match2 = ("test.lua"):match(pattern) ~= nil + return match1 and match2 + ]]) + h.eq(true, result) +end + +T["glob_to_lua_pattern"]["robustness"]["does not crash on long input"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local long_input = string.rep("a", 1000) .. "*.txt" + local pattern = GitUtils.glob_to_lua_pattern(long_input) + return type(pattern) == "string" and #pattern > 0 + ]]) + h.eq(true, result) +end + +T["glob_to_lua_pattern"]["robustness"]["handles only wildcards"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local pattern1 = GitUtils.glob_to_lua_pattern("*") + local pattern2 = GitUtils.glob_to_lua_pattern("**") + local pattern3 = GitUtils.glob_to_lua_pattern("?") + return type(pattern1) == "string" and type(pattern2) == "string" and type(pattern3) == "string" + ]]) + h.eq(true, result) +end + T["matches_glob"] = new_set() T["matches_glob"]["matches simple file extension"] = function() @@ -521,4 +602,205 @@ T["is_windows"]["returns boolean"] = function() h.eq(true, result) end +T["parse_conflicts"] = new_set() + +T["parse_conflicts"]["parses single conflict"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local content = "<<<<<<< HEAD\nour changes\n=======\ntheir changes\n>>>>>>> branch" + local conflicts = GitUtils.parse_conflicts(content) + return #conflicts == 1 + ]]) + h.eq(true, result) +end + +T["parse_conflicts"]["parses multiple conflicts"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local content = "<<<<<<< HEAD\nour changes 1\n=======\ntheir changes 1\n>>>>>>> branch\nsome code\n<<<<<<< HEAD\nour changes 2\n=======\ntheir changes 2\n>>>>>>> branch" + local conflicts = GitUtils.parse_conflicts(content) + return #conflicts == 2 + ]]) + h.eq(true, result) +end + +T["parse_conflicts"]["handles multiline conflict blocks"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local content = "<<<<<<< HEAD\nline 1\nline 2\nline 3\n=======\ndifferent line 1\ndifferent line 2\n>>>>>>> branch" + local conflicts = GitUtils.parse_conflicts(content) + local has_multiple_lines = conflicts[1]:find("line 2") ~= nil + return #conflicts == 1 and has_multiple_lines + ]]) + h.eq(true, result) +end + +T["parse_conflicts"]["returns empty array for no conflicts"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local content = "just normal code\nno conflicts here" + local conflicts = GitUtils.parse_conflicts(content) + return #conflicts == 0 + ]]) + h.eq(true, result) +end + +T["parse_conflicts"]["handles incomplete conflict (no end marker)"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local content = "<<<<<<< HEAD\nour changes\n=======\ntheir changes" + local conflicts = GitUtils.parse_conflicts(content) + return #conflicts == 0 + ]]) + h.eq(true, result) +end + +T["parse_conflicts"]["handles empty content"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local conflicts = GitUtils.parse_conflicts("") + return #conflicts == 0 + ]]) + h.eq(true, result) +end + +T["has_conflicts"] = new_set() + +T["has_conflicts"]["returns true when conflicts exist"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.has_conflicts("<<<<<<< HEAD\ncode") + ]]) + h.eq(true, result) +end + +T["has_conflicts"]["returns false when no conflicts"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.has_conflicts("normal code") + ]]) + h.eq(false, result) +end + +T["filter_diff"]["robustness"] = new_set() + +T["filter_diff"]["robustness"]["handles empty diff"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.filter_diff("", {"*.js"}) == "" + ]]) + h.eq(true, result) +end + +T["filter_diff"]["robustness"]["handles diff with special chars in filename"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local diff = "diff --git a/file with spaces.lua b/file with spaces.lua\n+hello" + local filtered = GitUtils.filter_diff(diff, {"*.js"}) + return filtered:find("file with spaces") ~= nil + ]]) + h.eq(true, result) +end + +T["filter_diff"]["robustness"]["handles diff with unicode filename"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local diff = "diff --git a/文件.lua b/文件.lua\n+code" + local filtered = GitUtils.filter_diff(diff, {"*.js"}) + return filtered:find("文件") ~= nil + ]]) + h.eq(true, result) +end + +T["filter_diff"]["robustness"]["handles malformed diff header"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local diff = "not a valid diff header\n+some content" + local filtered = GitUtils.filter_diff(diff, {"*.js"}) + return type(filtered) == "string" + ]]) + h.eq(true, result) +end + +T["shell_quote_unix"]["robustness"] = new_set() + +T["shell_quote_unix"]["robustness"]["handles newlines"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local quoted = GitUtils.shell_quote_unix("line1\nline2") + return quoted:find("\n") ~= nil + ]]) + h.eq(true, result) +end + +T["shell_quote_unix"]["robustness"]["handles tabs"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local quoted = GitUtils.shell_quote_unix("col1\tcol2") + return quoted:find("\t") ~= nil + ]]) + h.eq(true, result) +end + +T["shell_quote_unix"]["robustness"]["handles shell metacharacters"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local dangerous = "$(whoami); rm -rf /" + local quoted = GitUtils.shell_quote_unix(dangerous) + return quoted == "'" .. dangerous .. "'" + ]]) + h.eq(true, result) +end + +T["shell_quote_unix"]["robustness"]["handles backticks"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local input = "`whoami`" + local quoted = GitUtils.shell_quote_unix(input) + return quoted == "'`whoami`'" + ]]) + h.eq(true, result) +end + +T["shell_quote_unix"]["robustness"]["handles null bytes"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local input = "before\0after" + local quoted = GitUtils.shell_quote_unix(input) + return type(quoted) == "string" + ]]) + h.eq(true, result) +end + +T["shell_quote_windows"]["robustness"] = new_set() + +T["shell_quote_windows"]["robustness"]["handles newlines"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local quoted = GitUtils.shell_quote_windows("line1\nline2") + return quoted:find("\n") ~= nil + ]]) + h.eq(true, result) +end + +T["shell_quote_windows"]["robustness"]["handles cmd special chars"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local dangerous = "test & del /f /q *" + local quoted = GitUtils.shell_quote_windows(dangerous) + return quoted == '"' .. dangerous .. '"' + ]]) + h.eq(true, result) +end + +T["shell_quote_windows"]["robustness"]["handles percent signs"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local input = "%PATH%" + local quoted = GitUtils.shell_quote_windows(input) + return quoted == '"%PATH%"' + ]]) + h.eq(true, result) +end + return T diff --git a/tests/test_validation.lua b/tests/test_validation.lua index 3c601f0..feb7443 100644 --- a/tests/test_validation.lua +++ b/tests/test_validation.lua @@ -260,4 +260,162 @@ T["first_error"]["returns nil for empty array"] = function() h.eq(true, result) end +T["robustness"] = new_set() + +T["robustness"]["require_string handles various types without crash"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local test_values = { + nil, + true, + false, + 0, + -1, + 3.14, + "", + "valid", + {}, + {1, 2, 3}, + {key = "value"}, + function() end, + } + for _, val in ipairs(test_values) do + local result = v.require_string(val, "param", "test") + if result ~= nil and type(result) ~= "table" then + return false + end + end + return true + ]]) + h.eq(true, result) +end + +T["robustness"]["optional_string handles various types without crash"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local test_values = { nil, true, false, 0, "", "valid", {}, function() end } + for _, val in ipairs(test_values) do + local result = v.optional_string(val, "param", "test") + if result ~= nil and type(result) ~= "table" then + return false + end + end + return true + ]]) + h.eq(true, result) +end + +T["robustness"]["require_array handles various types without crash"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local test_values = { nil, true, 0, "", {}, {1}, {"a", "b"}, function() end } + for _, val in ipairs(test_values) do + local result = v.require_array(val, "param", "test") + if result ~= nil and type(result) ~= "table" then + return false + end + end + return true + ]]) + h.eq(true, result) +end + +T["robustness"]["optional_integer handles various types without crash"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local test_values = { nil, true, 0, 1, -1, 3.14, math.huge, -math.huge, "", {}, function() end } + for _, val in ipairs(test_values) do + local result = v.optional_integer(val, "param", "test") + if result ~= nil and type(result) ~= "table" then + return false + end + end + return true + ]]) + h.eq(true, result) +end + +T["robustness"]["optional_boolean handles various types without crash"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local test_values = { nil, true, false, 0, 1, "", "true", "false", {}, function() end } + for _, val in ipairs(test_values) do + local result = v.optional_boolean(val, "param", "test") + if result ~= nil and type(result) ~= "table" then + return false + end + end + return true + ]]) + h.eq(true, result) +end + +T["robustness"]["require_enum handles various types without crash"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local allowed = {"one", "two", "three"} + local test_values = { nil, true, 0, "", "one", "invalid", {}, function() end } + for _, val in ipairs(test_values) do + local result = v.require_enum(val, "param", allowed, "test") + if result ~= nil and type(result) ~= "table" then + return false + end + end + return true + ]]) + h.eq(true, result) +end + +T["robustness"]["require_args handles various types without crash"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local test_values = { nil, true, 0, "", {}, {key = "value"}, function() end } + for _, val in ipairs(test_values) do + local result = v.require_args(val, "test") + if result ~= nil and type(result) ~= "table" then + return false + end + end + return true + ]]) + h.eq(true, result) +end + +T["robustness"]["format_error handles special characters"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local special_msgs = { + "", + "normal message", + "message with 'quotes'", + 'message with "double quotes"', + "message with tags", + "message\nwith\nnewlines", + "message with unicode: 中文 日本語", + } + for _, msg in ipairs(special_msgs) do + local result = v.format_error("test", msg) + if type(result) ~= "table" or result.status ~= "error" then + return false + end + end + return true + ]]) + h.eq(true, result) +end + +T["robustness"]["first_error handles large arrays"] = function() + local result = child.lua([[ + local v = require("codecompanion._extensions.gitcommit.tools.validation") + local large_array = {} + for i = 1, 1000 do + large_array[i] = nil + end + large_array[500] = v.format_error("test", "error at 500") + local first = v.first_error(large_array) + return first ~= nil and first.data.output == "error at 500" + ]]) + h.eq(true, result) +end + return T From 36e69bff45b670c1718f3899612383f4f29ec062 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Mon, 22 Dec 2025 09:53:52 +0800 Subject: [PATCH 16/17] refactor(gitcommit): address Gemini code review feedback - Remove duplicate is_windows/shell_quote from command.lua, import from git_utils - Refactor contributors() to limit results in Lua instead of platform-specific pipes - Simplify filter_diff() by consolidating file path extraction logic --- .../_extensions/gitcommit/git_utils.lua | 25 ++++------------ .../_extensions/gitcommit/tools/command.lua | 30 ++++--------------- .../_extensions/gitcommit/tools/git.lua | 13 +++++++- 3 files changed, 22 insertions(+), 46 deletions(-) diff --git a/lua/codecompanion/_extensions/gitcommit/git_utils.lua b/lua/codecompanion/_extensions/gitcommit/git_utils.lua index fbdc765..5f7aa04 100644 --- a/lua/codecompanion/_extensions/gitcommit/git_utils.lua +++ b/lua/codecompanion/_extensions/gitcommit/git_utils.lua @@ -118,27 +118,12 @@ function M.filter_diff(diff_content, exclude_patterns) local skip_current_file = false for _, line in ipairs(lines) do - local file_match = line:match("^diff %-%-git a/(.*) b/") - if file_match then - current_file = file_match - table.insert(all_files, current_file) - skip_current_file = M.should_exclude_file(current_file, exclude_patterns) - if skip_current_file then - table.insert(excluded_files, current_file) - end - end + local file_path = line:match("^diff %-%-git a/(.*) b/") + or line:match("^%+%+%+ b/(.*)") + or line:match("^%-%-%-a/(.*)") - local plus_file = line:match("^%+%+%+ b/(.*)") - local minus_file = line:match("^%-%-%-a/(.*)") - if plus_file then - current_file = plus_file - table.insert(all_files, current_file) - skip_current_file = M.should_exclude_file(current_file, exclude_patterns) - if skip_current_file then - table.insert(excluded_files, current_file) - end - elseif minus_file then - current_file = minus_file + if file_path then + current_file = file_path table.insert(all_files, current_file) skip_current_file = M.should_exclude_file(current_file, exclude_patterns) if skip_current_file then diff --git a/lua/codecompanion/_extensions/gitcommit/tools/command.lua b/lua/codecompanion/_extensions/gitcommit/tools/command.lua index 5404227..8aa23c8 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/command.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/command.lua @@ -4,24 +4,10 @@ local M = {} ---- Check if running on Windows ----@return boolean -local function is_windows() - return vim.loop.os_uname().sysname == "Windows_NT" -end +local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") ---- Quote a string for shell command (Windows uses double quotes, Unix uses single quotes) ----@param str string The string to quote ----@return string -local function shell_quote(str) - if is_windows() then - -- Windows CMD: use double quotes, escape internal double quotes with \" - return '"' .. str:gsub('"', '\\"') .. '"' - else - -- Unix: use single quotes, escape internal single quotes - return "'" .. str:gsub("'", "'\\''") .. "'" - end -end +local is_windows = GitUtils.is_windows +local shell_quote = GitUtils.shell_quote -------------------------------------------------------------------------------- -- CommandBuilder: Pure functions for generating git command strings @@ -244,15 +230,9 @@ function CommandBuilder.diff_commits(commit1, commit2, file_path) end ---Build git shortlog (contributors) command ----@param count? number Number of top contributors ---@return string command -function CommandBuilder.contributors(count) - count = count or 10 - if is_windows() then - return string.format("git shortlog -sn | Select-Object -First %d", count) - else - return string.format("git shortlog -sn | head -%d", count) - end +function CommandBuilder.contributors() + return "git shortlog -sn" end ---Build git log search command diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git.lua b/lua/codecompanion/_extensions/gitcommit/tools/git.lua index 9e5cf13..851176c 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git.lua @@ -414,8 +414,19 @@ function GitTool.get_contributors(count) "✗ Not in a git repository", "fail: Not in a git repository" end - local cmd = CommandBuilder.contributors(count) + count = count or 10 + local cmd = CommandBuilder.contributors() local success, output = CommandExecutor.run(cmd) + if success and output then + local lines = vim.split(output, "\n") + local limited_lines = {} + for i = 1, math.min(count, #lines) do + if lines[i] and lines[i] ~= "" then + table.insert(limited_lines, lines[i]) + end + end + output = table.concat(limited_lines, "\n") + end local user_msg, llm_msg = format_git_response("contributors", success, output) return success, output, user_msg, llm_msg end From 37be0d9d445d765e601108cab20ab67b2ed15a64 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Mon, 22 Dec 2025 10:59:23 +0800 Subject: [PATCH 17/17] chore: remove configuration example files --- config_example.lua | 48 ------------------------- config_example_with_history.lua | 64 --------------------------------- 2 files changed, 112 deletions(-) delete mode 100644 config_example.lua delete mode 100644 config_example_with_history.lua diff --git a/config_example.lua b/config_example.lua deleted file mode 100644 index 719a159..0000000 --- a/config_example.lua +++ /dev/null @@ -1,48 +0,0 @@ --- CodeCompanion GitCommit Extension Configuration Example --- This file demonstrates how to configure the GitCommit extension with git tools - -return { - -- Basic configuration - adapter = "anthropic", -- or "openai", "copilot", etc. - model = "claude-3-5-sonnet-20241022", - - -- Languages for commit message generation - languages = { "English", "Chinese", "Japanese", "French" }, - - -- Files to exclude from git diff (supports glob patterns) - exclude_files = { - "*.pb.go", -- Protocol buffer files - "*.min.js", -- Minified JavaScript - "*.min.css", -- Minified CSS - "package-lock.json", -- NPM lock files - "yarn.lock", -- Yarn lock files - "*.log", -- Log files - "dist/*", -- Distribution directories - "build/*", -- Build directories - ".next/*", -- Next.js build - "node_modules/*", -- Node modules - "vendor/*", -- Vendor directories - }, - - -- Buffer configuration - buffer = { - enabled = true, -- Enable buffer integration - keymap = "gc", -- Keymap - auto_generate = true, -- Auto-generate - auto_generate_delay = 200, -- Auto-generation delay (ms) - skip_auto_generate_on_amend = true, -- Skip auto-generation during git commit --amend - }, - - -- Feature toggles - add_slash_command = true, -- Enable slash command in chat buffer - add_git_tool = true, -- Add @{git_read} and @{git_edit} tools to CodeCompanion - enable_git_read = true, -- Enable read-only Git operations - enable_git_edit = true, -- Enable write-access Git operations - enable_git_bot = true, -- Enable @{git_bot} tool group (requires both read/write enabled) - add_git_commands = true, -- Add :CodeCompanionGitCommit commands - - -- Git tool configuration - git_tool_auto_submit_errors = false, -- Don't auto-submit errors to LLM - git_tool_auto_submit_success = true, -- Auto-submit success to LLM - gitcommit_select_count = 100, -- Number of recent commits for /gitcommit slash command -} diff --git a/config_example_with_history.lua b/config_example_with_history.lua deleted file mode 100644 index a464dfd..0000000 --- a/config_example_with_history.lua +++ /dev/null @@ -1,64 +0,0 @@ --- Example configuration showing how to use the new commit history feature - -require("codecompanion").setup({ - adapters = { - openai = function() - return require("codecompanion.adapters").extend("openai", { - env = { - api_key = "your_api_key_here", - }, - }) - end, - }, - extensions = { - -- GitCommit extension with history context enabled - gitcommit = { - callback = "codecompanion._extensions.gitcommit", - opts = { - -- Core configuration - adapter = "openai", - model = "gpt-4-turbo-preview", - languages = { "English", "Chinese" }, - - -- NEW: History commit context configuration - use_commit_history = true, -- Enable using commit history as context - commit_history_count = 15, -- Use 15 recent commits for context (default: 10) - - -- Existing configuration options - buffer = { - enabled = true, - keymap = "gc", - auto_generate = true, - auto_generate_delay = 200, - skip_auto_generate_on_amend = true, - }, - - exclude_files = { - "*.pb.go", - "*.min.js", - "package-lock.json", - "yarn.lock", - "*.log", - "dist/*", - "build/*", - "node_modules/*", - }, - - add_git_tool = true, - enable_git_read = true, - enable_git_edit = true, - enable_git_bot = true, - git_tool_auto_submit_success = true, - }, - }, - }, -}) - --- Alternative configuration with history disabled --- gitcommit = { --- callback = "codecompanion._extensions.gitcommit", --- opts = { --- use_commit_history = false, -- Disable history context --- -- ... other options --- } --- }