Repository: grafana/tanka
Pull request #769 (2022-09-29 17:00:53)

Replace external call to `diffstat` with native code
Closes https://github.com/grafana/tanka/issues/767

I got nerd sniped... though it was pretty quick to implement

- `diffstat` is not available in the `apk` registry so we would have to compile it manually to add it to the docker image
- Parsing a diff to get the number of lines is pretty simple
- I tried a few external libraries but they didn't work right + less libs is better

So I opted to write our own implementation of diffstat (probably much simpler than the real thing, less cases to handle)


Patch:
diff --git a/cmd/tk/lint.go b/cmd/tk/lint.go
index 3c435f04..6a1291b4 100644
--- a/cmd/tk/lint.go
+++ b/cmd/tk/lint.go
@@ -27,7 +27,12 @@ func lintCmd() *cli.Command {
 
 	exclude := cmd.Flags().StringSliceP("exclude", "e", []string{"**/.*", ".*", "**/vendor/**", "vendor/**"}, "globs to exclude")
 	parallelism := cmd.Flags().IntP("parallelism", "n", 4, "amount of workers")
-	verbose := cmd.Flags().BoolP("verbose", "v", false, "print each checked file")
+
+	// this is now always sent as debug logs
+	cmd.Flags().BoolP("verbose", "v", false, "print each checked file")
+	if err := cmd.Flags().MarkDeprecated("verbose", "logs are sent to debug now, this is unused"); err != nil {
+		panic(err)
+	}
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
 		globs := make([]glob.Glob, len(*exclude))
@@ -39,7 +44,7 @@ func lintCmd() *cli.Command {
 			globs[i] = g
 		}
 
-		return jsonnet.Lint(args, &jsonnet.LintOpts{Excludes: globs, PrintNames: *verbose, Parallelism: *parallelism})
+		return jsonnet.Lint(args, &jsonnet.LintOpts{Excludes: globs, Parallelism: *parallelism})
 	}
 
 	return cmd
