From c1f1a89d7437fa5fe5a228331cd0cd45d7a5442b Mon Sep 17 00:00:00 2001 From: Hanchin Hsieh Date: Tue, 18 Feb 2025 22:56:50 +0800 Subject: [PATCH] feat(llm_tools): add git diff and commit functions (#1295) Added two new functions to support git operations: - git_diff: Get git diff for generating commit message (both staged and unstaged changes) - git_commit: Commit changes with commit message and optional signing These functions also support: - GPG signing if available and configured - Automatic staging of files if scope is provided - User confirmation before committing - Signed-off-by line with git user info Signed-off-by: Hanchin Hsieh --- lua/avante/llm_tools.lua | 182 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/lua/avante/llm_tools.lua b/lua/avante/llm_tools.lua index 5dbec4d..d60c917 100644 --- a/lua/avante/llm_tools.lua +++ b/lua/avante/llm_tools.lua @@ -394,6 +394,128 @@ function M.fetch(opts, on_log) return res, nil end +---@param opts { scope?: string } +---@param on_log? fun(log: string): nil +---@return string|nil result +---@return string|nil error +function M.git_diff(opts, on_log) + local git_cmd = vim.fn.exepath("git") + if git_cmd == "" then return nil, "Git command not found" end + local project_root = Utils.get_project_root() + if not project_root then return nil, "Not in a git repository" end + + -- Check if we're in a git repository + local git_dir = vim.fn.system("git rev-parse --git-dir"):gsub("\n", "") + if git_dir == "" then return nil, "Not a git repository" end + + -- Get the diff + local scope = opts.scope or "" + local cmd = string.format("git diff --cached %s", scope) + if on_log then on_log("Running command: " .. cmd) end + local diff = vim.fn.system(cmd) + + if diff == "" then + -- If there's no staged changes, get unstaged changes + cmd = string.format("git diff %s", scope) + if on_log then on_log("No staged changes. Running command: " .. cmd) end + diff = vim.fn.system(cmd) + end + + if diff == "" then return nil, "No changes detected" end + + return diff, nil +end + +---@param opts { message: string, scope?: string } +---@param on_log? fun(log: string): nil +---@return boolean success +---@return string|nil error +function M.git_commit(opts, on_log) + local git_cmd = vim.fn.exepath("git") + if git_cmd == "" then return false, "Git command not found" end + local project_root = Utils.get_project_root() + if not project_root then return false, "Not in a git repository" end + + -- Check if we're in a git repository + local git_dir = vim.fn.system("git rev-parse --git-dir"):gsub("\n", "") + if git_dir == "" then return false, "Not a git repository" end + + -- First check if there are any changes to commit + local status = vim.fn.system("git status --porcelain") + if status == "" then return false, "No changes to commit" end + + -- Get git user name and email + local git_user = vim.fn.system("git config user.name"):gsub("\n", "") + local git_email = vim.fn.system("git config user.email"):gsub("\n", "") + + -- Check if GPG signing is available and configured + local has_gpg = false + local signing_key = vim.fn.system("git config --get user.signingkey"):gsub("\n", "") + + if signing_key ~= "" then + -- Try to find gpg executable based on OS + local gpg_cmd + if vim.fn.has("win32") == 1 then + -- Check common Windows GPG paths + gpg_cmd = vim.fn.exepath("gpg.exe") ~= "" and vim.fn.exepath("gpg.exe") or vim.fn.exepath("gpg2.exe") + else + -- Unix-like systems (Linux/MacOS) + gpg_cmd = vim.fn.exepath("gpg") ~= "" and vim.fn.exepath("gpg") or vim.fn.exepath("gpg2") + end + + if gpg_cmd ~= "" then + -- Verify GPG is working + local _ = vim.fn.system(string.format('"%s" --version', gpg_cmd)) + has_gpg = vim.v.shell_error == 0 + end + end + + if on_log then on_log(string.format("GPG signing %s", has_gpg and "enabled" or "disabled")) end + + -- Prepare commit message + local commit_msg_lines = {} + for line in opts.message:gmatch("[^\r\n]+") do + commit_msg_lines[#commit_msg_lines + 1] = line:gsub('"', '\\"') + end + if git_user ~= "" and git_email ~= "" then + commit_msg_lines[#commit_msg_lines + 1] = string.format("Signed-off-by: %s <%s>", git_user, git_email) + end + + -- Construct full commit message for confirmation + local full_commit_msg = table.concat(commit_msg_lines, "\n") + + -- Confirm with user + if not M.confirm("Are you sure you want to commit with message:\n" .. full_commit_msg) then + return false, "User canceled" + end + + -- Stage changes if scope is provided + if opts.scope then + local stage_cmd = string.format("git add %s", opts.scope) + if on_log then on_log("Staging files: " .. stage_cmd) end + local stage_result = vim.fn.system(stage_cmd) + if vim.v.shell_error ~= 0 then return false, "Failed to stage files: " .. stage_result end + end + + -- Construct git commit command + local cmd_parts = { "git", "commit" } + -- Only add -S flag if GPG is available + if has_gpg then table.insert(cmd_parts, "-S") end + for _, line in ipairs(commit_msg_lines) do + table.insert(cmd_parts, "-m") + table.insert(cmd_parts, '"' .. line .. '"') + end + local cmd = table.concat(cmd_parts, " ") + + -- Execute git commit + if on_log then on_log("Running command: " .. cmd) end + local result = vim.fn.system(cmd) + + if vim.v.shell_error ~= 0 then return false, "Failed to commit: " .. result end + + return true, nil +end + ---@class AvanteLLMTool ---@field name string ---@field description string @@ -419,6 +541,66 @@ end ---@type AvanteLLMTool[] M.tools = { + { + name = "git_diff", + description = "Get git diff for generating commit message", + param = { + type = "table", + fields = { + { + name = "scope", + description = "Scope for the git diff (e.g. specific files or directories)", + type = "string", + }, + }, + }, + returns = { + { + name = "result", + description = "Git diff output to be used for generating commit message", + type = "string", + }, + { + name = "error", + description = "Error message if the diff generation failed", + type = "string", + optional = true, + }, + }, + }, + { + name = "git_commit", + description = "Commit changes with the given commit message", + param = { + type = "table", + fields = { + { + name = "message", + description = "Commit message to use", + type = "string", + }, + { + name = "scope", + description = "Scope for staging files (e.g. specific files or directories)", + type = "string", + optional = true, + }, + }, + }, + returns = { + { + name = "success", + description = "True if the commit was successful, false otherwise", + type = "boolean", + }, + { + name = "error", + description = "Error message if the commit failed", + type = "string", + optional = true, + }, + }, + }, { name = "list_files", description = "List files in a directory",