package git import ( "context" "fmt" "os/exec" "path/filepath" "strings" "time" "laodingbot/internal/logger" ) // Tool provides common Git operations through a safe command whitelist. type Tool struct { repoDir string allowedSubcmds map[string]struct{} timeout time.Duration maxOutputChars int log *logger.Logger } // New creates a new git tool. func New(repoDir string, timeout time.Duration, maxOutputChars int, log *logger.Logger) *Tool { absRepo, err := filepath.Abs(strings.TrimSpace(repoDir)) if err != nil { absRepo = strings.TrimSpace(repoDir) } if timeout <= 0 { timeout = 20 * time.Second } if maxOutputChars <= 0 { maxOutputChars = 4000 } allowed := map[string]struct{}{ "status": {}, "log": {}, "show": {}, "diff": {}, "branch": {}, "checkout": {}, "switch": {}, "restore": {}, "add": {}, "commit": {}, "reset": {}, "revert": {}, "merge": {}, "rebase": {}, "cherry-pick": {}, "fetch": {}, "pull": {}, "push": {}, "remote": {}, "tag": {}, "stash": {}, "blame": {}, "rev-parse": {}, } if log != nil { log.Infof("git tool initialized repo_dir=%s timeout=%s max_output_chars=%d", absRepo, timeout, maxOutputChars) } return &Tool{ repoDir: absRepo, allowedSubcmds: allowed, timeout: timeout, maxOutputChars: maxOutputChars, log: log, } } func (t *Tool) Name() string { return "git" } func (t *Tool) Description() string { return "Run common git commands inside repository. Input examples: status --short | log --oneline -n 10 | add . | commit -m fix | branch" } func (t *Tool) Call(ctx context.Context, input string) (string, error) { trimmed := strings.TrimSpace(input) if trimmed == "" { if t.log != nil { t.log.Warnf("git tool rejected empty command") } return "", fmt.Errorf("empty git command") } parts := strings.Fields(trimmed) if len(parts) == 0 { return "", fmt.Errorf("empty git command") } if strings.EqualFold(parts[0], "git") { parts = parts[1:] } if len(parts) == 0 { return "", fmt.Errorf("missing git subcommand") } subcmd := strings.ToLower(parts[0]) if _, ok := t.allowedSubcmds[subcmd]; !ok { if t.log != nil { t.log.Warnf("git tool rejected subcommand=%s", subcmd) } return "", fmt.Errorf("unsupported git subcommand: %s", subcmd) } runCtx, cancel := context.WithTimeout(ctx, t.timeout) defer cancel() args := append([]string{subcmd}, parts[1:]...) if t.log != nil { t.log.Infof("git command start subcommand=%s args=%d full=%q", subcmd, len(parts)-1, strings.Join(args, " ")) } cmd := exec.CommandContext(runCtx, "git", args...) cmd.Dir = t.repoDir cmd.Env = append(cmd.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_PAGER=cat", "GIT_EDITOR=true", ) out, err := cmd.CombinedOutput() outText := strings.TrimSpace(string(out)) if len(outText) > t.maxOutputChars { outText = outText[:t.maxOutputChars] } if err != nil { if t.log != nil { t.log.Errorf("git command failed subcommand=%s err=%v output=%q", subcmd, err, outText) } if outText == "" { return "", err } return outText, err } if t.log != nil { t.log.Infof("git command success subcommand=%s output_bytes=%d", subcmd, len(out)) } if outText == "" { return "ok", nil } return outText, nil }