Repository: numtide/treefmt (go)

Issue:
treefmt cache results in warnings getting suppressed
**Describe the bug**

treefmt helpfully logs warnings when it doesn't know how to format a file:

```
$ treefmt test.py
WARN format: no formatter for path: test.py
traversed 1 files
emitted 1 files for processing
formatted 0 files (0 changed) in 21ms
```

However, if you try to format that exact same file again, you won't see the warning:

```
$ treefmt test.py
traversed 1 files
emitted 0 files for processing
formatted 0 files (0 changed) in 13ms
```

Note how the warning reappears if you invoke treefmt with `--no-cache`:

```
$ treefmt --no-cache test.py
WARN format: no formatter for path: test.py
traversed 1 files
emitted 1 files for processing
formatted 0 files (0 changed) in 3ms
```

**To Reproduce**

Run treefmt twice on the same file.

**Expected behavior**

I'd like to see the same set of warnings every time, regardless of the state of my treefmt cache.

**System information**

I'm using the the latest code on `main` (714cf33489c936d118d60dcbc066b79a53b8b271 at time of writing).

Pull request description:
feat: match before checking cache
This changes the flow of processing to make unmatched behaviour more consistent.

Before, we had been:

- traversing the filesystem
- comparing with the cache and only emitting files which had changed
- applying the matching rules to determine which formatters should be applied to a given file
- applying the formatters

Now, we do the following:

- traverse the filesystem
- apply the matching rules to determine which formatters should be applied to a given file
- compare with the cache and only emit files which have changed for formatting
- apply the formatters

It does mean we are applying the matching rules against files which we may not have to format, but in testing against Nixpkgs the performance impact appears negligible.

This makes sense since most of the processing time will be spent in the formatters, not applying some globs to file paths.

Close #433 

Patch:
diff --git a/cmd/format/format.go b/cmd/format/format.go
index 2c3c77f..8a69b98 100644
--- a/cmd/format/format.go
+++ b/cmd/format/format.go
@@ -205,33 +205,34 @@ func Run(v *viper.Viper, statz *stats.Stats, cmd *cobra.Command, paths []string)
 		return fmt.Errorf("failed to create walker: %w", err)
 	}
 
+	// start traversing
 	files := make([]*walk.File, BatchSize)
+
 	for {
+		// read the next batch
 		ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
-
 		n, err := reader.Read(ctx, files)
 
-		for idx := 0; idx < n; idx++ {
-			file := files[idx]
+		// ensure context is cancelled to release resources
+		cancel()
 
-			// check if this file is new or has changed when compared to the cache entry
-			if file.Cache == nil || file.Cache.HasChanged(file.Info) {
-				filesCh <- file
-				statz.Add(stats.Emitted, 1)
-			}
+		// pass each file into the file channel for processing
+		for idx := 0; idx < n; idx++ {
+			filesCh <- files[idx]
 		}
 
-		cancel()
-
 		if errors.Is(err, io.EOF) {
+			// we have finished traversing
 			break
 		} else if err != nil {
+			// something went wrong
 			log.Errorf("failed to read files: %v", err)
 			cancel()
 			break
 		}
 	}
 
+	// indicate no further files for processing
 	close(filesCh)
 
 	// wait for everything to complete
@@ -263,6 +264,8 @@ func applyFormatters(
 	// formatters which should be applied to their respective files
 	batches := make(map[string][]*format.Task)
 
+	// apply check if the given batch key has enough tasks to trigger processing
+	// flush is used to force processing regardless of the number of tasks
 	apply := func(key string, flush bool) {
 		// lookup the batch and exit early if it's empty
 		batch := batches[key]
@@ -304,6 +307,7 @@ func applyFormatters(
 		}
 	}
 
+	// tryApply batches tasks by their batch key and processes the batch if there is enough ready
 	tryApply := func(task *format.Task) {
 		// append to batch
 		key := task.BatchKey
@@ -314,53 +318,68 @@ func applyFormatters(
 
 	return func() error {
 		defer func() {
-			// close processed channel
+			// indicate processing has finished
 			close(formattedCh)
 		}()
 
+		// parse unmatched log level
 		unmatchedLevel, err := log.ParseLevel(cfg.OnUnmatched)
 		if err != nil {
 			return fmt.Errorf("invalid on-unmatched value: %w", err)
 		}
 
-		// iterate the files channel
+		// iterate the file channel
 		for file := range filesCh {
 
+			// a list of formatters that match this file
+			var matches []*format.Formatter
+
 			// first check if this file has been globally excluded
 			if format.PathMatches(file.RelPath, globalExcludes) {
 				log.Debugf("path matched global excludes: %s", file.RelPath)
-				// mark it as processed and continue to the next
-				formattedCh <- &format.Task{
-					File: file,
+			} else {
+				// otherwise, check if any formatters are interested in it
+				for _, formatter := range formatters {
+					if formatter.Wants(file) {
+						matches = append(matches, formatter)
+					}
 				}
-				continue
 			}
 
-			// check if any formatters are interested in this file
-			var matches []*format.Formatter
-			for _, formatter := range formatters {
-				if formatter.Wants(file) {
-					matches = append(matches, formatter)
-				}
-			}
+			// indicates no further processing
+			var release bool
 
-			// see if any formatters matched
+			// check if there were no matches
 			if len(matches) == 0 {
-
+				// log that there was no match, exiting with an error if the unmatched level was set to fatal
 				if unmatchedLevel == log.FatalLevel {
 					return fmt.Errorf("no formatter for path: %s", file.RelPath)
 				}
+
 				log.Logf(unmatchedLevel, "no formatter for path: %s", file.RelPath)
-				// mark it as processed and continue to the next
-				formattedCh <- &format.Task{
-					File: file,
-				}
+
+				// no further processing
+				release = true
 			} else {
-				// record the match
+				// record there was a match
 				statz.Add(stats.Matched, 1)
-				// create a new format task, add it to a batch based on its batch key and try to apply if the batch is full
-				task := format.NewTask(file, matches)
-				tryApply(&task)
+
+				// check if the file is new or has changed when compared to the cache entry
+				if file.Cache == nil || file.Cache.HasChanged(file.Info) {
+					// if so, generate a format task, add it to the relevant batch (by batch key) and try to process
+					task := format.NewTask(file, matches)
+					tryApply(&task)
+				} else {
+					// indicate no further processing
+					release = true
+				}
+			}
+
+			if release {
+				// release the file as there is no more processing to be done on it
+				if err := file.Release(); err != nil {
+					return fmt.Errorf("failed to release file: %w", err)
+				}
 			}
 		}
 
@@ -398,16 +417,20 @@ func postProcessing(
 					break LOOP
 				}
 
-				// check if the file has changed
+				// grab the underlying file reference
 				file := task.File
+
+				// check if the file has changed
 				changed, newInfo, err := file.Stat()
 				if err != nil {
 					return err
 				}
 
+				statz.Add(stats.Formatted, 1)
+
 				if changed {
-					// record the change
-					statz.Add(stats.Formatted, 1)
+					// record that a change in the underlying file occurred
+					statz.Add(stats.Changed, 1)
 
 					logMethod := log.Debug
 					if cfg.FailOnChange {
@@ -434,8 +457,8 @@ func postProcessing(
 			}
 		}
 
-		// if fail on change has been enabled, check that no files were actually formatted, throwing an error if so
-		if cfg.FailOnChange && statz.Value(stats.Formatted) != 0 {
+		// if fail on change has been enabled, check that no files were actually changed, throwing an error if so
+		if cfg.FailOnChange && statz.Value(stats.Changed) != 0 {
 			return ErrFailOnChange
 		}
 
diff --git a/stats/stats.go b/stats/stats.go
index 8c5e960..7c38d0b 100644
--- a/stats/stats.go
+++ b/stats/stats.go
@@ -7,13 +7,14 @@ import (
 	"time"
 )
 
+//go:generate enumer -type=Type -text -transform=snake -output=./stats_type.go
 type Type int
 
 const (
 	Traversed Type = iota
-	Emitted
 	Matched
 	Formatted
+	Changed
 )
 
 type Stats struct {
@@ -44,9 +45,9 @@ func (s *Stats) Print() {
 	fmt.Printf(
 		strings.Join(components, "\n"),
 		s.Value(Traversed),
-		s.Value(Emitted),
 		s.Value(Matched),
 		s.Value(Formatted),
+		s.Value(Changed),
 		s.Elapsed().Round(time.Millisecond),
 	)
 }
@@ -55,9 +56,9 @@ func New() Stats {
 	// init counters
 	counters := make(map[Type]*atomic.Int32)
 	counters[Traversed] = &atomic.Int32{}
-	counters[Emitted] = &atomic.Int32{}
 	counters[Matched] = &atomic.Int32{}
 	counters[Formatted] = &atomic.Int32{}
+	counters[Changed] = &atomic.Int32{}
 
 	return Stats{
 		start:    time.Now(),
diff --git a/stats/stats_type.go b/stats/stats_type.go
new file mode 100644
index 0000000..e865fdf
--- /dev/null
+++ b/stats/stats_type.go
@@ -0,0 +1,98 @@
+// Code generated by "enumer -type=Type -text -transform=snake -output=./stats_type.go"; DO NOT EDIT.
+
+package stats
+
+import (
+	"fmt"
+	"strings"
+)
+
+const _TypeName = "traversedmatchedformattedchanged"
+
+var _TypeIndex = [...]uint8{0, 9, 16, 25, 32}
+
+const _TypeLowerName = "traversedmatchedformattedchanged"
+
+func (i Type) String() string {
+	if i < 0 || i >= Type(len(_TypeIndex)-1) {
+		return fmt.Sprintf("Type(%d)", i)
+	}
+	return _TypeName[_TypeIndex[i]:_TypeIndex[i+1]]
+}
+
+// An "invalid array index" compiler error signifies that the constant values have changed.
+// Re-run the stringer command to generate them again.
+func _TypeNoOp() {
+	var x [1]struct{}
+	_ = x[Traversed-(0)]
+	_ = x[Matched-(1)]
+	_ = x[Formatted-(2)]
+	_ = x[Changed-(3)]
+}
+
+var _TypeValues = []Type{Traversed, Matched, Formatted, Changed}
+
+var _TypeNameToValueMap = map[string]Type{
+	_TypeName[0:9]:        Traversed,
+	_TypeLowerName[0:9]:   Traversed,
+	_TypeName[9:16]:       Matched,
+	_TypeLowerName[9:16]:  Matched,
+	_TypeName[16:25]:      Formatted,
+	_TypeLowerName[16:25]: Formatted,
+	_TypeName[25:32]:      Changed,
+	_TypeLowerName[25:32]: Changed,
+}
+
+var _TypeNames = []string{
+	_TypeName[0:9],
+	_TypeName[9:16],
+	_TypeName[16:25],
+	_TypeName[25:32],
+}
+
+// TypeString retrieves an enum value from the enum constants string name.
+// Throws an error if the param is not part of the enum.
+func TypeString(s string) (Type, error) {
+	if val, ok := _TypeNameToValueMap[s]; ok {
+		return val, nil
+	}
+
+	if val, ok := _TypeNameToValueMap[strings.ToLower(s)]; ok {
+		return val, nil
+	}
+	return 0, fmt.Errorf("%s does not belong to Type values", s)
+}
+
+// TypeValues returns all values of the enum
+func TypeValues() []Type {
+	return _TypeValues
+}
+
+// TypeStrings returns a slice of all String values of the enum
+func TypeStrings() []string {
+	strs := make([]string, len(_TypeNames))
+	copy(strs, _TypeNames)
+	return strs
+}
+
+// IsAType returns "true" if the value is listed in the enum definition. "false" otherwise
+func (i Type) IsAType() bool {
+	for _, v := range _TypeValues {
+		if i == v {
+			return true
+		}
+	}
+	return false
+}
+
+// MarshalText implements the encoding.TextMarshaler interface for Type
+func (i Type) MarshalText() ([]byte, error) {
+	return []byte(i.String()), nil
+}
+
+// UnmarshalText implements the encoding.TextUnmarshaler interface for Type
+func (i *Type) UnmarshalText(text []byte) error {
+	var err error
+	*i, err = TypeString(string(text))
+	return err
+}


Test patch:
diff --git a/cmd/root_test.go b/cmd/root_test.go
index 6365f80..b037f4d 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -72,24 +72,24 @@ func TestOnUnmatched(t *testing.T) {
 	var out []byte
 
 	// default is warn
-	out, _, err = treefmt(t, "-C", tempDir, "--allow-missing-formatter", "-c")
+	out, _, err = treefmt(t, "-C", tempDir, "--allow-missing-formatter")
 	as.NoError(err)
 	checkOutput("WARN", out)
 
-	out, _, err = treefmt(t, "-C", tempDir, "--allow-missing-formatter", "-c", "--on-unmatched", "warn")
+	out, _, err = treefmt(t, "-C", tempDir, "--allow-missing-formatter", "--on-unmatched", "warn")
 	as.NoError(err)
 	checkOutput("WARN", out)
 
-	out, _, err = treefmt(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-u", "error")
+	out, _, err = treefmt(t, "-C", tempDir, "--allow-missing-formatter", "-u", "error")
 	as.NoError(err)
 	checkOutput("ERRO", out)
 
-	out, _, err = treefmt(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-v", "--on-unmatched", "info")
+	out, _, err = treefmt(t, "-C", tempDir, "--allow-missing-formatter", "-v", "--on-unmatched", "info")
 	as.NoError(err)
 	checkOutput("INFO", out)
 
 	t.Setenv("TREEFMT_ON_UNMATCHED", "debug")
-	out, _, err = treefmt(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-vv")
+	out, _, err = treefmt(t, "-C", tempDir, "--allow-missing-formatter", "-vv")
 	as.NoError(err)
 	checkOutput("DEBU", out)
 }
@@ -182,25 +182,53 @@ func TestSpecifyingFormatters(t *testing.T) {
 	setup()
 	_, statz, err := treefmt(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 3, 3)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   3,
+		stats.Formatted: 3,
+		stats.Changed:   3,
+	})
 
 	setup()
+
 	_, statz, err = treefmt(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "elm,nix")
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 2, 2)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   2,
+		stats.Formatted: 2,
+		stats.Changed:   2,
+	})
 
 	setup()
+
 	_, statz, err = treefmt(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "-f", "ruby,nix")
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 2, 2)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   2,
+		stats.Formatted: 2,
+		stats.Changed:   2,
+	})
 
 	setup()
+
 	_, statz, err = treefmt(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "nix")
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 1, 1)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   1,
+		stats.Formatted: 1,
+		stats.Changed:   1,
+	})
 
 	// test bad names
 	setup()
+
 	_, _, err = treefmt(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "foo")
 	as.Errorf(err, "formatter not found in config: foo")
 
@@ -228,7 +256,13 @@ func TestIncludesAndExcludes(t *testing.T) {
 	test.WriteConfig(t, configPath, cfg)
 	_, statz, err := treefmt(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 32, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   32,
+		stats.Formatted: 32,
+		stats.Changed:   0,
+	})
 
 	// globally exclude nix files
 	cfg.Excludes = []string{"*.nix"}
@@ -236,7 +270,13 @@ func TestIncludesAndExcludes(t *testing.T) {
 	test.WriteConfig(t, configPath, cfg)
 	_, statz, err = treefmt(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 31, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   31,
+		stats.Formatted: 31,
+		stats.Changed:   0,
+	})
 
 	// add haskell files to the global exclude
 	cfg.Excludes = []string{"*.nix", "*.hs"}
@@ -244,7 +284,13 @@ func TestIncludesAndExcludes(t *testing.T) {
 	test.WriteConfig(t, configPath, cfg)
 	_, statz, err = treefmt(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 25, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   25,
+		stats.Formatted: 25,
+		stats.Changed:   0,
+	})
 
 	echo := cfg.FormatterConfigs["echo"]
 
@@ -254,7 +300,13 @@ func TestIncludesAndExcludes(t *testing.T) {
 	test.WriteConfig(t, configPath, cfg)
 	_, statz, err = treefmt(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 23, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   23,
+		stats.Formatted: 23,
+		stats.Changed:   0,
+	})
 
 	// remove go files from the echo formatter via env
 	t.Setenv("TREEFMT_FORMATTER_ECHO_EXCLUDES", "*.py,*.go")
@@ -262,7 +314,13 @@ func TestIncludesAndExcludes(t *testing.T) {
 	test.WriteConfig(t, configPath, cfg)
 	_, statz, err = treefmt(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 22, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   22,
+		stats.Formatted: 22,
+		stats.Changed:   0,
+	})
 
 	t.Setenv("TREEFMT_FORMATTER_ECHO_EXCLUDES", "") // reset
 
@@ -272,7 +330,13 @@ func TestIncludesAndExcludes(t *testing.T) {
 	test.WriteConfig(t, configPath, cfg)
 	_, statz, err = treefmt(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 1, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   1,
+		stats.Formatted: 1,
+		stats.Changed:   0,
+	})
 
 	// add js files to echo formatter via env
 	t.Setenv("TREEFMT_FORMATTER_ECHO_INCLUDES", "*.elm,*.js")
@@ -280,7 +344,13 @@ func TestIncludesAndExcludes(t *testing.T) {
 	test.WriteConfig(t, configPath, cfg)
 	_, statz, err = treefmt(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 2, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   2,
+		stats.Formatted: 2,
+		stats.Changed:   0,
+	})
 }
 
 func TestPrjRootEnvVariable(t *testing.T) {
@@ -303,7 +373,13 @@ func TestPrjRootEnvVariable(t *testing.T) {
 	t.Setenv("PRJ_ROOT", tempDir)
 	_, statz, err := treefmt(t, "--config-file", configPath)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 32, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   32,
+		stats.Formatted: 32,
+		stats.Changed:   0,
+	})
 }
 
 func TestCache(t *testing.T) {
@@ -327,34 +403,76 @@ func TestCache(t *testing.T) {
 	test.WriteConfig(t, configPath, cfg)
 	_, statz, err := treefmt(t, "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 32, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   32,
+		stats.Formatted: 32,
+		stats.Changed:   0,
+	})
 
 	_, statz, err = treefmt(t, "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 0, 0, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   32,
+		stats.Formatted: 0,
+		stats.Changed:   0,
+	})
 
 	// clear cache
 	_, statz, err = treefmt(t, "--config-file", configPath, "--tree-root", tempDir, "-c")
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 32, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   32,
+		stats.Formatted: 32,
+		stats.Changed:   0,
+	})
 
 	_, statz, err = treefmt(t, "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 0, 0, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   32,
+		stats.Formatted: 0,
+		stats.Changed:   0,
+	})
 
 	// clear cache
 	_, statz, err = treefmt(t, "--config-file", configPath, "--tree-root", tempDir, "-c")
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 32, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   32,
+		stats.Formatted: 32,
+		stats.Changed:   0,
+	})
 
 	_, statz, err = treefmt(t, "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 0, 0, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   32,
+		stats.Formatted: 0,
+		stats.Changed:   0,
+	})
 
 	// no cache
 	_, statz, err = treefmt(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache")
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 32, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   32,
+		stats.Formatted: 32,
+		stats.Changed:   0,
+	})
 }
 
 func TestChangeWorkingDirectory(t *testing.T) {
@@ -388,13 +506,25 @@ func TestChangeWorkingDirectory(t *testing.T) {
 	// this should fail if the working directory hasn't been changed first
 	_, statz, err := treefmt(t, "-C", tempDir)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 32, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   32,
+		stats.Formatted: 32,
+		stats.Changed:   0,
+	})
 
 	// use env
 	t.Setenv("TREEFMT_WORKING_DIR", tempDir)
 	_, statz, err = treefmt(t, "-c")
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 32, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   32,
+		stats.Formatted: 32,
+		stats.Changed:   0,
+	})
 }
 
 func TestFailOnChange(t *testing.T) {
@@ -467,31 +597,61 @@ func TestBustCacheOnFormatterChange(t *testing.T) {
 	args := []string{"--config-file", configPath, "--tree-root", tempDir}
 	_, statz, err := treefmt(t, args...)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 3, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   3,
+		stats.Formatted: 3,
+		stats.Changed:   0,
+	})
 
 	// tweak mod time of elm formatter
 	as.NoError(test.RecreateSymlink(t, binPath+"/"+"elm-format"))
 
 	_, statz, err = treefmt(t, args...)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 3, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   3,
+		stats.Formatted: 3,
+		stats.Changed:   0,
+	})
 
 	// check cache is working
 	_, statz, err = treefmt(t, args...)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 0, 0, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   3,
+		stats.Formatted: 0,
+		stats.Changed:   0,
+	})
 
 	// tweak mod time of python formatter
 	as.NoError(test.RecreateSymlink(t, binPath+"/"+"black"))
 
 	_, statz, err = treefmt(t, args...)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 3, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   3,
+		stats.Formatted: 3,
+		stats.Changed:   0,
+	})
 
 	// check cache is working
 	_, statz, err = treefmt(t, args...)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 0, 0, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   3,
+		stats.Formatted: 0,
+		stats.Changed:   0,
+	})
 
 	// add go formatter
 	cfg.FormatterConfigs["go"] = &config.Formatter{
@@ -503,12 +663,24 @@ func TestBustCacheOnFormatterChange(t *testing.T) {
 
 	_, statz, err = treefmt(t, args...)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 4, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   4,
+		stats.Formatted: 4,
+		stats.Changed:   0,
+	})
 
 	// check cache is working
 	_, statz, err = treefmt(t, args...)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 0, 0, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   4,
+		stats.Formatted: 0,
+		stats.Changed:   0,
+	})
 
 	// remove python formatter
 	delete(cfg.FormatterConfigs, "python")
@@ -516,12 +688,24 @@ func TestBustCacheOnFormatterChange(t *testing.T) {
 
 	_, statz, err = treefmt(t, args...)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 2, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   2,
+		stats.Formatted: 2,
+		stats.Changed:   0,
+	})
 
 	// check cache is working
 	_, statz, err = treefmt(t, args...)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 0, 0, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   2,
+		stats.Formatted: 0,
+		stats.Changed:   0,
+	})
 
 	// remove elm formatter
 	delete(cfg.FormatterConfigs, "elm")
@@ -529,12 +713,24 @@ func TestBustCacheOnFormatterChange(t *testing.T) {
 
 	_, statz, err = treefmt(t, args...)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 1, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   1,
+		stats.Formatted: 1,
+		stats.Changed:   0,
+	})
 
 	// check cache is working
 	_, statz, err = treefmt(t, args...)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 0, 0, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   1,
+		stats.Formatted: 0,
+		stats.Changed:   0,
+	})
 }
 
 func TestGitWorktree(t *testing.T) {
@@ -569,10 +765,16 @@ func TestGitWorktree(t *testing.T) {
 	wt, err := repo.Worktree()
 	as.NoError(err, "failed to get git worktree")
 
-	run := func(traversed int32, emitted int32, matched int32, formatted int32) {
+	run := func(traversed int32, matched int32, formatted int32, changed int32) {
 		_, statz, err := treefmt(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 		as.NoError(err)
-		assertStats(t, as, statz, traversed, emitted, matched, formatted)
+
+		assertStats(t, as, statz, map[stats.Type]int32{
+			stats.Traversed: traversed,
+			stats.Matched:   matched,
+			stats.Formatted: formatted,
+			stats.Changed:   changed,
+		})
 	}
 
 	// run before adding anything to the worktree
@@ -592,9 +794,15 @@ func TestGitWorktree(t *testing.T) {
 	run(28, 28, 28, 0)
 
 	// walk with filesystem instead of git
+	// we should traverse more files since we will look in the .git folder
 	_, statz, err := treefmt(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--walk", "filesystem")
 	as.NoError(err)
-	assertStats(t, as, statz, 60, 60, 60, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 60,
+		stats.Matched:   60,
+		stats.Changed:   0,
+	})
 
 	// capture current cwd, so we can replace it after the test is finished
 	cwd, err := os.Getwd()
@@ -608,15 +816,30 @@ func TestGitWorktree(t *testing.T) {
 	// format specific sub paths
 	_, statz, err = treefmt(t, "-C", tempDir, "-c", "go", "-vv")
 	as.NoError(err)
-	assertStats(t, as, statz, 2, 2, 2, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 2,
+		stats.Matched:   2,
+		stats.Changed:   0,
+	})
 
 	_, statz, err = treefmt(t, "-C", tempDir, "-c", "go", "haskell")
 	as.NoError(err)
-	assertStats(t, as, statz, 9, 9, 9, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 9,
+		stats.Matched:   9,
+		stats.Changed:   0,
+	})
 
 	_, statz, err = treefmt(t, "-C", tempDir, "-c", "go", "haskell", "ruby")
 	as.NoError(err)
-	assertStats(t, as, statz, 10, 10, 10, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 10,
+		stats.Matched:   10,
+		stats.Changed:   0,
+	})
 
 	// try with a bad path
 	_, _, err = treefmt(t, "-C", tempDir, "-c", "haskell", "foo")
@@ -628,11 +851,21 @@ func TestGitWorktree(t *testing.T) {
 
 	_, statz, err = treefmt(t, "-C", tempDir, "-c", "haskell", "foo.txt")
 	as.NoError(err)
-	assertStats(t, as, statz, 8, 8, 8, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 8,
+		stats.Matched:   8,
+		stats.Changed:   0,
+	})
 
 	_, statz, err = treefmt(t, "-C", tempDir, "-c", "foo.txt")
 	as.NoError(err)
-	assertStats(t, as, statz, 1, 1, 1, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 1,
+		stats.Matched:   1,
+		stats.Changed:   0,
+	})
 }
 
 func TestPathsArg(t *testing.T) {
@@ -677,19 +910,38 @@ func TestPathsArg(t *testing.T) {
 	// without any path args
 	_, statz, err := treefmt(t)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 32, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   32,
+		stats.Formatted: 32,
+		stats.Changed:   0,
+	})
 
 	// specify some explicit paths
 	_, statz, err = treefmt(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs")
 	as.NoError(err)
-	assertStats(t, as, statz, 2, 2, 2, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 2,
+		stats.Matched:   2,
+		stats.Formatted: 2,
+		stats.Changed:   0,
+	})
 
 	// specify an absolute path
 	absoluteInternalPath, err := filepath.Abs("elm/elm.json")
 	as.NoError(err)
+
 	_, statz, err = treefmt(t, "-c", absoluteInternalPath)
 	as.NoError(err)
-	assertStats(t, as, statz, 1, 1, 1, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 1,
+		stats.Matched:   1,
+		stats.Formatted: 1,
+		stats.Changed:   0,
+	})
 
 	// specify a bad path
 	_, _, err = treefmt(t, "-c", "elm/elm.json", "haskell/Nested/Bar.hs")
