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