diff options
| author | Martin Fischer <martin@push-f.com> | 2025-12-28 16:03:35 +0100 |
|---|---|---|
| committer | Martin Fischer <martin@push-f.com> | 2025-12-30 18:56:26 +0100 |
| commit | d18ad307fc8084f9b5479e8fc08d4fbc427738c5 (patch) | |
| tree | 8a164ed40d14d86efb61b8f0022c174078d26dcf | |
initial commit
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | LICENSE | 19 | ||||
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | default.nix | 15 | ||||
| -rw-r--r-- | go.mod | 22 | ||||
| -rw-r--r-- | go.sum | 54 | ||||
| -rw-r--r-- | hashes.nix | 3 | ||||
| -rw-r--r-- | main.go | 149 | ||||
| -rw-r--r-- | main_test.go | 63 | ||||
| -rw-r--r-- | service.nix | 51 |
10 files changed, 388 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4a847d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/result @@ -0,0 +1,19 @@ +Copyright (c) 2025 Martin Fischer <martin@push-f.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6ebb75 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# git-grep-exporter + +Prometheus metrics for how many lines in certain git repos match certain regular expressions. + +Only the HEAD of the repositories will be grepped. +The metrics are named `{pattern}_lines` and have `repo` labels. + +I made this to track the amount of TODO and FIXME comments in my repositories +because often while fixing one TODO I end up writing two more and it feels like +it's never ending but surely there's an end? The patterns are fully configurable +so you could also use this to track curse words ... or something else? diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..27d801c --- /dev/null +++ b/default.nix @@ -0,0 +1,15 @@ +{ pkgs ? import <nixpkgs> {} }: + +pkgs.buildGoModule { + pname = "git-grep-exporter"; + version = "0+git"; + src = pkgs.lib.cleanSource ./.; + vendorHash = (import ./hashes.nix).vendor; + + nativeBuildInputs = with pkgs; [makeWrapper]; + + postInstall = '' + wrapProgram $out/bin/git-grep-exporter \ + --prefix PATH : ${pkgs.lib.makeBinPath [pkgs.gitMinimal]} + ''; +} @@ -0,0 +1,22 @@ +module push-f.com/git-grep-exporter + +go 1.25.4 + +require ( + github.com/alecthomas/kong v1.13.0 + github.com/google/go-cmp v0.7.0 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/common v0.66.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) @@ -0,0 +1,54 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v1.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA= +github.com/alecthomas/kong v1.13.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hashes.nix b/hashes.nix new file mode 100644 index 0000000..4b7d66b --- /dev/null +++ b/hashes.nix @@ -0,0 +1,3 @@ +{ + vendor = "sha256-IO+MeOuwXcbKbLUkXFv6aJYfymbCd9kZXOyLMPAARLE="; +} @@ -0,0 +1,149 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "log/slog" + "maps" + "net/http" + "os" + "os/exec" + "regexp" + "slices" + "strings" + "time" + + "github.com/alecthomas/kong" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +type Args struct { + Port int `help:"Port to listen on" short:"p" default:"8888" env:"PORT"` + Repos StringMap `placeholder:"{\"name\": \"path\", ...}" required:"" help:"Repositories to be grepped" env:"REPOS"` + Patterns StringMap `placeholder:"{\"name\": \"regex\", ...}" required:"" help:"Patterns to be grepped for" env:"PATTERNS"` + Interval time.Duration `help:"How often the repos should be grepped (units: s,m,h for seconds, minutes, hours)" default:"10m" env:"INTERVAL"` +} + +type StringMap map[string]string + +func (p *StringMap) UnmarshalJSON(data []byte) error { + stringMap := (*map[string]string)(p) + return json.Unmarshal(data, &stringMap) +} + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + slog.SetDefault(logger) + + var args Args + ctx := kong.Parse(&args, kong.Description( + "Prometheus metrics for how many lines in certain git repos match certain regular expressions.\n\n"+ + "Only the HEAD of the repositories will be grepped.", + )) + + http.Handle("/metrics", promhttp.Handler()) + + te, err := newGitGrepExporter(args.Repos, args.Patterns, prometheus.DefaultRegisterer) + ctx.FatalIfErrorf(err) + + go func() { + ticker := time.NewTicker(args.Interval) + defer ticker.Stop() + + // execute immediately on start + te.grepRepos() + + for range ticker.C { + te.grepRepos() + } + }() + + slog.Info("starting", "repos", args.Repos, "patterns", args.Patterns, "interval", args.Interval) + slog.Info(fmt.Sprintf("serving metrics at http://localhost:%d/metrics", args.Port)) + http.ListenAndServe(fmt.Sprintf(":%d", args.Port), nil) +} + +type GitGrepExporter struct { + repos map[string]string + gauges map[string]*prometheus.GaugeVec + regexes map[string]*regexp.Regexp + combinedPattern string +} + +func newGitGrepExporter(repos map[string]string, patterns map[string]string, reg prometheus.Registerer) (*GitGrepExporter, error) { + if len(repos) == 0 { + return nil, fmt.Errorf("no repositories specified") + } + if len(patterns) == 0 { + return nil, fmt.Errorf("no patterns specified") + } + + gauges := map[string]*prometheus.GaugeVec{} + regexes := map[string]*regexp.Regexp{} + + for name, pattern := range patterns { + gauges[name] = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: fmt.Sprintf("%s_lines", name), + }, []string{"repo"}) + reg.MustRegister(gauges[name]) + regexes[name] = regexp.MustCompile(pattern) + } + + return &GitGrepExporter{repos, gauges, regexes, strings.Join(slices.Collect(maps.Values(patterns)), "|")}, nil +} + +func (g *GitGrepExporter) grepRepos() { + for name, path := range g.repos { + slog.Info("starting grep", "path", path) + g.grepRepo(name) + slog.Info("finished grep", "path", path) + } +} + +func (g *GitGrepExporter) grepRepo(name string) { + path := g.repos[name] + output, err := run("git", "-C", path, "grep", "-hE", g.combinedPattern, "HEAD") + + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() == 1 { + // no matches found + + for _, gauge := range g.gauges { + gauge.With(map[string]string{"repo": name}).Set(0) + } + return + } + + slog.Error("git grep failed", "stderr", string(output)) + return + } + + slog.Error("failed to execute git grep", "error", err) + return + } + + scanner := bufio.NewScanner(bytes.NewReader(output)) + counts := map[string]float64{} + for scanner.Scan() { + line := scanner.Text() + for word, regex := range g.regexes { + if regex.FindString(line) != "" { + counts[word] += 1 + } + } + } + for word, gauge := range g.gauges { + gauge.With(map[string]string{"repo": name}).Set(counts[word]) + } +} + +var run = func(name string, args ...string) ([]byte, error) { + cmd := exec.Command(name, args...) + return cmd.CombinedOutput() +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..bd5130a --- /dev/null +++ b/main_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "log" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/expfmt" +) + +func TestGrepRepo(t *testing.T) { + repos := map[string]string{"project1": "/srv/repos/project1"} + patterns := map[string]string{"match": "\\bMATCH\\b", "match_start": "^MATCH"} + reg := prometheus.NewRegistry() + te, err := newGitGrepExporter(repos, patterns, reg) + if err != nil { + t.Fatal(err) + } + + run = func(name string, args ...string) ([]byte, error) { + return []byte( + "not a match\n" + + "MATCH can be at the start\n" + + "here MATCH is in the middle\n" + + "the end can also be MATCH\n" + + "MATCH, MATCH counts only once\n", + ), nil + } + + te.grepRepo("project1") + + want := "# HELP match_lines \n" + + "# TYPE match_lines gauge\n" + + "match_lines{repo=\"project1\"} 4\n" + + "# HELP match_start_lines \n" + + "# TYPE match_start_lines gauge\n" + + "match_start_lines{repo=\"project1\"} 2\n" + + if diff := cmp.Diff(collectMetrics(reg), want); diff != "" { + t.Errorf("unexpected metrics: %v", diff) + } +} + +func collectMetrics(reg *prometheus.Registry) string { + mfs, done, err := prometheus.ToTransactionalGatherer(reg).Gather() + if err != nil { + log.Fatal(err) + } + defer done() + + var buf strings.Builder + enc := expfmt.NewEncoder(&buf, expfmt.NewFormat(expfmt.TypeTextPlain)) + + for _, mf := range mfs { + err := enc.Encode(mf) + if err != nil { + log.Fatal(err) + } + } + return buf.String() +} diff --git a/service.nix b/service.nix new file mode 100644 index 0000000..0b5aa91 --- /dev/null +++ b/service.nix @@ -0,0 +1,51 @@ +{ config, lib, pkgs, ... }: + +let + git_grep_exporter = pkgs.callPackage ./default.nix {}; + cfg = config.services.gitGrepExporter; +in +{ + options.services.gitGrepExporter = { + enable = lib.mkEnableOption "git-grep-exporter"; + + port = lib.mkOption { + type = lib.types.int; + }; + + patterns = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + }; + + repos = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + }; + + extraGroups = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + }; + + interval = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = "10m"; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.git-grep-exporter = { + serviceConfig = { + DynamicUser = true; + SupplementaryGroups = cfg.extraGroups; + ExecStart = "${git_grep_exporter}/bin/git-grep-exporter"; + }; + environment = { + PORT = toString cfg.port; + REPOS = builtins.toJSON cfg.repos; + PATTERNS = builtins.toJSON cfg.patterns; + INTERVAL = cfg.interval; + }; + + wantedBy = ["multi-user.target"]; + }; + }; +} |