@@ -742,7 +994,13 @@ func TestStdin(t *testing.T) {
 
 	out, statz, err := treefmt(t, "-C", tempDir, "--allow-missing-formatter", "--stdin", "test.nix")
 	as.NoError(err)
-	assertStats(t, as, statz, 1, 1, 1, 1)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 1,
+		stats.Matched:   1,
+		stats.Formatted: 1,
+		stats.Changed:   1,
+	})
 
 	// the nix formatters should have reduced the example to the following
 	as.Equal(`{ ...}: "hello"
@@ -767,7 +1025,13 @@ func TestStdin(t *testing.T) {
 
 	out, statz, err = treefmt(t, "-C", tempDir, "--allow-missing-formatter", "--stdin", "test.md")
 	as.NoError(err)
-	assertStats(t, as, statz, 1, 1, 1, 1)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 1,
+		stats.Matched:   1,
+		stats.Formatted: 1,
+		stats.Changed:   1,
+	})
 
 	as.Equal(`| col1   | col2      |
 | ------ | --------- |
@@ -881,7 +1145,13 @@ func TestRunInSubdir(t *testing.T) {
 	// without any path args, should reformat the whole tree
 	_, statz, err := treefmt(t)
 	as.NoError(err)
-	assertStats(t, as, statz, 32, 32, 32, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 32,
+		stats.Matched:   32,
+		stats.Formatted: 32,
+		stats.Changed:   0,
+	})
 
 	// specify some explicit paths, relative to the tree root
 	// this should not work, as we're in a subdirectory
@@ -891,7 +1161,13 @@ func TestRunInSubdir(t *testing.T) {
 	// specify some explicit paths, relative to the current directory
 	_, statz, err = treefmt(t, "-c", "elm.json", "../haskell/Nested/Foo.hs")
 	as.NoError(err)
-	assertStats(t, as, statz, 2, 2, 2, 0)
+
+	assertStats(t, as, statz, map[stats.Type]int32{
+		stats.Traversed: 2,
+		stats.Matched:   2,
+		stats.Formatted: 2,
+		stats.Changed:   0,
+	})
 }
 
 func treefmt(t *testing.T, args ...string) ([]byte, *stats.Stats, error) {
@@ -945,10 +1221,15 @@ func treefmt(t *testing.T, args ...string) ([]byte, *stats.Stats, error) {
 	return out, statz, nil
 }
 
-func assertStats(t *testing.T, as *require.Assertions, statz *stats.Stats, traversed int32, emitted int32, matched int32, formatted int32) {
+func assertStats(
+	t *testing.T,
+	as *require.Assertions,
+	statz *stats.Stats,
+	expected map[stats.Type]int32,
+) {
 	t.Helper()
-	as.Equal(traversed, statz.Value(stats.Traversed), "stats.traversed")
-	as.Equal(emitted, statz.Value(stats.Emitted), "stats.emitted")
-	as.Equal(matched, statz.Value(stats.Matched), "stats.matched")
-	as.Equal(formatted, statz.Value(stats.Formatted), "stats.formatted")
+
+	for k, v := range expected {
+		as.Equal(v, statz.Value(k), k.String())
+	}
 }
diff --git a/walk/filesystem_test.go b/walk/filesystem_test.go
index 8b1a2e8..60870fb 100644
--- a/walk/filesystem_test.go
+++ b/walk/filesystem_test.go
@@ -81,7 +81,7 @@ func TestFilesystemReader(t *testing.T) {
 
 	as.Equal(32, count)
 	as.Equal(int32(32), statz.Value(stats.Traversed))
-	as.Equal(int32(0), statz.Value(stats.Emitted))
 	as.Equal(int32(0), statz.Value(stats.Matched))
 	as.Equal(int32(0), statz.Value(stats.Formatted))
+	as.Equal(int32(0), statz.Value(stats.Changed))
 }
diff --git a/walk/git_test.go b/walk/git_test.go
index d7ed418..2b2abbc 100644
--- a/walk/git_test.go
+++ b/walk/git_test.go
@@ -62,7 +62,7 @@ func TestGitReader(t *testing.T) {
 
 	as.Equal(32, count)
 	as.Equal(int32(32), statz.Value(stats.Traversed))
-	as.Equal(int32(0), statz.Value(stats.Emitted))
 	as.Equal(int32(0), statz.Value(stats.Matched))
 	as.Equal(int32(0), statz.Value(stats.Formatted))
+	as.Equal(int32(0), statz.Value(stats.Changed))
 }