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}