Repository: numtide/treefmt (go)

Issue:
A `--quiet` flag that will prevent this tool from outputing anything
Currently, regardless of the verbosity I specify, I always get at minimum:

```log
2024/12/19 18:43:16 INFO using config file: /path/to/my-repo
traversed 89 files
emitted 3 files for processing
formatted 3 files (0 changed) in 173ms
```

When in quiet mode, I don't want anything on `stdout` and only want errors on `stderr`.

Pull request description:
Verbosity changes
I've made some changes to how verbose we are being:

- **feat: log the config file used at DEBUG level instead of INFO**
- **feat: print stats to stderr instead of stdout**
- **feat: change default log level for unmatched to INFO instead of WARN**
- **feat: add a quiet flag to suppress all output except for errors**

Closes #501

Patch:
diff --git a/cmd/format/format.go b/cmd/format/format.go
index 7c053d5..041a28d 100644
--- a/cmd/format/format.go
+++ b/cmd/format/format.go
@@ -197,9 +197,9 @@ func Run(v *viper.Viper, statz *stats.Stats, cmd *cobra.Command, paths []string)
 		return fmt.Errorf("failed to close walker: %w", err)
 	}
 
-	// print stats to stdout, unless we are processing from stdin and therefore outputting the results to stdout
-	if !cfg.Stdin {
-		statz.Print()
+	// print stats to stderr
+	if !cfg.Quiet {
+		statz.PrintToStderr()
 	}
 
 	if formatErr != nil {
diff --git a/cmd/root.go b/cmd/root.go
index d2d9f97..cd3f8f7 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -131,7 +131,7 @@ func runE(v *viper.Viper, statz *stats.Stats, cmd *cobra.Command, args []string)
 		return fmt.Errorf("failed to find treefmt config file: %w", err)
 	}
 
-	log.Infof("using config file: %s", configFile)
+	log.Debugf("using config file: %s", configFile)
 
 	// read in the config
 	v.SetConfigFile(configFile)
@@ -144,13 +144,19 @@ func runE(v *viper.Viper, statz *stats.Stats, cmd *cobra.Command, args []string)
 	log.SetOutput(os.Stderr)
 	log.SetReportTimestamp(false)
 
-	switch v.GetInt("verbose") {
-	case 0:
-		log.SetLevel(log.WarnLevel)
-	case 1:
-		log.SetLevel(log.InfoLevel)
-	default:
-		log.SetLevel(log.DebugLevel)
+	if v.GetBool("quiet") {
+		// if quiet, we only log errors
+		log.SetLevel(log.ErrorLevel)
+	} else {
+		// otherwise, the verbose flag controls the log level
+		switch v.GetInt("verbose") {
+		case 0:
+			log.SetLevel(log.WarnLevel)
+		case 1:
+			log.SetLevel(log.InfoLevel)
+		default:
+			log.SetLevel(log.DebugLevel)
+		}
 	}
 
 	// format
diff --git a/config/config.go b/config/config.go
index 2d80021..c10df3f 100644
--- a/config/config.go
+++ b/config/config.go
@@ -22,6 +22,7 @@ type Config struct {
 	Formatters            []string `mapstructure:"formatters"              toml:"formatters,omitempty"`
 	NoCache               bool     `mapstructure:"no-cache"                toml:"-"` // not allowed in config
 	OnUnmatched           string   `mapstructure:"on-unmatched"            toml:"on-unmatched,omitempty"`
+	Quiet                 bool     `mapstructure:"quiet"                   toml:"-"` // not allowed in config
 	TreeRoot              string   `mapstructure:"tree-root"               toml:"tree-root,omitempty"`
 	TreeRootFile          string   `mapstructure:"tree-root-file"          toml:"tree-root-file,omitempty"`
 	Verbose               uint8    `mapstructure:"verbose"                 toml:"verbose,omitempty"`
@@ -89,7 +90,7 @@ func SetFlags(fs *pflag.FlagSet) {
 		"Ignore the evaluation cache entirely. Useful for CI. (env $TREEFMT_NO_CACHE)",
 	)
 	fs.StringP(
-		"on-unmatched", "u", "warn",
+		"on-unmatched", "u", "info",
 		"Log paths that did not match any formatters at the specified log level. Possible values are "+
 			"<debug|info|warn|error|fatal>. (env $TREEFMT_ON_UNMATCHED)",
 	)
@@ -110,6 +111,9 @@ func SetFlags(fs *pflag.FlagSet) {
 		"verbose", "v",
 		"Set the verbosity of logs e.g. -vv. (env $TREEFMT_VERBOSE)",
 	)
+	fs.BoolP(
+		"quiet", "q", false, "Disable all logs except errors. (env $TREEFMT_QUIET)",
+	)
 	fs.String(
 		"walk", "auto",
 		"The method used to traverse the files within the tree root. Currently supports "+
diff --git a/docs/content/getting-started/configure.md b/docs/content/getting-started/configure.md
index efc8f2b..623077e 100644
--- a/docs/content/getting-started/configure.md
+++ b/docs/content/getting-started/configure.md
@@ -254,6 +254,22 @@ Possible values are `<debug|info|warn|error|fatal>`.
     on-unmatched = "debug"
     ```
 
+### `quiet`
+
+Suppress all output except for errors.
+
+=== "Flag"
+
+    ```console
+    treefmt --quiet
+    ```
+
+=== "Env"
+
+    ```console
+    TREEFMT_QUIET=true treefmt
+    ```
+
 ### `stdin`
 
 Format the context passed in via stdin.
diff --git a/docs/content/guides/unmatched-formatters.md b/docs/content/guides/unmatched-formatters.md
index 7deab4c..5b77df7 100644
--- a/docs/content/guides/unmatched-formatters.md
+++ b/docs/content/guides/unmatched-formatters.md
@@ -18,23 +18,28 @@ This helps you decide whether to add formatters for specific files or ignore the
 ## Customizing Notifications
 
 ### Reducing Log Verbosity
+
 If you find the unmatched file warnings too noisy, you can lower the logging level in your config:
 
 `treefmt.toml`:
+
 ```toml
 on-unmatched = "debug"
 ```
 
 To later find out what files are unmatched, you can override this setting via the command line:
+
 ```console
 $ treefmt --on-unmatched warn
 ```
 
 ### Enforcing Strict Matching
+
 Another stricter policy approach is to fail the run if any unmatched files are found.
 This can be paired with an `excludes` list to ignore specific files:
 
 `treefmt.toml`:
+
 ```toml
 # Fail if any unmatched files are found
 on-unmatched = "fatal"
diff --git a/go.mod b/go.mod
index 1e3309c..39d5ed6 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
 	github.com/adrg/xdg v0.5.3
 	github.com/charmbracelet/log v0.4.0
 	github.com/gobwas/glob v0.2.3
-	github.com/otiai10/copy v1.14.1
+	github.com/otiai10/copy v1.14.0
 	github.com/rogpeppe/go-internal v1.13.1
 	github.com/spf13/cobra v1.8.1
 	github.com/spf13/pflag v1.0.5
@@ -15,7 +15,7 @@ require (
 	github.com/stretchr/testify v1.10.0
 	go.etcd.io/bbolt v1.3.11
 	golang.org/x/sync v0.10.0
-	golang.org/x/sys v0.29.0
+	golang.org/x/sys v0.28.0
 	mvdan.cc/sh/v3 v3.10.0
 )
 
@@ -35,7 +35,6 @@ require (
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/muesli/reflow v0.3.0 // indirect
 	github.com/muesli/termenv v0.15.2 // indirect
-	github.com/otiai10/mint v1.6.3 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.2 // indirect
 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
diff --git a/go.sum b/go.sum
index 213b3c4..b236c21 100644
--- a/go.sum
+++ b/go.sum
@@ -61,12 +61,8 @@ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo
 github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
 github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
 github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
-github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8=
-github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I=
 github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks=
 github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
-github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=
-github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
 github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -133,8 +129,6 @@ golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
 golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
-golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
 golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
 golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
diff --git a/mkdocs.yml b/mkdocs.yml
index ead0f5f..9618090 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -2,7 +2,7 @@
 site_name: Treefmt
 site_url: https://treefmt.com
 site_description: >-
-  The formatter multiplexer.
+    The formatter multiplexer.
 
 # Repository
 repo_name: numtide/treefmt
diff --git a/nix/packages/treefmt/gomod2nix.toml b/nix/packages/treefmt/gomod2nix.toml
index 354e612..a354349 100644
--- a/nix/packages/treefmt/gomod2nix.toml
+++ b/nix/packages/treefmt/gomod2nix.toml
@@ -60,11 +60,8 @@ schema = 3
     version = "v0.15.2"
     hash = "sha256-Eum/SpyytcNIchANPkG4bYGBgcezLgej7j/+6IhqoMU="
   [mod."github.com/otiai10/copy"]
-    version = "v1.14.1"
-    hash = "sha256-8RR7u17SbYg9AeBXVHIv5ZMU+kHmOcx0rLUKyz6YtU0="
-  [mod."github.com/otiai10/mint"]
-    version = "v1.6.3"
-    hash = "sha256-/FT3dYP2+UiW/qe1pxQ7HiS8et4+KHGPIMhc+8mHvzw="
+    version = "v1.14.0"
+    hash = "sha256-xsaL1ddkPS544y0Jv7u/INUALBYmYq29ddWvysLXk4A="
   [mod."github.com/pelletier/go-toml/v2"]
     version = "v2.2.2"
     hash = "sha256-ukxk1Cfm6cQW18g/aa19tLcUu5BnF7VmfAvrDHAOl6A="
@@ -123,8 +120,8 @@ schema = 3
     version = "v0.10.0"
     hash = "sha256-HWruKClrdoBKVdxKCyoazxeQV4dIYLdkHekQvx275/o="
   [mod."golang.org/x/sys"]
-    version = "v0.29.0"
-    hash = "sha256-qfsodJQ1H1CBI8yQWOvsXJgY5qHmiuw566HrrIseYHI="
+    version = "v0.28.0"
+    hash = "sha256-kzSlDo5FKsQU9cLefIt2dueGUfz9XuEW+mGSGlPATGc="
   [mod."golang.org/x/term"]
     version = "v0.25.0"
     hash = "sha256-vwNqnxEcgeVxQxdKBUnkb7y9jg6LlNDJxfG1GugSeRQ="
diff --git a/stats/stats.go b/stats/stats.go
index 70c174f..7c00efd 100644
--- a/stats/stats.go
+++ b/stats/stats.go
@@ -2,6 +2,7 @@ package stats
 
 import (
 	"fmt"
+	"os"
 	"strings"
 	"sync/atomic"
 	"time"
@@ -34,7 +35,7 @@ func (s *Stats) Elapsed() time.Duration {
 	return time.Since(s.start)
 }
 
-func (s *Stats) Print() {
+func (s *Stats) PrintToStderr() {
 	components := []string{
 		"traversed %d files",
 		"emitted %d files for processing",
@@ -42,7 +43,8 @@ func (s *Stats) Print() {
 		"",
 	}
 
-	fmt.Printf(
+	_, _ = fmt.Fprintf(
+		os.Stderr,
 		strings.Join(components, "\n"),
 		s.Value(Traversed),
 		s.Value(Matched),


Test patch:
diff --git a/cmd/root_test.go b/cmd/root_test.go
index 9d42266..628a69b 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -72,9 +72,9 @@ func TestOnUnmatched(t *testing.T) {
 		}
 	}
 
-	// default is WARN
+	// default is INFO
 	t.Run("default", func(t *testing.T) {
-		treefmt(t, withNoError(t), withOutput(checkOutput(log.WarnLevel)))
+		treefmt(t, withArgs("-v"), withNoError(t), withStderr(checkOutput(log.InfoLevel)))
 	})
 
 	// should exit with error when using fatal
@@ -99,7 +99,7 @@ func TestOnUnmatched(t *testing.T) {
 			treefmt(t,
 				withArgs("-vv", "--on-unmatched", levelStr),
 				withNoError(t),
-				withOutput(checkOutput(level)),
+				withStderr(checkOutput(level)),
 			)
 
 			t.Setenv("TREEFMT_ON_UNMATCHED", levelStr)
@@ -107,7 +107,7 @@ func TestOnUnmatched(t *testing.T) {
 			treefmt(t,
 				withArgs("-vv"),
 				withNoError(t),
-				withOutput(checkOutput(level)),
+				withStderr(checkOutput(level)),
 			)
 		})
 	}
@@ -131,6 +131,33 @@ func TestOnUnmatched(t *testing.T) {
 	})
 }
 
+func TestQuiet(t *testing.T) {
+	as := require.New(t)
+	tempDir := test.TempExamples(t)
+
+	test.ChangeWorkDir(t, tempDir)
+
+	// allow missing formatter
+	t.Setenv("TREEFMT_ALLOW_MISSING_FORMATTER", "true")
+
+	noOutput := func(out []byte) {
+		as.Empty(out)
+	}
+
+	treefmt(t, withArgs("-q"), withNoError(t), withStdout(noOutput), withStderr(noOutput))
+	treefmt(t, withArgs("--quiet"), withNoError(t), withStdout(noOutput), withStderr(noOutput))
+
+	t.Setenv("TREEFMT_QUIET", "true")
+	treefmt(t, withNoError(t), withStdout(noOutput), withStderr(noOutput))
+
+	t.Setenv("TREEFMT_ALLOW_MISSING_FORMATTER", "false")
+
+	// check it doesn't suppress errors
+	treefmt(t, withError(func(err error) {
+		as.ErrorContains(err, "error looking up 'foo-fmt'")
+	}))
+}
+
 func TestCpuProfile(t *testing.T) {
 	as := require.New(t)
 	tempDir := test.TempExamples(t)
@@ -1583,7 +1610,7 @@ func TestStdin(t *testing.T) {
 		withError(func(err error) {
 			as.EqualError(err, "exactly one path should be specified when using the --stdin flag")
 		}),
-		withOutput(func(out []byte) {
+		withStderr(func(out []byte) {
 			as.Equal("Error: exactly one path should be specified when using the --stdin flag\n", string(out))
 		}),
 	)
@@ -1600,7 +1627,7 @@ func TestStdin(t *testing.T) {
 			stats.Formatted: 1,
 			stats.Changed:   1,
 		}),
-		withOutput(func(out []byte) {
+		withStdout(func(out []byte) {
 			as.Equal(`{ ...}: "hello"
 `, string(out))
 		}),
@@ -1616,7 +1643,7 @@ func TestStdin(t *testing.T) {
 		withError(func(err error) {
 			as.Errorf(err, "path ../test.nix not inside the tree root %s", tempDir)
 		}),
-		withOutput(func(out []byte) {
+		withStderr(func(out []byte) {
 			as.Contains(string(out), "Error: path ../test.nix not inside the tree root")
 		}),
 	)
@@ -1639,7 +1666,7 @@ func TestStdin(t *testing.T) {
 			stats.Formatted: 1,
 			stats.Changed:   1,
 		}),
-		withOutput(func(out []byte) {
+		withStdout(func(out []byte) {
 			as.Equal(`| col1   | col2      |
 | ------ | --------- |
 | nice   | fits      |
@@ -1806,7 +1833,9 @@ type options struct {
 		value *config.Config
 	}
 
-	assertOut   func([]byte)
+	assertStdout func([]byte)
+	assertStderr func([]byte)
+
 	assertError func(error)
 	assertStats func(*stats.Stats)
 
@@ -1873,9 +1902,15 @@ func withNoError(t *testing.T) option {
 	}
 }
 
-func withOutput(fn func([]byte)) option {
+func withStdout(fn func([]byte)) option {
+	return func(o *options) {
+		o.assertStdout = fn
+	}
+}
+
+func withStderr(fn func([]byte)) option {
 	return func(o *options) {
-		o.assertOut = fn
+		o.assertStderr = fn
 	}
 }
 
@@ -1931,17 +1966,19 @@ func treefmt(
 	t.Logf("treefmt %s", strings.Join(args, " "))
 
 	tempDir := t.TempDir()
-	tempOut := test.TempFile(t, tempDir, "combined_output", nil)
+
+	tempStdout := test.TempFile(t, tempDir, "stdout", nil)
+	tempStderr := test.TempFile(t, tempDir, "stderr", nil)
 
 	// capture standard outputs before swapping them
 	stdout := os.Stdout
 	stderr := os.Stderr
 
 	// swap them temporarily
-	os.Stdout = tempOut
-	os.Stderr = tempOut
+	os.Stdout = tempStdout
+	os.Stderr = tempStderr
 
-	log.SetOutput(tempOut)
+	log.SetOutput(tempStdout)
 
 	defer func() {
 		// swap outputs back
@@ -1954,30 +1991,49 @@ func treefmt(
 	root, statz := cmd.NewRoot()
 
 	root.SetArgs(args)
-	root.SetOut(tempOut)
-	root.SetErr(tempOut)
+	root.SetOut(tempStdout)
+	root.SetErr(tempStderr)
 
 	// execute the command
 	cmdErr := root.Execute()
 
-	// reset and read the temporary output
-	if _, resetErr := tempOut.Seek(0, 0); resetErr != nil {
+	// reset and read the temporary outputs
+	if _, resetErr := tempStdout.Seek(0, 0); resetErr != nil {
 		t.Fatal(fmt.Errorf("failed to reset temp output for reading: %w", resetErr))
 	}
 
-	out, readErr := io.ReadAll(tempOut)
+	if _, resetErr := tempStderr.Seek(0, 0); resetErr != nil {
+		t.Fatal(fmt.Errorf("failed to reset temp output for reading: %w", resetErr))
+	}
+
+	// read back stderr and validate
+	out, readErr := io.ReadAll(tempStderr)
 	if readErr != nil {
-		t.Fatal(fmt.Errorf("failed to read temp output: %w", readErr))
+		t.Fatal(fmt.Errorf("failed to read temp stderr: %w", readErr))
+	}
+
+	if opts.assertStderr != nil {
+		opts.assertStderr(out)
 	}
 
 	t.Log("\n" + string(out))
 
-	if opts.assertStats != nil {
-		opts.assertStats(statz)
+	// read back stdout and validate
+	out, readErr = io.ReadAll(tempStdout)
+	if readErr != nil {
+		t.Fatal(fmt.Errorf("failed to read temp stdout: %w", readErr))
 	}
 
-	if opts.assertOut != nil {
-		opts.assertOut(out)
+	t.Log("\n" + string(out))
+
+	if opts.assertStdout != nil {
+		opts.assertStdout(out)
+	}
+
+	// assert other properties
+
+	if opts.assertStats != nil {
+		opts.assertStats(statz)
 	}
 
 	if opts.assertError != nil {
diff --git a/config/config_test.go b/config/config_test.go
index f9cb12f..ae6dc83 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -323,6 +323,36 @@ func TestNoCache(t *testing.T) {
 	checkValue(true)
 }
 
+func TestQuiet(t *testing.T) {
+	as := require.New(t)
+
+	cfg := &config.Config{}
+	v, flags := newViper(t)
+
+	checkValue := func(expected bool) {
+		readValue(t, v, cfg, func(cfg *config.Config) {
+			as.Equal(expected, cfg.Quiet)
+		})
+	}
+
+	// default with no flag, env or config
+	checkValue(false)
+
+	// set config value and check that it has no effect
+	// you are not allowed to set no-cache in config
+	cfg.Quiet = true
+
+	checkValue(false)
+
+	// env override
+	t.Setenv("TREEFMT_QUIET", "false")
+	checkValue(false)
+
+	// flag override
+	as.NoError(flags.Set("quiet", "true"))
+	checkValue(true)
+}
+
 func TestOnUnmatched(t *testing.T) {
 	as := require.New(t)
 
@@ -336,7 +366,7 @@ func TestOnUnmatched(t *testing.T) {
 	}
 
 	// default with no flag, env or config
-	checkValue("warn")
+	checkValue("info")
 
 	// set config value
 	cfg.OnUnmatched = "error"