Skip to content
This repository was archived by the owner on Jan 14, 2026. It is now read-only.

Commit 7eb124c

Browse files
committed
refactor(tools): add centralized parameter validation module
- Create validation.lua with reusable validation functions - Update git_read.lua and git_edit.lua to use validation module - Add type checking for all required and optional parameters - Improve error messages with consistent XML-tagged format
1 parent 84fb65d commit 7eb124c

File tree

3 files changed

+495
-125
lines changed

3 files changed

+495
-125
lines changed

lua/codecompanion/_extensions/gitcommit/tools/git_edit.lua

Lines changed: 193 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
local GitTool = require("codecompanion._extensions.gitcommit.tools.git").GitTool
2+
local validation = require("codecompanion._extensions.gitcommit.tools.validation")
23

34
---@class CodeCompanion.GitCommit.Tools.GitEdit
45
local GitEdit = {}
@@ -150,40 +151,87 @@ GitEdit.schema = {
150151
},
151152
}
152153

153-
GitEdit.system_prompt = [[Execute write-access Git repository operations
154+
GitEdit.system_prompt = [[# Git Edit Tool (`git_edit`)
154155
155-
When to use:
156-
• When staging or unstaging file changes
157-
• When creating or switching between branches
158-
• When managing stashes and repository state
159-
• When performing safe repository modifications
156+
## CONTEXT
157+
- You have access to a write-access Git tool running within CodeCompanion, in Neovim.
158+
- Use this tool to modify repository state: staging, committing, branching, etc.
159+
- These operations can modify the repository, so use them carefully.
160160
161-
Best practices:
162-
• Must verify Git repository before operations
163-
• Always specify files parameter for stage/unstage operations
164-
• Use '.' to stage all modified files or specific file paths
165-
• For commit operations, if no commit_message provided, automatically generate AI message
166-
• Auto-generation analyzes staged changes and creates Conventional Commit compliant messages
167-
• Use format: type(scope): description with lowercase type and imperative verb description
168-
• Include body with bullet points for complex changes, keep description under 50 characters
169-
• Use the `set_upstream` option to create and track a remote branch if it doesn't exist
170-
• Avoid force push operations that rewrite history
171-
• Ensure file paths and branch names are valid
161+
## OBJECTIVE
162+
- Follow the tool's schema strictly.
163+
- Use the appropriate operation for the task.
164+
- For commits without a message, analyze staged changes and generate Conventional Commit format.
172165
173-
Available operations: stage, unstage, commit, create_branch, checkout, stash, apply_stash, reset, gitignore_add, gitignore_remove, push, cherry_pick, revert, create_tag, delete_tag, merge, help]]
166+
## AVAILABLE OPERATIONS
167+
| Operation | Description | Required Args |
168+
|-----------|-------------|---------------|
169+
| `stage` | Stage files for commit | files (required) |
170+
| `unstage` | Unstage files | files (required) |
171+
| `commit` | Commit staged changes | commit_message? (auto-generates if empty) |
172+
| `create_branch` | Create new branch | branch_name (required), checkout? |
173+
| `checkout` | Switch branch/commit | target (required) |
174+
| `stash` | Stash changes | message?, include_untracked? |
175+
| `apply_stash` | Apply stash | stash_ref? |
176+
| `reset` | Reset to commit | commit_hash (required), mode? |
177+
| `gitignore_add` | Add .gitignore rules | gitignore_rules (required) |
178+
| `gitignore_remove` | Remove .gitignore rules | gitignore_rule (required) |
179+
| `push` | Push to remote | remote?, branch?, set_upstream? |
180+
| `cherry_pick` | Apply commit | cherry_pick_commit_hash (required) |
181+
| `revert` | Revert commit | revert_commit_hash (required) |
182+
| `create_tag` | Create tag | tag_name (required), tag_message? |
183+
| `delete_tag` | Delete tag | tag_name (required) |
184+
| `merge` | Merge branch | branch (required) |
185+
| `help` | Show help | - |
174186
175-
-- Helper function to validate required parameters
176-
local function validate_required_param(param_name, param_value, error_msg)
177-
if not param_value or (type(param_value) == "table" and #param_value == 0) then
178-
return { status = "error", data = error_msg or (param_name .. " is required") }
179-
end
180-
return nil
181-
end
187+
## SAFETY RESTRICTIONS
188+
- Never use force push without explicit user confirmation.
189+
- Always verify staged changes before committing.
190+
- Warn users before destructive operations (reset --hard, delete).
191+
192+
## RESPONSE
193+
- Only invoke this tool when modifying Git repository state.
194+
- For commit messages, use Conventional Commit format: type(scope): description.]]
195+
196+
local TOOL_NAME = "gitEdit"
197+
local VALID_OPERATIONS = {
198+
"stage",
199+
"unstage",
200+
"commit",
201+
"create_branch",
202+
"checkout",
203+
"stash",
204+
"apply_stash",
205+
"reset",
206+
"gitignore_add",
207+
"gitignore_remove",
208+
"push",
209+
"cherry_pick",
210+
"revert",
211+
"create_tag",
212+
"delete_tag",
213+
"merge",
214+
"help",
215+
}
216+
local VALID_RESET_MODES = { "soft", "mixed", "hard" }
182217

183218
GitEdit.cmds = {
184219
function(self, args, input, output_handler)
220+
if args == nil or type(args) ~= "table" then
221+
return validation.format_error(TOOL_NAME, "Invalid arguments: expected object")
222+
end
223+
185224
local operation = args.operation
186-
local op_args = args.args or {}
225+
local err = validation.require_enum(operation, "operation", VALID_OPERATIONS, TOOL_NAME)
226+
if err then
227+
return err
228+
end
229+
230+
local op_args = args.args
231+
if op_args ~= nil and type(op_args) ~= "table" then
232+
return validation.format_error(TOOL_NAME, "args must be an object")
233+
end
234+
op_args = op_args or {}
187235

188236
if operation == "help" then
189237
local help_text = [[
@@ -207,6 +255,17 @@ Available write-access Git operations:
207255
end
208256

209257
if operation == "push" then
258+
local param_err = validation.first_error({
259+
validation.optional_string(op_args.remote, "remote", TOOL_NAME),
260+
validation.optional_string(op_args.branch, "branch", TOOL_NAME),
261+
validation.optional_boolean(op_args.force, "force", TOOL_NAME),
262+
validation.optional_boolean(op_args.set_upstream, "set_upstream", TOOL_NAME),
263+
validation.optional_boolean(op_args.tags, "tags", TOOL_NAME),
264+
validation.optional_string(op_args.single_tag_name, "single_tag_name", TOOL_NAME),
265+
})
266+
if param_err then
267+
return param_err
268+
end
210269
-- If set_upstream is not explicitly specified, default to true for automatic remote tracking
211270
if op_args.set_upstream == nil then
212271
op_args.set_upstream = true
@@ -225,20 +284,29 @@ Available write-access Git operations:
225284
-- Safely execute operations through pcall to ensure there's always a response
226285
local ok, result = pcall(function()
227286
local success, output
287+
local param_err
228288

229289
if operation == "stage" then
230-
local validation_error = validate_required_param("files", op_args.files, "No files specified for staging")
231-
if validation_error then
232-
return validation_error
290+
param_err = validation.require_array(op_args.files, "files", TOOL_NAME)
291+
if param_err then
292+
return param_err
233293
end
234294
success, output = GitTool.stage_files(op_args.files)
235295
elseif operation == "unstage" then
236-
local validation_error = validate_required_param("files", op_args.files, "No files specified for unstaging")
237-
if validation_error then
238-
return validation_error
296+
param_err = validation.require_array(op_args.files, "files", TOOL_NAME)
297+
if param_err then
298+
return param_err
239299
end
240300
success, output = GitTool.unstage_files(op_args.files)
241301
elseif operation == "commit" then
302+
param_err = validation.first_error({
303+
validation.optional_string(op_args.commit_message, "commit_message", TOOL_NAME),
304+
validation.optional_string(op_args.message, "message", TOOL_NAME),
305+
validation.optional_boolean(op_args.amend, "amend", TOOL_NAME),
306+
})
307+
if param_err then
308+
return param_err
309+
end
242310
local message = op_args.commit_message or op_args.message
243311
if not message then
244312
-- Check if there are staged changes
@@ -258,81 +326,107 @@ Available write-access Git operations:
258326
end
259327
success, output = GitTool.commit(message, op_args.amend)
260328
elseif operation == "create_branch" then
261-
if not op_args.branch_name then
262-
return { status = "error", data = "Branch name is required" }
329+
param_err = validation.first_error({
330+
validation.require_string(op_args.branch_name, "branch_name", TOOL_NAME),
331+
validation.optional_boolean(op_args.checkout, "checkout", TOOL_NAME),
332+
})
333+
if param_err then
334+
return param_err
263335
end
264336
success, output = GitTool.create_branch(op_args.branch_name, op_args.checkout)
265337
elseif operation == "checkout" then
266-
local validation_error =
267-
validate_required_param("target", op_args.target, "Target branch or commit is required")
268-
if validation_error then
269-
return validation_error
338+
param_err = validation.require_string(op_args.target, "target", TOOL_NAME)
339+
if param_err then
340+
return param_err
270341
end
271342
success, output = GitTool.checkout(op_args.target)
272343
elseif operation == "stash" then
344+
param_err = validation.first_error({
345+
validation.optional_string(op_args.message, "message", TOOL_NAME),
346+
validation.optional_boolean(op_args.include_untracked, "include_untracked", TOOL_NAME),
347+
})
348+
if param_err then
349+
return param_err
350+
end
273351
success, output = GitTool.stash(op_args.message, op_args.include_untracked)
274352
elseif operation == "apply_stash" then
353+
param_err = validation.optional_string(op_args.stash_ref, "stash_ref", TOOL_NAME)
354+
if param_err then
355+
return param_err
356+
end
275357
success, output = GitTool.apply_stash(op_args.stash_ref)
276358
elseif operation == "reset" then
277-
local validation_error =
278-
validate_required_param("commit_hash", op_args.commit_hash, "Commit hash is required for reset")
279-
if validation_error then
280-
return validation_error
359+
param_err = validation.first_error({
360+
validation.require_string(op_args.commit_hash, "commit_hash", TOOL_NAME),
361+
op_args.mode and validation.require_enum(op_args.mode, "mode", VALID_RESET_MODES, TOOL_NAME) or nil,
362+
})
363+
if param_err then
364+
return param_err
281365
end
282366
success, output = GitTool.reset(op_args.commit_hash, op_args.mode)
283367
elseif operation == "gitignore_add" then
284368
local rules = op_args.gitignore_rules or op_args.gitignore_rule
285-
if not rules then
286-
return { status = "error", data = "No rule(s) specified for .gitignore add" }
369+
if rules == nil then
370+
return validation.format_error(TOOL_NAME, "gitignore_rules or gitignore_rule is required")
371+
end
372+
if type(rules) ~= "table" and type(rules) ~= "string" then
373+
return validation.format_error(
374+
TOOL_NAME,
375+
"gitignore_rules must be an array or gitignore_rule must be a string"
376+
)
287377
end
288378
success, output = GitTool.add_gitignore_rule(rules)
289379
elseif operation == "gitignore_remove" then
290380
local rules = op_args.gitignore_rules or op_args.gitignore_rule
291-
if not rules then
292-
return { status = "error", data = "No rule(s) specified for .gitignore remove" }
381+
if rules == nil then
382+
return validation.format_error(TOOL_NAME, "gitignore_rules or gitignore_rule is required")
383+
end
384+
if type(rules) ~= "table" and type(rules) ~= "string" then
385+
return validation.format_error(
386+
TOOL_NAME,
387+
"gitignore_rules must be an array or gitignore_rule must be a string"
388+
)
293389
end
294390
success, output = GitTool.remove_gitignore_rule(rules)
295391
elseif operation == "cherry_pick" then
296-
local validation_error = validate_required_param(
297-
"cherry_pick_commit_hash",
298-
op_args.cherry_pick_commit_hash,
299-
"Commit hash is required for cherry-pick"
300-
)
301-
if validation_error then
302-
return validation_error
392+
param_err = validation.require_string(op_args.cherry_pick_commit_hash, "cherry_pick_commit_hash", TOOL_NAME)
393+
if param_err then
394+
return param_err
303395
end
304396
success, output = GitTool.cherry_pick(op_args.cherry_pick_commit_hash)
305397
elseif operation == "revert" then
306-
local validation_error = validate_required_param(
307-
"revert_commit_hash",
308-
op_args.revert_commit_hash,
309-
"Commit hash is required for revert"
310-
)
311-
if validation_error then
312-
return validation_error
398+
param_err = validation.require_string(op_args.revert_commit_hash, "revert_commit_hash", TOOL_NAME)
399+
if param_err then
400+
return param_err
313401
end
314402
success, output = GitTool.revert(op_args.revert_commit_hash)
315403
elseif operation == "create_tag" then
316-
local validation_error = validate_required_param("tag_name", op_args.tag_name, "Tag name is required")
317-
if validation_error then
318-
return validation_error
404+
param_err = validation.first_error({
405+
validation.require_string(op_args.tag_name, "tag_name", TOOL_NAME),
406+
validation.optional_string(op_args.tag_message, "tag_message", TOOL_NAME),
407+
validation.optional_string(op_args.tag_commit_hash, "tag_commit_hash", TOOL_NAME),
408+
})
409+
if param_err then
410+
return param_err
319411
end
320412
success, output = GitTool.create_tag(op_args.tag_name, op_args.tag_message, op_args.tag_commit_hash)
321413
elseif operation == "delete_tag" then
322-
local validation_error =
323-
validate_required_param("tag_name", op_args.tag_name, "Tag name is required for deletion")
324-
if validation_error then
325-
return validation_error
414+
param_err = validation.first_error({
415+
validation.require_string(op_args.tag_name, "tag_name", TOOL_NAME),
416+
validation.optional_string(op_args.remote, "remote", TOOL_NAME),
417+
})
418+
if param_err then
419+
return param_err
326420
end
327421
success, output = GitTool.delete_tag(op_args.tag_name, op_args.remote)
328422
elseif operation == "merge" then
329-
local validation_error = validate_required_param("branch", op_args.branch, "Branch to merge is required")
330-
if validation_error then
331-
return validation_error
423+
param_err = validation.require_string(op_args.branch, "branch", TOOL_NAME)
424+
if param_err then
425+
return param_err
332426
end
333427
success, output = GitTool.merge(op_args.branch)
334428
else
335-
return { status = "error", data = "Unknown Git edit operation: " .. operation }
429+
return validation.format_error(TOOL_NAME, "Unknown Git edit operation: " .. tostring(operation))
336430
end
337431

338432
return { success = success, output = output }
@@ -368,19 +462,45 @@ GitEdit.handlers = {
368462
}
369463

370464
GitEdit.output = {
371-
success = function(self, agent, cmd, stdout)
465+
prompt = function(self, _tools)
466+
local operation = self.args and self.args.operation or "unknown"
467+
local details = ""
468+
if operation == "stage" or operation == "unstage" then
469+
local files = self.args.args and self.args.args.files
470+
if files then
471+
details = string.format(" (%s)", type(files) == "table" and table.concat(files, ", ") or files)
472+
end
473+
elseif operation == "commit" then
474+
local msg = self.args.args and self.args.args.commit_message
475+
details = msg and string.format(" with message: %s", msg:sub(1, 50)) or " (auto-generate message)"
476+
elseif operation == "create_branch" then
477+
local branch = self.args.args and self.args.args.branch_name
478+
details = branch and string.format(": %s", branch) or ""
479+
end
480+
return string.format("Execute git %s%s?", operation, details)
481+
end,
482+
483+
success = function(self, agent, _cmd, stdout)
372484
local chat = agent.chat
373485
local operation = self.args.operation
374486
local user_msg = string.format("Git edit operation [%s] executed successfully", operation)
375487
return chat:add_tool_output(self, stdout[1], user_msg)
376488
end,
377-
error = function(self, agent, cmd, stderr, stdout)
489+
490+
error = function(self, agent, _cmd, stderr, stdout)
378491
local chat = agent.chat
379492
local operation = self.args.operation
380493
local error_msg = stderr and stderr[1] or ("Git edit operation [%s] failed"):format(operation)
381494
local user_msg = string.format("Git edit operation [%s] failed", operation)
382495
return chat:add_tool_output(self, error_msg, user_msg)
383496
end,
497+
498+
rejected = function(self, tools, _cmd, _opts)
499+
local chat = tools.chat
500+
local operation = self.args and self.args.operation or "unknown"
501+
local message = string.format("User rejected the git %s operation", operation)
502+
return chat:add_tool_output(self, message, message)
503+
end,
384504
}
385505

386506
GitEdit.opts = {

0 commit comments

Comments
 (0)