Repository: numtide/treefmt
Pull request #432 (2024-10-09 14:34:37)

feat: remove globals from stats package
Converts the stats package into a struct and removes the use of package global state. 


Patch:
diff --git a/cache/cache.go b/cache/cache.go
index 7effc93..f0ed587 100644
--- a/cache/cache.go
+++ b/cache/cache.go
@@ -188,7 +188,7 @@ func putEntry(bucket *bolt.Bucket, path string, entry *Entry) error {
 
 // ChangeSet is used to walk a filesystem, starting at root, and outputting any new or changed paths using pathsCh.
 // It determines if a path is new or has changed by comparing against cache entries.
-func ChangeSet(ctx context.Context, walker walk.Walker, filesCh chan<- *walk.File) error {
+func ChangeSet(ctx context.Context, statz *stats.Stats, walker walk.Walker, filesCh chan<- *walk.File) error {
 	start := time.Now()
 
 	defer func() {
@@ -236,13 +236,13 @@ func ChangeSet(ctx context.Context, walker walk.Walker, filesCh chan<- *walk.Fil
 
 		changedOrNew := cached == nil || !(cached.Modified == file.Info.ModTime() && cached.Size == file.Info.Size())
 
-		stats.Add(stats.Traversed, 1)
+		statz.Add(stats.Traversed, 1)
 		if !changedOrNew {
 			// no change
 			return nil
 		}
 
-		stats.Add(stats.Emitted, 1)
+		statz.Add(stats.Emitted, 1)
 
 		// pass on the path
 		select {
diff --git a/cmd/format/format.go b/cmd/format/format.go
index 9046d29..3b7010a 100644
--- a/cmd/format/format.go
+++ b/cmd/format/format.go
@@ -33,7 +33,7 @@ const (
 
 var ErrFailOnChange = errors.New("unexpected changes detected, --fail-on-change is enabled")
 
-func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error {
+func Run(v *viper.Viper, statz *stats.Stats, cmd *cobra.Command, paths []string) error {
 	cmd.SilenceUsage = true
 
 	cfg, err := config.FromViper(v)
@@ -41,9 +41,6 @@ func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error {
 		return fmt.Errorf("failed to load config: %w", err)
 	}
 
-	// initialise stats collection
-	stats.Init()
-
 	if cfg.CI {
 		log.Info("ci mode enabled")
 
@@ -191,10 +188,10 @@ func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error {
 	processedCh := make(chan *format.Task, cap(filesCh))
 
 	// start concurrent processing tasks in reverse order
-	eg.Go(updateCache(ctx, cfg, processedCh))
-	eg.Go(detectFormatted(ctx, cfg, formattedCh, processedCh))
-	eg.Go(applyFormatters(ctx, cfg, globalExcludes, formatters, filesCh, formattedCh))
-	eg.Go(walkFilesystem(ctx, cfg, paths, filesCh))
+	eg.Go(updateCache(ctx, cfg, statz, processedCh))
+	eg.Go(detectFormatted(ctx, cfg, statz, formattedCh, processedCh))
+	eg.Go(applyFormatters(ctx, cfg, statz, globalExcludes, formatters, filesCh, formattedCh))
+	eg.Go(walkFilesystem(ctx, cfg, statz, paths, filesCh))
 
 	// wait for everything to complete
 	return eg.Wait()
@@ -203,6 +200,7 @@ func Run(v *viper.Viper, cmd *cobra.Command, paths []string) error {
 func walkFilesystem(
 	ctx context.Context,
 	cfg *config.Config,
+	statz *stats.Stats,
 	paths []string,
 	filesCh chan *walk.File,
 ) func() error {
@@ -258,8 +256,8 @@ func walkFilesystem(
 				case <-ctx.Done():
 					return ctx.Err()
 				default:
-					stats.Add(stats.Traversed, 1)
-					stats.Add(stats.Emitted, 1)
+					statz.Add(stats.Traversed, 1)
+					statz.Add(stats.Emitted, 1)
 					filesCh <- file
 					return nil
 				}
@@ -268,7 +266,7 @@ func walkFilesystem(
 
 		// otherwise we pass the walker to the cache and have it generate files for processing based on whether or not
 		// they have been added/changed since the last invocation
-		if err = cache.ChangeSet(ctx, walker, filesCh); err != nil {
+		if err = cache.ChangeSet(ctx, statz, walker, filesCh); err != nil {
 			return fmt.Errorf("failed to generate change set: %w", err)
 		}
 		return nil
@@ -279,6 +277,7 @@ func walkFilesystem(
 func applyFormatters(
 	ctx context.Context,
 	cfg *config.Config,
+	statz *stats.Stats,
 	globalExcludes []glob.Glob,
 	formatters map[string]*format.Formatter,
 	filesCh chan *walk.File,
@@ -389,7 +388,7 @@ func applyFormatters(
 				}
 			} else {
 				// record the match
-				stats.Add(stats.Matched, 1)
+				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)
@@ -409,7 +408,13 @@ func applyFormatters(
 	}
 }
 
-func detectFormatted(ctx context.Context, cfg *config.Config, formattedCh chan *format.Task, processedCh chan *format.Task) func() error {
+func detectFormatted(
+	ctx context.Context,
+	cfg *config.Config,
+	statz *stats.Stats,
+	formattedCh chan *format.Task,
+	processedCh chan *format.Task,
+) func() error {
 	return func() error {
 		defer func() {
 			// close formatted channel
@@ -438,7 +443,7 @@ func detectFormatted(ctx context.Context, cfg *config.Config, formattedCh chan *
 
 				if changed {
 					// record the change
-					stats.Add(stats.Formatted, 1)
+					statz.Add(stats.Formatted, 1)
 
 					logMethod := log.Debug
 					if cfg.FailOnChange {
@@ -466,7 +471,12 @@ func detectFormatted(ctx context.Context, cfg *config.Config, formattedCh chan *
 	}
 }
 
-func updateCache(ctx context.Context, cfg *config.Config, processedCh chan *format.Task) func() error {
+func updateCache(
+	ctx context.Context,
+	cfg *config.Config,
+	statz *stats.Stats,
+	processedCh chan *format.Task,
+) func() error {
 	return func() error {
 		// used to batch updates for more efficient txs
 		batch := make([]*format.Task, 0, BatchSize)
@@ -544,13 +554,13 @@ func updateCache(ctx context.Context, cfg *config.Config, processedCh chan *form
 		}
 
 		// if fail on change has been enabled, check that no files were actually formatted, throwing an error if so
-		if cfg.FailOnChange && stats.Value(stats.Formatted) != 0 {
+		if cfg.FailOnChange && statz.Value(stats.Formatted) != 0 {
 			return ErrFailOnChange
 		}
 
 		// print stats to stdout unless we are processing stdin and printing the results to stdout
 		if !cfg.Stdin {
-			stats.Print()
+			statz.Print()
 		}
 
 		return nil
diff --git a/cmd/root.go b/cmd/root.go
index 58064eb..89ae12e 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -5,6 +5,8 @@ import (
 	"os"
 	"path/filepath"
 
+	"github.com/numtide/treefmt/stats"
+
 	"github.com/charmbracelet/log"
 	"github.com/numtide/treefmt/build"
 	"github.com/numtide/treefmt/cmd/format"
@@ -14,7 +16,7 @@ import (
 	"github.com/spf13/viper"
 )
 
-func NewRoot() *cobra.Command {
+func NewRoot() (*cobra.Command, *stats.Stats) {
 	var (
 		treefmtInit bool
 		configFile  string
@@ -26,13 +28,16 @@ func NewRoot() *cobra.Command {
 		cobra.CheckErr(fmt.Errorf("failed to create viper instance: %w", err))
 	}
 
+	// create a new stats instance
+	statz := stats.New()
+
 	// create out root command
 	cmd := &cobra.Command{
 		Use:     "treefmt <paths...>",
 		Short:   "One CLI to format your repo",
 		Version: build.Version,
 		RunE: func(cmd *cobra.Command, args []string) error {
-			return runE(v, cmd, args)
+			return runE(v, &statz, cmd, args)
 		},
 	}
 
@@ -62,10 +67,10 @@ func NewRoot() *cobra.Command {
 	// conforms with https://github.com/numtide/prj-spec/blob/main/PRJ_SPEC.md
 	cobra.CheckErr(v.BindPFlag("prj_root", fs.Lookup("tree-root")))
 
-	return cmd
+	return cmd, &statz
 }
 
-func runE(v *viper.Viper, cmd *cobra.Command, args []string) error {
+func runE(v *viper.Viper, statz *stats.Stats, cmd *cobra.Command, args []string) error {
 	flags := cmd.Flags()
 
 	// change working directory if required
@@ -123,5 +128,5 @@ func runE(v *viper.Viper, cmd *cobra.Command, args []string) error {
 	}
 
 	// format
-	return format.Run(v, cmd, args)
+	return format.Run(v, statz, cmd, args)
 }
diff --git a/main.go b/main.go
index 1e6f073..2bb7242 100644
--- a/main.go
+++ b/main.go
@@ -8,7 +8,8 @@ import (
 
 func main() {
 	// todo how are exit codes thrown by commands?
-	if err := cmd.NewRoot().Execute(); err != nil {
+	root, _ := cmd.NewRoot()
+	if err := root.Execute(); err != nil {
 		os.Exit(1)
 	}
 }
diff --git a/stats/stats.go b/stats/stats.go
index 80f9da3..8c5e960 100644
--- a/stats/stats.go
+++ b/stats/stats.go
@@ -16,36 +16,24 @@ const (
 	Formatted
 )
 
-var (
-	counters map[Type]*atomic.Int32
+type Stats struct {
 	start    time.Time
-)
-
-func Init() {
-	// record start time
-	start = time.Now()
-
-	// 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 map[Type]*atomic.Int32
 }
 
-func Add(t Type, delta int32) int32 {
-	return counters[t].Add(delta)
+func (s *Stats) Add(t Type, delta int32) int32 {
+	return s.counters[t].Add(delta)
 }
 
-func Value(t Type) int32 {
-	return counters[t].Load()
+func (s *Stats) Value(t Type) int32 {
+	return s.counters[t].Load()
 }
 
-func Elapsed() time.Duration {
-	return time.Since(start)
+func (s *Stats) Elapsed() time.Duration {
+	return time.Since(s.start)
 }
 
-func Print() {
+func (s *Stats) Print() {
 	components := []string{
 		"traversed %d files",
 		"emitted %d files for processing",
@@ -55,10 +43,24 @@ func Print() {
 
 	fmt.Printf(
 		strings.Join(components, "\n"),
-		Value(Traversed),
-		Value(Emitted),
-		Value(Matched),
-		Value(Formatted),
-		Elapsed().Round(time.Millisecond),
+		s.Value(Traversed),
+		s.Value(Emitted),
+		s.Value(Matched),
+		s.Value(Formatted),
+		s.Elapsed().Round(time.Millisecond),
 	)
 }
+
+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{}
+
+	return Stats{
+		start:    time.Now(),
+		counters: counters,
+	}
+}


Test patch:
diff --git a/cmd/root_test.go b/cmd/root_test.go
index 0da2479..050450d 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -58,7 +58,7 @@ func TestOnUnmatched(t *testing.T) {
 		// - "haskell/treefmt.toml"
 	}
 
-	_, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "--on-unmatched", "fatal")
+	_, _, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "--on-unmatched", "fatal")
 	as.ErrorContains(err, fmt.Sprintf("no formatter for path: %s", paths[0]))
 
 	checkOutput := func(level string, output []byte) {
@@ -70,24 +70,24 @@ func TestOnUnmatched(t *testing.T) {
 	var out []byte
 
 	// default is warn
-	out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c")
+	out, _, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c")
 	as.NoError(err)
 	checkOutput("WARN", out)
 
-	out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c", "--on-unmatched", "warn")
+	out, _, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c", "--on-unmatched", "warn")
 	as.NoError(err)
 	checkOutput("WARN", out)
 
-	out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-u", "error")
+	out, _, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-u", "error")
 	as.NoError(err)
 	checkOutput("ERRO", out)
 
-	out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-v", "--on-unmatched", "info")
+	out, _, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-v", "--on-unmatched", "info")
 	as.NoError(err)
 	checkOutput("INFO", out)
 
 	t.Setenv("TREEFMT_ON_UNMATCHED", "debug")
-	out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-vv")
+	out, _, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "-c", "-vv")
 	as.NoError(err)
 	checkOutput("DEBU", out)
 }
@@ -105,14 +105,14 @@ func TestCpuProfile(t *testing.T) {
 		as.NoError(os.Chdir(cwd))
 	})
 
-	_, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "--cpu-profile", "cpu.pprof")
+	_, _, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "--cpu-profile", "cpu.pprof")
 	as.NoError(err)
 	as.FileExists(filepath.Join(tempDir, "cpu.pprof"))
 	_, err = os.Stat(filepath.Join(tempDir, "cpu.pprof"))
 	as.NoError(err)
 
 	t.Setenv("TREEFMT_CPU_PROFILE", "env.pprof")
-	_, err = cmd(t, "-C", tempDir, "--allow-missing-formatter")
+	_, _, err = cmd(t, "-C", tempDir, "--allow-missing-formatter")
 	as.NoError(err)
 	as.FileExists(filepath.Join(tempDir, "env.pprof"))
 	_, err = os.Stat(filepath.Join(tempDir, "env.pprof"))
@@ -133,14 +133,14 @@ func TestAllowMissingFormatter(t *testing.T) {
 		},
 	})
 
-	_, err := cmd(t, "--config-file", configPath, "--tree-root", tempDir, "-vv")
+	_, _, err := cmd(t, "--config-file", configPath, "--tree-root", tempDir, "-vv")
 	as.ErrorIs(err, format.ErrCommandNotFound)
 
-	_, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "--allow-missing-formatter")
+	_, _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "--allow-missing-formatter")
 	as.NoError(err)
 
 	t.Setenv("TREEFMT_ALLOW_MISSING_FORMATTER", "true")
-	_, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir)
+	_, _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
 }
 
@@ -178,32 +178,32 @@ func TestSpecifyingFormatters(t *testing.T) {
 	}
 
 	setup()
-	_, err := cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
+	_, statz, err := cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 3, 3)
+	assertStats(t, as, statz, 32, 32, 3, 3)
 
 	setup()
-	_, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "elm,nix")
+	_, statz, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "elm,nix")
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 2, 2)
+	assertStats(t, as, statz, 32, 32, 2, 2)
 
 	setup()
-	_, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "-f", "ruby,nix")
+	_, statz, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "-f", "ruby,nix")
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 2, 2)
+	assertStats(t, as, statz, 32, 32, 2, 2)
 
 	setup()
-	_, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "nix")
+	_, statz, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "nix")
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 1, 1)
+	assertStats(t, as, statz, 32, 32, 1, 1)
 
 	// test bad names
 	setup()
-	_, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "foo")
+	_, _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--formatters", "foo")
 	as.Errorf(err, "formatter not found in config: foo")
 
 	t.Setenv("TREEFMT_FORMATTERS", "bar,foo")
-	_, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
+	_, _, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.Errorf(err, "formatter not found in config: bar")
 }
 
@@ -224,25 +224,25 @@ func TestIncludesAndExcludes(t *testing.T) {
 	}
 
 	test.WriteConfig(t, configPath, cfg)
-	_, err := cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
+	_, statz, err := cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 32, 0)
+	assertStats(t, as, statz, 32, 32, 32, 0)
 
 	// globally exclude nix files
 	cfg.Excludes = []string{"*.nix"}
 
 	test.WriteConfig(t, configPath, cfg)
-	_, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
+	_, statz, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 31, 0)
+	assertStats(t, as, statz, 32, 32, 31, 0)
 
 	// add haskell files to the global exclude
 	cfg.Excludes = []string{"*.nix", "*.hs"}
 
 	test.WriteConfig(t, configPath, cfg)
-	_, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
+	_, statz, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 25, 0)
+	assertStats(t, as, statz, 32, 32, 25, 0)
 
 	echo := cfg.FormatterConfigs["echo"]
 
@@ -250,17 +250,17 @@ func TestIncludesAndExcludes(t *testing.T) {
 	echo.Excludes = []string{"*.py"}
 
 	test.WriteConfig(t, configPath, cfg)
-	_, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
+	_, statz, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 23, 0)
+	assertStats(t, as, statz, 32, 32, 23, 0)
 
 	// remove go files from the echo formatter via env
 	t.Setenv("TREEFMT_FORMATTER_ECHO_EXCLUDES", "*.py,*.go")
 
 	test.WriteConfig(t, configPath, cfg)
-	_, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
+	_, statz, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 22, 0)
+	assertStats(t, as, statz, 32, 32, 22, 0)
 
 	t.Setenv("TREEFMT_FORMATTER_ECHO_EXCLUDES", "") // reset
 
@@ -268,17 +268,17 @@ func TestIncludesAndExcludes(t *testing.T) {
 	echo.Includes = []string{"*.elm"}
 
 	test.WriteConfig(t, configPath, cfg)
-	_, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
+	_, statz, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 1, 0)
+	assertStats(t, as, statz, 32, 32, 1, 0)
 
 	// add js files to echo formatter via env
 	t.Setenv("TREEFMT_FORMATTER_ECHO_INCLUDES", "*.elm,*.js")
 
 	test.WriteConfig(t, configPath, cfg)
-	_, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
+	_, statz, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 2, 0)
+	assertStats(t, as, statz, 32, 32, 2, 0)
 }
 
 func TestPrjRootEnvVariable(t *testing.T) {
@@ -299,9 +299,9 @@ func TestPrjRootEnvVariable(t *testing.T) {
 
 	test.WriteConfig(t, configPath, cfg)
 	t.Setenv("PRJ_ROOT", tempDir)
-	_, err := cmd(t, "--config-file", configPath)
+	_, statz, err := cmd(t, "--config-file", configPath)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 32, 0)
+	assertStats(t, as, statz, 32, 32, 32, 0)
 }
 
 func TestCache(t *testing.T) {
@@ -323,36 +323,36 @@ func TestCache(t *testing.T) {
 	var err error
 
 	test.WriteConfig(t, configPath, cfg)
-	_, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir)
+	_, statz, err := cmd(t, "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 32, 0)
+	assertStats(t, as, statz, 32, 32, 32, 0)
 
-	_, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir)
+	_, statz, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, 32, 0, 0, 0)
+	assertStats(t, as, statz, 32, 0, 0, 0)
 
 	// clear cache
-	_, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "-c")
+	_, statz, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "-c")
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 32, 0)
+	assertStats(t, as, statz, 32, 32, 32, 0)
 
-	_, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir)
+	_, statz, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, 32, 0, 0, 0)
+	assertStats(t, as, statz, 32, 0, 0, 0)
 
 	// clear cache
-	_, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "-c")
+	_, statz, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "-c")
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 32, 0)
+	assertStats(t, as, statz, 32, 32, 32, 0)
 
-	_, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir)
+	_, statz, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir)
 	as.NoError(err)
-	assertStats(t, as, 32, 0, 0, 0)
+	assertStats(t, as, statz, 32, 0, 0, 0)
 
 	// no cache
-	_, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache")
+	_, statz, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache")
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 32, 0)
+	assertStats(t, as, statz, 32, 32, 32, 0)
 }
 
 func TestChangeWorkingDirectory(t *testing.T) {
@@ -384,15 +384,15 @@ func TestChangeWorkingDirectory(t *testing.T) {
 
 	// by default, we look for ./treefmt.toml and use the cwd for the tree root
 	// this should fail if the working directory hasn't been changed first
-	_, err = cmd(t, "-C", tempDir)
+	_, statz, err := cmd(t, "-C", tempDir)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 32, 0)
+	assertStats(t, as, statz, 32, 32, 32, 0)
 
 	// use env
 	t.Setenv("TREEFMT_WORKING_DIR", tempDir)
-	_, err = cmd(t, "-c")
+	_, statz, err = cmd(t, "-c")
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 32, 0)
+	assertStats(t, as, statz, 32, 32, 32, 0)
 }
 
 func TestFailOnChange(t *testing.T) {
@@ -412,7 +412,7 @@ func TestFailOnChange(t *testing.T) {
 	}
 
 	test.WriteConfig(t, configPath, cfg)
-	_, err := cmd(t, "--fail-on-change", "--config-file", configPath, "--tree-root", tempDir)
+	_, _, err := cmd(t, "--fail-on-change", "--config-file", configPath, "--tree-root", tempDir)
 	as.ErrorIs(err, format2.ErrFailOnChange)
 
 	// we have second precision mod time tracking
@@ -421,7 +421,7 @@ func TestFailOnChange(t *testing.T) {
 	// test with no cache
 	t.Setenv("TREEFMT_FAIL_ON_CHANGE", "true")
 	test.WriteConfig(t, configPath, cfg)
-	_, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache")
+	_, _, err = cmd(t, "--config-file", configPath, "--tree-root", tempDir, "--no-cache")
 	as.ErrorIs(err, format2.ErrFailOnChange)
 }
 
@@ -463,33 +463,33 @@ func TestBustCacheOnFormatterChange(t *testing.T) {
 
 	test.WriteConfig(t, configPath, cfg)
 	args := []string{"--config-file", configPath, "--tree-root", tempDir}
-	_, err := cmd(t, args...)
+	_, statz, err := cmd(t, args...)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 3, 0)
+	assertStats(t, as, statz, 32, 32, 3, 0)
 
 	// tweak mod time of elm formatter
 	as.NoError(test.RecreateSymlink(t, binPath+"/"+"elm-format"))
 
-	_, err = cmd(t, args...)
+	_, statz, err = cmd(t, args...)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 3, 0)
+	assertStats(t, as, statz, 32, 32, 3, 0)
 
 	// check cache is working
