Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions lua/codecompanion/_extensions/gitcommit/buffer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
},
Expand Down
20 changes: 11 additions & 9 deletions lua/codecompanion/_extensions/gitcommit/git.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -58,8 +57,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"

Expand All @@ -78,15 +76,13 @@ 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
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
Expand Down Expand Up @@ -185,7 +181,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"
Expand Down
34 changes: 33 additions & 1 deletion lua/codecompanion/_extensions/gitcommit/git_utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,39 @@ 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

---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
Comment on lines +227 to 253
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The custom path_join implementation has some bugs that can lead to incorrect paths with double separators (e.g., path_join('a/', 'b') would result in a//b). Since your project targets Neovim 0.8+, you can simplify this greatly and improve robustness by using the built-in vim.fs.joinpath. It's designed for this purpose and handles cross-platform differences correctly.

function M.path_join(...)
  return vim.fs.joinpath(...)
end


---Quote a string for shell command (cross-platform)
Expand Down
79 changes: 74 additions & 5 deletions lua/codecompanion/_extensions/gitcommit/prompts/release_notes.lua
Original file line number Diff line number Diff line change
@@ -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<string, number> Author -> commit count

M.style_guides = {
detailed = [[You are creating comprehensive release notes for developers.
Write thorough explanations of changes with technical context.
Expand Down Expand Up @@ -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)))
Expand All @@ -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) }
Expand All @@ -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 = {},
Expand All @@ -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)
Expand All @@ -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

Expand Down
22 changes: 13 additions & 9 deletions lua/codecompanion/_extensions/gitcommit/tools/ai_release_notes.lua
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading