Repository: numtide/treefmt (go)

Issue:
Dynamic tree root detection
**Is your feature request related to a problem? Please describe.**

Sometimes the project root cannot be easily identified, for example by generic tools such as treefmt wrappers.

This came up while reviewing `pkgs.nixfmt-tree`, a wrapper tool that uses treefmt to make nixfmt easier to use in simple scenarios.

In an ideal world, we wouldn't have to do any work to identify the root of the project. However `treefmt` is used in many scenarios, so I don't think that'll ever be possible.

**Describe the solution you'd like**

I think the most generic solution would be to allow configuring a script (or other executable) that can be used to test whether or not a given directory is the project root.

I propose adding a new setting: `tree-root-tool`, `tree-root-test`, `tree-root-exe` (or some other name). When defined, the specified script would be run instead of testing for `tree-root-file`.

The script could _either_ be passed the directory being tested as a CLI argument or be run with that directory as its current working directory (or both, I guess).

If the script exits 0, the directory is the root. If the script exits 1, the directory is not the root. If the script has another exit code, an error has occurred.

Perhaps it would be simpler to call the script with the starting directory and have it print the root directory to stdout. That way, such a script could be implemented using `git rev-parse --show-toplevel`. This also has the advantage that the script only needs to be invoked once.

Maybe both approaches could be supported by using multiple setting names, or by reading stdout before falling back to the exit code strategy. Although this feels a little excessive.

**Describe alternatives you've considered**

Alternatively, we could define `tree-root-file` or `tree-root-files` as a list of files, any of which being present would indicate the project root. This may be nice to have, but isn't as powerful as running some arbitrary executable.

Another alternative may be for `nix fmt` to support specifying the flake root, e.g. as an environment variable. This would be useful, but is out of the hands of treefmt and is only useful when treefmt is being run via `nix fmt`.

Another alternative may be to offer an setting that tells treefmt to find the root using `git rev-parse --show-toplevel` instead of looking for `tree-root-file`.

For simple tools like `nixpkgs-tree`, it'd probably also be ok to _not_ attempt to find the project root at all, and instead always treat the current working directory as the tree's root. That way we wouldn't have to try and guess at how to detect the project root. Perhaps an option to enable this behavior could be added?

**Additional context**

- https://github.com/NixOS/nixpkgs/pull/384857#discussion_r1979553258
- https://github.com/NixOS/nixpkgs/pull/384857#issuecomment-2698764313

cc @jfly @infinisil 

Pull request description:
feat: find tree root via a user-specified command
Closes #530 

Patch:
diff --git a/cmd/init/init.toml b/cmd/init/init.toml
index d10ef96..d0e29b1 100644
--- a/cmd/init/init.toml
+++ b/cmd/init/init.toml
@@ -28,7 +28,8 @@
 # on-unmatched = "info"
 
 # The root directory from which treefmt will start walking the filesystem
-# Defaults to the directory containing the config file
+# Defaults to the root of the current git worktree.
+# If not in a git repo, defaults to the directory containing the config file.
 # Env $TREEFMT_TREE_ROOT
 # tree-root = "/tmp/foo"
 
@@ -36,6 +37,11 @@
 # Env $TREEFMT_TREE_ROOT_FILE
 # tree-root-file = ".git/config"
 
+# Command to run to find the tree root. It is parsed using shlex, to allow quoting arguments that contain whitespace.
+# If you wish to pass arguments containing quotes, you should use nested quotes e.g. "'" or '"'
+# Env $TREEFMT_TREE_ROOT_CMD
+# tree-root-cmd = "git rev-parse --show-toplevel"
+
 # Set the verbosity of logs
 # 0 = warn, 1 = info, 2 = debug
 # Env $TREEFMT_VERBOSE