-	_, err = cmd(t, args...)
+	_, statz, err = cmd(t, args...)
 	as.NoError(err)
-	assertStats(t, as, 32, 0, 0, 0)
+	assertStats(t, as, statz, 32, 0, 0, 0)
 
 	// tweak mod time of python formatter
 	as.NoError(test.RecreateSymlink(t, binPath+"/"+"black"))
 
-	_, err = cmd(t, args...)
+	_, statz, err = cmd(t, args...)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 3, 0)
+	assertStats(t, as, statz, 32, 32, 3, 0)
 
 	// check cache is working
-	_, err = cmd(t, args...)
+	_, statz, err = cmd(t, args...)
 	as.NoError(err)
-	assertStats(t, as, 32, 0, 0, 0)
+	assertStats(t, as, statz, 32, 0, 0, 0)
 
 	// add go formatter
 	cfg.FormatterConfigs["go"] = &config.Formatter{
@@ -499,40 +499,40 @@ func TestBustCacheOnFormatterChange(t *testing.T) {
 	}
 	test.WriteConfig(t, configPath, cfg)
 
-	_, err = cmd(t, args...)
+	_, statz, err = cmd(t, args...)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 4, 0)
+	assertStats(t, as, statz, 32, 32, 4, 0)
 
 	// check cache is working
-	_, err = cmd(t, args...)
+	_, statz, err = cmd(t, args...)
 	as.NoError(err)
-	assertStats(t, as, 32, 0, 0, 0)
+	assertStats(t, as, statz, 32, 0, 0, 0)
 
 	// remove python formatter
 	delete(cfg.FormatterConfigs, "python")
 	test.WriteConfig(t, configPath, cfg)
 
-	_, err = cmd(t, args...)
+	_, statz, err = cmd(t, args...)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 2, 0)
+	assertStats(t, as, statz, 32, 32, 2, 0)
 
 	// check cache is working
-	_, err = cmd(t, args...)
+	_, statz, err = cmd(t, args...)
 	as.NoError(err)
-	assertStats(t, as, 32, 0, 0, 0)
+	assertStats(t, as, statz, 32, 0, 0, 0)
 
 	// remove elm formatter
 	delete(cfg.FormatterConfigs, "elm")
 	test.WriteConfig(t, configPath, cfg)
 
-	_, err = cmd(t, args...)
+	_, statz, err = cmd(t, args...)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 1, 0)
+	assertStats(t, as, statz, 32, 32, 1, 0)
 
 	// check cache is working
-	_, err = cmd(t, args...)
+	_, statz, err = cmd(t, args...)
 	as.NoError(err)
