package tools import ( "context" "encoding/json" "fmt" "os/exec" "path/filepath" "strings" "agentu/pkg/llm" ) const ( defaultGitLogMaxCount = 20 maxGitLogMaxCount = 100 ) func runGit(ctx context.Context, workingDir string, args ...string) (string, error) { gitArgs := append([]string{"-C", workingDir}, args...) cmd := exec.CommandContext(ctx, "git", gitArgs...) output, err := cmd.CombinedOutput() text := FormatResult(string(output)) if err != nil { if strings.Contains(err.Error(), "context deadline exceeded") || strings.Contains(err.Error(), "signal: killed") { if text != "" { return text, fmt.Errorf("git command timed out: %w", err) } return "", fmt.Errorf("git command timed out: %w", err) } if text != "" { return text, fmt.Errorf("git command failed: %w", err) } return "", fmt.Errorf("git command failed: %w", err) } return text, nil } func gitPathspec(baseDir, path string) (string, error) { if strings.TrimSpace(path) == "" { return "", nil } resolved, err := resolvePath(baseDir, path) if err != nil { return "", err } rel, err := filepath.Rel(baseDir, resolved) if err != nil { return "", err } return filepath.ToSlash(filepath.Clean(rel)), nil } func normalizeGitLogMaxCount(value int) int { if value <= 0 { return defaultGitLogMaxCount } if value > maxGitLogMaxCount { return maxGitLogMaxCount } return value } // git_status type GitStatusTool struct { workingDir string } func NewGitStatusTool(workingDir string) *GitStatusTool { return &GitStatusTool{workingDir: workingDir} } func (t *GitStatusTool) Definition() llm.Tool { return llm.Tool{ Type: "function", Function: llm.ToolFunction{ Name: "git_status", Description: "Show changed files using git status --short.", Parameters: JSONSchema(`{ "type": "object", "properties": { "path": {"type": "string", "description": "Optional path to limit status to. Relative paths are resolved from the configured agent working directory."} }, "additionalProperties": false }`), }, } } type gitStatusArgs struct { Path string `json:"path"` } func (t *GitStatusTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { var args gitStatusArgs if len(raw) > 0 { if err := json.Unmarshal(raw, &args); err != nil { return "", fmt.Errorf("parse arguments: %w", err) } } gitArgs := []string{"status", "--short"} if args.Path != "" { pathspec, err := gitPathspec(t.workingDir, args.Path) if err != nil { return "", err } gitArgs = append(gitArgs, "--", pathspec) } text, err := runGit(ctx, t.workingDir, gitArgs...) if err != nil { return text, err } if strings.TrimSpace(text) == "" { return "Working tree clean.", nil } return "Changed files:\n" + text, nil } // git_diff type GitDiffTool struct { workingDir string } func NewGitDiffTool(workingDir string) *GitDiffTool { return &GitDiffTool{workingDir: workingDir} } func (t *GitDiffTool) Definition() llm.Tool { return llm.Tool{ Type: "function", Function: llm.ToolFunction{ Name: "git_diff", Description: "Show unstaged or staged git diff output, optionally as --stat.", Parameters: JSONSchema(`{ "type": "object", "properties": { "path": {"type": "string", "description": "Optional path to limit diff to. Relative paths are resolved from the configured agent working directory."}, "staged": {"type": "boolean", "description": "Show staged diff instead of unstaged."}, "stat": {"type": "boolean", "description": "Show diffstat summary instead of full diff."} }, "additionalProperties": false }`), }, } } type gitDiffArgs struct { Path string `json:"path"` Staged bool `json:"staged"` Stat bool `json:"stat"` } func (t *GitDiffTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { var args gitDiffArgs if len(raw) > 0 { if err := json.Unmarshal(raw, &args); err != nil { return "", fmt.Errorf("parse arguments: %w", err) } } gitArgs := []string{"diff"} if args.Staged { gitArgs = append(gitArgs, "--cached") } if args.Stat { gitArgs = append(gitArgs, "--stat") } if args.Path != "" { pathspec, err := gitPathspec(t.workingDir, args.Path) if err != nil { return "", err } gitArgs = append(gitArgs, "--", pathspec) } text, err := runGit(ctx, t.workingDir, gitArgs...) if err != nil { return text, err } if strings.TrimSpace(text) == "" { if args.Staged { return "No staged diff.", nil } return "No unstaged diff.", nil } return text, nil } // git_log type GitLogTool struct { workingDir string } func NewGitLogTool(workingDir string) *GitLogTool { return &GitLogTool{workingDir: workingDir} } func (t *GitLogTool) Definition() llm.Tool { return llm.Tool{ Type: "function", Function: llm.ToolFunction{ Name: "git_log", Description: "Show recent git commits with git log --oneline --decorate.", Parameters: JSONSchema(`{ "type": "object", "properties": { "path": {"type": "string", "description": "Optional path to limit log to. Relative paths are resolved from the configured agent working directory."}, "max_count": {"type": "integer", "description": "Maximum number of commits to show. Defaults to 20, capped at 100."} }, "additionalProperties": false }`), }, } } type gitLogArgs struct { Path string `json:"path"` MaxCount int `json:"max_count"` } func (t *GitLogTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { var args gitLogArgs if len(raw) > 0 { if err := json.Unmarshal(raw, &args); err != nil { return "", fmt.Errorf("parse arguments: %w", err) } } maxCount := normalizeGitLogMaxCount(args.MaxCount) gitArgs := []string{"log", "--oneline", "--decorate", fmt.Sprintf("-n%d", maxCount)} if args.Path != "" { pathspec, err := gitPathspec(t.workingDir, args.Path) if err != nil { return "", err } gitArgs = append(gitArgs, "--", pathspec) } text, err := runGit(ctx, t.workingDir, gitArgs...) if err != nil { return text, err } if strings.TrimSpace(text) == "" { return "No git history.", nil } return text, nil }