diff --git a/pkg/helm/charts.go b/pkg/helm/charts.go
index c7d561d3..18453cb1 100644
--- a/pkg/helm/charts.go
+++ b/pkg/helm/charts.go
@@ -138,7 +138,7 @@ func (c Charts) Vendor(prune bool) error {
 			}
 
 			if chartYAML.Version.String() == r.Version.String() {
-				log.Info().Msgf(" %s exists", r)
+				log.Info().Msgf("%s exists", r)
 				continue
 			} else {
 				log.Info().Msgf("Removing %s", r)
@@ -174,7 +174,7 @@ func (c Charts) Vendor(prune bool) error {
 			return err
 		}
 
-		log.Info().Msgf(" %s@%s downloaded", r.Chart, r.Version.String())
+		log.Info().Msgf("%s@%s downloaded", r.Chart, r.Version.String())
 	}
 
 	if prune {
@@ -202,7 +202,7 @@ func (c Charts) Vendor(prune bool) error {
 // Add adds every Chart in reqs to the Manifest after validation, and runs
 // Vendor afterwards
 func (c *Charts) Add(reqs []string) error {
-	log.Printf("Adding %v Charts ...", len(reqs))
+	log.Info().Msgf("Adding %v Charts ...", len(reqs))
 
 	// parse new charts, append in memory
 	requirements := c.Manifest.Requires
@@ -219,7 +219,7 @@ func (c *Charts) Add(reqs []string) error {
 		}
 
 		requirements = append(requirements, *r)
-		log.Info().Msgf(" OK: %s", s)
+		log.Info().Msgf("OK: %s", s)
 	}
 
 	if err := requirements.Validate(); err != nil {
@@ -239,7 +239,7 @@ func (c *Charts) Add(reqs []string) error {
 	}
 
 	// worked fine? vendor it
-	log.Printf("Added %v Charts to helmfile.yaml. Vendoring ...", added)
+	log.Info().Msgf("Added %v Charts to helmfile.yaml. Vendoring ...", added)
 	return c.Vendor(false)
 }
 
@@ -258,7 +258,7 @@ func (c *Charts) AddRepos(repos ...Repo) error {
 
 		c.Manifest.Repositories = append(c.Manifest.Repositories, r)
 		added++
-		log.Info().Msgf(" OK: %s", r.Name)
+		log.Info().Msgf("OK: %s", r.Name)
 	}
 
 	// write out
@@ -346,5 +346,5 @@ func parseReqName(s string) string {
 }
 
 func skip(s string, err error) {
-	log.Printf(" Skipping %s: %s.", s, err)
+	log.Info().Msgf("Skipping %s: %s.", s, err)
 }
diff --git a/pkg/jsonnet/eval.go b/pkg/jsonnet/eval.go
index c9d47e00..cbf64780 100644
--- a/pkg/jsonnet/eval.go
+++ b/pkg/jsonnet/eval.go
@@ -3,9 +3,11 @@ package jsonnet
 import (
 	"os"
 	"regexp"
+	"time"
 
 	jsonnet "github.com/google/go-jsonnet"
 	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
 
 	"github.com/grafana/tanka/pkg/jsonnet/jpath"
 	"github.com/grafana/tanka/pkg/jsonnet/native"
@@ -141,14 +143,18 @@ func evaluateSnippet(evalFunc evalFunc, path, data string, opts Opts) (string, e
 
 	var hash string
 	if cache != nil {
+		startTime := time.Now()
 		if hash, err = getSnippetHash(vm, path, data); err != nil {
 			return "", err
 		}
+		cacheLog := log.Debug().Str("path", path).Str("hash", hash).Dur("duration_ms", time.Since(startTime))
 		if v, err := cache.Get(hash); err != nil {
 			return "", err
 		} else if v != "" {
+			cacheLog.Bool("cache_hit", true).Msg("computed snippet hash")
 			return v, nil
 		}
+		cacheLog.Bool("cache_hit", false).Msg("computed snippet hash")
 	}
 
 	content, err := evalFunc(vm)
diff --git a/pkg/jsonnet/lint.go b/pkg/jsonnet/lint.go
index b3a36990..31a45da5 100644
--- a/pkg/jsonnet/lint.go
+++ b/pkg/jsonnet/lint.go
@@ -5,11 +5,13 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"time"
 
 	"github.com/gobwas/glob"
 	"github.com/google/go-jsonnet/linter"
 	"github.com/grafana/tanka/pkg/jsonnet/jpath"
 	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
 )
 
 // LintOpts modifies the behaviour of Lint
@@ -18,9 +20,6 @@ type LintOpts struct {
 	// files
 	Excludes []glob.Glob
 
-	// PrintNames causes all filenames to be printed
-	PrintNames bool
-
 	// Parallelism determines the number of workers that will process files
 	Parallelism int
 }
@@ -54,9 +53,8 @@ func Lint(fds []string, opts *LintOpts) error {
 				continue
 			}
 
-			if opts.PrintNames {
-				fmt.Printf("Linting %s...\n", file)
-			}
+			log.Debug().Str("file", file).Msg("linting file")
+			startTime := time.Now()
 
 			vm := MakeVM(Opts{})
 			jpaths, _, _, err := jpath.Resolve(file, true)
@@ -71,6 +69,7 @@ func Lint(fds []string, opts *LintOpts) error {
 			content, _ := os.ReadFile(file)
 			failed := linter.LintSnippet(vm, buf, []linter.Snippet{{FileName: file, Code: string(content)}})
 			resultCh <- result{failed: failed, output: buf.String()}
+			log.Debug().Str("file", file).Dur("duration_ms", time.Since(startTime)).Msg("linted file")
 		}
 	}
 
diff --git a/pkg/kubernetes/diff.go b/pkg/kubernetes/diff.go
index 84401e0d..7eac9b90 100644
--- a/pkg/kubernetes/diff.go
+++ b/pkg/kubernetes/diff.go
@@ -90,7 +90,8 @@ Please upgrade kubectl to at least version 1.18.1`)
 	}
 
 	if opts.Summarize {
-		return util.Diffstat(*d)
+		result, err := util.DiffStat(*d)
+		return &result, err
 	}
 
 	return d, nil
diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go
index 0eb3c940..8bbbb9bc 100644
--- a/pkg/kubernetes/kubernetes.go
+++ b/pkg/kubernetes/kubernetes.go
@@ -71,7 +71,7 @@ func (k *Kubernetes) Close() error {
 
 // DiffOpts allow to specify additional parameters for diff operations
 type DiffOpts struct {
-	// Use `diffstat(1)` to create a histogram of the changes instead
+	// Create a histogram of the changes instead
 	Summarize bool
 	// Find orphaned resources and include them in the diff
 	WithPrune bool
diff --git a/pkg/kubernetes/util/diff.go b/pkg/kubernetes/util/diff.go
index 1ffd5fc1..a979ec36 100644
--- a/pkg/kubernetes/util/diff.go
+++ b/pkg/kubernetes/util/diff.go
@@ -3,12 +3,15 @@ package util
 import (
 	"bytes"
 	"fmt"
+	"math"
 	"os"
 	"os/exec"
 	"path/filepath"
 	"regexp"
+	"sort"
 	"strings"
 
+	"github.com/fatih/color"
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
 )
 
@@ -60,20 +63,58 @@ func DiffStr(name, is, should string) (string, error) {
 	return out, nil
 }
 
-// Diffstat uses `diffstat(1)` utility to summarize a `diff(1)` output
-func Diffstat(d string) (*string, error) {
-	cmd := exec.Command("diffstat", "-C")
-	buf := bytes.Buffer{}
-	cmd.Stdout = &buf
-	cmd.Stderr = os.Stderr
-	cmd.Stdin = strings.NewReader(d)
+// Diffstat creates a histogram of a diff
+func DiffStat(d string) (string, error) {
+	lines := strings.Split(d, "\n")
+	type diff struct {
+		added, removed int
+	}
+
+	maxFilenameLength := 0
+	maxChanges := 0
+	var fileNames []string
+	diffMap := map[string]diff{}
+
+	currentFileName := ""
+	totalAdded, added, totalRemoved, removed := 0, 0, 0, 0
+	for i, line := range lines {
+		if strings.HasPrefix(line, "diff ") {
+			splitLine := strings.Split(line, " ")
+			currentFileName = findStringsCommonSuffix(splitLine[len(splitLine)-2], splitLine[len(splitLine)-1])
+			added, removed = 0, 0
+			continue
+		}
+
+		if strings.HasPrefix(line, "+ ") {
+			added++
+		} else if strings.HasPrefix(line, "- ") {
+			removed++
+		}
 
-	if err := cmd.Run(); err != nil {
-		return nil, fmt.Errorf("invoking diffstat(1): %s", err.Error())
+		if currentFileName != "" && (i == len(lines)-1 || strings.HasPrefix(lines[i+1], "diff ")) {
+			totalAdded += added
+			totalRemoved += removed
+			if added+removed > maxChanges {
+				maxChanges = added + removed
+			}
+
+			fileNames = append(fileNames, currentFileName)
+			diffMap[currentFileName] = diff{added, removed}
+			if len(currentFileName) > maxFilenameLength {
+				maxFilenameLength = len(currentFileName)
+			}
+		}
 	}
+	sort.Strings(fileNames)
 
-	out := buf.String()
-	return &out, nil
+	builder := strings.Builder{}
+	for _, fileName := range fileNames {
+		f := diffMap[fileName]
+		builder.WriteString(fmt.Sprintf("%-*s | %4d %s\n", maxFilenameLength, fileName, f.added+f.removed, printPlusAndMinuses(f.added, f.removed, maxChanges)))
+	}
+	builder.WriteString(fmt.Sprintf("%d files changed, %d insertions(+), %d deletions(-)", len(fileNames), totalAdded, totalRemoved))
+
+	return builder.String(), nil
 }
 
 // FilteredErr is a filtered Stderr. If one of the regular expressions match, the current input is discarded.
@@ -88,3 +129,44 @@ func (r FilteredErr) Write(p []byte) (n int, err error) {
 	}
 	return os.Stderr.Write(p)
 }
+
+// printPlusAndMinuses prints colored plus and minus signs for the given number of added and removed lines.
+// The number of characters is calculated based on the maximum number of changes in all files (maxChanges).
+// The number of characters is capped at 40.
+func printPlusAndMinuses(added, removed int, maxChanges int) string {
+	addedAndRemoved := float64(added + removed)
+	chars := math.Ceil(addedAndRemoved / float64(maxChanges) * 40)
+
+	added = min(added, int(float64(added)/addedAndRemoved*chars))
+	removed = min(removed, int(chars)-added)
+
+	return color.New(color.FgGreen).Sprint(strings.Repeat("+", added)) +
+		color.New(color.FgRed).Sprint(strings.Repeat("-", removed))
+}
+
+func min(a, b int) int {
+	if a < b {
+		return a
+	}
+	return b
+}
+
+// findStringsCommonSuffix returns the common suffix of the two strings (removing leading `/` or `-`)
+// e.g. findStringsCommonSuffix("foo/bar/baz", "other/bar/baz") -> "bar/baz"
+func findStringsCommonSuffix(a, b string) string {
+	if a == b {
+		return a
+	}
+
+	if len(a) > len(b) {
+		a, b = b, a
+	}
+
+	for i := 0; i < len(a); i++ {
+		if a[len(a)-i-1] != b[len(b)-i-1] {
+			return strings.TrimLeft(a[len(a)-i:], "/-")
+		}
+	}
+
+	return ""
+}
diff --git a/pkg/tanka/parallel.go b/pkg/tanka/parallel.go
index c07c4a29..9c8ef23a 100644
--- a/pkg/tanka/parallel.go
+++ b/pkg/tanka/parallel.go
@@ -98,6 +98,6 @@ func parallelWorker(jobsCh <-chan parallelJob, outCh chan parallelOut) {
 		}
 		outCh <- parallelOut{env: env, err: err}
 
-		log.Debug().Str("name", job.opts.Name).Str("path", job.path).Dur("time", time.Since(startTime)).Msg("Finished loading environment")
+		log.Debug().Str("name", job.opts.Name).Str("path", job.path).Dur("duration_ms", time.Since(startTime)).Msg("Finished loading environment")
 	}
 }
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index 145448ee..953d1678 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -166,10 +166,9 @@ type DiffOpts struct {
 
 // Diff parses the environment at the given directory (a `baseDir`) and returns
 // the differences from the live cluster state in `diff(1)` format. If the
-// `WithDiffSummarize` modifier is used, a histogram created using `diffstat(1)`
-// is returned instead.
+// `WithDiffSummarize` modifier is used, a histogram is returned instead.
 // The cluster information is retrieved from the environments `spec.json`.
-// NOTE: This function requires on `diff(1)`, `kubectl(1)` and perhaps `diffstat(1)`
+// NOTE: This function requires on `diff(1)` and `kubectl(1)`
 func Diff(baseDir string, opts DiffOpts) (*string, error) {
 	l, err := Load(baseDir, opts.Opts)
 	if err != nil {


Test patch:
diff --git a/pkg/kubernetes/util/diff_test.go b/pkg/kubernetes/util/diff_test.go
new file mode 100644
index 00000000..cb390b55
--- /dev/null
+++ b/pkg/kubernetes/util/diff_test.go
@@ -0,0 +1,31 @@
+package util
+
+import (
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDiffStat(t *testing.T) {
+	cases := []string{
+		"empty",
+		"added-and-removed",
+		"changed-attributes",
+		"changed-lots-of-attributes",
+	}
+	for _, c := range cases {
+		t.Run(c, func(t *testing.T) {
+			content, err := os.ReadFile("testdata/" + c + ".diff")
+			require.NoError(t, err)
+			expected, err := os.ReadFile("testdata/" + c + ".stat")
+			require.NoError(t, err)
+
+			got, err := DiffStat(string(content))
+			require.NoError(t, err)
+
+			assert.Equal(t, string(expected), got)
+		})
+	}
+}
diff --git a/pkg/kubernetes/util/testdata/added-and-removed.diff b/pkg/kubernetes/util/testdata/added-and-removed.diff
new file mode 100644
index 00000000..52e7722f
--- /dev/null
+++ b/pkg/kubernetes/util/testdata/added-and-removed.diff
@@ -0,0 +1,109 @@
+diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4199451777/apps.v1.Deployment.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-1314023776/apps.v1.Deployment.bors-ng.bors-ng
+--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4199451777/apps.v1.Deployment.bors-ng.bors-ng	2022-09-29 11:48:13.345562149 -0400
++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-1314023776/apps.v1.Deployment.bors-ng.bors-ng	2022-09-29 11:48:13.347038829 -0400
+@@ -216,7 +229,7 @@
+         - name: DATABASE_USE_SSL
+           value: "false"
+         - name: DATABASE_AUTO_MIGRATE
+-          value: "true"
++          value: tru
+         - name: COMMAND_TRIGGER
+           value: bors
+         - name: DASHBOARD_HEADER_HTML
+diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4199451777/apps.v1.Deployment.bors-ng.bors-ngtest /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-1314023776/apps.v1.Deployment.bors-ng.bors-ngtest
+--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4199451777/apps.v1.Deployment.bors-ng.bors-ngtest	2022-09-29 11:48:13.519024640 -0400
++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-1314023776/apps.v1.Deployment.bors-ng.bors-ngtest	2022-09-29 11:48:13.520387827 -0400
+@@ -0,0 +1,270 @@
++apiVersion: apps/v1
++kind: Deployment
++metadata:
++  labels:
++    tanka.dev/environment: 108b68a3c424de9dd80f2cf125b9c7a498d65dd4c8cf55b7
++spec:
++  minReadySeconds: 10
++  progressDeadlineSeconds: 600
++  replicas: 1
++  revisionHistoryLimit: 5
++  selector:
++    matchLabels:
++      app: bors-ng
++  strategy:
++    rollingUpdate:
++      maxSurge: 25%
++      maxUnavailable: 25%
++    type: RollingUpdate
++  template:
++    metadata:
++      creationTimestamp: null
++      labels:
++        app: bors-ng
++        name: bors-ng
++    spec:
++      containers:
++      - env:
++        - name: PUBLIC_HOST
++          value: bors-ng.test.net
++        image: bors-image
++        imagePullPolicy: IfNotPresent
++        livenessProbe:
++          failureThreshold: 3
++          httpGet:
++            path: /health
++            port: http
++            scheme: HTTP
++          periodSeconds: 10
++          successThreshold: 1
++          timeoutSeconds: 3
++        name: bors-ng
++        ports:
++        - containerPort: 80
++          name: http
++          protocol: TCP
++        readinessProbe:
++          failureThreshold: 3
++          httpGet:
++            path: /health
++            port: http
++            scheme: HTTP
++          periodSeconds: 10
++          successThreshold: 1
++          timeoutSeconds: 3
++      dnsPolicy: ClusterFirst
++      restartPolicy: Always
+diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4199451777/networking.k8s.io.v1.Ingress.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-1314023776/networking.k8s.io.v1.Ingress.bors-ng.bors-ng
+--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4199451777/networking.k8s.io.v1.Ingress.bors-ng.bors-ng	2022-09-29 11:48:13.787324397 -0400
++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-1314023776/networking.k8s.io.v1.Ingress.bors-ng.bors-ng	2022-09-29 11:48:13.787954356 -0400
+@@ -58,7 +65,7 @@
+             port:
+               number: 80
+         path: /
+-        pathType: Prefix
++        pathType: Exact
+   tls:
+   - hosts:
+     - bors-ng.test.net
+diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/diff3561117184/LIVE-v1.Service.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/diff3561117184/MERGED-v1.Service.bors-ng.bors-ng
+--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/diff3561117184/LIVE-v1.Service.bors-ng.bors-ng	2022-09-29 11:54:18.319484889 -0400
++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/diff3561117184/MERGED-v1.Service.bors-ng.bors-ng	2022-09-29 11:54:18.319567564 -0400
+@@ -1,78 +0,0 @@
+-apiVersion: v1
+-kind: Service
+-metadata:
+-  creationTimestamp: "2021-08-17T16:51:55Z"
+-  labels:
+-    name: bors-ng
+-    tanka.dev/environment: 108b68a3c424de9dd80f2cf125b9c7a498d65dd4c8cf55b7
+-  name: bors-ng
+-  namespace: bors-ng
+-spec:
+-  ports:
+-  - name: bors-ng-http
+-    port: 80
+-    protocol: TCP
+-    targetPort: 80
+-  selector:
+-    app: bors-ng
+-    name: bors-ng
+-  sessionAffinity: None
+-  type: ClusterIP
+
diff --git a/pkg/kubernetes/util/testdata/added-and-removed.stat b/pkg/kubernetes/util/testdata/added-and-removed.stat
new file mode 100644
index 00000000..5c645374
--- /dev/null
+++ b/pkg/kubernetes/util/testdata/added-and-removed.stat
@@ -0,0 +1,5 @@
+apps.v1.Deployment.bors-ng.bors-ng           |    2 +-
+apps.v1.Deployment.bors-ng.bors-ngtest       |   52 ++++++++++++++++++++++++++++++++++++++++
+networking.k8s.io.v1.Ingress.bors-ng.bors-ng |    2 +-
+v1.Service.bors-ng.bors-ng                   |   16 -------------
+4 files changed, 54 insertions(+), 18 deletions(-)
\ No newline at end of file
diff --git a/pkg/kubernetes/util/testdata/changed-attributes.diff b/pkg/kubernetes/util/testdata/changed-attributes.diff
new file mode 100644
index 00000000..2e994ccf
--- /dev/null
+++ b/pkg/kubernetes/util/testdata/changed-attributes.diff
@@ -0,0 +1,24 @@
+diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/apps.v1.Deployment.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/apps.v1.Deployment.bors-ng.bors-ng
+--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/apps.v1.Deployment.bors-ng.bors-ng	2022-09-29 11:30:26.173560807 -0400
++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/apps.v1.Deployment.bors-ng.bors-ng	2022-09-29 11:30:26.177099345 -0400
+@@ -216,7 +229,7 @@
+         - name: DATABASE_USE_SSL
+           value: "false"
+         - name: DATABASE_AUTO_MIGRATE
+-          value: "true"
++          value: tru
+         - name: COMMAND_TRIGGER
+           value: bors
+         - name: DASHBOARD_HEADER_HTML
+diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/networking.k8s.io.v1.Ingress.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/networking.k8s.io.v1.Ingress.bors-ng.bors-ng
+--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/networking.k8s.io.v1.Ingress.bors-ng.bors-ng	2022-09-29 11:30:26.451654927 -0400
++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/networking.k8s.io.v1.Ingress.bors-ng.bors-ng	2022-09-29 11:30:26.452224214 -0400
+@@ -58,7 +65,7 @@
+             port:
+               number: 80
+         path: /
+-        pathType: Prefix
++        pathType: Exact
+   tls:
+   - hosts:
+     - bors-ng.test.net
diff --git a/pkg/kubernetes/util/testdata/changed-attributes.stat b/pkg/kubernetes/util/testdata/changed-attributes.stat
new file mode 100644
index 00000000..85454569
--- /dev/null
+++ b/pkg/kubernetes/util/testdata/changed-attributes.stat
@@ -0,0 +1,3 @@
+apps.v1.Deployment.bors-ng.bors-ng           |    2 +-
+networking.k8s.io.v1.Ingress.bors-ng.bors-ng |    2 +-
+2 files changed, 2 insertions(+), 2 deletions(-)
\ No newline at end of file
diff --git a/pkg/kubernetes/util/testdata/changed-lots-of-attributes.diff b/pkg/kubernetes/util/testdata/changed-lots-of-attributes.diff
new file mode 100644
index 00000000..97ea43fb
--- /dev/null
+++ b/pkg/kubernetes/util/testdata/changed-lots-of-attributes.diff
@@ -0,0 +1,88 @@
+diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/apps.v1.Deployment.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/apps.v1.Deployment.bors-ng.bors-ng
+--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/apps.v1.Deployment.bors-ng.bors-ng	2022-09-29 11:30:26.173560807 -0400
++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/apps.v1.Deployment.bors-ng.bors-ng	2022-09-29 11:30:26.177099345 -0400
+@@ -216,7 +229,7 @@
+         - name: DATABASE_USE_SSL
+           value: "false"
+         - name: DATABASE_AUTO_MIGRATE
+-          value: "true"
++          value: tru
+         - name: COMMAND_TRIGGER
+           value: bors
+         - name: DASHBOARD_HEADER_HTML
+diff -u -N /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/networking.k8s.io.v1.Ingress.bors-ng.bors-ng /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/networking.k8s.io.v1.Ingress.bors-ng.bors-ng
+--- /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/LIVE-4258784861/networking.k8s.io.v1.Ingress.bors-ng.bors-ng	2022-09-29 11:30:26.451654927 -0400
++++ /var/folders/ps/4g9m79b14j90ljcs37lpbw500000gn/T/MERGED-2011481016/networking.k8s.io.v1.Ingress.bors-ng.bors-ng	2022-09-29 11:30:26.452224214 -0400
+@@ -58,7 +65,7 @@
+             port:
+               number: 80
+         path: /
+-        pathType1: Prefix
++        pathType1: Exact
+-        pathType2: Prefix
++        pathType2: Exact
+-        pathType3: Prefix
++        pathType3: Exact
+-        pathType4: Prefix
++        pathType4: Exact
+-        pathType5: Prefix
++        pathType5: Exact
+-        pathType6: Prefix
++        pathType6: Exact
+-        pathType7: Prefix
++        pathType7: Exact
+-        pathType8: Prefix
++        pathType8: Exact
+-        pathType9: Prefix
++        pathType9: Exact
+-        pathType10: Prefix
++        pathType10: Exact
+-        pathType11: Prefix
++        pathType11: Exact
+-        pathType12: Prefix
++        pathType12: Exact
+-        pathType13: Prefix
++        pathType13: Exact
+-        pathType14: Prefix
++        pathType14: Exact
+-        pathType15: Prefix
++        pathType15: Exact
+-        pathType16: Prefix
++        pathType16: Exact
+-        pathType17: Prefix
++        pathType17: Exact
+-        pathType18: Prefix
++        pathType18: Exact
+-        pathType19: Prefix
++        pathType19: Exact
+-        pathType20: Prefix
++        pathType20: Exact
+-        pathType21: Prefix
++        pathType21: Exact
+-        pathType22: Prefix
++        pathType22: Exact
+-        pathType23: Prefix
++        pathType23: Exact
+-        pathType24: Prefix
++        pathType24: Exact
+-        pathType25: Prefix
++        pathType25: Exact
+-        pathType26: Prefix
++        pathType26: Exact
+-        pathType27: Prefix
++        pathType27: Exact
+-        pathType28: Prefix
++        pathType28: Exact
+-        pathType29: Prefix
++        pathType29: Exact
+-        pathType30: Prefix
++        pathType30: Exact
+-        pathType31: Prefix
++        pathType31: Exact
++        pathType32: Exact
++        pathType33: Exact
++        pathType34: Exact
++        pathType35: Exact
+   tls:
+   - hosts:
+     - bors-ng.test.net
diff --git a/pkg/kubernetes/util/testdata/changed-lots-of-attributes.stat b/pkg/kubernetes/util/testdata/changed-lots-of-attributes.stat
new file mode 100644
index 00000000..a3c31d5d
--- /dev/null
+++ b/pkg/kubernetes/util/testdata/changed-lots-of-attributes.stat
@@ -0,0 +1,3 @@
+apps.v1.Deployment.bors-ng.bors-ng           |    2 +-
+networking.k8s.io.v1.Ingress.bors-ng.bors-ng |   66 +++++++++++++++++++++-------------------
+2 files changed, 36 insertions(+), 32 deletions(-)
\ No newline at end of file
diff --git a/pkg/kubernetes/util/testdata/empty.diff b/pkg/kubernetes/util/testdata/empty.diff
new file mode 100644
index 00000000..e69de29b
diff --git a/pkg/kubernetes/util/testdata/empty.stat b/pkg/kubernetes/util/testdata/empty.stat
new file mode 100644
index 00000000..33b543a5
--- /dev/null
+++ b/pkg/kubernetes/util/testdata/empty.stat
@@ -0,0 +1,1 @@
+0 files changed, 0 insertions(+), 0 deletions(-)
\ No newline at end of file