From 313237c42c6cbe2307dc104077e7a28f3bc71690 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 23 Dec 2025 19:03:04 +0800 Subject: [PATCH 1/5] test(gitcommit): add comprehensive unit tests Add unit tests for buffer, git, and command modules. Add -- separator to git commands in CommandBuilder. Add window_stability_delay setting to config_validation.lua. Check window visibility in Buffer._insert_commit_message. Add fallback to git diff in Git.get_contextual_diff. Remove fatal string check from CommandExecutor.run. --- .../_extensions/gitcommit/buffer.lua | 7 +- .../gitcommit/config_validation.lua | 1 + .../_extensions/gitcommit/git.lua | 8 +- .../_extensions/gitcommit/tools/command.lua | 13 +- tests/test_buffer.lua | 412 ++++++++ tests/test_command_builder.lua | 13 +- tests/test_command_executor.lua | 112 +++ tests/test_config.lua | 1 + tests/test_generator.lua | 208 +++- tests/test_git.lua | 463 +++++++++ tests/test_git_tool.lua | 918 ++++++++++++++++++ tests/test_git_utils.lua | 36 + tests/test_ui.lua | 19 + 13 files changed, 2199 insertions(+), 12 deletions(-) create mode 100644 tests/test_buffer.lua create mode 100644 tests/test_command_executor.lua create mode 100644 tests/test_git_tool.lua diff --git a/lua/codecompanion/_extensions/gitcommit/buffer.lua b/lua/codecompanion/_extensions/gitcommit/buffer.lua index 54ac40c..16caca9 100644 --- a/lua/codecompanion/_extensions/gitcommit/buffer.lua +++ b/lua/codecompanion/_extensions/gitcommit/buffer.lua @@ -259,8 +259,11 @@ function Buffer._insert_commit_message(bufnr, message) vim.api.nvim_buf_set_lines(bufnr, #message_lines, #message_lines, false, { "" }) end - -- Move cursor to beginning of commit message - vim.api.nvim_win_set_cursor(0, { 1, 0 }) + -- Move cursor to beginning of commit message if buffer is visible + local winid = vim.fn.bufwinid(bufnr) + if winid ~= -1 then + vim.api.nvim_win_set_cursor(winid, { 1, 0 }) + end vim.notify("Commit message generated and inserted!", vim.log.levels.INFO) end diff --git a/lua/codecompanion/_extensions/gitcommit/config_validation.lua b/lua/codecompanion/_extensions/gitcommit/config_validation.lua index 4fe8256..466af93 100644 --- a/lua/codecompanion/_extensions/gitcommit/config_validation.lua +++ b/lua/codecompanion/_extensions/gitcommit/config_validation.lua @@ -34,6 +34,7 @@ M.schema = { keymap = { "string", "nil" }, auto_generate = { "boolean", "nil" }, auto_generate_delay = { "number", "nil" }, + window_stability_delay = { "number", "nil" }, skip_auto_generate_on_amend = { "boolean", "nil" }, }, }, diff --git a/lua/codecompanion/_extensions/gitcommit/git.lua b/lua/codecompanion/_extensions/gitcommit/git.lua index be2e859..1b34c7c 100644 --- a/lua/codecompanion/_extensions/gitcommit/git.lua +++ b/lua/codecompanion/_extensions/gitcommit/git.lua @@ -185,7 +185,13 @@ function Git.get_contextual_diff() end local all_local_diff = vim.fn.system("git diff --no-ext-diff HEAD") - if vim.v.shell_error == 0 and GitUtils.trim(all_local_diff) ~= "" then + if vim.v.shell_error ~= 0 then + all_local_diff = vim.fn.system("git diff --no-ext-diff") + if vim.v.shell_error ~= 0 then + return nil, "git_operation_failed" + end + end + if GitUtils.trim(all_local_diff) ~= "" then local filtered_diff = Git._filter_diff(all_local_diff) if GitUtils.trim(filtered_diff) ~= "" then return filtered_diff, "unstaged_or_all_local" diff --git a/lua/codecompanion/_extensions/gitcommit/tools/command.lua b/lua/codecompanion/_extensions/gitcommit/tools/command.lua index 400f446..9635bc8 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/command.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/command.lua @@ -61,6 +61,7 @@ function CommandBuilder.diff(staged, file) table.insert(parts, "--cached") end if file then + table.insert(parts, "--") table.insert(parts, vim.fn.shellescape(file)) end return table.concat(parts, " ") @@ -90,7 +91,7 @@ function CommandBuilder.stage(files) for _, file in ipairs(files) do table.insert(escaped_files, vim.fn.shellescape(file)) end - return "git add " .. table.concat(escaped_files, " ") + return "git add -- " .. table.concat(escaped_files, " ") end ---Build git reset (unstage) command @@ -104,7 +105,7 @@ function CommandBuilder.unstage(files) for _, file in ipairs(files) do table.insert(escaped_files, vim.fn.shellescape(file)) end - return "git reset HEAD " .. table.concat(escaped_files, " ") + return "git reset HEAD -- " .. table.concat(escaped_files, " ") end ---Build git commit command @@ -158,7 +159,7 @@ end ---@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) } + local parts = { "git", "blame" } if line_start and line_end then table.insert(parts, "-L") table.insert(parts, line_start .. "," .. line_end) @@ -166,6 +167,8 @@ function CommandBuilder.blame(file_path, line_start, line_end) table.insert(parts, "-L") table.insert(parts, line_start .. ",+10") end + table.insert(parts, "--") + table.insert(parts, vim.fn.shellescape(file_path)) return table.concat(parts, " ") end @@ -542,7 +545,7 @@ end ---@param file string File to check ---@return string[] command array for system call function CommandBuilder.check_ignore(file) - return { "git", "check-ignore", file } + return { "git", "check-ignore", "--", file } end -------------------------------------------------------------------------------- @@ -563,7 +566,7 @@ function CommandExecutor.run(cmd) end local exit_code = vim.v.shell_error - if exit_code ~= 0 or (output and output:match("fatal: ")) then + if exit_code ~= 0 then return false, output or "Git command failed" end diff --git a/tests/test_buffer.lua b/tests/test_buffer.lua new file mode 100644 index 0000000..be7a9cf --- /dev/null +++ b/tests/test_buffer.lua @@ -0,0 +1,412 @@ +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["_insert_commit_message"] = new_set() + +T["_insert_commit_message"]["replaces existing message and keeps comments"] = function() + local result = child.lua([[ + package.preload["codecompanion._extensions.gitcommit.generator"] = function() + return {} + end + local Buffer = require("codecompanion._extensions.gitcommit.buffer") + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "old subject", + "old body", + "# comment", + "# more", + }) + Buffer._insert_commit_message(bufnr, "feat: new") + return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + ]]) + h.eq({ "feat: new", "", "# comment", "# more" }, result) +end + +T["_insert_commit_message"]["handles verbose separator"] = function() + local result = child.lua([[ + package.preload["codecompanion._extensions.gitcommit.generator"] = function() + return {} + end + local Buffer = require("codecompanion._extensions.gitcommit.buffer") + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "old subject", + "# ------------------------ >8 ------------------------", + "# diff", + }) + Buffer._insert_commit_message(bufnr, "fix: bug") + return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + ]]) + h.eq({ "fix: bug", "", "# ------------------------ >8 ------------------------", "# diff" }, result) +end + +T["_insert_commit_message"]["adds blank line when missing"] = function() + local result = child.lua([[ + package.preload["codecompanion._extensions.gitcommit.generator"] = function() + return {} + end + local Buffer = require("codecompanion._extensions.gitcommit.buffer") + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "# comment" }) + Buffer._insert_commit_message(bufnr, "chore: update") + return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + ]]) + h.eq({ "chore: update", "", "# comment" }, result) +end + +T["_insert_commit_message"]["skips cursor move when buffer hidden"] = function() + local result = child.lua([[ + package.preload["codecompanion._extensions.gitcommit.generator"] = function() + return {} + end + local Buffer = require("codecompanion._extensions.gitcommit.buffer") + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "# comment" }) + + local called = 0 + local orig_bufwinid = vim.fn.bufwinid + local orig_set_cursor = vim.api.nvim_win_set_cursor + vim.fn.bufwinid = function() return -1 end + vim.api.nvim_win_set_cursor = function() + called = called + 1 + end + + Buffer._insert_commit_message(bufnr, "feat: hidden") + + vim.fn.bufwinid = orig_bufwinid + vim.api.nvim_win_set_cursor = orig_set_cursor + return called + ]]) + h.eq(0, result) +end + +T["auto_generate"] = new_set() + +T["auto_generate"]["skips when amending"] = function() + local result = child.lua([[ + package.preload["codecompanion._extensions.gitcommit.generator"] = function() + return {} + end + local Buffer = require("codecompanion._extensions.gitcommit.buffer") + local Git = require("codecompanion._extensions.gitcommit.git") + + local autocmds = {} + vim.api.nvim_create_autocmd = function(event, opts) + table.insert(autocmds, { event = event, opts = opts }) + end + + vim.defer_fn = function(fn, _delay) + fn() + return { stop = function() end } + end + + Git.is_amending = function() return true end + + local called = 0 + Buffer._generate_and_insert_commit_message = function() + called = called + 1 + end + + Buffer.setup({ auto_generate = true, auto_generate_delay = 0, window_stability_delay = 0 }) + + local bufnr = vim.api.nvim_create_buf(false, true) + local filetype_cb + for _, item in ipairs(autocmds) do + if item.event == "FileType" then + filetype_cb = item.opts.callback + end + end + + filetype_cb({ buf = bufnr }) + + for _, item in ipairs(autocmds) do + if item.event == "WinEnter" then + item.opts.callback({ buf = bufnr }) + end + end + + return called + ]]) + h.eq(0, result) +end + +T["auto_generate"]["skips when buffer has message"] = function() + local result = child.lua([[ + package.preload["codecompanion._extensions.gitcommit.generator"] = function() + return {} + end + local Buffer = require("codecompanion._extensions.gitcommit.buffer") + local Git = require("codecompanion._extensions.gitcommit.git") + + local autocmds = {} + vim.api.nvim_create_autocmd = function(event, opts) + table.insert(autocmds, { event = event, opts = opts }) + end + + vim.defer_fn = function(fn, _delay) + fn() + return { stop = function() end } + end + + Git.is_amending = function() return false end + + local called = 0 + Buffer._generate_and_insert_commit_message = function() + called = called + 1 + end + + Buffer.setup({ auto_generate = true, auto_generate_delay = 0, window_stability_delay = 0 }) + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "existing subject", "# comment" }) + + local filetype_cb + for _, item in ipairs(autocmds) do + if item.event == "FileType" then + filetype_cb = item.opts.callback + end + end + + filetype_cb({ buf = bufnr }) + + for _, item in ipairs(autocmds) do + if item.event == "WinEnter" then + item.opts.callback({ buf = bufnr }) + end + end + + return called + ]]) + h.eq(0, result) +end + +T["auto_generate"]["triggers on empty buffer"] = function() + local result = child.lua([[ + package.preload["codecompanion._extensions.gitcommit.generator"] = function() + return {} + end + local Buffer = require("codecompanion._extensions.gitcommit.buffer") + local Git = require("codecompanion._extensions.gitcommit.git") + + local autocmds = {} + vim.api.nvim_create_autocmd = function(event, opts) + table.insert(autocmds, { event = event, opts = opts }) + end + + vim.defer_fn = function(fn, _delay) + fn() + return { stop = function() end } + end + + Git.is_amending = function() return false end + + local called = 0 + Buffer._generate_and_insert_commit_message = function() + called = called + 1 + end + + Buffer.setup({ auto_generate = true, auto_generate_delay = 0, window_stability_delay = 0 }) + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "# comment" }) + + local filetype_cb + for _, item in ipairs(autocmds) do + if item.event == "FileType" then + filetype_cb = item.opts.callback + end + end + + filetype_cb({ buf = bufnr }) + + for _, item in ipairs(autocmds) do + if item.event == "WinEnter" then + item.opts.callback({ buf = bufnr }) + end + end + + return called + ]]) + h.eq(1, result) +end + +T["auto_generate"]["ignores verbose diff separator"] = function() + local result = child.lua([[ + package.preload["codecompanion._extensions.gitcommit.generator"] = function() + return {} + end + local Buffer = require("codecompanion._extensions.gitcommit.buffer") + local Git = require("codecompanion._extensions.gitcommit.git") + + local autocmds = {} + vim.api.nvim_create_autocmd = function(event, opts) + table.insert(autocmds, { event = event, opts = opts }) + end + + vim.defer_fn = function(fn, _delay) + fn() + return { stop = function() end } + end + + Git.is_amending = function() return false end + + local called = 0 + Buffer._generate_and_insert_commit_message = function() + called = called + 1 + end + + Buffer.setup({ auto_generate = true, auto_generate_delay = 0, window_stability_delay = 0 }) + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "# ------------------------ >8 ------------------------", + "# diff", + }) + + local filetype_cb + for _, item in ipairs(autocmds) do + if item.event == "FileType" then + filetype_cb = item.opts.callback + end + end + + filetype_cb({ buf = bufnr }) + + for _, item in ipairs(autocmds) do + if item.event == "WinEnter" then + item.opts.callback({ buf = bufnr }) + end + end + + return called + ]]) + h.eq(1, result) +end + +T["auto_generate"]["debounces repeated triggers"] = function() + local result = child.lua([[ + package.preload["codecompanion._extensions.gitcommit.generator"] = function() + return {} + end + local Buffer = require("codecompanion._extensions.gitcommit.buffer") + local Git = require("codecompanion._extensions.gitcommit.git") + + local autocmds = {} + vim.api.nvim_create_autocmd = function(event, opts) + table.insert(autocmds, { event = event, opts = opts }) + end + + vim.defer_fn = function(fn, _delay) + fn() + return { stop = function() end } + end + + Git.is_amending = function() return false end + + local called = 0 + Buffer._generate_and_insert_commit_message = function() + called = called + 1 + end + + Buffer.setup({ auto_generate = true, auto_generate_delay = 0, window_stability_delay = 0 }) + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "# comment" }) + + local filetype_cb + for _, item in ipairs(autocmds) do + if item.event == "FileType" then + filetype_cb = item.opts.callback + end + end + + filetype_cb({ buf = bufnr }) + + for _, item in ipairs(autocmds) do + if item.event == "WinEnter" then + item.opts.callback({ buf = bufnr }) + item.opts.callback({ buf = bufnr }) + end + end + + return called + ]]) + h.eq(1, result) +end + +T["auto_generate"]["cleans up pending timer on buffer unload"] = function() + local result = child.lua([[ + package.preload["codecompanion._extensions.gitcommit.generator"] = function() + return {} + end + local Buffer = require("codecompanion._extensions.gitcommit.buffer") + local Git = require("codecompanion._extensions.gitcommit.git") + + local autocmds = {} + vim.api.nvim_create_autocmd = function(event, opts) + table.insert(autocmds, { event = event, opts = opts }) + end + + local stopped = 0 + vim.defer_fn = function(_fn, _delay) + return { + stop = function() + stopped = stopped + 1 + end, + } + end + + Git.is_amending = function() return false end + + Buffer.setup({ auto_generate = true, auto_generate_delay = 0, window_stability_delay = 0 }) + + local bufnr = vim.api.nvim_create_buf(false, true) + local filetype_cb + for _, item in ipairs(autocmds) do + if item.event == "FileType" then + filetype_cb = item.opts.callback + end + end + + filetype_cb({ buf = bufnr }) + + for _, item in ipairs(autocmds) do + if item.event == "WinEnter" then + item.opts.callback({ buf = bufnr }) + end + end + + for _, item in ipairs(autocmds) do + if item.event == "BufDelete" then + item.opts.callback({ buf = bufnr }) + elseif type(item.event) == "table" then + for _, event in ipairs(item.event) do + if event == "BufDelete" then + item.opts.callback({ buf = bufnr }) + break + end + end + end + end + + return stopped + ]]) + h.eq(1, result) +end + +return T diff --git a/tests/test_command_builder.lua b/tests/test_command_builder.lua index b0a5bb4..ef400b5 100644 --- a/tests/test_command_builder.lua +++ b/tests/test_command_builder.lua @@ -90,6 +90,7 @@ T["diff"]["adds file path"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.diff(false, "test.lua") ]]) + h.eq(true, result:find("%-%-") ~= nil) h.eq(true, result:find("test.lua") ~= nil) end @@ -99,6 +100,7 @@ T["diff"]["adds both staged and file"] = function() return Command.CommandBuilder.diff(true, "test.lua") ]]) h.eq(true, result:find("--cached") ~= nil) + h.eq(true, result:find("%-%-") ~= nil) h.eq(true, result:find("test.lua") ~= nil) end @@ -136,6 +138,7 @@ T["stage"]["handles single file as string"] = function() return Command.CommandBuilder.stage("test.lua") ]]) h.eq(true, result:find("git add") ~= nil) + h.eq(true, result:find("%-%-") ~= nil) h.eq(true, result:find("test.lua") ~= nil) end @@ -145,6 +148,7 @@ T["stage"]["handles multiple files"] = function() return Command.CommandBuilder.stage({"a.lua", "b.lua"}) ]]) h.eq(true, result:find("git add") ~= nil) + h.eq(true, result:find("%-%-") ~= nil) h.eq(true, result:find("a.lua") ~= nil) h.eq(true, result:find("b.lua") ~= nil) end @@ -157,6 +161,7 @@ T["unstage"]["returns correct command"] = function() return Command.CommandBuilder.unstage("test.lua") ]]) h.eq(true, result:find("git reset HEAD") ~= nil) + h.eq(true, result:find("%-%-") ~= nil) h.eq(true, result:find("test.lua") ~= nil) end @@ -248,6 +253,7 @@ T["blame"]["returns basic command"] = function() return Command.CommandBuilder.blame("test.lua") ]]) h.eq(true, result:find("git blame") ~= nil) + h.eq(true, result:find("%-%-") ~= nil) h.eq(true, result:find("test.lua") ~= nil) end @@ -258,6 +264,7 @@ T["blame"]["adds line range"] = function() ]]) h.eq(true, result:find("-L") ~= nil) h.eq(true, result:find("10,20") ~= nil) + h.eq(true, result:find("%-%-") ~= nil) end T["blame"]["adds line start with default range"] = function() @@ -730,7 +737,11 @@ 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" + return type(cmd) == "table" + and cmd[1] == "git" + and cmd[2] == "check-ignore" + and cmd[3] == "--" + and cmd[4] == "test.lua" ]]) h.eq(true, result) end diff --git a/tests/test_command_executor.lua b/tests/test_command_executor.lua new file mode 100644 index 0000000..e6686ce --- /dev/null +++ b/tests/test_command_executor.lua @@ -0,0 +1,112 @@ +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["run"] = new_set() + +T["run"]["returns success on ok command"] = function() + local result = child.lua([[ + local CommandExecutor = require("codecompanion._extensions.gitcommit.tools.command").CommandExecutor + local cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) and "cmd /c echo ok" or "printf 'ok'" + local success, output = CommandExecutor.run(cmd) + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("ok", result.output) +end + +T["run"]["does not treat fatal output as error with zero exit"] = function() + local result = child.lua([[ + local CommandExecutor = require("codecompanion._extensions.gitcommit.tools.command").CommandExecutor + local cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c echo fatal: boom" + or "printf 'fatal: boom'" + local success, output = CommandExecutor.run(cmd) + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("fatal:", result.output) +end + +T["run"]["returns error on non-zero exit"] = function() + local result = child.lua([[ + local CommandExecutor = require("codecompanion._extensions.gitcommit.tools.command").CommandExecutor + local cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) and "cmd /c exit /b 1" or "false" + local success, output = CommandExecutor.run(cmd) + return { success = success, output = output } + ]]) + h.eq(false, result.success) +end + +T["run_array"] = new_set() + +T["run_array"]["returns success for array command"] = function() + local result = child.lua([[ + local CommandExecutor = require("codecompanion._extensions.gitcommit.tools.command").CommandExecutor + local cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and { "cmd", "/c", "echo ok" } + or { "sh", "-c", "printf 'ok'" } + local success, output = CommandExecutor.run_array(cmd) + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("ok", result.output) +end + +T["run_async"] = new_set() + +T["run_async"]["aggregates stdout on success"] = function() + local result = child.lua([[ + local CommandExecutor = require("codecompanion._extensions.gitcommit.tools.command").CommandExecutor + local orig = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + opts.on_stdout(nil, { "line1", "line2" }) + opts.on_stderr(nil, {}) + opts.on_exit(nil, 0) + return 1 + end + + local out = nil + CommandExecutor.run_async({ "git", "status" }, function(result) + out = result + end) + vim.fn.jobstart = orig + return out + ]]) + h.eq("success", result.status) + h.eq("line1\nline2", result.data) +end + +T["run_async"]["aggregates stderr on error"] = function() + local result = child.lua([[ + local CommandExecutor = require("codecompanion._extensions.gitcommit.tools.command").CommandExecutor + local orig = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + opts.on_stdout(nil, {}) + opts.on_stderr(nil, { "err1", "err2" }) + opts.on_exit(nil, 1) + return 1 + end + + local out = nil + CommandExecutor.run_async({ "git", "status" }, function(result) + out = result + end) + vim.fn.jobstart = orig + return out + ]]) + h.eq("error", result.status) + h.eq("err1\nerr2", result.data) +end + +return T diff --git a/tests/test_config.lua b/tests/test_config.lua index a4bcc03..8e4f927 100644 --- a/tests/test_config.lua +++ b/tests/test_config.lua @@ -36,6 +36,7 @@ T["validate"]["returns valid for correct types"] = function() model = "gpt-4", languages = { "English", "Chinese" }, exclude_files = { "*.log" }, + buffer = { window_stability_delay = 200 }, add_slash_command = true, gitcommit_select_count = 100, use_commit_history = true, diff --git a/tests/test_generator.lua b/tests/test_generator.lua index 3651ab5..36aa21c 100644 --- a/tests/test_generator.lua +++ b/tests/test_generator.lua @@ -129,7 +129,7 @@ T["build_commit_prompt"]["includes commit history when provided"] = function() 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 + return prompt:find("BEGIN HISTORY") ~= nil ]]) h.eq(true, result) end @@ -148,7 +148,7 @@ 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 + return prompt:find("BEGIN HISTORY") == nil ]]) h.eq(true, result) end @@ -157,7 +157,7 @@ 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 + return prompt:find("BEGIN HISTORY") == nil ]]) h.eq(true, result) end @@ -189,4 +189,206 @@ T["build_commit_prompt"]["defaults to English when lang is nil"] = function() h.eq(true, result) end +T["generate_commit_message"] = new_set() + +T["generate_commit_message"]["returns error when adapter cannot resolve"] = function() + local result = child.lua([[ + package.preload["codecompanion.adapters"] = function() + return { resolve = function() return nil end } + end + package.preload["codecompanion.schema"] = function() + return { get_default = function() return {} end } + end + package.preload["codecompanion.http"] = function() + return { new = function() return {} end } + end + + local Generator = require("codecompanion._extensions.gitcommit.generator") + Generator.setup("missing", nil) + local out, err + Generator.generate_commit_message("diff", "English", nil, function(result, error) + out = result + err = error + end) + local function norm(value) + return value == nil and vim.NIL or value + end + return { out = norm(out), err = norm(err) } + ]]) + h.eq(vim.NIL, result.out) + h.expect_match("Failed to resolve adapter", result.err) +end + +T["generate_commit_message"]["returns error for unsupported adapter type"] = function() + local result = child.lua([[ + package.preload["codecompanion.adapters"] = function() + return { resolve = function() return { type = "unknown" } end } + end + package.preload["codecompanion.schema"] = function() + return { get_default = function() return {} end } + end + package.preload["codecompanion.http"] = function() + return { new = function() return {} end } + end + + local Generator = require("codecompanion._extensions.gitcommit.generator") + local out, err + Generator.generate_commit_message("diff", "English", nil, function(result, error) + out = result + err = error + end) + local function norm(value) + return value == nil and vim.NIL or value + end + return { out = norm(out), err = norm(err) } + ]]) + h.eq(vim.NIL, result.out) + h.expect_match("Invalid or unsupported adapter type", result.err) +end + +T["generate_commit_message"]["cleans streamed HTTP output"] = function() + local result = child.lua([[ + package.preload["codecompanion.adapters"] = function() + return { + resolve = function() + return { + type = "http", + name = "test", + formatted_name = "Test", + schema = { model = { default = "model" } }, + map_schema_to_params = function(self) return self end, + map_roles = function(_, messages) return messages end, + } + end, + call_handler = function(_, _, chunk) + return { status = "success", output = { content = chunk } } + end, + } + end + package.preload["codecompanion.schema"] = function() + return { get_default = function() return {} end } + end + package.preload["codecompanion.http"] = function() + return { + new = function() + return { + send = function(_, _, opts) + opts.on_chunk("```") + opts.on_chunk("\nfeat: add feature\n") + opts.on_chunk("```") + opts.on_done() + end, + } + end, + } + end + + local Generator = require("codecompanion._extensions.gitcommit.generator") + local out, err + Generator.generate_commit_message("diff", "English", nil, function(result, error) + out = result + err = error + end) + local function norm(value) + return value == nil and vim.NIL or value + end + return { out = norm(out), err = norm(err) } + ]]) + h.eq("feat: add feature", result.out) + h.eq(vim.NIL, result.err) +end + +T["generate_commit_message"]["returns error when ACP returns empty response"] = function() + local result = child.lua([[ + package.preload["codecompanion.adapters"] = function() + return { resolve = function() return { type = "acp", name = "acp" } end } + end + package.preload["codecompanion.schema"] = function() + return { get_default = function() return {} end } + end + package.preload["codecompanion.acp"] = function() + return { + new = function() + local client = {} + function client:connect_and_initialize() return true end + function client:session_prompt(_) return self end + function client:with_options(_) return self end + function client:on_message_chunk(fn) self._on_chunk = fn; return self end + function client:on_complete(fn) self._on_complete = fn; return self end + function client:on_error(fn) self._on_error = fn; return self end + function client:send() + if self._on_complete then + self._on_complete("stop") + end + end + function client:disconnect() _G._acp_disconnected = true end + return client + end, + } + end + + local Generator = require("codecompanion._extensions.gitcommit.generator") + local out, err + Generator.generate_commit_message("diff", "English", nil, function(result, error) + out = result + err = error + end) + local function norm(value) + return value == nil and vim.NIL or value + end + return { out = norm(out), err = norm(err), disconnected = _G._acp_disconnected } + ]]) + h.eq(vim.NIL, result.out) + h.expect_match("ACP returned empty response", result.err) + h.eq(true, result.disconnected) +end + +T["generate_commit_message"]["returns ACP content and disconnects"] = function() + local result = child.lua([[ + package.preload["codecompanion.adapters"] = function() + return { resolve = function() return { type = "acp", name = "acp" } end } + end + package.preload["codecompanion.schema"] = function() + return { get_default = function() return {} end } + end + package.preload["codecompanion.acp"] = function() + return { + new = function() + local client = {} + function client:connect_and_initialize() return true end + function client:session_prompt(_) return self end + function client:with_options(_) return self end + function client:on_message_chunk(fn) self._on_chunk = fn; return self end + function client:on_complete(fn) self._on_complete = fn; return self end + function client:on_error(fn) self._on_error = fn; return self end + function client:send() + if self._on_chunk then + self._on_chunk("feat: acp") + end + if self._on_complete then + self._on_complete("stop") + end + end + function client:disconnect() _G._acp_disconnected = true end + return client + end, + } + end + + local Generator = require("codecompanion._extensions.gitcommit.generator") + local out, err + Generator.generate_commit_message("diff", "English", nil, function(result, error) + out = result + err = error + end) + local function norm(value) + return value == nil and vim.NIL or value + end + return { out = norm(out), err = norm(err), disconnected = _G._acp_disconnected } + ]]) + h.eq("feat: acp", result.out) + h.eq(vim.NIL, result.err) + h.eq(true, result.disconnected) +end + return T diff --git a/tests/test_git.lua b/tests/test_git.lua index 89394e5..6e1a111 100644 --- a/tests/test_git.lua +++ b/tests/test_git.lua @@ -333,4 +333,467 @@ diff --git a/cache.tmp b/cache.tmp h.eq("", result) end +-- ============================================================================= +-- Git.get_contextual_diff +-- ============================================================================= + +T["get_contextual_diff"] = new_set() + +T["get_contextual_diff"]["returns staged diff when available"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + Git.is_amending = function() return false end + Git._filter_diff = function(diff) return diff end + vim.fn.system = function(cmd) + if cmd == "git diff --no-ext-diff --staged" then + return "diff content" + end + return "" + end + local diff, context = Git.get_contextual_diff() + return { diff, context } + ]]) + h.eq("diff content", result[1]) + h.eq("staged", result[2]) +end + +T["get_contextual_diff"]["returns amend with parent diff"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + Git.is_amending = function() return true end + Git._filter_diff = function(diff) return diff end + vim.fn.system = function(cmd) + if cmd == "git diff --no-ext-diff --staged" then + return "" + end + if cmd == "git diff --no-ext-diff HEAD~1" then + return "amend parent diff" + end + return "" + end + local diff, context = Git.get_contextual_diff() + return { diff, context } + ]]) + h.eq("amend parent diff", result[1]) + h.eq("amend_with_parent", result[2]) +end + +T["get_contextual_diff"]["returns amend initial diff"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + Git.is_amending = function() return true end + Git._filter_diff = function(diff) return diff end + vim.fn.system = function(cmd) + if cmd == "git diff --no-ext-diff --staged" then + return "" + end + if cmd == "git diff --no-ext-diff HEAD~1" then + return "" + end + if cmd == "git show --no-ext-diff --format= HEAD" then + return "amend initial diff" + end + return "" + end + local diff, context = Git.get_contextual_diff() + return { diff, context } + ]]) + h.eq("amend initial diff", result[1]) + h.eq("amend_initial", result[2]) +end + +T["get_contextual_diff"]["returns unstaged diff when no staged"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + Git.is_amending = function() return false end + Git._filter_diff = function(diff) return diff end + vim.fn.system = function(cmd) + if cmd == "git diff --no-ext-diff --staged" then + return "" + end + if cmd == "git diff --no-ext-diff HEAD" then + return "unstaged diff" + end + return "" + end + local diff, context = Git.get_contextual_diff() + return { diff, context } + ]]) + h.eq("unstaged diff", result[1]) + h.eq("unstaged_or_all_local", result[2]) +end + +T["get_contextual_diff"]["falls back to git diff when HEAD missing"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + Git.is_amending = function() return false end + Git._filter_diff = function(diff) return diff end + + local orig = vim.fn.system + local fail_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) and "cmd /c exit /b 1" or "false" + local ok_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) and "cmd /c exit /b 0" or "true" + + vim.fn.system = function(cmd) + if cmd == "git diff --no-ext-diff --staged" then + orig(ok_cmd) + return "" + end + if cmd == "git diff --no-ext-diff HEAD" then + orig(fail_cmd) + return "" + end + if cmd == "git diff --no-ext-diff" then + orig(ok_cmd) + return "unstaged diff" + end + return "" + end + + local diff, context = Git.get_contextual_diff() + return { diff, context } + ]]) + h.eq("unstaged diff", result[1]) + h.eq("unstaged_or_all_local", result[2]) +end + +T["get_contextual_diff"]["returns no changes after filter"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + Git.is_amending = function() return false end + Git._filter_diff = function() return "" end + vim.fn.system = function(cmd) + if cmd == "git diff --no-ext-diff --staged" then + return "diff content" + end + return "" + end + local diff, context = Git.get_contextual_diff() + return { diff, context } + ]]) + h.eq(vim.NIL, result[1]) + h.eq("no_changes_after_filter", result[2]) +end + +T["get_contextual_diff"]["returns no changes"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + Git.is_amending = function() return false end + Git._filter_diff = function(diff) return diff end + vim.fn.system = function(_cmd) + return "" + end + local diff, context = Git.get_contextual_diff() + return { diff, context } + ]]) + h.eq(vim.NIL, result[1]) + h.eq("no_changes", result[2]) +end + +T["get_contextual_diff"]["returns git operation failed on error"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + vim.fn.system = function() + error("boom") + end + local diff, context = Git.get_contextual_diff() + return { diff, context } + ]]) + h.eq(vim.NIL, result[1]) + h.eq("git_operation_failed", result[2]) +end + +-- ============================================================================= +-- Git.get_commit_history +-- ============================================================================= + +T["get_commit_history"] = new_set() + +T["get_commit_history"]["returns nil when not in repo"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return false end + return Git.get_commit_history(5) + ]]) + h.eq(vim.NIL, result) +end + +T["get_commit_history"]["returns trimmed commit messages"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + vim.fn.system = function(_cmd) + return "feat: one\n\nfix: two\n \n" + end + return Git.get_commit_history(5) + ]]) + h.eq({ "feat: one", "fix: two" }, result) +end + +-- ============================================================================= +-- Git.is_repository cache +-- ============================================================================= + +T["is_repository_cache"] = new_set() + +T["is_repository_cache"]["uses cached result within ttl"] = function() + local result = child.lua([[ + package.loaded["codecompanion._extensions.gitcommit.git"] = nil + local Git = require("codecompanion._extensions.gitcommit.git") + + local calls = 0 + vim.fn.getcwd = function() return "/tmp/repo" end + vim.uv.now = function() return 1000 end + vim.uv.fs_stat = function(path) + if path:match("%.git$") then + calls = calls + 1 + return {} + end + return nil + end + + local first = Git.is_repository() + local second = Git.is_repository() + return { first = first, second = second, calls = calls } + ]]) + h.eq(true, result.first) + h.eq(true, result.second) + h.eq(1, result.calls) +end + +T["is_repository_cache"]["refreshes after ttl expiry"] = function() + local result = child.lua([[ + package.loaded["codecompanion._extensions.gitcommit.git"] = nil + local Git = require("codecompanion._extensions.gitcommit.git") + + local calls = 0 + local times = { 0, 2000 } + local idx = 0 + vim.fn.getcwd = function() return "/tmp/repo" end + vim.uv.now = function() + idx = idx + 1 + return times[idx] or 3000 + end + vim.uv.fs_stat = function(path) + if path:match("%.git$") then + calls = calls + 1 + return {} + end + return nil + end + + local first = Git.is_repository() + local second = Git.is_repository() + return { first = first, second = second, calls = calls } + ]]) + h.eq(true, result.first) + h.eq(true, result.second) + h.eq(2, result.calls) +end + +T["is_repository_fallback"] = new_set() + +T["is_repository_fallback"]["returns true when git rev-parse says true"] = function() + local result = child.lua([[ + package.loaded["codecompanion._extensions.gitcommit.git"] = nil + local Git = require("codecompanion._extensions.gitcommit.git") + + local orig_system = vim.fn.system + vim.fn.getcwd = function() return "/tmp/no_repo" end + vim.uv.fs_stat = function(_) return nil end + vim.fn.system = function(_cmd) + local ok_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c exit /b 0" + or "true" + orig_system(ok_cmd) + return "true" + end + + local result = Git.is_repository() + vim.fn.system = orig_system + return result + ]]) + h.eq(true, result) +end + +T["is_repository_fallback"]["returns false when git rev-parse fails"] = function() + local result = child.lua([[ + package.loaded["codecompanion._extensions.gitcommit.git"] = nil + local Git = require("codecompanion._extensions.gitcommit.git") + + local orig_system = vim.fn.system + vim.fn.getcwd = function() return "/tmp/no_repo" end + vim.uv.fs_stat = function(_) return nil end + vim.fn.system = function(_cmd) + local fail_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c exit /b 1" + or "false" + orig_system(fail_cmd) + return "false" + end + + local result = Git.is_repository() + vim.fn.system = orig_system + return result + ]]) + h.eq(false, result) +end + +-- ============================================================================= +-- Git.get_staged_diff +-- ============================================================================= + +T["get_staged_diff"] = new_set() + +T["get_staged_diff"]["returns staged diff when present"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + Git.is_amending = function() return false end + Git._filter_diff = function(diff) return diff end + vim.fn.system = function(cmd) + if cmd == "git diff --no-ext-diff --staged" then + return "staged diff" + end + return "" + end + return Git.get_staged_diff() + ]]) + h.eq("staged diff", result) +end + +T["get_staged_diff"]["returns amend diff when amending"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + Git.is_amending = function() return true end + Git._filter_diff = function(diff) return diff end + vim.fn.system = function(cmd) + if cmd == "git diff --no-ext-diff --staged" then + return "" + end + if cmd == "git diff --no-ext-diff HEAD~1" then + return "amend diff" + end + return "" + end + return Git.get_staged_diff() + ]]) + h.eq("amend diff", result) +end + +T["get_staged_diff"]["returns nil when no changes"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + Git.is_amending = function() return false end + vim.fn.system = function(_cmd) + return "" + end + return Git.get_staged_diff() + ]]) + h.eq(vim.NIL, result) +end + +-- ============================================================================= +-- Git.commit_changes +-- ============================================================================= + +T["commit_changes"] = new_set() + +T["commit_changes"]["returns false when not in repo"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return false end + return Git.commit_changes("feat: test") + ]]) + h.eq(false, result) +end + +T["commit_changes"]["returns false when no diff"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + Git.get_contextual_diff = function() return nil, "no_changes" end + return Git.commit_changes("feat: test") + ]]) + h.eq(false, result) +end + +T["commit_changes"]["commits via system when diff present"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + Git.is_amending = function() return false end + Git.get_contextual_diff = function() return "diff", "staged" end + + local called = nil + vim.fn.system = function(cmd, input) + called = { cmd = cmd, input = input } + return "" + end + vim.notify = function() end + + local ok = Git.commit_changes("feat: test") + return { ok = ok, called = called } + ]]) + h.eq(true, result.ok) + h.expect_match("git commit", result.called.cmd) + h.eq("feat: test", result.called.input) +end + +T["commit_changes"]["returns false on git operation failure"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + Git.get_contextual_diff = function() return nil, "git_operation_failed" end + return Git.commit_changes("feat: test") + ]]) + h.eq(false, result) +end + +T["commit_changes"]["returns false when no changes after filter"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + Git.is_repository = function() return true end + Git.get_contextual_diff = function() return nil, "no_changes_after_filter" end + return Git.commit_changes("feat: test") + ]]) + h.eq(false, result) +end + +T["is_repository_cache"]["invalidates when cwd changes"] = function() + local result = child.lua([[ + package.loaded["codecompanion._extensions.gitcommit.git"] = nil + local Git = require("codecompanion._extensions.gitcommit.git") + + local calls = 0 + local cwd = "/tmp/repo1" + vim.fn.getcwd = function() return cwd end + vim.uv.now = function() return 1000 end + vim.uv.fs_stat = function(path) + if path:match("%.git$") then + calls = calls + 1 + return {} + end + return nil + end + + local first = Git.is_repository() + cwd = "/tmp/repo2" + local second = Git.is_repository() + return { first = first, second = second, calls = calls } + ]]) + h.eq(true, result.first) + h.eq(true, result.second) + h.eq(2, result.calls) +end + return T diff --git a/tests/test_git_tool.lua b/tests/test_git_tool.lua new file mode 100644 index 0000000..c0b7029 --- /dev/null +++ b/tests/test_git_tool.lua @@ -0,0 +1,918 @@ +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["gitignore"] = new_set() + +T["gitignore"]["reads existing .gitignore"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local CommandBuilder = Command.CommandBuilder + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + local dir = vim.fn.tempname() + vim.fn.mkdir(dir, "p") + local sep = package.config:sub(1, 1) + local path = dir .. sep .. ".gitignore" + local fd = vim.uv.fs_open(path, "w", 420) + vim.uv.fs_write(fd, "node_modules\n", 0) + vim.uv.fs_close(fd) + + CommandExecutor.run = function(cmd) + if cmd == CommandBuilder.repo_root() then + return true, dir + end + return false, "unexpected" + end + + local success, output = GitTool.get_gitignore() + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("node_modules", result.output) +end + +T["gitignore"]["handles missing .gitignore"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local CommandBuilder = Command.CommandBuilder + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + local dir = vim.fn.tempname() + vim.fn.mkdir(dir, "p") + + CommandExecutor.run = function(cmd) + if cmd == CommandBuilder.repo_root() then + return true, dir + end + return false, "unexpected" + end + + local success, output = GitTool.get_gitignore() + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.eq("", result.output) +end + +T["gitignore"]["adds and removes rules"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local CommandBuilder = Command.CommandBuilder + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + local dir = vim.fn.tempname() + vim.fn.mkdir(dir, "p") + local sep = package.config:sub(1, 1) + local path = dir .. sep .. ".gitignore" + local fd = vim.uv.fs_open(path, "w", 420) + vim.uv.fs_write(fd, "node_modules\n", 0) + vim.uv.fs_close(fd) + + CommandExecutor.run = function(cmd) + if cmd == CommandBuilder.repo_root() then + return true, dir + end + return false, "unexpected" + end + + local add_ok, add_msg = GitTool.add_gitignore_rule({ "node_modules", "dist/" }) + local remove_ok, remove_msg = GitTool.remove_gitignore_rule("dist/") + + local fd2 = vim.uv.fs_open(path, "r", 438) + local data = vim.uv.fs_read(fd2, vim.uv.fs_stat(path).size, 0) + vim.uv.fs_close(fd2) + + return { + add_ok = add_ok, + add_msg = add_msg, + remove_ok = remove_ok, + remove_msg = remove_msg, + content = data, + } + ]]) + h.eq(true, result.add_ok) + h.expect_match("dist/", result.add_msg) + h.eq(true, result.remove_ok) + h.expect_match("dist/", result.remove_msg) + h.expect_match("node_modules", result.content) + h.eq(nil, result.content:match("dist/")) +end + +T["is_ignored"] = new_set() + +T["is_ignored"]["returns success when ignored"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + CommandExecutor.run_array = function(_cmd) + return true, "ignored.txt" + end + + local success, output = GitTool.is_ignored("ignored.txt") + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("ignored.txt", result.output) +end + +T["is_ignored"]["returns error when not ignored"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + CommandExecutor.run_array = function(_cmd) + return false, "not ignored" + end + + local success, output = GitTool.is_ignored("file.txt") + return { success = success, output = output } + ]]) + h.eq(false, result.success) + h.expect_match("not ignored", result.output) +end + +T["conflicts"] = new_set() + +T["conflicts"]["cherry_pick reports conflict"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local orig_system = vim.fn.system + vim.fn.system = function(_cmd) + local fail_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c exit 1" + or "false" + orig_system(fail_cmd) + return "CONFLICT (content): conflict" + end + + local success, output = GitTool.cherry_pick("abc123") + return { success = success, output = output } + ]]) + h.eq(false, result.success) + h.expect_match("Cherry-pick conflict detected", result.output) +end + +T["conflicts"]["merge reports conflict"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local orig_system = vim.fn.system + vim.fn.system = function(_cmd) + local fail_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c exit 1" + or "false" + orig_system(fail_cmd) + return "CONFLICT (content): conflict" + end + + local success, output = GitTool.merge("feature/test") + return { success = success, output = output } + ]]) + h.eq(false, result.success) + h.expect_match("Merge conflict detected", result.output) +end + +T["conflicts"]["conflict status reports none"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local orig_system = vim.fn.system + vim.fn.system = function(_cmd) + local ok_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c echo." + or "printf ''" + return orig_system(ok_cmd) + end + + local success, output = GitTool.get_conflict_status() + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.eq("No conflicts found", result.output) +end + +T["conflicts"]["conflict status returns conflicted files"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local orig_system = vim.fn.system + vim.fn.system = function(_cmd) + local ok_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c echo file1 & echo file2" + or "printf 'file1\nfile2\n'" + return orig_system(ok_cmd) + end + + local success, output = GitTool.get_conflict_status() + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("file1", result.output) + h.expect_match("file2", result.output) +end + +T["conflicts"]["show_conflict finds markers"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local path = vim.fn.tempname() + local fd = vim.uv.fs_open(path, "w", 420) + local content = table.concat({ + "<<<<<<< HEAD", + "ours", + "=======", + "theirs", + ">>>>>>> branch", + }, "\n") + vim.uv.fs_write(fd, content, 0) + vim.uv.fs_close(fd) + + local success, output = GitTool.show_conflict(path) + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("<<<<<<<", result.output) + h.expect_match(">>>>>>>", result.output) +end + +T["conflicts"]["show_conflict reports no markers"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local path = vim.fn.tempname() + local fd = vim.uv.fs_open(path, "w", 420) + vim.uv.fs_write(fd, "clean file", 0) + vim.uv.fs_close(fd) + + local success, output = GitTool.show_conflict(path) + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("No conflict markers found", result.output) +end + +T["conflicts"]["conflict status reports failure"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local orig_system = vim.fn.system + vim.fn.system = function(_cmd) + local fail_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c exit /b 1" + or "false" + return orig_system(fail_cmd) + end + + local success, output = GitTool.get_conflict_status() + return { success = success, output = output } + ]]) + h.eq(false, result.success) + h.expect_match("Failed to get conflict status", result.output) +end + +T["conflicts"]["show_conflict reports missing file"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local path = vim.fn.tempname() + local success, output = GitTool.show_conflict(path) + return { success = success, output = output } + ]]) + h.eq(false, result.success) + h.expect_match("File not found", result.output) +end + +T["param_validation"] = new_set() + +T["param_validation"]["create_tag requires tag_name"] = function() + local result = child.lua([[ + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + local success, output = GitTool.create_tag(nil) + return { success = success, output = output } + ]]) + h.eq(false, result.success) + h.expect_match("Tag name is required", result.output) +end + +T["param_validation"]["delete_tag requires tag_name"] = function() + local result = child.lua([[ + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + local success, output = GitTool.delete_tag(nil) + return { success = success, output = output } + ]]) + h.eq(false, result.success) + h.expect_match("Tag name is required for deletion", result.output) +end + +T["param_validation"]["add_remote requires name and url"] = function() + local result = child.lua([[ + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + local success1, output1 = GitTool.add_remote(nil, "http://example.com") + local success2, output2 = GitTool.add_remote("origin", nil) + return { + success1 = success1, + output1 = output1, + success2 = success2, + output2 = output2, + } + ]]) + h.eq(false, result.success1) + h.expect_match("Remote name is required", result.output1) + h.eq(false, result.success2) + h.expect_match("Remote URL is required", result.output2) +end + +T["param_validation"]["rename_remote requires names"] = function() + local result = child.lua([[ + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + local success1, output1 = GitTool.rename_remote(nil, "new") + local success2, output2 = GitTool.rename_remote("old", nil) + return { + success1 = success1, + output1 = output1, + success2 = success2, + output2 = output2, + } + ]]) + h.eq(false, result.success1) + h.expect_match("Current remote name is required", result.output1) + h.eq(false, result.success2) + h.expect_match("New remote name is required", result.output2) +end + +T["param_validation"]["set_remote_url requires name and url"] = function() + local result = child.lua([[ + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + local success1, output1 = GitTool.set_remote_url(nil, "http://example.com") + local success2, output2 = GitTool.set_remote_url("origin", nil) + return { + success1 = success1, + output1 = output1, + success2 = success2, + output2 = output2, + } + ]]) + h.eq(false, result.success1) + h.expect_match("Remote name is required", result.output1) + h.eq(false, result.success2) + h.expect_match("Remote URL is required", result.output2) +end + +T["write_ops"] = new_set() + +T["write_ops"]["commit requires message"] = function() + local result = child.lua([[ + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + local success, output = GitTool.commit("") + return { success = success, output = output } + ]]) + h.eq(false, result.success) + h.expect_match("Commit message is required", result.output) +end + +T["write_ops"]["commit fails when not in repo"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return false end + local success, output = GitTool.commit("feat: test") + return { success = success, output = output } + ]]) + h.eq(false, result.success) + h.expect_match("Not in a git repository", result.output) +end + +T["write_ops"]["push_async reports not in repo"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return false end + local payload = nil + GitTool.push_async("origin", "main", false, false, false, nil, function(res) + payload = res + end) + return payload + ]]) + h.eq("error", result.status) + h.expect_match("Not in a git repository", result.data) +end + +T["write_ops"]["merge_continue reports conflicts"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local orig_system = vim.fn.system + vim.fn.system = function(_cmd) + local fail_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c exit /b 1" + or "false" + orig_system(fail_cmd) + return "CONFLICT (content)" + end + + local success, output = GitTool.merge_continue() + return { success = success, output = output } + ]]) + h.eq(false, result.success) + h.expect_match("Conflicts still exist", result.output) +end + +T["write_ops"]["merge_abort reports no merge"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local orig_system = vim.fn.system + vim.fn.system = function(_cmd) + local fail_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c exit /b 1" + or "false" + orig_system(fail_cmd) + return "not merging" + end + + local success, output = GitTool.merge_abort() + return { success = success, output = output } + ]]) + h.eq(false, result.success) + h.expect_match("No merge in progress", result.output) +end + +T["write_ops"]["cherry_pick_continue reports conflicts"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local orig_system = vim.fn.system + vim.fn.system = function(_cmd) + local fail_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c exit /b 1" + or "false" + orig_system(fail_cmd) + return "CONFLICT (content)" + end + + local success, output = GitTool.cherry_pick_continue() + return { success = success, output = output } + ]]) + h.eq(false, result.success) + h.expect_match("Conflicts still exist", result.output) +end + +T["write_ops"]["cherry_pick_abort reports none in progress"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local orig_system = vim.fn.system + vim.fn.system = function(_cmd) + local fail_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c exit /b 1" + or "false" + orig_system(fail_cmd) + return "no cherry-pick in progress" + end + + local success, output = GitTool.cherry_pick_abort() + return { success = success, output = output } + ]]) + h.eq(false, result.success) + h.expect_match("No cherry-pick in progress", result.output) +end + +T["write_ops"]["merge_continue succeeds"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local orig_system = vim.fn.system + vim.fn.system = function(_cmd) + local ok_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c exit /b 0" + or "true" + return orig_system(ok_cmd) + end + + local success, output = GitTool.merge_continue() + return { success = success, output = output } + ]]) + h.eq(true, result.success) +end + +T["write_ops"]["cherry_pick_continue succeeds"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local orig_system = vim.fn.system + vim.fn.system = function(_cmd) + local ok_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c exit /b 0" + or "true" + return orig_system(ok_cmd) + end + + local success, output = GitTool.cherry_pick_continue() + return { success = success, output = output } + ]]) + h.eq(true, result.success) +end + +T["conflicts"]["conflict status formats user message"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + Git.is_repository = function() return true end + + local orig_system = vim.fn.system + vim.fn.system = function(_cmd) + local ok_cmd = (vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1) + and "cmd /c echo file1" + or "printf 'file1\n'" + return orig_system(ok_cmd) + end + + local success, output, user_msg, llm_msg = GitTool.get_conflict_status() + return { success = success, output = output, user_msg = user_msg, llm_msg = llm_msg } + ]]) + h.eq(true, result.success) + h.expect_match("file1", result.user_msg) + h.expect_match("", result.llm_msg) +end + +T["write_ops"]["commit uses executor when in repo"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + Git.is_repository = function() return true end + CommandExecutor.run = function(cmd) + return true, cmd + end + + local success, output = GitTool.commit("feat: test", false) + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("git commit", result.output) +end + +T["write_ops"]["push delegates to executor"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + Git.is_repository = function() return true end + CommandExecutor.run = function(cmd) + return true, cmd + end + + local success, output = GitTool.push("origin", "main", false, false, false, nil) + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("git push", result.output) +end + +T["write_ops"]["fetch delegates to executor"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + Git.is_repository = function() return true end + CommandExecutor.run = function(cmd) + return true, cmd + end + + local success, output = GitTool.fetch("origin", "main", false) + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("git fetch", result.output) +end + +T["write_ops"]["pull delegates to executor"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + Git.is_repository = function() return true end + CommandExecutor.run = function(cmd) + return true, cmd + end + + local success, output = GitTool.pull("origin", "main", false) + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("git pull", result.output) +end + +T["write_ops"]["stash delegates to executor"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + Git.is_repository = function() return true end + CommandExecutor.run = function(cmd) + return true, cmd + end + + local success, output = GitTool.stash("msg", true) + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("git stash", result.output) +end + +T["write_ops"]["reset delegates to executor"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + Git.is_repository = function() return true end + CommandExecutor.run = function(cmd) + return true, cmd + end + + local success, output = GitTool.reset("abc123", "soft") + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("git reset", result.output) +end + +T["write_ops"]["rebase delegates to executor"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + Git.is_repository = function() return true end + CommandExecutor.run = function(cmd) + return true, cmd + end + + local success, output = GitTool.rebase("main", "HEAD~2", true) + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("git rebase", result.output) +end + +T["read_ops"] = new_set() + +T["read_ops"]["get_log formats success output"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + Git.is_repository = function() return true end + CommandExecutor.run = function(_cmd) + return true, "commit1\ncommit2\n" + end + + local success, output, user_msg, llm_msg = GitTool.get_log(2, "oneline") + return { success = success, output = output, user_msg = user_msg, llm_msg = llm_msg } + ]]) + h.eq(true, result.success) + h.expect_match("commit1", result.user_msg) + h.expect_match("success:", result.llm_msg) +end + +T["read_ops"]["get_log formats empty output"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + Git.is_repository = function() return true end + CommandExecutor.run = function(_cmd) + return true, "" + end + + local success, output, user_msg, llm_msg = GitTool.get_log(2, "oneline") + return { success = success, output = output, user_msg = user_msg, llm_msg = llm_msg } + ]]) + h.eq(true, result.success) + h.expect_match("no commits found", result.user_msg) + h.expect_match("success:", result.llm_msg) +end + +T["read_ops"]["get_diff formats error output"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + Git.is_repository = function() return true end + CommandExecutor.run = function(_cmd) + return false, "diff failed" + end + + local success, output, user_msg, llm_msg = GitTool.get_diff(true, nil) + return { success = success, output = output, user_msg = user_msg, llm_msg = llm_msg } + ]]) + h.eq(false, result.success) + h.expect_match("diff failed", result.user_msg) + h.expect_match("fail:", result.llm_msg) +end + +T["read_ops"]["show_commit formats output"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + Git.is_repository = function() return true end + CommandExecutor.run = function(_cmd) + return true, "commit detail" + end + + local success, output, user_msg, llm_msg = GitTool.show_commit("abc123") + return { success = success, output = output, user_msg = user_msg, llm_msg = llm_msg } + ]]) + h.eq(true, result.success) + h.expect_match("commit detail", result.user_msg) + h.expect_match("success:", result.llm_msg) +end + +T["read_ops"]["get_status handles empty output"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + Git.is_repository = function() return true end + CommandExecutor.run = function(_cmd) + return true, "" + end + + local success, output, user_msg, llm_msg = GitTool.get_status() + return { success = success, output = output, user_msg = user_msg, llm_msg = llm_msg } + ]]) + h.eq(true, result.success) + h.expect_match("no changes found", result.user_msg) + h.expect_match("success:", result.llm_msg) +end + +T["read_ops"]["get_tags handles empty output"] = function() + local result = child.lua([[ + local Git = require("codecompanion._extensions.gitcommit.git") + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + Git.is_repository = function() return true end + CommandExecutor.run = function(_cmd) + return true, "" + end + + local success, output, user_msg, llm_msg = GitTool.get_tags() + return { success = success, output = output, user_msg = user_msg, llm_msg = llm_msg } + ]]) + h.eq(true, result.success) + h.expect_match("No tag data available", result.user_msg) + h.expect_match("success:", result.llm_msg) +end + +T["release_notes"] = new_set() + +T["release_notes"]["returns error when no tags"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local CommandBuilder = Command.CommandBuilder + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + CommandExecutor.run = function(cmd) + if cmd == CommandBuilder.tags_sorted() then + return true, "" + end + return false, "unexpected" + end + + local success, output = GitTool.generate_release_notes(nil, nil, "markdown") + return { success = success, output = output } + ]]) + h.eq(false, result.success) + h.expect_match("No tags found", result.output) +end + +T["release_notes"]["defaults tags and builds markdown"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local CommandBuilder = Command.CommandBuilder + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + CommandExecutor.run = function(cmd) + if cmd == CommandBuilder.tags_sorted() then + return true, "v2.0\nv1.0\n" + end + if cmd == CommandBuilder.release_notes_log("v1.0", "v2.0") then + local line1 = table.concat({ "abc123", "feat: add api", "me", "2024-01-01" }, "\x01") + local line2 = table.concat({ "def456", "fix: repair bug", "you", "2024-01-02" }, "\x01") + return true, line1 .. "\n" .. line2 + end + return false, "unexpected" + end + + local success, output = GitTool.generate_release_notes(nil, nil, "markdown") + return { success = success, output = output } + ]]) + h.eq(true, result.success) + h.expect_match("Release Notes: v1.0", result.output) + h.expect_match("feat: add api", result.output) + h.expect_match("fix: repair bug", result.output) +end + +T["release_notes"]["returns json output"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + local CommandExecutor = Command.CommandExecutor + local CommandBuilder = Command.CommandBuilder + local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool + + CommandExecutor.run = function(cmd) + if cmd == CommandBuilder.tags_sorted() then + return true, "v2.0\nv1.0\n" + end + if cmd == CommandBuilder.release_notes_log("v1.0", "v2.0") then + local line1 = table.concat({ "abc123", "feat: add api", "me", "2024-01-01" }, "\x01") + return true, line1 + end + return false, "unexpected" + end + + local success, output = GitTool.generate_release_notes(nil, nil, "json") + local decoded = vim.fn.json_decode(output) + return { success = success, decoded = decoded } + ]]) + h.eq(true, result.success) + h.eq("v1.0", result.decoded.from_tag) + h.eq("v2.0", result.decoded.to_tag) + h.eq(1, result.decoded.total_commits) +end + +return T diff --git a/tests/test_git_utils.lua b/tests/test_git_utils.lua index 249d1e6..52f536a 100644 --- a/tests/test_git_utils.lua +++ b/tests/test_git_utils.lua @@ -803,4 +803,40 @@ T["shell_quote_windows"]["robustness"]["handles percent signs"] = function() h.eq(true, result) end +T["conflict_markers"] = new_set() + +T["conflict_markers"]["detects conflict markers"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local content = table.concat({ + "<<<<<<< HEAD", + "ours", + "=======", + "theirs", + ">>>>>>> branch", + }, "\n") + return GitUtils.has_conflicts(content) + ]]) + h.eq(true, result) +end + +T["conflict_markers"]["parses conflict blocks"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local content = table.concat({ + "<<<<<<< HEAD", + "ours", + "=======", + "theirs", + ">>>>>>> branch", + "after", + }, "\n") + local conflicts = GitUtils.parse_conflicts(content) + return { count = #conflicts, block = conflicts[1] } + ]]) + h.eq(1, result.count) + h.expect_match("<<<<<<<", result.block) + h.expect_match(">>>>>>>", result.block) +end + return T diff --git a/tests/test_ui.lua b/tests/test_ui.lua index 206021d..14bc790 100644 --- a/tests/test_ui.lua +++ b/tests/test_ui.lua @@ -155,4 +155,23 @@ T["_calculate_dimensions"]["height includes padding"] = function() h.eq(true, result) end +T["_prepare_content"]["handles empty message"] = function() + local result = child.lua([[ + local UI = require("codecompanion._extensions.gitcommit.ui") + local content = UI._prepare_content("") + return content[4] + ]]) + h.eq("", result) +end + +T["_prepare_content"]["preserves long single line"] = function() + local result = child.lua([[ + local UI = require("codecompanion._extensions.gitcommit.ui") + local msg = string.rep("x", 200) + local content = UI._prepare_content(msg) + return content[4] + ]]) + h.eq(string.rep("x", 200), result) +end + return T From 7ee9b5b5aed63e19cf7b68265e8238a9b74c1cef Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 23 Dec 2025 19:15:08 +0800 Subject: [PATCH 2/5] refactor(gitcommit): add types and input validation Add EmmyLua annotations and nil-safety checks to release notes functions to ensure robust processing of commit data and version information. --- .../gitcommit/prompts/release_notes.lua | 79 +++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/lua/codecompanion/_extensions/gitcommit/prompts/release_notes.lua b/lua/codecompanion/_extensions/gitcommit/prompts/release_notes.lua index c3f2477..83820a3 100644 --- a/lua/codecompanion/_extensions/gitcommit/prompts/release_notes.lua +++ b/lua/codecompanion/_extensions/gitcommit/prompts/release_notes.lua @@ -1,5 +1,26 @@ local M = {} +---@alias ReleaseNotesStyle "detailed" | "concise" | "changelog" | "marketing" + +---@class CommitInfo +---@field hash string Full commit hash +---@field subject string Commit subject line +---@field body string|nil Commit body (optional) +---@field author string|nil Author name (optional) +---@field type string|nil Commit type from subject (feat, fix, etc.) + +---@class CommitAnalysis +---@field features CommitInfo[] +---@field fixes CommitInfo[] +---@field breaking_changes CommitInfo[] +---@field performance CommitInfo[] +---@field documentation CommitInfo[] +---@field refactoring CommitInfo[] +---@field tests CommitInfo[] +---@field chore CommitInfo[] +---@field other CommitInfo[] +---@field contributors table Author -> commit count + M.style_guides = { detailed = [[You are creating comprehensive release notes for developers. Write thorough explanations of changes with technical context. @@ -37,7 +58,14 @@ WRITING GUIDELINES: - Merge similar commits into single entries when appropriate ]] +---@param commit CommitInfo Commit information to format +---@param include_hash boolean Whether to include commit hash +---@return string Formatted commit entry local function format_commit(commit, include_hash) + if not commit or not commit.subject then + return "- (invalid commit data)" + end + local parts = { "- ", commit.subject } if include_hash and commit.hash then table.insert(parts, string.format(" (%s)", commit.hash:sub(1, 7))) @@ -51,8 +79,12 @@ local function format_commit(commit, include_hash) return table.concat(parts) end +---@param name string Category name +---@param commits CommitInfo[] List of commits in this category +---@param include_hash boolean Whether to include commit hashes +---@return string|nil Formatted category section or nil if empty local function format_category(name, commits, include_hash) - if #commits == 0 then + if not commits or #commits == 0 then return nil end local lines = { string.format("\n### %s", name) } @@ -62,7 +94,24 @@ local function format_category(name, commits, include_hash) return table.concat(lines, "\n") end +---@param commits CommitInfo[] List of commits to analyze +---@return CommitAnalysis Analysis results with categorized commits and contributor counts function M.analyze_commits(commits) + if type(commits) ~= "table" then + return { + features = {}, + fixes = {}, + breaking_changes = {}, + performance = {}, + documentation = {}, + refactoring = {}, + tests = {}, + chore = {}, + other = {}, + contributors = {}, + } + end + local analysis = { features = {}, fixes = {}, @@ -77,11 +126,18 @@ function M.analyze_commits(commits) } for _, commit in ipairs(commits) do - analysis.contributors[commit.author] = (analysis.contributors[commit.author] or 0) + 1 + -- Track contributors (skip nil authors) + if commit.author then + analysis.contributors[commit.author] = (analysis.contributors[commit.author] or 0) + 1 + end - local is_breaking = commit.subject:match("^%w+!:") -- feat!: xxx - or commit.subject:match("^%w+%b()!:") -- feat(scope)!: xxx - or commit.subject:upper():match("BREAKING") + -- Check for breaking changes (nil-safe) + local is_breaking = false + if commit.subject then + is_breaking = commit.subject:match("^%w+!:") -- feat!: xxx + or commit.subject:match("^%w+%b()!:") -- feat(scope)!: xxx + or commit.subject:upper():match("BREAKING") + end if is_breaking then table.insert(analysis.breaking_changes, commit) @@ -107,7 +163,20 @@ function M.analyze_commits(commits) return analysis end +---@param commits CommitInfo[] List of commits to analyze +---@param style ReleaseNotesStyle Style of release notes to generate +---@param version_info table { from: string, to: string } Version range information +---@return string Generated prompt for AI release notes generation function M.create_smart_prompt(commits, style, version_info) + -- Input validation + if type(commits) ~= "table" then + commits = {} + end + + if not version_info or type(version_info) ~= "table" or not version_info.from or not version_info.to then + version_info = { from = "unknown", to = "unknown" } + end + local analysis = M.analyze_commits(commits) local guide = M.style_guides[style] or M.style_guides.detailed From c91de17fcdeb154a23fa05f4941356368bf3224c Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 23 Dec 2025 19:40:40 +0800 Subject: [PATCH 3/5] refactor(gitcommit): migrate git commands to arrays Replace string shell commands and redirects with arrays in git.lua and command.lua. Replace vim.fn.system with CommandExecutor and replace vim.loop with vim.uv. --- .../_extensions/gitcommit/git.lua | 6 ++--- .../_extensions/gitcommit/git_utils.lua | 2 +- .../gitcommit/tools/ai_release_notes.lua | 22 +++++++++++-------- .../_extensions/gitcommit/tools/command.lua | 15 ++++++++----- tests/test_command_builder.lua | 10 ++++++++- 5 files changed, 35 insertions(+), 20 deletions(-) diff --git a/lua/codecompanion/_extensions/gitcommit/git.lua b/lua/codecompanion/_extensions/gitcommit/git.lua index 1b34c7c..65ef331 100644 --- a/lua/codecompanion/_extensions/gitcommit/git.lua +++ b/lua/codecompanion/_extensions/gitcommit/git.lua @@ -58,8 +58,7 @@ function Git.is_repository() check_dir = parent end - local redirect = (vim.uv.os_uname().sysname == "Windows_NT") and " 2>nul" or " 2>/dev/null" - local cmd = "git rev-parse --is-inside-work-tree" .. redirect + local cmd = { "git", "rev-parse", "--is-inside-work-tree" } local result = vim.fn.system(cmd) local is_repo = vim.v.shell_error == 0 and vim.trim(result) == "true" @@ -85,8 +84,7 @@ function Git.is_amending() return false end - 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) + vim.fn.system({ "git", "rev-parse", "--verify", "HEAD" }) if vim.v.shell_error ~= 0 then return false end diff --git a/lua/codecompanion/_extensions/gitcommit/git_utils.lua b/lua/codecompanion/_extensions/gitcommit/git_utils.lua index bccbde6..39158c6 100644 --- a/lua/codecompanion/_extensions/gitcommit/git_utils.lua +++ b/lua/codecompanion/_extensions/gitcommit/git_utils.lua @@ -217,7 +217,7 @@ end ---Check if running on Windows ---@return boolean function M.is_windows() - return vim.loop.os_uname().sysname == "Windows_NT" + return vim.uv.os_uname().sysname == "Windows_NT" end ---Quote a string for shell command (cross-platform) diff --git a/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua b/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua index 577a748..82224dd 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua @@ -1,6 +1,8 @@ local prompts = require("codecompanion._extensions.gitcommit.prompts.release_notes") local git_utils = require("codecompanion._extensions.gitcommit.git_utils") +local Command = require("codecompanion._extensions.gitcommit.tools.command") +local CommandExecutor = Command.CommandExecutor local shell_quote = git_utils.shell_quote ---@class CodeCompanion.GitCommit.Tools.AIReleaseNotes: CodeCompanion.Tools.Tool @@ -51,7 +53,9 @@ Output styles: - changelog: Developer-focused changelog format - marketing: User-friendly marketing release notes]] --- Helper function to get commit details with diffs +---@param from_ref string Starting reference (tag or commit hash) +---@param to_ref string Ending reference (tag or commit hash or HEAD) +---@return table|nil, string|nil Commits array and error message local function get_detailed_commits(from_ref, to_ref) -- Git range A..B = commits reachable from B but not from A -- This correctly excludes from_ref itself and includes up to to_ref @@ -63,8 +67,8 @@ local function get_detailed_commits(from_ref, to_ref) 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 + local success, output = CommandExecutor.run(commit_cmd) + if not success then return nil, "Failed to get commit history" end @@ -79,12 +83,12 @@ local function get_detailed_commits(from_ref, to_ref) for _, entry in ipairs(commit_entries) do if entry and vim.trim(entry) ~= "" then - -- Find the first non-empty line with commit info + -- Find first non-empty line with commit info local lines = vim.split(entry, "\n") local commit_line = nil local body_start_idx = 1 - -- Find the line with the commit info (has || separators) + -- Find line with commit info (has || separators) for i, line in ipairs(lines) do if line:match("||") then commit_line = line @@ -145,8 +149,8 @@ AIReleaseNotes.cmds = { -- Get tags if not specified if not to_tag or not from_tag then -- Try to get tags sorted by version - local success, tags_output = pcall(vim.fn.system, "git tag --sort=-version:refname") - if success and vim.v.shell_error == 0 and tags_output and vim.trim(tags_output) ~= "" then + local success, tags_output = CommandExecutor.run("git tag --sort=-version:refname") + if success and tags_output and vim.trim(tags_output) ~= "" then local tags = {} for tag in tags_output:gmatch("[^\r\n]+") do local trimmed = vim.trim(tag) @@ -171,8 +175,8 @@ AIReleaseNotes.cmds = { elseif #tags == 1 then -- Only one tag, get first commit as starting point local first_commit_cmd = "git rev-list --max-parents=0 HEAD" - local fc_success, first_commit_output = pcall(vim.fn.system, first_commit_cmd) - if fc_success and vim.v.shell_error == 0 then + local fc_success, first_commit_output = CommandExecutor.run(first_commit_cmd) + if fc_success and first_commit_output and vim.trim(first_commit_output) ~= "" then from_tag = vim.trim(first_commit_output):sub(1, 8) else -- Fallback to 10 commits ago diff --git a/lua/codecompanion/_extensions/gitcommit/tools/command.lua b/lua/codecompanion/_extensions/gitcommit/tools/command.lua index 9635bc8..01a98d5 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/command.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/command.lua @@ -523,10 +523,15 @@ function CommandBuilder.pull(remote, branch, rebase) end ---Build git rev-parse command (check repo) ----@return string command +---@return string[] command array 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 + return { "git", "rev-parse", "--is-inside-work-tree" } +end + +---Build git rev-parse --verify HEAD command +---@return string[] command array +function CommandBuilder.verify_head() + return { "git", "rev-parse", "--verify", "HEAD" } end ---Build git rev-parse git-dir command @@ -631,8 +636,8 @@ end ---@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" + local ok, result = CommandExecutor.run_array(cmd) + return ok and vim.trim(result) == "true" end ---Execute a git command with repo check diff --git a/tests/test_command_builder.lua b/tests/test_command_builder.lua index ef400b5..6ad43b4 100644 --- a/tests/test_command_builder.lua +++ b/tests/test_command_builder.lua @@ -714,7 +714,15 @@ T["utility"]["is_inside_work_tree"] = function() 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) + h.eq({ "git", "rev-parse", "--is-inside-work-tree" }, result) +end + +T["utility"]["verify_head"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.verify_head() + ]]) + h.eq({ "git", "rev-parse", "--verify", "HEAD" }, result) end T["utility"]["git_dir"] = function() From 9860d7310e40011f711429a86827e7e346ca19e3 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 23 Dec 2025 20:24:13 +0800 Subject: [PATCH 4/5] refactor(gitcommit): replace command strings with arrays Replace string-based command generation in CommandBuilder with arrays to improve cross-platform reliability. Update CommandExecutor and associated tests to handle the new format and remove manual shell quoting logic. --- .../_extensions/gitcommit/tools/command.lua | 353 ++++++++---------- .../_extensions/gitcommit/tools/git.lua | 2 +- tests/helpers.lua | 16 +- tests/test_command_builder.lua | 302 ++++++++------- tests/test_git_tool.lua | 37 +- 5 files changed, 348 insertions(+), 362 deletions(-) diff --git a/lua/codecompanion/_extensions/gitcommit/tools/command.lua b/lua/codecompanion/_extensions/gitcommit/tools/command.lua index 01a98d5..20362b2 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/command.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/command.lua @@ -1,16 +1,16 @@ ---@class CodeCompanion.GitCommit.Tools.Command ---Command building and execution utilities for git operations. ---Separates pure command generation (testable) from side-effectful execution. +---All command builders return arrays for cross-platform compatibility. local M = {} local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") local is_windows = GitUtils.is_windows -local shell_quote = GitUtils.shell_quote -------------------------------------------------------------------------------- --- CommandBuilder: Pure functions for generating git command strings +-- CommandBuilder: Pure functions for generating git command arrays -- These are easily testable without requiring a git repository -------------------------------------------------------------------------------- @@ -35,216 +35,218 @@ CommandBuilder.RESET_MODES = { } ---Build git status command ----@return string command +---@return string[] command array function CommandBuilder.status() - return "git status --porcelain" + 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 +---@return string[] command array 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) + return { "git", "log", "-" .. count, format_option } end ---Build git diff command ---@param staged? boolean Show staged changes ---@param file? string Specific file path ----@return string command +---@return string[] command array function CommandBuilder.diff(staged, file) - local parts = { "git", "diff" } + local cmd = { "git", "diff" } if staged then - table.insert(parts, "--cached") + table.insert(cmd, "--cached") end if file then - table.insert(parts, "--") - table.insert(parts, vim.fn.shellescape(file)) + table.insert(cmd, "--") + table.insert(cmd, file) end - return table.concat(parts, " ") + return cmd end ---Build git branch command (show current) ----@return string command +---@return string[] command array function CommandBuilder.current_branch() - return "git branch --show-current" + return { "git", "branch", "--show-current" } end ---Build git branch list command ---@param remote_only? boolean Show only remote branches ----@return string command +---@return string[] command array function CommandBuilder.branches(remote_only) - return remote_only and "git branch -r" or "git branch -a" + if remote_only then + return { "git", "branch", "-r" } + else + return { "git", "branch", "-a" } + end end ---Build git add command ---@param files string|string[] Files to stage ----@return string command +---@return string[] command array function CommandBuilder.stage(files) if type(files) == "string" then files = { files } end - local escaped_files = {} + local cmd = { "git", "add", "--" } for _, file in ipairs(files) do - table.insert(escaped_files, vim.fn.shellescape(file)) + table.insert(cmd, file) end - return "git add -- " .. table.concat(escaped_files, " ") + return cmd end ---Build git reset (unstage) command ---@param files string|string[] Files to unstage ----@return string command +---@return string[] command array function CommandBuilder.unstage(files) if type(files) == "string" then files = { files } end - local escaped_files = {} + local cmd = { "git", "reset", "HEAD", "--" } for _, file in ipairs(files) do - table.insert(escaped_files, vim.fn.shellescape(file)) + table.insert(cmd, file) end - return "git reset HEAD -- " .. table.concat(escaped_files, " ") + return cmd end ---Build git commit command ---@param message string Commit message ---@param amend? boolean Amend the last commit ----@return string command +---@return string[] command array function CommandBuilder.commit(message, amend) - local parts = { "git", "commit" } + local cmd = { "git", "commit" } if amend then - table.insert(parts, "--amend") + table.insert(cmd, "--amend") end - table.insert(parts, "-m") - table.insert(parts, vim.fn.shellescape(message)) - return table.concat(parts, " ") + table.insert(cmd, "-m") + table.insert(cmd, message) + return cmd 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 +---@return string[] command array 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) + if checkout then + return { "git", "checkout", "-b", branch_name } + else + return { "git", "branch", branch_name } + end end ---Build git checkout command ---@param target string Branch name or commit hash ----@return string command +---@return string[] command array function CommandBuilder.checkout(target) - return "git checkout " .. vim.fn.shellescape(target) + return { "git", "checkout", target } end ---Build git remote command ----@return string command +---@return string[] command array function CommandBuilder.remotes() - return "git remote -v" + return { "git", "remote", "-v" } end ---Build git show command ---@param commit_hash? string Commit hash (default: HEAD) ----@return string command +---@return string[] command array function CommandBuilder.show(commit_hash) commit_hash = commit_hash or "HEAD" - return "git show " .. vim.fn.shellescape(commit_hash) + return { "git", "show", 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 +---@return string[] command array function CommandBuilder.blame(file_path, line_start, line_end) - local parts = { "git", "blame" } + local cmd = { "git", "blame" } if line_start and line_end then - table.insert(parts, "-L") - table.insert(parts, line_start .. "," .. line_end) + table.insert(cmd, "-L") + table.insert(cmd, line_start .. "," .. line_end) elseif line_start then - table.insert(parts, "-L") - table.insert(parts, line_start .. ",+10") + table.insert(cmd, "-L") + table.insert(cmd, line_start .. ",+10") end - table.insert(parts, "--") - table.insert(parts, vim.fn.shellescape(file_path)) - return table.concat(parts, " ") + table.insert(cmd, "--") + table.insert(cmd, file_path) + return cmd end ---Build git stash command ---@param message? string Stash message ---@param include_untracked? boolean Include untracked files ----@return string command +---@return string[] command array function CommandBuilder.stash(message, include_untracked) - local parts = { "git", "stash" } + local cmd = { "git", "stash" } if include_untracked then - table.insert(parts, "-u") + table.insert(cmd, "-u") end if message then - table.insert(parts, "-m") - table.insert(parts, vim.fn.shellescape(message)) + table.insert(cmd, "-m") + table.insert(cmd, message) end - return table.concat(parts, " ") + return cmd end ---Build git stash list command ----@return string command +---@return string[] command array function CommandBuilder.stash_list() - return "git stash list" + return { "git", "stash", "list" } end ---Build git stash apply command ---@param stash_ref? string Stash reference (default: stash@{0}) ----@return string command +---@return string[] command array function CommandBuilder.stash_apply(stash_ref) stash_ref = stash_ref or "stash@{0}" - return "git stash apply " .. vim.fn.shellescape(stash_ref) + return { "git", "stash", "apply", 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 +---@return string[] command array 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)) + return { "git", "reset", mode_flag, 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 +---@return string[] command array function CommandBuilder.diff_commits(commit1, commit2, file_path) commit2 = commit2 or "HEAD" - local parts = { - "git", - "diff", - vim.fn.shellescape(commit1), - vim.fn.shellescape(commit2), - } + local cmd = { "git", "diff", commit1, commit2 } if file_path then - table.insert(parts, "--") - table.insert(parts, vim.fn.shellescape(file_path)) + table.insert(cmd, "--") + table.insert(cmd, file_path) end - return table.concat(parts, " ") + return cmd end ---Build git shortlog (contributors) command ----@return string command +---@return string[] command array function CommandBuilder.contributors() - return "git shortlog -sn" + return { "git", "shortlog", "-sn" } end ---Build git log search command ---@param pattern string Search pattern ---@param count? number Maximum number of results ----@return string command +---@return string[] command array function CommandBuilder.search_commits(pattern, count) count = count or 20 - return string.format("git log --grep=%s --oneline -%d", vim.fn.shellescape(pattern), count) + return { "git", "log", "--grep=" .. pattern, "--oneline", "-" .. count } end ---Build git push command @@ -254,45 +256,8 @@ end ---@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) +function CommandBuilder.push(remote, branch, force, set_upstream, tags, tag_name) local cmd = { "git", "push" } if force then table.insert(cmd, "--force") @@ -301,6 +266,7 @@ function CommandBuilder.push_array(remote, branch, force, set_upstream, tags, ta 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 table.insert(cmd, remote or "origin") table.insert(cmd, tag_name) @@ -322,204 +288,202 @@ end ---@param onto? string Branch to rebase onto ---@param base? string Upstream branch ---@param interactive? boolean Interactive rebase ----@return string command +---@return string[] command array function CommandBuilder.rebase(onto, base, interactive) - local parts = { "git", "rebase" } + local cmd = { "git", "rebase" } if interactive then - table.insert(parts, "--interactive") + table.insert(cmd, "--interactive") end if onto then - table.insert(parts, "--onto") - table.insert(parts, vim.fn.shellescape(onto)) + table.insert(cmd, "--onto") + table.insert(cmd, onto) end if base then - table.insert(parts, vim.fn.shellescape(base)) + table.insert(cmd, base) end - return table.concat(parts, " ") + return cmd end ---Build git cherry-pick command ---@param commit_hash string Commit hash ----@return string command +---@return string[] command array function CommandBuilder.cherry_pick(commit_hash) - return "git cherry-pick --no-edit " .. vim.fn.shellescape(commit_hash) + return { "git", "cherry-pick", "--no-edit", commit_hash } end ---Build git cherry-pick abort command ----@return string command +---@return string[] command array function CommandBuilder.cherry_pick_abort() - return "git cherry-pick --abort" + return { "git", "cherry-pick", "--abort" } end ---Build git cherry-pick continue command ----@return string command +---@return string[] command array function CommandBuilder.cherry_pick_continue() - return "git cherry-pick --continue" + return { "git", "cherry-pick", "--continue" } end ---Build git cherry-pick skip command ----@return string command +---@return string[] command array function CommandBuilder.cherry_pick_skip() - return "git cherry-pick --skip" + return { "git", "cherry-pick", "--skip" } end ---Build git revert command ---@param commit_hash string Commit hash ----@return string command +---@return string[] command array function CommandBuilder.revert(commit_hash) - return "git revert --no-edit " .. vim.fn.shellescape(commit_hash) + return { "git", "revert", "--no-edit", commit_hash } end ---Build git tag list command ----@return string command +---@return string[] command array function CommandBuilder.tags() - return "git tag" + return { "git", "tag" } end ---Build git tag sorted command ----@return string command +---@return string[] command array function CommandBuilder.tags_sorted() - return "git tag --sort=-version:refname" + 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 +---@return string[] command array function CommandBuilder.create_tag(tag_name, message, commit_hash) - local parts = { "git", "tag" } + local cmd = { "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)) + table.insert(cmd, "-a") + table.insert(cmd, tag_name) + table.insert(cmd, "-m") + table.insert(cmd, message) else - table.insert(parts, vim.fn.shellescape(tag_name)) + table.insert(cmd, tag_name) end if commit_hash then - table.insert(parts, vim.fn.shellescape(commit_hash)) + table.insert(cmd, commit_hash) end - return table.concat(parts, " ") + return cmd end ---Build git delete tag command ---@param tag_name string Tag name ---@param remote? string Remote to delete from ----@return string command +---@return string[] command array function CommandBuilder.delete_tag(tag_name, remote) if remote then - return "git push --delete " .. vim.fn.shellescape(remote) .. " " .. vim.fn.shellescape(tag_name) + return { "git", "push", "--delete", remote, tag_name } else - return "git tag -d " .. vim.fn.shellescape(tag_name) + return { "git", "tag", "-d", tag_name } end end ---Build git merge command ---@param branch string Branch to merge ----@return string command +---@return string[] command array function CommandBuilder.merge(branch) - return "git merge " .. vim.fn.shellescape(branch) .. " --no-edit" + return { "git", "merge", branch, "--no-edit" } end ---Build git merge abort command ----@return string command +---@return string[] command array function CommandBuilder.merge_abort() - return "git merge --abort" + return { "git", "merge", "--abort" } end ---Build git merge continue command ----@return string command +---@return string[] command array function CommandBuilder.merge_continue() - return "git merge --continue" + return { "git", "merge", "--continue" } end ---Build git diff conflict status command ----@return string command +---@return string[] command array function CommandBuilder.conflict_status() - return "git diff --name-only --diff-filter=U" + 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 +---@return string[] command array 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 + return { "git", "log", "--pretty=format:%h\x01%s\x01%an\x01%ad", "--date=short", range } end ---Build git remote add command ---@param name string Remote name ---@param url string Remote URL ----@return string command +---@return string[] command array function CommandBuilder.add_remote(name, url) - return "git remote add " .. vim.fn.shellescape(name) .. " " .. vim.fn.shellescape(url) + return { "git", "remote", "add", name, url } end ---Build git remote remove command ---@param name string Remote name ----@return string command +---@return string[] command array function CommandBuilder.remove_remote(name) - return "git remote remove " .. vim.fn.shellescape(name) + return { "git", "remote", "remove", name } end ---Build git remote rename command ---@param old_name string Current name ---@param new_name string New name ----@return string command +---@return string[] command array function CommandBuilder.rename_remote(old_name, new_name) - return "git remote rename " .. vim.fn.shellescape(old_name) .. " " .. vim.fn.shellescape(new_name) + return { "git", "remote", "rename", old_name, new_name } end ---Build git remote set-url command ---@param name string Remote name ---@param url string New URL ----@return string command +---@return string[] command array function CommandBuilder.set_remote_url(name, url) - return "git remote set-url " .. vim.fn.shellescape(name) .. " " .. vim.fn.shellescape(url) + return { "git", "remote", "set-url", name, 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 +---@return string[] command array function CommandBuilder.fetch(remote, branch, prune) - local parts = { "git", "fetch" } + local cmd = { "git", "fetch" } if prune then - table.insert(parts, "--prune") + table.insert(cmd, "--prune") end if remote then - table.insert(parts, vim.fn.shellescape(remote)) + table.insert(cmd, remote) if branch then - table.insert(parts, vim.fn.shellescape(branch)) + table.insert(cmd, branch) end else - table.insert(parts, "--all") + table.insert(cmd, "--all") end - return table.concat(parts, " ") + return cmd end ---Build git pull command ---@param remote? string Remote name ---@param branch? string Branch name ---@param rebase? boolean Use rebase ----@return string command +---@return string[] command array function CommandBuilder.pull(remote, branch, rebase) - local parts = { "git", "pull" } + local cmd = { "git", "pull" } if rebase then - table.insert(parts, "--rebase") + table.insert(cmd, "--rebase") end if remote then - table.insert(parts, vim.fn.shellescape(remote)) + table.insert(cmd, remote) if branch then - table.insert(parts, vim.fn.shellescape(branch)) + table.insert(cmd, branch) end end - return table.concat(parts, " ") + return cmd end ---Build git rev-parse command (check repo) @@ -535,20 +499,20 @@ function CommandBuilder.verify_head() end ---Build git rev-parse git-dir command ----@return string command +---@return string[] command array function CommandBuilder.git_dir() - return "git rev-parse --git-dir" + return { "git", "rev-parse", "--git-dir" } end ---Build git rev-parse show-toplevel command ----@return string command +---@return string[] command array function CommandBuilder.repo_root() - return "git rev-parse --show-toplevel" + 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 +---@return string[] command array function CommandBuilder.check_ignore(file) return { "git", "check-ignore", "--", file } end @@ -560,8 +524,8 @@ end ---@class CodeCompanion.GitCommit.Tools.CommandExecutor local CommandExecutor = {} ----Execute a git command string ----@param cmd string Command to execute +---Execute a git command (string or array) +---@param cmd string|string[] Command to execute ---@return boolean success ---@return string output function CommandExecutor.run(cmd) @@ -578,22 +542,12 @@ function CommandExecutor.run(cmd) return true, output or "" end ----Execute a git command array (for better escaping) +---Execute a git command array (alias for run, kept for compatibility) ---@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 "" + return CommandExecutor.run(cmd) end ---Execute a git command asynchronously @@ -636,12 +590,12 @@ end ---@return boolean function CommandExecutor.is_git_repo() local cmd = CommandBuilder.is_inside_work_tree() - local ok, result = CommandExecutor.run_array(cmd) + local ok, result = CommandExecutor.run(cmd) return ok and vim.trim(result) == "true" end ---Execute a git command with repo check ----@param cmd string Command to execute +---@param cmd string|string[] Command to execute ---@return boolean success ---@return string output function CommandExecutor.run_in_repo(cmd) @@ -654,6 +608,5 @@ 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 4c9b389..e7ebc43 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git.lua @@ -435,7 +435,7 @@ function GitTool.push_async(remote, branch, force, set_upstream, tags, tag_name, on_exit({ status = "error", data = NOT_IN_REPO_MSG }) return end - local cmd = CommandBuilder.push_array(remote, branch, force, set_upstream, tags, tag_name) + local cmd = CommandBuilder.push(remote, branch, force, set_upstream, tags, tag_name) CommandExecutor.run_async(cmd, on_exit) end diff --git a/tests/helpers.lua b/tests/helpers.lua index 14fc25b..581bfe9 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -4,11 +4,25 @@ H.eq = MiniTest.expect.equality H.not_eq = MiniTest.expect.no_equality H.expect_match = MiniTest.new_expectation("string matching", function(pattern, str) - return str:find(pattern, 1, true) ~= nil -- plain text match + return str:find(pattern, 1, true) ~= nil end, function(pattern, str) return string.format("Pattern: %s\nObserved string: %s", vim.inspect(pattern), str) end) +H.expect_array_contains = MiniTest.new_expectation("array contains element", function(element, arr) + if type(arr) ~= "table" then + return false + end + for _, v in ipairs(arr) do + if v == element then + return true + end + end + return false +end, function(element, arr) + return string.format("Element: %s\nArray: %s", vim.inspect(element), vim.inspect(arr)) +end) + H.child_start = function(child) child.restart({ "-u", "tests/minimal_init.lua" }) end diff --git a/tests/test_command_builder.lua b/tests/test_command_builder.lua index 6ad43b4..e295ed2 100644 --- a/tests/test_command_builder.lua +++ b/tests/test_command_builder.lua @@ -12,6 +12,24 @@ local T = new_set({ }, }) +local function has_element(tbl, element) + for _, v in ipairs(tbl) do + if v == element then + return true + end + end + return false +end + +local function has_element_containing(tbl, pattern) + for _, v in ipairs(tbl) do + if type(v) == "string" and v:find(pattern) then + return true + end + end + return false +end + T["status"] = new_set() T["status"]["returns correct command"] = function() @@ -19,7 +37,7 @@ T["status"]["returns correct command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.status() ]]) - h.eq("git status --porcelain", result) + h.eq({ "git", "status", "--porcelain" }, result) end T["log"] = new_set() @@ -29,7 +47,7 @@ T["log"]["returns default command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.log() ]]) - h.eq("git log -10 --oneline", result) + h.eq({ "git", "log", "-10", "--oneline" }, result) end T["log"]["respects count parameter"] = function() @@ -37,7 +55,7 @@ T["log"]["respects count parameter"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.log(5) ]]) - h.eq("git log -5 --oneline", result) + h.eq({ "git", "log", "-5", "--oneline" }, result) end T["log"]["respects format parameter"] = function() @@ -45,7 +63,7 @@ T["log"]["respects format parameter"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.log(10, "short") ]]) - h.eq("git log -10 --pretty=short", result) + h.eq({ "git", "log", "-10", "--pretty=short" }, result) end T["log"]["handles all format types"] = function() @@ -60,11 +78,11 @@ T["log"]["handles all format types"] = function() 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) + 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() @@ -74,7 +92,7 @@ T["diff"]["returns basic command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.diff() ]]) - h.eq("git diff", result) + h.eq({ "git", "diff" }, result) end T["diff"]["adds cached flag for staged"] = function() @@ -82,7 +100,7 @@ T["diff"]["adds cached flag for staged"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.diff(true) ]]) - h.eq("git diff --cached", result) + h.eq({ "git", "diff", "--cached" }, result) end T["diff"]["adds file path"] = function() @@ -90,8 +108,7 @@ T["diff"]["adds file path"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.diff(false, "test.lua") ]]) - h.eq(true, result:find("%-%-") ~= nil) - h.eq(true, result:find("test.lua") ~= nil) + h.eq({ "git", "diff", "--", "test.lua" }, result) end T["diff"]["adds both staged and file"] = function() @@ -99,9 +116,7 @@ T["diff"]["adds both staged and file"] = function() 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("%-%-") ~= nil) - h.eq(true, result:find("test.lua") ~= nil) + h.eq({ "git", "diff", "--cached", "--", "test.lua" }, result) end T["branch"] = new_set() @@ -111,7 +126,7 @@ T["branch"]["current_branch returns correct command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.current_branch() ]]) - h.eq("git branch --show-current", result) + h.eq({ "git", "branch", "--show-current" }, result) end T["branch"]["branches returns all branches by default"] = function() @@ -119,7 +134,7 @@ T["branch"]["branches returns all branches by default"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.branches() ]]) - h.eq("git branch -a", result) + h.eq({ "git", "branch", "-a" }, result) end T["branch"]["branches returns remote only when specified"] = function() @@ -127,7 +142,7 @@ T["branch"]["branches returns remote only when specified"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.branches(true) ]]) - h.eq("git branch -r", result) + h.eq({ "git", "branch", "-r" }, result) end T["stage"] = new_set() @@ -137,9 +152,7 @@ T["stage"]["handles single file as string"] = function() 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("%-%-") ~= nil) - h.eq(true, result:find("test.lua") ~= nil) + h.eq({ "git", "add", "--", "test.lua" }, result) end T["stage"]["handles multiple files"] = function() @@ -147,10 +160,7 @@ T["stage"]["handles multiple files"] = function() 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("%-%-") ~= nil) - h.eq(true, result:find("a.lua") ~= nil) - h.eq(true, result:find("b.lua") ~= nil) + h.eq({ "git", "add", "--", "a.lua", "b.lua" }, result) end T["unstage"] = new_set() @@ -160,9 +170,7 @@ T["unstage"]["returns correct command"] = function() 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("%-%-") ~= nil) - h.eq(true, result:find("test.lua") ~= nil) + h.eq({ "git", "reset", "HEAD", "--", "test.lua" }, result) end T["commit"] = new_set() @@ -172,9 +180,7 @@ T["commit"]["returns basic commit command"] = function() 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) + h.eq({ "git", "commit", "-m", "test message" }, result) end T["commit"]["adds amend flag"] = function() @@ -182,7 +188,7 @@ T["commit"]["adds amend flag"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.commit("test message", true) ]]) - h.eq(true, result:find("--amend") ~= nil) + h.eq({ "git", "commit", "--amend", "-m", "test message" }, result) end T["create_branch"] = new_set() @@ -192,8 +198,7 @@ T["create_branch"]["creates and checks out by default"] = function() 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) + h.eq({ "git", "checkout", "-b", "feature/test" }, result) end T["create_branch"]["creates without checkout when specified"] = function() @@ -201,8 +206,7 @@ T["create_branch"]["creates without checkout when specified"] = function() 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) + h.eq({ "git", "branch", "feature/test" }, result) end T["checkout"] = new_set() @@ -212,8 +216,7 @@ T["checkout"]["returns correct command"] = function() 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) + h.eq({ "git", "checkout", "main" }, result) end T["remotes"] = new_set() @@ -223,7 +226,7 @@ T["remotes"]["returns correct command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.remotes() ]]) - h.eq("git remote -v", result) + h.eq({ "git", "remote", "-v" }, result) end T["show"] = new_set() @@ -233,8 +236,7 @@ T["show"]["defaults to HEAD"] = function() 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) + h.eq({ "git", "show", "HEAD" }, result) end T["show"]["accepts commit hash"] = function() @@ -242,7 +244,7 @@ T["show"]["accepts commit hash"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.show("abc123") ]]) - h.eq(true, result:find("abc123") ~= nil) + h.eq({ "git", "show", "abc123" }, result) end T["blame"] = new_set() @@ -252,9 +254,7 @@ T["blame"]["returns basic command"] = function() 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("%-%-") ~= nil) - h.eq(true, result:find("test.lua") ~= nil) + h.eq({ "git", "blame", "--", "test.lua" }, result) end T["blame"]["adds line range"] = function() @@ -262,9 +262,7 @@ T["blame"]["adds line range"] = function() 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) - h.eq(true, result:find("%-%-") ~= nil) + h.eq({ "git", "blame", "-L", "10,20", "--", "test.lua" }, result) end T["blame"]["adds line start with default range"] = function() @@ -272,8 +270,7 @@ T["blame"]["adds line start with default range"] = function() 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) + h.eq({ "git", "blame", "-L", "10,+10", "--", "test.lua" }, result) end T["stash"] = new_set() @@ -283,7 +280,7 @@ T["stash"]["returns basic command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.stash() ]]) - h.eq("git stash", result) + h.eq({ "git", "stash" }, result) end T["stash"]["adds untracked flag"] = function() @@ -291,7 +288,7 @@ T["stash"]["adds untracked flag"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.stash(nil, true) ]]) - h.eq(true, result:find("-u") ~= nil) + h.eq({ "git", "stash", "-u" }, result) end T["stash"]["adds message"] = function() @@ -299,8 +296,7 @@ T["stash"]["adds message"] = function() 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) + h.eq({ "git", "stash", "-m", "WIP" }, result) end T["stash_list"] = new_set() @@ -310,7 +306,7 @@ T["stash_list"]["returns correct command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.stash_list() ]]) - h.eq("git stash list", result) + h.eq({ "git", "stash", "list" }, result) end T["stash_apply"] = new_set() @@ -320,8 +316,7 @@ T["stash_apply"]["defaults to stash@{0}"] = function() 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) + h.eq({ "git", "stash", "apply", "stash@{0}" }, result) end T["stash_apply"]["accepts custom ref"] = function() @@ -329,7 +324,7 @@ T["stash_apply"]["accepts custom ref"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.stash_apply("stash@{2}") ]]) - h.eq(true, result:find("stash@{2}") ~= nil) + h.eq({ "git", "stash", "apply", "stash@{2}" }, result) end T["reset"] = new_set() @@ -339,9 +334,7 @@ T["reset"]["defaults to mixed mode"] = function() 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) + h.eq({ "git", "reset", "--mixed", "HEAD~1" }, result) end T["reset"]["handles all modes"] = function() @@ -354,9 +347,9 @@ T["reset"]["handles all modes"] = function() 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) + h.eq({ "git", "reset", "--soft", "HEAD~1" }, modes.soft) + h.eq({ "git", "reset", "--mixed", "HEAD~1" }, modes.mixed) + h.eq({ "git", "reset", "--hard", "HEAD~1" }, modes.hard) end T["diff_commits"] = new_set() @@ -366,9 +359,7 @@ T["diff_commits"]["compares two commits"] = function() 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) + h.eq({ "git", "diff", "abc123", "def456" }, result) end T["diff_commits"]["defaults second commit to HEAD"] = function() @@ -376,7 +367,7 @@ T["diff_commits"]["defaults second commit to HEAD"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.diff_commits("abc123") ]]) - h.eq(true, result:find("HEAD") ~= nil) + h.eq({ "git", "diff", "abc123", "HEAD" }, result) end T["diff_commits"]["adds file path"] = function() @@ -384,8 +375,7 @@ T["diff_commits"]["adds file path"] = function() 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) + h.eq({ "git", "diff", "abc123", "def456", "--", "test.lua" }, result) end T["push"] = new_set() @@ -395,7 +385,7 @@ T["push"]["returns basic command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.push() ]]) - h.eq("git push", result) + h.eq({ "git", "push" }, result) end T["push"]["adds remote and branch"] = function() @@ -403,8 +393,7 @@ T["push"]["adds remote and branch"] = function() 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) + h.eq({ "git", "push", "origin", "main" }, result) end T["push"]["adds force flag"] = function() @@ -412,7 +401,7 @@ T["push"]["adds force flag"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.push("origin", "main", true) ]]) - h.eq(true, result:find("--force") ~= nil) + h.eq({ "git", "push", "--force", "origin", "main" }, result) end T["push"]["adds set-upstream flag"] = function() @@ -420,7 +409,7 @@ T["push"]["adds set-upstream flag"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.push("origin", "main", false, true) ]]) - h.eq(true, result:find("%-%-set%-upstream") ~= nil) + h.eq({ "git", "push", "--set-upstream", "origin", "main" }, result) end T["push"]["handles tags flag"] = function() @@ -428,8 +417,7 @@ T["push"]["handles tags flag"] = function() 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) + h.eq({ "git", "push", "origin", "--tags" }, result) end T["push"]["handles single tag"] = function() @@ -437,19 +425,7 @@ T["push"]["handles single tag"] = function() 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) + h.eq({ "git", "push", "origin", "v1.0.0" }, result) end T["cherry_pick"] = new_set() @@ -459,9 +435,7 @@ T["cherry_pick"]["returns correct command"] = function() 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) + h.eq({ "git", "cherry-pick", "--no-edit", "abc123" }, result) end T["cherry_pick_abort"] = new_set() @@ -471,7 +445,7 @@ T["cherry_pick_abort"]["returns correct command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.cherry_pick_abort() ]]) - h.eq("git cherry-pick --abort", result) + h.eq({ "git", "cherry-pick", "--abort" }, result) end T["cherry_pick_continue"] = new_set() @@ -481,7 +455,7 @@ T["cherry_pick_continue"]["returns correct command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.cherry_pick_continue() ]]) - h.eq("git cherry-pick --continue", result) + h.eq({ "git", "cherry-pick", "--continue" }, result) end T["cherry_pick_skip"] = new_set() @@ -491,7 +465,7 @@ T["cherry_pick_skip"]["returns correct command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.cherry_pick_skip() ]]) - h.eq("git cherry-pick --skip", result) + h.eq({ "git", "cherry-pick", "--skip" }, result) end T["revert"] = new_set() @@ -501,9 +475,7 @@ T["revert"]["returns correct command"] = function() 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) + h.eq({ "git", "revert", "--no-edit", "abc123" }, result) end T["tags"] = new_set() @@ -513,7 +485,7 @@ T["tags"]["returns correct command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.tags() ]]) - h.eq("git tag", result) + h.eq({ "git", "tag" }, result) end T["tags_sorted"] = new_set() @@ -523,7 +495,7 @@ T["tags_sorted"]["returns correct command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.tags_sorted() ]]) - h.eq("git tag --sort=-version:refname", result) + h.eq({ "git", "tag", "--sort=-version:refname" }, result) end T["create_tag"] = new_set() @@ -533,8 +505,7 @@ T["create_tag"]["creates lightweight tag"] = function() 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) + h.eq({ "git", "tag", "v1.0.0" }, result) end T["create_tag"]["creates annotated tag"] = function() @@ -542,9 +513,7 @@ T["create_tag"]["creates annotated tag"] = function() 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) + h.eq({ "git", "tag", "-a", "v1.0.0", "-m", "Release 1.0" }, result) end T["create_tag"]["tags specific commit"] = function() @@ -552,7 +521,7 @@ T["create_tag"]["tags specific commit"] = function() 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) + h.eq({ "git", "tag", "v1.0.0", "abc123" }, result) end T["delete_tag"] = new_set() @@ -562,8 +531,7 @@ T["delete_tag"]["deletes local tag"] = function() 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) + h.eq({ "git", "tag", "-d", "v1.0.0" }, result) end T["delete_tag"]["deletes remote tag"] = function() @@ -571,8 +539,7 @@ T["delete_tag"]["deletes remote tag"] = function() 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) + h.eq({ "git", "push", "--delete", "origin", "v1.0.0" }, result) end T["merge"] = new_set() @@ -582,9 +549,7 @@ T["merge"]["returns correct command"] = function() 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) + h.eq({ "git", "merge", "feature/test", "--no-edit" }, result) end T["merge_abort"] = new_set() @@ -594,7 +559,7 @@ T["merge_abort"]["returns correct command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.merge_abort() ]]) - h.eq("git merge --abort", result) + h.eq({ "git", "merge", "--abort" }, result) end T["merge_continue"] = new_set() @@ -604,7 +569,7 @@ T["merge_continue"]["returns correct command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.merge_continue() ]]) - h.eq("git merge --continue", result) + h.eq({ "git", "merge", "--continue" }, result) end T["conflict_status"] = new_set() @@ -614,7 +579,35 @@ T["conflict_status"]["returns correct command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.conflict_status() ]]) - h.eq("git diff --name-only --diff-filter=U", result) + h.eq({ "git", "diff", "--name-only", "--diff-filter=U" }, result) +end + +T["contributors"] = new_set() + +T["contributors"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.contributors() + ]]) + h.eq({ "git", "shortlog", "-sn" }, result) +end + +T["search_commits"] = new_set() + +T["search_commits"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.search_commits("fix") + ]]) + h.eq({ "git", "log", "--grep=fix", "--oneline", "-20" }, result) +end + +T["search_commits"]["respects count parameter"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.search_commits("feat", 10) + ]]) + h.eq({ "git", "log", "--grep=feat", "--oneline", "-10" }, result) end T["remote_operations"] = new_set() @@ -624,8 +617,7 @@ T["remote_operations"]["add_remote"] = function() 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) + h.eq({ "git", "remote", "add", "upstream", "https://github.com/test/repo.git" }, result) end T["remote_operations"]["remove_remote"] = function() @@ -633,8 +625,7 @@ T["remote_operations"]["remove_remote"] = function() 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) + h.eq({ "git", "remote", "remove", "upstream" }, result) end T["remote_operations"]["rename_remote"] = function() @@ -642,7 +633,7 @@ T["remote_operations"]["rename_remote"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.rename_remote("origin", "upstream") ]]) - h.eq(true, result:find("git remote rename") ~= nil) + h.eq({ "git", "remote", "rename", "origin", "upstream" }, result) end T["remote_operations"]["set_remote_url"] = function() @@ -650,7 +641,7 @@ T["remote_operations"]["set_remote_url"] = function() 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) + h.eq({ "git", "remote", "set-url", "origin", "https://new-url.git" }, result) end T["fetch"] = new_set() @@ -660,8 +651,7 @@ T["fetch"]["fetches all by default"] = function() 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) + h.eq({ "git", "fetch", "--all" }, result) end T["fetch"]["fetches specific remote"] = function() @@ -669,7 +659,7 @@ T["fetch"]["fetches specific remote"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.fetch("origin") ]]) - h.eq(true, result:find("origin") ~= nil) + h.eq({ "git", "fetch", "origin" }, result) end T["fetch"]["adds prune flag"] = function() @@ -677,7 +667,15 @@ T["fetch"]["adds prune flag"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.fetch(nil, nil, true) ]]) - h.eq(true, result:find("--prune") ~= nil) + h.eq({ "git", "fetch", "--prune", "--all" }, result) +end + +T["fetch"]["fetches remote with branch"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.fetch("origin", "main") + ]]) + h.eq({ "git", "fetch", "origin", "main" }, result) end T["pull"] = new_set() @@ -687,7 +685,7 @@ T["pull"]["returns basic command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.pull() ]]) - h.eq("git pull", result) + h.eq({ "git", "pull" }, result) end T["pull"]["adds remote and branch"] = function() @@ -695,8 +693,7 @@ T["pull"]["adds remote and branch"] = function() 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) + h.eq({ "git", "pull", "origin", "main" }, result) end T["pull"]["adds rebase flag"] = function() @@ -704,7 +701,7 @@ T["pull"]["adds rebase flag"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.pull("origin", "main", true) ]]) - h.eq(true, result:find("--rebase") ~= nil) + h.eq({ "git", "pull", "--rebase", "origin", "main" }, result) end T["utility"] = new_set() @@ -730,7 +727,7 @@ T["utility"]["git_dir"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.git_dir() ]]) - h.eq("git rev-parse --git-dir", result) + h.eq({ "git", "rev-parse", "--git-dir" }, result) end T["utility"]["repo_root"] = function() @@ -738,20 +735,15 @@ T["utility"]["repo_root"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.repo_root() ]]) - h.eq("git rev-parse --show-toplevel", result) + 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] == "--" - and cmd[4] == "test.lua" + return Command.CommandBuilder.check_ignore("test.lua") ]]) - h.eq(true, result) + h.eq({ "git", "check-ignore", "--", "test.lua" }, result) end T["rebase"] = new_set() @@ -761,7 +753,7 @@ T["rebase"]["returns basic command"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.rebase() ]]) - h.eq("git rebase", result) + h.eq({ "git", "rebase" }, result) end T["rebase"]["adds onto flag"] = function() @@ -769,8 +761,7 @@ T["rebase"]["adds onto flag"] = function() 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) + h.eq({ "git", "rebase", "--onto", "main" }, result) end T["rebase"]["adds interactive flag"] = function() @@ -778,7 +769,28 @@ T["rebase"]["adds interactive flag"] = function() local Command = require("codecompanion._extensions.gitcommit.tools.command") return Command.CommandBuilder.rebase(nil, nil, true) ]]) - h.eq(true, result:find("--interactive") ~= nil) + h.eq({ "git", "rebase", "--interactive" }, result) +end + +T["rebase"]["adds base branch"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.rebase(nil, "develop") + ]]) + h.eq({ "git", "rebase", "develop" }, result) +end + +T["release_notes_log"] = new_set() + +T["release_notes_log"]["returns correct command"] = function() + local result = child.lua([[ + local Command = require("codecompanion._extensions.gitcommit.tools.command") + return Command.CommandBuilder.release_notes_log("v1.0.0", "v1.1.0") + ]]) + h.eq(result[1], "git") + h.eq(result[2], "log") + h.eq(result[4], "--date=short") + h.eq(result[5], "v1.0.0..v1.1.0") end return T diff --git a/tests/test_git_tool.lua b/tests/test_git_tool.lua index c0b7029..fb51b71 100644 --- a/tests/test_git_tool.lua +++ b/tests/test_git_tool.lua @@ -30,7 +30,7 @@ T["gitignore"]["reads existing .gitignore"] = function() vim.uv.fs_close(fd) CommandExecutor.run = function(cmd) - if cmd == CommandBuilder.repo_root() then + if vim.deep_equal(cmd, CommandBuilder.repo_root()) then return true, dir end return false, "unexpected" @@ -54,7 +54,7 @@ T["gitignore"]["handles missing .gitignore"] = function() vim.fn.mkdir(dir, "p") CommandExecutor.run = function(cmd) - if cmd == CommandBuilder.repo_root() then + if vim.deep_equal(cmd, CommandBuilder.repo_root()) then return true, dir end return false, "unexpected" @@ -83,7 +83,7 @@ T["gitignore"]["adds and removes rules"] = function() vim.uv.fs_close(fd) CommandExecutor.run = function(cmd) - if cmd == CommandBuilder.repo_root() then + if vim.deep_equal(cmd, CommandBuilder.repo_root()) then return true, dir end return false, "unexpected" @@ -597,7 +597,8 @@ T["write_ops"]["commit uses executor when in repo"] = function() return { success = success, output = output } ]]) h.eq(true, result.success) - h.expect_match("git commit", result.output) + h.expect_array_contains("git", result.output) + h.expect_array_contains("commit", result.output) end T["write_ops"]["push delegates to executor"] = function() @@ -616,7 +617,8 @@ T["write_ops"]["push delegates to executor"] = function() return { success = success, output = output } ]]) h.eq(true, result.success) - h.expect_match("git push", result.output) + h.expect_array_contains("git", result.output) + h.expect_array_contains("push", result.output) end T["write_ops"]["fetch delegates to executor"] = function() @@ -635,7 +637,8 @@ T["write_ops"]["fetch delegates to executor"] = function() return { success = success, output = output } ]]) h.eq(true, result.success) - h.expect_match("git fetch", result.output) + h.expect_array_contains("git", result.output) + h.expect_array_contains("fetch", result.output) end T["write_ops"]["pull delegates to executor"] = function() @@ -654,7 +657,8 @@ T["write_ops"]["pull delegates to executor"] = function() return { success = success, output = output } ]]) h.eq(true, result.success) - h.expect_match("git pull", result.output) + h.expect_array_contains("git", result.output) + h.expect_array_contains("pull", result.output) end T["write_ops"]["stash delegates to executor"] = function() @@ -673,7 +677,8 @@ T["write_ops"]["stash delegates to executor"] = function() return { success = success, output = output } ]]) h.eq(true, result.success) - h.expect_match("git stash", result.output) + h.expect_array_contains("git", result.output) + h.expect_array_contains("stash", result.output) end T["write_ops"]["reset delegates to executor"] = function() @@ -692,7 +697,8 @@ T["write_ops"]["reset delegates to executor"] = function() return { success = success, output = output } ]]) h.eq(true, result.success) - h.expect_match("git reset", result.output) + h.expect_array_contains("git", result.output) + h.expect_array_contains("reset", result.output) end T["write_ops"]["rebase delegates to executor"] = function() @@ -711,7 +717,8 @@ T["write_ops"]["rebase delegates to executor"] = function() return { success = success, output = output } ]]) h.eq(true, result.success) - h.expect_match("git rebase", result.output) + h.expect_array_contains("git", result.output) + h.expect_array_contains("rebase", result.output) end T["read_ops"] = new_set() @@ -846,7 +853,7 @@ T["release_notes"]["returns error when no tags"] = function() local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool CommandExecutor.run = function(cmd) - if cmd == CommandBuilder.tags_sorted() then + if vim.deep_equal(cmd, CommandBuilder.tags_sorted()) then return true, "" end return false, "unexpected" @@ -867,10 +874,10 @@ T["release_notes"]["defaults tags and builds markdown"] = function() local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool CommandExecutor.run = function(cmd) - if cmd == CommandBuilder.tags_sorted() then + if vim.deep_equal(cmd, CommandBuilder.tags_sorted()) then return true, "v2.0\nv1.0\n" end - if cmd == CommandBuilder.release_notes_log("v1.0", "v2.0") then + if vim.deep_equal(cmd, CommandBuilder.release_notes_log("v1.0", "v2.0")) then local line1 = table.concat({ "abc123", "feat: add api", "me", "2024-01-01" }, "\x01") local line2 = table.concat({ "def456", "fix: repair bug", "you", "2024-01-02" }, "\x01") return true, line1 .. "\n" .. line2 @@ -895,10 +902,10 @@ T["release_notes"]["returns json output"] = function() local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool CommandExecutor.run = function(cmd) - if cmd == CommandBuilder.tags_sorted() then + if vim.deep_equal(cmd, CommandBuilder.tags_sorted()) then return true, "v2.0\nv1.0\n" end - if cmd == CommandBuilder.release_notes_log("v1.0", "v2.0") then + if vim.deep_equal(cmd, CommandBuilder.release_notes_log("v1.0", "v2.0")) then local line1 = table.concat({ "abc123", "feat: add api", "me", "2024-01-01" }, "\x01") return true, line1 end From 106517daace16b25a050c4d5e58fce96fc884fdf Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Wed, 24 Dec 2025 00:32:46 +0800 Subject: [PATCH 5/5] refactor(gitcommit): use path_join for file paths Add GitUtils.path_join to handle platform-specific separators and normalization. Replace manual string concatenation in git.lua and tools/git.lua with the new helper. --- .../_extensions/gitcommit/git.lua | 6 +- .../_extensions/gitcommit/git_utils.lua | 32 ++++++ .../_extensions/gitcommit/tools/git.lua | 3 +- tests/test_git_utils.lua | 98 +++++++++++++++++++ 4 files changed, 133 insertions(+), 6 deletions(-) diff --git a/lua/codecompanion/_extensions/gitcommit/git.lua b/lua/codecompanion/_extensions/gitcommit/git.lua index 65ef331..90b2349 100644 --- a/lua/codecompanion/_extensions/gitcommit/git.lua +++ b/lua/codecompanion/_extensions/gitcommit/git.lua @@ -38,8 +38,7 @@ function Git.is_repository() end local function check_git_dir(path) - local sep = package.config:sub(1, 1) - local git_path = path .. sep .. ".git" + local git_path = GitUtils.path_join(path, ".git") local stat = vim.uv.fs_stat(git_path) return stat ~= nil end @@ -77,8 +76,7 @@ function Git.is_amending() return false end - local path_sep = package.config:sub(1, 1) - local commit_editmsg = git_dir .. path_sep .. "COMMIT_EDITMSG" + local commit_editmsg = GitUtils.path_join(git_dir, "COMMIT_EDITMSG") local stat = vim.uv.fs_stat(commit_editmsg) if not stat then return false diff --git a/lua/codecompanion/_extensions/gitcommit/git_utils.lua b/lua/codecompanion/_extensions/gitcommit/git_utils.lua index 39158c6..f29a5bb 100644 --- a/lua/codecompanion/_extensions/gitcommit/git_utils.lua +++ b/lua/codecompanion/_extensions/gitcommit/git_utils.lua @@ -220,6 +220,38 @@ function M.is_windows() return vim.uv.os_uname().sysname == "Windows_NT" end +---Join path parts with platform-specific separator +---Normalizes all parts to forward slashes internally, then converts to platform format +---@param ... string Path parts to join +---@return string joined_path +function M.path_join(...) + local parts = { ... } + if #parts == 0 then + return "" + end + + -- Normalize all parts to use forward slashes + local normalized = {} + for i, part in ipairs(parts) do + normalized[i] = part:gsub("\\+", "/") + end + + -- Remove leading/trailing slashes from middle parts + for i = 2, #normalized - 1 do + normalized[i] = normalized[i]:gsub("^/", ""):gsub("/$", "") + end + + -- Join with forward slashes + local result = table.concat(normalized, "/") + + -- Convert to Windows backslashes if on Windows + if M.is_windows() then + result = result:gsub("/", "\\") + end + + return result +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) diff --git a/lua/codecompanion/_extensions/gitcommit/tools/git.lua b/lua/codecompanion/_extensions/gitcommit/tools/git.lua index e7ebc43..065bde3 100644 --- a/lua/codecompanion/_extensions/gitcommit/tools/git.lua +++ b/lua/codecompanion/_extensions/gitcommit/tools/git.lua @@ -36,8 +36,7 @@ local function get_gitignore_path() if not git_dir or git_dir == "" then return nil end - local sep = package.config:sub(1, 1) - return git_dir .. sep .. ".gitignore" + return GitUtils.path_join(git_dir, ".gitignore") end function GitTool.get_gitignore() diff --git a/tests/test_git_utils.lua b/tests/test_git_utils.lua index 52f536a..a4b0ada 100644 --- a/tests/test_git_utils.lua +++ b/tests/test_git_utils.lua @@ -592,6 +592,104 @@ T["shell_quote_windows"]["handles spaces"] = function() h.eq('"hello world"', result) end +T["path_join"] = new_set() + +T["path_join"]["joins two path parts"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local result = GitUtils.path_join("src", "main.lua") + local has_sep = GitUtils.is_windows() and result:find("\\") ~= nil or result:find("/") ~= nil + return has_sep and result:match("main%.lua") ~= nil + ]]) + h.eq(true, result) +end + +T["path_join"]["joins multiple path parts"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local result = GitUtils.path_join("src", "components", "ui", "Button.tsx") + return result:match("src") ~= nil + and result:match("components") ~= nil + and result:match("ui") ~= nil + and result:match("Button%.tsx") ~= nil + ]]) + h.eq(true, result) +end + +T["path_join"]["handles windows backslashes"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local result = GitUtils.path_join("C:\\\\Users", "test", "file.lua") + local has_backslash = result:find("\\") ~= nil + local has_users = result:find("Users") ~= nil + local has_test = result:find("test") ~= nil + local has_file = result:find("file%.lua") ~= nil + return has_backslash and has_users and has_test and has_file + ]]) + h.eq(true, result) +end + +T["path_join"]["handles mixed separators"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local result = GitUtils.path_join("C:/project", "src", "main.lua") + return result:match("src") ~= nil and result:match("main%.lua") ~= nil + ]]) + h.eq(true, result) +end + +T["path_join"]["handles trailing slashes"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local result = GitUtils.path_join("src/", "test/") + return not result:match("//") and result:match("src") ~= nil and result:match("test") ~= nil + ]]) + h.eq(true, result) +end + +T["path_join"]["handles leading slashes in middle parts"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local result = GitUtils.path_join("src", "/utils.lua") + return not result:match("/utils") and result:match("utils%.lua") ~= nil + ]]) + h.eq(true, result) +end + +T["path_join"]["handles single part"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.path_join("test.lua") == "test.lua" + ]]) + h.eq(true, result) +end + +T["path_join"]["returns empty string for no parts"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + return GitUtils.path_join() == "" + ]]) + h.eq(true, result) +end + +T["path_join"]["handles empty middle parts"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local result = GitUtils.path_join("src", "", "test.lua") + return not result:match("//") and result:match("test%.lua") ~= nil + ]]) + h.eq(true, result) +end + +T["path_join"]["preserves dots in path"] = function() + local result = child.lua([[ + local GitUtils = require("codecompanion._extensions.gitcommit.git_utils") + local result = GitUtils.path_join("..", "utils.lua") + return result:match("%.%.") ~= nil and result:match("utils%.lua") ~= nil + ]]) + h.eq(true, result) +end + T["is_windows"] = new_set() T["is_windows"]["returns boolean"] = function()