internal/gitutil/grep.go
57 lines · 1352 bytes
1package gitutil
2
3import (
4 "context"
5 "fmt"
6 "os/exec"
7 "strconv"
8 "strings"
9 "time"
10)
11
12type GrepMatch struct {
13 Path string
14 Line int
15 Text string
16}
17
18// Grep runs a literal, case-insensitive git grep over the tree at ref,
19// skipping binary files. Matches are capped at max; "no matches" is an
20// empty result, not an error.
21func Grep(dir, ref, query string, max int) ([]GrepMatch, error) {
22 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
23 defer cancel()
24 // -z: NUL after the path and the line number, so paths containing
25 // ':' parse unambiguously (format: "ref:path\0line\0text\n").
26 cmd := exec.CommandContext(ctx, "git", "-C", dir, "grep", "-nIiF", "-z", "-e", query, ref)
27 out, err := cmd.Output()
28 if err != nil {
29 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
30 return nil, nil
31 }
32 return nil, fmt.Errorf("git grep at %s: %w", ref, err)
33 }
34 var matches []GrepMatch
35 for _, line := range strings.Split(string(out), "\n") {
36 if line == "" {
37 continue
38 }
39 parts := strings.SplitN(line, "\x00", 3)
40 if len(parts) != 3 {
41 continue
42 }
43 n, err := strconv.Atoi(parts[1])
44 if err != nil {
45 continue
46 }
47 matches = append(matches, GrepMatch{
48 Path: strings.TrimPrefix(parts[0], ref+":"),
49 Line: n,
50 Text: parts[2],
51 })
52 if len(matches) >= max {
53 break
54 }
55 }
56 return matches, nil
57}