This repository was archived by the owner on Jan 14, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathgit_utils.lua
More file actions
395 lines (339 loc) · 11.3 KB
/
git_utils.lua
File metadata and controls
395 lines (339 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
---@class CodeCompanion.GitCommit.GitUtils
---Pure utility functions for git operations.
---These are testable without requiring a git repository.
local M = {}
---Trim whitespace from string
---@param s string The string to trim
---@return string trimmed_string
function M.trim(s)
return (s:gsub("^%s*(.-)%s*$", "%1"))
end
---Convert a glob pattern to a Lua pattern
---Handles: * (any non-slash), ** (any including slash), ? (single char), escapes special chars
---@param glob string The glob pattern (e.g., "*.lua", "**/*.js", "dist/*")
---@return string lua_pattern The converted Lua pattern
function M.glob_to_lua_pattern(glob)
local escaped = glob
escaped = escaped:gsub("%%", "%%%%")
escaped = escaped:gsub("%.", "%%%.")
escaped = escaped:gsub("%-", "%%%-")
escaped = escaped:gsub("%^", "%%%^")
escaped = escaped:gsub("%$", "%%%$")
escaped = escaped:gsub("%(", "%%%(")
escaped = escaped:gsub("%)", "%%%)")
escaped = escaped:gsub("%+", "%%%+")
escaped = escaped:gsub("%[", "%%%[")
escaped = escaped:gsub("%]", "%%%]")
local placeholder = "\001DOUBLESTAR\001"
escaped = escaped:gsub("%*%*", placeholder)
escaped = escaped:gsub("%*", "[^/]*")
escaped = escaped:gsub("%?", "[^/]")
escaped = escaped:gsub(placeholder, ".*")
if escaped:sub(1, 1) == "/" then
escaped = "^" .. escaped:sub(2)
end
escaped = escaped .. "$"
return escaped
end
---Check if filepath matches a glob pattern (handles basename matching for patterns without /)
---@param filepath string The file path to check
---@param pattern string The glob pattern
---@return boolean matches True if filepath matches
function M.matches_glob(filepath, pattern)
local lua_pattern = M.glob_to_lua_pattern(pattern)
if filepath:match(lua_pattern) then
return true
end
if not pattern:match("/") then
local basename = filepath:match("[^/]+$") or filepath
if basename:match(lua_pattern) then
return true
end
end
if pattern:match("/%*$") then
local dir_prefix = pattern:gsub("/%*$", "/")
if filepath:sub(1, #dir_prefix) == dir_prefix then
return true
end
end
-- Handle **/ at start of pattern - should match any depth including root
if pattern:match("^%*%*/") then
local suffix_pattern = pattern:gsub("^%*%*/", "")
local suffix_lua_pattern = M.glob_to_lua_pattern(suffix_pattern)
-- Try matching from root (no directory prefix)
if filepath:match(suffix_lua_pattern) then
return true
end
end
return false
end
---Check if file should be excluded by patterns
---@param filepath string The file path to check
---@param exclude_patterns string[]|nil List of glob patterns to exclude
---@return boolean should_exclude True if file should be excluded
function M.should_exclude_file(filepath, exclude_patterns)
if not exclude_patterns or #exclude_patterns == 0 then
return false
end
local normalized_path = filepath:gsub("\\", "/")
for _, pattern in ipairs(exclude_patterns) do
if M.matches_glob(normalized_path, pattern) then
return true
end
end
return false
end
---Filter diff content to exclude file patterns
---@param diff_content string The original diff content
---@param exclude_patterns string[]|nil List of glob patterns to exclude
---@return string filtered_diff The filtered diff content
function M.filter_diff(diff_content, exclude_patterns)
if not exclude_patterns or #exclude_patterns == 0 then
return diff_content
end
local lines = vim.split(diff_content, "\n")
local filtered_lines = {}
local all_files = {}
local excluded_files = {}
local current_file = nil
local skip_current_file = false
for _, line in ipairs(lines) do
local file_path = line:match("^diff %-%-git a/(.*) b/")
or line:match("^%+%+%+ b/(.*)")
or line:match("^%-%-%-a/(.*)")
if file_path then
current_file = file_path
table.insert(all_files, current_file)
skip_current_file = M.should_exclude_file(current_file, exclude_patterns)
if skip_current_file then
table.insert(excluded_files, current_file)
end
end
if not skip_current_file then
table.insert(filtered_lines, line)
end
end
return table.concat(filtered_lines, "\n")
end
---Parse commit line from git log output
---@param line string Git log output line (format: hash subject)
---@return table|nil commit Parsed commit {hash, subject} or nil
function M.parse_commit_line(line)
local trimmed = M.trim(line)
if trimmed == "" then
return nil
end
local hash, subject = trimmed:match("^(%S+)%s+(.*)$")
if hash and subject then
return { hash = hash, subject = subject }
end
return nil
end
---Extract file paths from diff header lines
---@param diff_content string The diff content
---@return string[] files List of file paths mentioned in diff
function M.extract_diff_files(diff_content)
local files = {}
local seen = {}
for line in diff_content:gmatch("[^\r\n]+") do
local file_match = line:match("^diff %-%-git a/(.*) b/")
if file_match and not seen[file_match] then
table.insert(files, file_match)
seen[file_match] = true
end
end
return files
end
---Validate conventional commit message format
---@param message string The commit message
---@return boolean valid True if message follows conventional commits
---@return string|nil type The commit type (feat, fix, etc.) or nil if invalid
function M.parse_conventional_commit(message)
local type_match = message:match("^(%w+)%(.*%):") or message:match("^(%w+):")
if type_match then
return true, type_match
end
return false, nil
end
---Group commits by conventional commit type
---@param commits table[] Array of commits with subject field
---@return table groups Table with keys: features, fixes, others
function M.group_commits_by_type(commits)
local features = {}
local fixes = {}
local others = {}
for _, commit in ipairs(commits) do
local _, type_match = M.parse_conventional_commit(commit.subject or "")
if type_match then
if type_match == "feat" then
table.insert(features, commit)
elseif type_match == "fix" then
table.insert(fixes, commit)
else
table.insert(others, commit)
end
else
table.insert(others, commit)
end
end
return {
features = features,
fixes = fixes,
others = others,
}
end
---Check if running on Windows
---@return boolean
function M.is_windows()
return vim.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
local windows_style = M.is_windows()
for _, part in ipairs(parts) do
if type(part) == "string" then
if part:find("\\") or part:match("^%a:[\\/]") then
windows_style = true
break
end
end
end
local cleaned = {}
local prefix = ""
for i, part in ipairs(parts) do
local raw_part = tostring(part or "")
local has_unc_prefix = i == 1 and (raw_part:match("^\\\\") or raw_part:match("^//"))
part = raw_part:gsub("\\+", "/")
if i == 1 then
if has_unc_prefix then
prefix = "//"
part = part:gsub("^/+", "")
elseif part:match("^/+") then
prefix = "/"
part = part:gsub("^/+", "")
end
part = part:gsub("/+$", "")
else
part = part:gsub("^/+", "")
part = part:gsub("/+$", "")
end
if part ~= "" then
table.insert(cleaned, part)
end
end
if #cleaned == 0 then
return prefix
end
local result = table.concat(cleaned, "/")
if prefix ~= "" then
result = prefix .. result
end
if windows_style 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)
---@return string quoted The quoted string
function M.shell_quote(str, force_windows)
local is_win = force_windows or M.is_windows()
if is_win then
return '"' .. str:gsub('"', '\\"') .. '"'
else
return "'" .. str:gsub("'", "'\\''") .. "'"
end
end
---Quote a string for Unix shell
---@param str string The string to quote
---@return string quoted The quoted string
function M.shell_quote_unix(str)
return "'" .. str:gsub("'", "'\\''") .. "'"
end
---Quote a string for Windows CMD
---@param str string The string to quote
---@return string quoted The quoted string
function M.shell_quote_windows(str)
return '"' .. str:gsub('"', '\\"') .. '"'
end
---Clean commit message by removing markdown code blocks and extra formatting
---@param message string Raw message from LLM
---@return string cleaned_message The cleaned commit message
function M.clean_commit_message(message)
local cleaned = vim.trim(message)
cleaned = cleaned:gsub("^```+%w*\n?", "")
cleaned = cleaned:gsub("\n?```+$", "")
cleaned = vim.trim(cleaned)
return cleaned
end
---Build commit message prompt with optional history context
---@param diff string The git diff content
---@param lang string The target language for the commit message
---@param commit_history? string[] Recent commit messages for context
---@param prompt_template? string Custom prompt template with placeholders
---@return string prompt The formatted prompt
function M.build_commit_prompt(diff, lang, commit_history, prompt_template)
local history_context = ""
if commit_history and #commit_history > 0 then
history_context = "BEGIN HISTORY (style reference only):\n"
for i, commit_msg in ipairs(commit_history) do
history_context = history_context .. string.format("%d. %s\n", i, commit_msg)
end
history_context = history_context
.. "END HISTORY\nStyle reference only. Do not copy content or topics; base the message ONLY on the diff.\n"
end
local template = prompt_template
if template == nil or template == "" then
local Config = require("codecompanion._extensions.gitcommit.config")
template = Config.default_opts.prompt_template
end
local prompt = template
prompt = prompt:gsub("%%{language}", function()
return lang or "English"
end)
prompt = prompt:gsub("%%{diff}", function()
return diff
end)
prompt = prompt:gsub("%%{history_context}", function()
return history_context
end)
return prompt
end
---Parse git conflict markers from file content
---@param content string The file content with potential conflicts
---@return table[] conflicts Array of conflict blocks, each with {ours, theirs, marker_start, marker_end}
function M.parse_conflicts(content)
local conflicts = {}
local lines = vim.split(content, "\n")
local in_conflict = false
local current_block = {}
for _, line in ipairs(lines) do
if line:match("^<<<<<<< ") then
in_conflict = true
current_block = { line }
elseif in_conflict then
table.insert(current_block, line)
if line:match("^>>>>>>> ") then
table.insert(conflicts, table.concat(current_block, "\n"))
in_conflict = false
current_block = {}
end
end
end
return conflicts
end
---Check if content has conflict markers
---@param content string The file content to check
---@return boolean has_conflicts True if conflict markers found
function M.has_conflicts(content)
return content:match("<<<<<<< ") ~= nil
end
return M