Files
2026-09-03 10:42:04 +08:00

258 lines
6.9 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
package tool
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
)
const (
gitGrepMaxCount = 100
gitGrepTimeout = 10 * time.Second
)
// CodeSearchProvider performs text search across the repository using git grep.
type CodeSearchProvider struct {
FileReader *FileReader
}
func NewCodeSearch(fr *FileReader) *CodeSearchProvider { return &CodeSearchProvider{FileReader: fr} }
func (p *CodeSearchProvider) Tool() Tool { return CodeSearch }
func (p *CodeSearchProvider) Execute(ctx context.Context, args map[string]any) (string, error) {
searchText, _ := args["search_text"].(string)
caseSensitive, _ := args["case_sensitive"].(bool)
usePerlRegexp, _ := args["use_perl_regexp"].(bool)
filePatternsIface, _ := args["file_patterns"].([]any)
var patterns []string
for _, item := range filePatternsIface {
if s, ok := item.(string); ok && s != "" {
if hasTraversalPathComponent(s) {
return "Error: file_patterns must not contain ..", nil
}
patterns = append(patterns, s)
}
}
if strings.TrimSpace(searchText) == "" {
return "Error: search_text is blank", nil
}
result, err := p.gitGrep(ctx, searchText, caseSensitive, usePerlRegexp, patterns)
if err != nil {
return "", err
}
return result, nil
}
func (p *CodeSearchProvider) buildGrepArgs(searchText string, caseSensitive bool, usePerlRegexp bool, noIndex bool, pathspec []string) []string {
cmdArgs := []string{"--no-pager", "grep"}
if noIndex {
// Non-git directory: search the working tree directly while still
// honoring .gitignore and skipping .git (via --exclude-standard).
cmdArgs = append(cmdArgs, "--no-index", "--exclude-standard")
} else if p.FileReader.Ref == "" {
cmdArgs = append(cmdArgs, "--untracked")
}
if !caseSensitive {
cmdArgs = append(cmdArgs, "-i")
}
if usePerlRegexp {
cmdArgs = append(cmdArgs, "-P")
} else {
cmdArgs = append(cmdArgs, "-F")
}
cmdArgs = append(cmdArgs, "-n", "--no-color")
cmdArgs = append(cmdArgs, "--max-count", fmt.Sprintf("%d", gitGrepMaxCount))
cmdArgs = append(cmdArgs, "-e", searchText)
if ref := p.FileReader.Ref; ref != "" {
if strings.HasPrefix(ref, "-") {
// Defense-in-depth: reject option-like refs here even though
// validateReviewRefs already verifies the ref upstream.
// NOTE: git grep < 2.45 does not support --end-of-options before
// the revision, so this is the one git invocation where we can't
// rely on that separator.
return nil
}
cmdArgs = append(cmdArgs, ref)
}
cmdArgs = append(cmdArgs, "--")
cmdArgs = append(cmdArgs, pathspec...)
return cmdArgs
}
func hasTraversalPathComponent(pathspec string) bool {
for _, part := range strings.Split(pathspec, "/") {
if part == ".." {
return true
}
}
return false
}
func (p *CodeSearchProvider) runGitGrep(parentCtx context.Context, cmdArgs []string) (string, string, error) {
ctx, cancel := context.WithTimeout(parentCtx, gitGrepTimeout)
defer cancel()
if p.FileReader.Runner != nil {
stdout, stderr, err := p.FileReader.Runner.RunSplit(ctx, p.FileReader.RepoDir, cmdArgs...)
if ctx.Err() != nil && err != nil {
return "", "", ctx.Err()
}
return stdout, stderr, err
}
cmd := exec.CommandContext(ctx, "git", cmdArgs...)
cmd.Dir = p.FileReader.RepoDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if ctx.Err() != nil && err != nil && cmd.ProcessState != nil && cmd.ProcessState.ExitCode() == -1 {
return "", "", ctx.Err()
}
return stdout.String(), stderr.String(), err
}
func (p *CodeSearchProvider) gitGrep(ctx context.Context, searchText string, caseSensitive bool, usePerlRegexp bool, pathspec []string) (string, error) {
cmdArgs := p.buildGrepArgs(searchText, caseSensitive, usePerlRegexp, false, pathspec)
if cmdArgs == nil {
return "Error: ref must not start with '-'", nil
}
outStr, errStr, err := p.runGitGrep(ctx, cmdArgs)
// Non-git directory: `git grep` exits 128 with "not a git repository".
// `ocr scan` supports plain directories, so retry in --no-index mode, which
// searches the working tree directly while still honoring .gitignore.
// Ref-based search needs a real repo, so it is not retried.
if err != nil && p.FileReader.Ref == "" && isNotGitRepoError(err, errStr) {
cmdArgs = p.buildGrepArgs(searchText, caseSensitive, usePerlRegexp, true, pathspec)
outStr, errStr, err = p.runGitGrep(ctx, cmdArgs)
}
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return "", fmt.Errorf("git grep timed out; try narrowing file_patterns to a more specific path: %w", err)
}
if errors.Is(err, context.Canceled) {
return "", err
}
if outStr == "" {
var exitErr *exec.ExitError
exitCode := -1
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
}
if errStr == "" && exitCode == 1 {
return "No matches found", nil
}
trimmedErr := trimGitUsage(errStr, exitCode)
if trimmedErr == "" {
return "", fmt.Errorf("git grep failed: %w", err)
}
return "", fmt.Errorf("git grep failed: %w: %s", err, trimmedErr)
}
}
lines := strings.Split(strings.TrimRight(outStr, "\n"), "\n")
truncated := len(lines) >= gitGrepMaxCount
type match struct {
lineNum int
content string
}
fileMatches := make(map[string][]match)
var fileOrder []string
seen := make(map[string]bool)
hasRef := p.FileReader.Ref != ""
splitN := 3
offset := 0
if hasRef {
splitN = 4
offset = 1
}
var sb strings.Builder
if truncated {
sb.WriteString(fmt.Sprintf("Note: The results have been truncated. Only showing first %d results.\n", gitGrepMaxCount))
}
for _, line := range lines {
if line == "" {
continue
}
parts := strings.SplitN(line, ":", splitN)
if len(parts) < splitN {
continue
}
fname := parts[offset]
m := match{}
ln, parseErr := strconv.Atoi(parts[offset+1])
if parseErr != nil {
continue
}
m.lineNum = ln
m.content = parts[offset+2]
if !seen[fname] {
seen[fname] = true
fileOrder = append(fileOrder, fname)
}
fileMatches[fname] = append(fileMatches[fname], m)
}
for _, path := range fileOrder {
matches := fileMatches[path]
sb.WriteString(fmt.Sprintf("File: %s\nMatch lines: %d\n", path, len(matches)))
for _, m := range matches {
sb.WriteString(fmt.Sprintf("%d|%s\n", m.lineNum, m.content))
}
sb.WriteString("\n")
}
if err != nil && errStr != "" {
sb.WriteString(fmt.Sprintf("Warning: %s\n", strings.TrimSpace(errStr)))
}
return sb.String(), nil
}
func trimGitUsage(stderr string, exitCode int) string {
stderr = strings.TrimSpace(stderr)
if exitCode == 129 {
if idx := strings.IndexByte(stderr, '\n'); idx >= 0 {
stderr = stderr[:idx]
}
}
return strings.TrimSpace(stderr)
}
func isNotGitRepoError(err error, stderr string) bool {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 128 &&
(strings.Contains(stderr, "not a git repository") || strings.Contains(stderr, ".git")) {
return true
}
return false
}