-	assertStats(t, as, 32, 0, 0, 0)
+	assertStats(t, as, statz, 32, 0, 0, 0)
 }
 
 func TestGitWorktree(t *testing.T) {
@@ -568,9 +568,9 @@ func TestGitWorktree(t *testing.T) {
 	as.NoError(err, "failed to get git worktree")
 
 	run := func(traversed int32, emitted int32, matched int32, formatted int32) {
-		_, err := cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
+		_, statz, err := cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir)
 		as.NoError(err)
-		assertStats(t, as, traversed, emitted, matched, formatted)
+		assertStats(t, as, statz, traversed, emitted, matched, formatted)
 	}
 
 	// run before adding anything to the worktree
@@ -590,9 +590,9 @@ func TestGitWorktree(t *testing.T) {
 	run(28, 28, 28, 0)
 
 	// walk with filesystem instead of git
-	_, err = cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--walk", "filesystem")
+	_, statz, err := cmd(t, "-c", "--config-file", configPath, "--tree-root", tempDir, "--walk", "filesystem")
 	as.NoError(err)
-	assertStats(t, as, 60, 60, 60, 0)
+	assertStats(t, as, statz, 60, 60, 60, 0)
 
 	// capture current cwd, so we can replace it after the test is finished
 	cwd, err := os.Getwd()
@@ -604,31 +604,33 @@ func TestGitWorktree(t *testing.T) {
 	})
 
 	// format specific sub paths
-	_, err = cmd(t, "-C", tempDir, "-c", "go", "-vv")
+	_, statz, err = cmd(t, "-C", tempDir, "-c", "go", "-vv")
 	as.NoError(err)
-	assertStats(t, as, 2, 2, 2, 0)
+	assertStats(t, as, statz, 2, 2, 2, 0)
 
-	_, err = cmd(t, "-C", tempDir, "-c", "go", "haskell")
+	_, statz, err = cmd(t, "-C", tempDir, "-c", "go", "haskell")
 	as.NoError(err)
-	assertStats(t, as, 9, 9, 9, 0)
+	assertStats(t, as, statz, 9, 9, 9, 0)
 
-	_, err = cmd(t, "-C", tempDir, "-c", "go", "haskell", "ruby")
+	_, statz, err = cmd(t, "-C", tempDir, "-c", "go", "haskell", "ruby")
 	as.NoError(err)
-	assertStats(t, as, 10, 10, 10, 0)
+	assertStats(t, as, statz, 10, 10, 10, 0)
 
 	// try with a bad path
-	_, err = cmd(t, "-C", tempDir, "-c", "haskell", "foo")
+	_, _, err = cmd(t, "-C", tempDir, "-c", "haskell", "foo")
 	as.ErrorContains(err, "path foo not found within the tree root")
-	assertStats(t, as, 0, 0, 0, 0)
 
 	// try with a path not in the git index, e.g. it is skipped
 	_, err = os.Create(filepath.Join(tempDir, "foo.txt"))
 	as.NoError(err)
-	assertStats(t, as, 0, 0, 0, 0)
 
-	_, err = cmd(t, "-C", tempDir, "-c", "foo.txt")
+	_, statz, err = cmd(t, "-C", tempDir, "-c", "haskell", "foo.txt")
 	as.NoError(err)
-	assertStats(t, as, 0, 0, 0, 0)
+	assertStats(t, as, statz, 7, 7, 7, 0)
+
+	_, statz, err = cmd(t, "-C", tempDir, "-c", "foo.txt")
+	as.NoError(err)
+	assertStats(t, as, statz, 0, 0, 0, 0)
 }
 
 func TestPathsArg(t *testing.T) {
@@ -662,22 +664,22 @@ func TestPathsArg(t *testing.T) {
 	test.WriteConfig(t, configPath, cfg)
 
 	// without any path args
-	_, err = cmd(t)
+	_, statz, err := cmd(t)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 32, 0)
+	assertStats(t, as, statz, 32, 32, 32, 0)
 
 	// specify some explicit paths
-	_, err = cmd(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs")
+	_, statz, err = cmd(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs")
 	as.NoError(err)
-	assertStats(t, as, 2, 2, 2, 0)
+	assertStats(t, as, statz, 2, 2, 2, 0)
 
 	// specify a bad path
-	_, err = cmd(t, "-c", "elm/elm.json", "haskell/Nested/Bar.hs")
+	_, _, err = cmd(t, "-c", "elm/elm.json", "haskell/Nested/Bar.hs")
 	as.ErrorContains(err, "path haskell/Nested/Bar.hs not found within the tree root")
 
 	// specify a path outside the tree root
 	externalPath := filepath.Join(cwd, "go.mod")
-	_, err = cmd(t, "-c", externalPath)
+	_, _, err = cmd(t, "-c", externalPath)
 	as.ErrorContains(err, fmt.Sprintf("path %s not found within the tree root", externalPath))
 }
 
@@ -704,7 +706,7 @@ func TestStdin(t *testing.T) {
 	contents := `{ foo, ... }: "hello"`
 	os.Stdin = test.TempFile(t, "", "stdin", &contents)
 	// we get an error about the missing filename parameter.
-	out, err := cmd(t, "-C", tempDir, "--allow-missing-formatter", "--stdin")
+	out, _, err := cmd(t, "-C", tempDir, "--allow-missing-formatter", "--stdin")
 	as.EqualError(err, "exactly one path should be specified when using the --stdin flag")
 	as.Equal("", string(out))
 
@@ -712,9 +714,9 @@ func TestStdin(t *testing.T) {
 	contents = `{ foo, ... }: "hello"`
 	os.Stdin = test.TempFile(t, "", "stdin", &contents)
 
-	out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "--stdin", "test.nix")
+	out, statz, err := cmd(t, "-C", tempDir, "--allow-missing-formatter", "--stdin", "test.nix")
 	as.NoError(err)
-	assertStats(t, as, 1, 1, 1, 1)
+	assertStats(t, as, statz, 1, 1, 1, 1)
 
 	// the nix formatters should have reduced the example to the following
 	as.Equal(`{ ...}: "hello"
@@ -729,9 +731,9 @@ func TestStdin(t *testing.T) {
 `
 	os.Stdin = test.TempFile(t, "", "stdin", &contents)
 
-	out, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "--stdin", "test.md")
+	out, statz, err = cmd(t, "-C", tempDir, "--allow-missing-formatter", "--stdin", "test.md")
 	as.NoError(err)
-	assertStats(t, as, 1, 1, 1, 1)
+	assertStats(t, as, statz, 1, 1, 1, 1)
 
 	as.Equal(`| col1   | col2      |
 | ------ | --------- |
@@ -778,7 +780,7 @@ func TestDeterministicOrderingInPipeline(t *testing.T) {
 			},
 		},
 	})
-	_, err = cmd(t, "-C", tempDir)
+	_, _, err = cmd(t, "-C", tempDir)
 	as.NoError(err)
 
 	matcher := regexp.MustCompile("^fmt-(.*)")
@@ -843,17 +845,17 @@ func TestRunInSubdir(t *testing.T) {
 	test.WriteConfig(t, configPath, cfg)
 
 	// without any path args, should reformat the whole tree
-	_, err = cmd(t)
+	_, statz, err := cmd(t)
 	as.NoError(err)
-	assertStats(t, as, 32, 32, 32, 0)
+	assertStats(t, as, statz, 32, 32, 32, 0)
 
 	// specify some explicit paths, relative to the tree root
-	_, err = cmd(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs")
+	_, statz, err = cmd(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs")
 	as.NoError(err)
-	assertStats(t, as, 2, 2, 2, 0)
+	assertStats(t, as, statz, 2, 2, 2, 0)
 }
 
-func cmd(t *testing.T, args ...string) ([]byte, error) {
+func cmd(t *testing.T, args ...string) ([]byte, *stats.Stats, error) {
 	t.Helper()
 
 	tempDir := t.TempDir()
@@ -877,7 +879,7 @@ func cmd(t *testing.T, args ...string) ([]byte, error) {
 	}()
 
 	// run the command
-	root := NewRoot()
+	root, statz := NewRoot()
 
 	if args == nil {
 		// we must pass an empty array otherwise cobra with use os.Args[1:]
@@ -888,26 +890,26 @@ func cmd(t *testing.T, args ...string) ([]byte, error) {
 	root.SetErr(tempOut)
 
 	if err := root.Execute(); err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	// reset and read the temporary output
 	if _, err := tempOut.Seek(0, 0); err != nil {
-		return nil, fmt.Errorf("failed to reset temp output for reading: %w", err)
+		return nil, nil, fmt.Errorf("failed to reset temp output for reading: %w", err)
 	}
 
 	out, err := io.ReadAll(tempOut)
 	if err != nil {
-		return nil, fmt.Errorf("failed to read temp output: %w", err)
+		return nil, nil, fmt.Errorf("failed to read temp output: %w", err)
 	}
 
-	return out, nil
+	return out, statz, nil
 }
 
-func assertStats(t *testing.T, as *require.Assertions, traversed int32, emitted int32, matched int32, formatted int32) {
+func assertStats(t *testing.T, as *require.Assertions, statz *stats.Stats, traversed int32, emitted int32, matched int32, formatted int32) {
 	t.Helper()
-	as.Equal(traversed, stats.Value(stats.Traversed), "stats.traversed")
-	as.Equal(emitted, stats.Value(stats.Emitted), "stats.emitted")
-	as.Equal(matched, stats.Value(stats.Matched), "stats.matched")
-	as.Equal(formatted, stats.Value(stats.Formatted), "stats.formatted")
+	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")
 }