diff --git a/cmd/root.go b/cmd/root.go
index b9b711d..a8e8439 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -46,8 +46,12 @@ func NewRoot() (*cobra.Command, *stats.Stats) {
 	// add our config flags to the command's flag set
 	config.SetFlags(fs)
 
-	// xor tree-root and tree-root-file flags
-	cmd.MarkFlagsMutuallyExclusive("tree-root", "tree-root-file")
+	// xor tree-root, tree-root-cmd and tree-root-file flags
+	cmd.MarkFlagsMutuallyExclusive(
+		"tree-root",
+		"tree-root-cmd",
+		"tree-root-file",
+	)
 
 	cmd.HelpTemplate()
 
diff --git a/config/config.go b/config/config.go
index 23c70af..ee6f343 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,12 +1,21 @@
 package config
 
 import (
+	"bufio"
+	"context"
+	"errors"
 	"fmt"
+	"io"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"regexp"
+	"slices"
 	"strings"
+	"time"
 
+	"github.com/charmbracelet/log"
+	"github.com/google/shlex"
 	"github.com/numtide/treefmt/v2/walk"
 	"github.com/spf13/pflag"
 	"github.com/spf13/viper"
@@ -25,6 +34,7 @@ type Config struct {
 	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"`
+	TreeRootCmd           string   `mapstructure:"tree-root-cmd"           toml:"tree-root-cmd,omitempty"`
 	TreeRootFile          string   `mapstructure:"tree-root-file"          toml:"tree-root-file,omitempty"`
 	Verbose               uint8    `mapstructure:"verbose"                 toml:"verbose,omitempty"`
 	Walk                  string   `mapstructure:"walk"                    toml:"walk,omitempty"`
@@ -101,12 +111,19 @@ func SetFlags(fs *pflag.FlagSet) {
 	)
 	fs.String(
 		"tree-root", "",
-		"The root directory from which treefmt will start walking the filesystem (defaults to the directory "+
-			"containing the config file). (env $TREEFMT_TREE_ROOT)",
+		"The root directory from which treefmt will start walking the filesystem. "+
+			"Defaults to the root of the current git worktree. If not in a git repo, defaults to the directory "+
+			"containing the config file. (env $TREEFMT_TREE_ROOT)",
+	)
+	fs.String(
+		"tree-root-cmd", "",
+		"Command to run to find the tree root. It is parsed using shlex, to allow quoting arguments that "+
+			"contain whitespace. If you wish to pass arguments containing quotes, you should use nested quotes "+
+			"e.g. \"'\" or '\"'. (env $TREEFMT_TREE_ROOT_CMD)",
 	)
 	fs.String(
 		"tree-root-file", "",
-		"File to search for to find the tree root (if --tree-root is not passed). (env $TREEFMT_TREE_ROOT_FILE)",
+		"File to search for to find the tree root. (env $TREEFMT_TREE_ROOT_FILE)",
 	)
 	fs.CountP(
 		"verbose", "v",
@@ -153,6 +170,8 @@ func NewViper() (*viper.Viper, error) {
 
 // FromViper takes a viper instance and produces a Config instance.
 func FromViper(v *viper.Viper) (*Config, error) {
+	logger := log.WithPrefix("config")
+
 	configReset := map[string]any{
 		"ci":          false,
 		"clear-cache": false,
@@ -186,24 +205,9 @@ func FromViper(v *viper.Viper) (*Config, error) {
 		cfg.Walk = walk.Stdin.String()
 	}
 
-	// determine the tree root
-	if cfg.TreeRoot == "" {
-		// if none was specified, we first try with tree-root-file
-		if cfg.TreeRootFile != "" {
-			// search the tree root using the --tree-root-file if specified
-			_, cfg.TreeRoot, err = FindUp(cfg.WorkingDirectory, cfg.TreeRootFile)
-			if err != nil {
-				return nil, fmt.Errorf("failed to find tree-root based on tree-root-file: %w", err)
-			}
-		} else {
-			// otherwise fallback to the directory containing the config file
-			cfg.TreeRoot = filepath.Dir(v.ConfigFileUsed())
-		}
-	}
-
-	// resolve tree root to an absolute path
-	if cfg.TreeRoot, err = filepath.Abs(cfg.TreeRoot); err != nil {
-		return nil, fmt.Errorf("failed to get absolute path for tree root: %w", err)
+	// determine tree root
+	if err = determineTreeRoot(v, cfg, logger); err != nil {
+		return nil, fmt.Errorf("failed to determine tree root: %w", err)
 	}
 
 	// prefer top level excludes, falling back to global.excludes for backwards compatibility
@@ -212,7 +216,6 @@ func FromViper(v *viper.Viper) (*Config, error) {
 	}
 
 	// validate formatter names do not contain invalid characters
-
 	nameRegex := regexp.MustCompile("^[a-zA-Z0-9_-]+$")
 
 	for name := range cfg.FormatterConfigs {
@@ -263,6 +266,178 @@ func FromViper(v *viper.Viper) (*Config, error) {
 	return cfg, nil
 }
 
+func determineTreeRoot(v *viper.Viper, cfg *Config, logger *log.Logger) error {
+	var err error
+
+	// enforce the various tree root options are mutually exclusive
+	// some of this is being done for us at the flag level, but you can also set these values in config or environment
+	// variables.
+	count := 0
+
+	if cfg.TreeRoot != "" {
+		count++
+	}
+
+	if cfg.TreeRootCmd != "" {
+		count++
+	}
+
+	if cfg.TreeRootFile != "" {
+		count++
+	}
+
+	if count > 1 {
+		return errors.New("at most one of tree-root, tree-root-cmd or tree-root-file can be specified")
+	}
+
+	// set git-based tree root command if the walker is git and no tree root has been specified
+	if cfg.Walk == walk.Git.String() && count == 0 {
+		cfg.TreeRootCmd = "git rev-parse --show-toplevel"
+
+		logger.Infof(
+			"git walker enabled and tree root has not been specified: defaulting tree-root-cmd to '%s'",
+			cfg.TreeRootCmd,
+		)
+	}
+
+	switch {
+	case cfg.TreeRoot != "":
+		logger.Debugf("tree root specified explicitly: %s", cfg.TreeRoot)
+
+	case cfg.TreeRootFile != "":
+		logger.Debugf("searching for tree root using tree-root-file: %s", cfg.TreeRootFile)
+
+		_, cfg.TreeRoot, err = FindUp(cfg.WorkingDirectory, cfg.TreeRootFile)
+		if err != nil {
+			return fmt.Errorf("failed to find tree-root based on tree-root-file: %w", err)
+		}
+
+	case cfg.TreeRootCmd != "":
+		logger.Debugf("searching for tree root using tree-root-cmd: %s", cfg.TreeRootCmd)
+
+		if cfg.TreeRoot, err = execTreeRootCmd(cfg); err != nil {
+			return err
+		}
+
+	default:
+		// no tree root was specified
+		logger.Debugf(
+			"no tree root specified, defaulting to the directory containing the config file: %s",
+			v.ConfigFileUsed(),
+		)
+
+		cfg.TreeRoot = filepath.Dir(v.ConfigFileUsed())
+	}
+
+	// resolve tree root to an absolute path
+	if cfg.TreeRoot, err = filepath.Abs(cfg.TreeRoot); err != nil {
+		return fmt.Errorf("failed to get absolute path for tree root: %w", err)
+	}
+
+	logger.Debugf("tree root: %s", cfg.TreeRoot)
+
+	return nil
+}
+
+func execTreeRootCmd(cfg *Config) (string, error) {
+	// split the command first, resolving any '' and "" entries
+	parts, splitErr := shlex.Split(cfg.TreeRootCmd)
+	if splitErr != nil {
+		return "", fmt.Errorf("failed to parse tree-root-cmd: %w", splitErr)
+	}
+
+	// set a reasonable timeout of 2 seconds to wait for the command to return
+	// it shouldn't take anywhere near this amount of time unless there's a problem
+	executionTimeout := 2 * time.Second
+
+	ctx, cancel := context.WithTimeout(context.Background(), executionTimeout)
+	defer cancel()
+
+	// construct the command, setting the correct working directory
+	//nolint:gosec
+	cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
+	cmd.Dir = cfg.WorkingDirectory
+
+	// setup some pipes to capture stdout and stderr
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return "", fmt.Errorf("failed to create stdout pipe for tree-root-cmd: %w", err)
+	}
+
+	stderr, err := cmd.StderrPipe()
+	if err != nil {
+		return "", fmt.Errorf("failed to create stderr pipe for tree-root-cmd: %w", err)
+	}
+
+	// start processing stderr before we begin executing the command
+	go func() {
+		// capture stderr line by line and log
+		l := log.WithPrefix("tree-root-cmd | stderr")
+
+		scanner := bufio.NewScanner(stderr)
+		for scanner.Scan() {
+			l.Debugf("%s", scanner.Text())
+		}
+	}()
+
+	// start executing without waiting
+	if cmdErr := cmd.Start(); cmdErr != nil {
+		return "", fmt.Errorf("failed to start tree-root-cmd: %w", cmdErr)
+	}
+
+	// read stdout until it is closed (command exits)
+	output, err := io.ReadAll(stdout)
+	if err != nil {
+		return "", fmt.Errorf("failed to read stdout from tree-root-cmd: %w", err)
+	}
+
+	log.WithPrefix("tree-root-cmd | stdout").Debugf("%s", output)
+
+	// check execution error
+	if cmdErr := cmd.Wait(); cmdErr != nil {
+		var exitErr *exec.ExitError
+
+		// by experimenting, I noticed that sometimes we received the deadline exceeded error first, other times
+		// the exit error indicating the process was killed, therefore, we look for both
+		tookTooLong := errors.Is(cmdErr, context.DeadlineExceeded)
+		tookTooLong = tookTooLong || (errors.As(cmdErr, &exitErr) && exitErr.ProcessState.String() == "signal: killed")
+
+		if tookTooLong {
+			return "", fmt.Errorf(
+				"tree-root-cmd was killed after taking more than %v to execute",
+				executionTimeout,
+			)
+		}
+
+		// otherwise, some other kind of error occurred
+		return "", fmt.Errorf("failed to execute tree-root-cmd: %w", cmdErr)
+	}
+
+	// validate the output
+	outputStr := string(output)
+
+	lines := strings.Split(outputStr, "\n")
+	nonEmptyLines := slices.DeleteFunc(lines, func(line string) bool {
+		return line == ""
+	})
+
+	switch len(nonEmptyLines) {
+	case 1:
+		// return the first line as the tree root
+		return nonEmptyLines[0], nil
+
+	case 0:
+		// no output was received on stdout
+		return "", fmt.Errorf("empty output received after executing tree-root-cmd: %s", cfg.TreeRootCmd)
+
+	default:
+		// multiple lines received on stdout, dump the output to make it clear what happened and throw an error
+		log.WithPrefix("tree-root-cmd | stdout").Errorf("\n%s", outputStr)
+
+		return "", fmt.Errorf("tree-root-cmd cannot output multiple lines: %s", cfg.TreeRootCmd)
+	}
+}
+
 func Find(searchDir string, fileNames ...string) (path string, err error) {
 	for _, f := range fileNames {
 		path := filepath.Join(searchDir, f)
diff --git a/go.mod b/go.mod
index b22bc4c..318bbb5 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
 	github.com/adrg/xdg v0.5.3
 	github.com/charmbracelet/log v0.4.1
 	github.com/gobwas/glob v0.2.3
+	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
 	github.com/otiai10/copy v1.14.1
 	github.com/spf13/cobra v1.9.1
 	github.com/spf13/pflag v1.0.6
@@ -26,21 +27,15 @@ require (
 	github.com/fsnotify/fsnotify v1.8.0 // indirect
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 	github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
-	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
-	github.com/magiconair/properties v1.8.7 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
-	github.com/mattn/go-runewidth v0.0.15 // indirect
-	github.com/mitchellh/mapstructure v1.5.0 // indirect
-	github.com/muesli/reflow v0.3.0 // indirect
 	github.com/muesli/termenv v0.16.0 // indirect
 	github.com/otiai10/mint v1.6.3 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.3 // indirect
 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/sagikazarmark/locafero v0.7.0 // indirect
-	github.com/sagikazarmark/slog-shim v0.1.0 // indirect
 	github.com/sourcegraph/conc v0.3.0 // indirect
 	github.com/spf13/afero v1.12.0 // indirect
 	github.com/spf13/cast v1.7.1 // indirect
@@ -51,6 +46,5 @@ require (
 	golang.org/x/term v0.29.0 // indirect
 	golang.org/x/text v0.21.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
-	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index d6e7619..e58a323 100644
--- a/go.sum
+++ b/go.sum
@@ -1,17 +1,11 @@
-github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
-github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
 github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
 github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
 github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
 github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
-github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s=
-github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE=
 github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
 github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
-github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
-github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
 github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
 github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
 github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
@@ -25,8 +19,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
-github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
 github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
 github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
@@ -39,8 +31,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -52,72 +44,40 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
-github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
-github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
-github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
-github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
-github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
 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.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/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
 github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
-github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
 github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
 github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
-github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
-github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
 github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
 github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
-github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
-github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
 github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
 github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
-github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
-github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
 github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
 github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
 github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
 github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
 github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
 github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
-github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
-github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
-github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
 github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
 github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -130,27 +90,18 @@ go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
 go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
-golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
-golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
 golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
-golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
 golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
 golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
-golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
-golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
 golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
-gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw=
diff --git a/nix/packages/treefmt/default.nix b/nix/packages/treefmt/default.nix
index 730ba73..0b1281f 100644
--- a/nix/packages/treefmt/default.nix
+++ b/nix/packages/treefmt/default.nix
@@ -32,7 +32,7 @@ in
         ];
       };
 
-    vendorHash = "sha256-47yOjk3eO5K0T01GUDvheJxoAJz0ZmiV2RdqTv01pYQ=";
+    vendorHash = "sha256-a43fwdsXHKzM5FrJBPCdeU+zwaL6fLhV1PHfCpBFsv8=";
 
     env.CGO_ENABLED = 0;
 
@@ -117,7 +117,7 @@ in
           env.CGO_ENABLED = 1;
           buildPhase = ''
             HOME=$TMPDIR
-            go test -race -covermode=atomic -coverprofile=coverage.out -v ./...
+            go test -race -covermode=atomic -coverprofile="coverage.out" -v ./...
           '';
           installPhase = ''
             mv coverage.out $out
diff --git a/nix/packages/treefmt/formatters.nix b/nix/packages/treefmt/formatters.nix
index 0179642..28b50f9 100644
--- a/nix/packages/treefmt/formatters.nix
+++ b/nix/packages/treefmt/formatters.nix
@@ -1,5 +1,8 @@
 pkgs:
 with pkgs; [
+  bash # used in tree-root-cmd tests
+
+  # rest are formatters
   alejandra
   gotools
   haskellPackages.cabal-fmt


Test patch:
diff --git a/cmd/root_test.go b/cmd/root_test.go
index 60fc54d..429c2cd 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -1531,6 +1531,221 @@ func TestGit(t *testing.T) {
 	)
 }
 
+func TestTreeRootCmd(t *testing.T) {
+	as := require.New(t)
+
+	tempDir := test.TempExamples(t)
+	configPath := filepath.Join(tempDir, "/treefmt.toml")
+
+	test.ChangeWorkDir(t, tempDir)
+
+	// basic config
+	cfg := &config.Config{
+		FormatterConfigs: map[string]*config.Formatter{
+			"echo": {
+				Command:  "echo", // will not generate any underlying changes in the file
+				Includes: []string{"*"},
+			},
+		},
+	}
+
+	test.WriteConfig(t, configPath, cfg)
+
+	// construct a tree root command with some error logging and dumping output on stdout
+	treeRootCmd := func(output string) string {
+		return fmt.Sprintf("bash -c '>&2 echo -e \"some error text\nsome more error text\" && echo %s'", output)
+	}
+
+	// helper for checking the contents of stderr matches our expected debug output
+	checkStderr := func(buf []byte) {
+		output := string(buf)
+		as.Contains(output, "DEBU tree-root-cmd | stderr: some error text\n")
+		as.Contains(output, "DEBU tree-root-cmd | stderr: some more error text\n")
+	}
+
+	// run treefmt with DEBUG logging enabled and with tree root cmd being the root of the temp directory
+	treefmt(t,
+		withArgs("-vv", "--tree-root-cmd", treeRootCmd(tempDir)),
+		withNoError(t),
+		withStderr(checkStderr),
+		withConfig(configPath, cfg),
+		withStats(t, map[stats.Type]int{
+			stats.Traversed: 32,
+			stats.Matched:   32,
+			stats.Formatted: 32,
+			stats.Changed:   0,
+		}),
+	)
+
+	// run from a subdirectory, mixing things up by specifying the command via an env variable
+	treefmt(t,
+		withArgs("-vv"),
+		withEnv(map[string]string{
+			"TREEFMT_TREE_ROOT_CMD": treeRootCmd(filepath.Join(tempDir, "go")),
+		}),
+		withNoError(t),
+		withStderr(checkStderr),
+		withConfig(configPath, cfg),
+		withStats(t, map[stats.Type]int{
+			stats.Traversed: 2,
+			stats.Matched:   2,
+			stats.Formatted: 2,
+			stats.Changed:   0,
+		}),
+	)
+
+	// run from a subdirectory, mixing things up by specifying the command via config
+	cfg.TreeRootCmd = treeRootCmd(filepath.Join(tempDir, "haskell"))
+
+	treefmt(t,
+		withArgs("-vv"),
+		withNoError(t),
+		withStderr(checkStderr),
+		withConfig(configPath, cfg),
+		withStats(t, map[stats.Type]int{
+			stats.Traversed: 7,
+			stats.Matched:   7,
+			stats.Formatted: 7,
+			stats.Changed:   0,
+		}),
+	)
+
+	// run with a long-running command (2 seconds or more)
+	treefmt(t,
+		withArgs(
+			"-vv",
+			"--tree-root-cmd", fmt.Sprintf(
+				"bash -c 'sleep 2 && echo %s'",
+				tempDir,
+			),
+		),
+		withError(func(as *require.Assertions, err error) {
+			as.ErrorContains(err, "tree-root-cmd was killed after taking more than 2s to execute")
+		}),
+		withConfig(configPath, cfg),
+	)
+
+	// run with a command that outputs multiple lines
+	treefmt(t,
+		withArgs(
+			"--tree-root-cmd", fmt.Sprintf(
+				"bash -c 'echo %s && echo %s'",
+				tempDir, tempDir,
+			),
+		),
+		withStderr(func(buf []byte) {
+			as.Contains(string(buf), fmt.Sprintf("ERRO tree-root-cmd | stdout: \n%s\n%s\n", tempDir, tempDir))
+		}),
+		withError(func(as *require.Assertions, err error) {
+			as.ErrorContains(err, "tree-root-cmd cannot output multiple lines")
+		}),
+		withConfig(configPath, cfg),
+	)
+}
+
+func TestTreeRootExclusivity(t *testing.T) {
+	tempDir := test.TempExamples(t)
+	configPath := filepath.Join(tempDir, "/treefmt.toml")
+
+	formatterConfigs := map[string]*config.Formatter{
+		"echo": {
+			Command:  "echo", // will not generate any underlying changes in the file
+			Includes: []string{"*"},
+		},
+	}
+
+	test.ChangeWorkDir(t, tempDir)
+
+	assertExclusiveFlag := func(as *require.Assertions, err error) {
+		as.ErrorContains(err,
+			"if any flags in the group [tree-root tree-root-cmd tree-root-file] are set none of the others can be;",
+		)
+	}
+
+	assertExclusiveConfig := func(as *require.Assertions, err error) {
+		as.ErrorContains(err,
+			"at most one of tree-root, tree-root-cmd or tree-root-file can be specified",
+		)
+	}
+
+	envValues := map[string][]string{
+		"tree-root":      {"TREEFMT_TREE_ROOT", "bar"},
+		"tree-root-cmd":  {"TREEFMT_TREE_ROOT_CMD", "echo /foo/bar"},
+		"tree-root-file": {"TREEFMT_TREE_ROOT_FILE", ".git/config"},
+	}
+
+	flagValues := map[string][]string{
+		"tree-root":      {"--tree-root", "bar"},
+		"tree-root-cmd":  {"--tree-root-cmd", "'echo /foo/bar'"},
+		"tree-root-file": {"--tree-root-file", ".git/config"},
+	}
+
+	configValues := map[string]func(*config.Config){
+		"tree-root": func(cfg *config.Config) {
+			cfg.TreeRoot = "bar"
+		},
+		"tree-root-cmd": func(cfg *config.Config) {
+			cfg.TreeRootCmd = "'echo /foo/bar'"
+		},
+		"tree-root-file": func(cfg *config.Config) {
+			cfg.TreeRootFile = ".git/config"
+		},
+	}
+
+	invalidCombinations := [][]string{
+		{"tree-root", "tree-root-cmd"},
+		{"tree-root", "tree-root-file"},
+		{"tree-root-cmd", "tree-root-file"},
+		{"tree-root", "tree-root-cmd", "tree-root-file"},
+	}
+
+	// TODO we should also test mixing the various methods in the same test e.g. env variable and config value
+	// Given that ultimately everything is being reduced into the config object after parsing from viper, I'm fairly
+	// confident if these tests all pass then the mixed methods should yield the same result.
+
+	// for each set of invalid args, test them with flags, environment variables, and config entries.
+	for _, combination := range invalidCombinations {
+		// test flags
+		var args []string
+		for _, key := range combination {
+			args = append(args, flagValues[key]...)
+		}
+
+		treefmt(t,
+			withArgs(args...),
+			withError(assertExclusiveFlag),
+		)
+
+		// test env variables
+		env := make(map[string]string)
+
+		for _, key := range combination {
+			entry := envValues[key]
+			env[entry[0]] = entry[1]
+		}
+
+		treefmt(t,
+			withEnv(env),
+			withError(assertExclusiveConfig),
+		)
+
+		// test config
+		cfg := &config.Config{
+			FormatterConfigs: formatterConfigs,
+		}
+
+		for _, key := range combination {
+			entry := configValues[key]
+			entry(cfg)
+		}
+
+		treefmt(t,
+			withConfig(configPath, cfg),
+			withError(assertExclusiveConfig),
+		)
+	}
+}
+
 func TestPathsArg(t *testing.T) {
 	as := require.New(t)
 
@@ -1998,6 +2213,7 @@ func treefmt(
 
 	// set env
 	for k, v := range opts.env {
+		t.Logf("setting env %s=%s", k, v)
 		t.Setenv(k, v)
 	}
 
diff --git a/config/config_test.go b/config/config_test.go
index 279771e..28876bf 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -5,6 +5,7 @@ import (
 	"bytes"
 	"fmt"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"testing"
 
@@ -26,6 +27,17 @@ func newViper(t *testing.T) (*viper.Viper, *pflag.FlagSet) {
 	tempDir := t.TempDir()
 	v.SetConfigFile(filepath.Join(tempDir, "treefmt.toml"))
 
+	// initialise a git repo to help with tree-root-cmd testing
+	cmd := exec.Command("git", "init")
+	cmd.Dir = tempDir
+
+	if err = cmd.Run(); err != nil {
+		t.Fatal(err)
+	}
+
+	// change working directory to the temp dir
+	t.Chdir(tempDir)
+
 	flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
 	config.SetFlags(flags)
 
@@ -36,7 +48,7 @@ func newViper(t *testing.T) (*viper.Viper, *pflag.FlagSet) {
 	return v, flags
 }
 
-func readValue(t *testing.T, v *viper.Viper, cfg *config.Config, test func(*config.Config)) {
+func writeAndReadBack(t *testing.T, v *viper.Viper, cfg *config.Config) {
 	t.Helper()
 
 	// serialise the config and read it into viper
@@ -48,6 +60,25 @@ func readValue(t *testing.T, v *viper.Viper, cfg *config.Config, test func(*conf
 	} else if err = v.ReadConfig(bufio.NewReader(buf)); err != nil {
 		t.Fatal(fmt.Errorf("failed to read config: %w", err))
 	}
+}
+
+func readError(t *testing.T, v *viper.Viper, cfg *config.Config, test func(error)) {
+	t.Helper()
+
+	writeAndReadBack(t, v, cfg)
+
+	_, err := config.FromViper(v)
+	if err == nil {
+		t.Fatal("error was expected but none was thrown")
+	}
+
+	test(err)
+}
+
+func readValue(t *testing.T, v *viper.Viper, cfg *config.Config, test func(*config.Config)) {
+	t.Helper()
+
+	writeAndReadBack(t, v, cfg)
 
 	//
 	decodedCfg, err := config.FromViper(v)
@@ -457,6 +488,48 @@ func TestTreeRootFile(t *testing.T) {
 	checkValue(tempDir, ".git/config")
 }
 
+func TestTreeRootCmd(t *testing.T) {
+	as := require.New(t)
+
+	cfg := &config.Config{}
+	v, flags := newViper(t)
+
+	checkValue := func(treeRoot string) {
+		readValue(t, v, cfg, func(cfg *config.Config) {
+			as.Equal(treeRoot, cfg.TreeRoot)
+		})
+	}
+
+	tempDir := t.TempDir()
+	as.NoError(os.MkdirAll(filepath.Join(tempDir, "foo"), 0o755))
+	as.NoError(os.MkdirAll(filepath.Join(tempDir, "bar"), 0o755))
+
+	// default with no flag, env or config
+	// should match the absolute path of the directory in which the config file is located
+	checkValue(filepath.Dir(v.ConfigFileUsed()))
+
+	// set config value
+	cfg.TreeRootCmd = "echo " + tempDir
+	checkValue(tempDir)
+
+	// env override
+	// should match the directory above
+	t.Setenv("TREEFMT_TREE_ROOT_CMD", fmt.Sprintf("echo \"%s/foo\"", tempDir))
+	checkValue(filepath.Join(tempDir, "foo"))
+
+	// flag override
+	// should match the root of the temp directory structure
+	as.NoError(flags.Set("tree-root-cmd", fmt.Sprintf("echo '%s/bar'", tempDir)))
+	checkValue(filepath.Join(tempDir, "bar"))
+
+	// empty output from tree-root-cmd
+	// should throw an error
+	as.NoError(flags.Set("tree-root-cmd", "echo ''"))
+	readError(t, v, cfg, func(err error) {
+		as.ErrorContains(err, "empty output received after executing tree-root-cmd: echo ''")
+	})
+}
+
 func TestVerbosity(t *testing.T) {
 	as := require.New(t)