internal/gitutil/grep.go

e2dec5d9ff2cd5dd54f68adec4190d8bafeaf302
gitbay/internal/gitutil/grep.go history · blame · raw

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}