Repository: numtide/treefmt
Pull request #398 (2024-08-28 13:26:29)

fix: resolve commands relative to the root
When configuring treefmt with relative paths to commands, for example if
you want to create your own repo-specific wrappers, treefmt would fail
if executed in a sub-directory.

This change makes sure that the relative path is resolved based on the
treefmt root instead of the current directory.

brianmcgee: @mergifyio rebase

Patch:
diff --git a/cli/format.go b/cli/format.go
index 4b8f7fa..584cece 100644
--- a/cli/format.go
+++ b/cli/format.go
@@ -15,6 +15,7 @@ import (
 
 	"git.numtide.com/numtide/treefmt/format"
 	"git.numtide.com/numtide/treefmt/stats"
+	"mvdan.cc/sh/v3/expand"
 
 	"git.numtide.com/numtide/treefmt/cache"
 	"git.numtide.com/numtide/treefmt/config"
@@ -136,8 +137,10 @@ func (f *Format) Run() (err error) {
 	// initialise formatters
 	f.formatters = make(map[string]*format.Formatter)
 
+	env := expand.ListEnviron(os.Environ()...)
+
 	for name, formatterCfg := range cfg.Formatters {
-		formatter, err := format.NewFormatter(name, f.TreeRoot, formatterCfg)
+		formatter, err := format.NewFormatter(name, f.TreeRoot, env, formatterCfg)
 
 		if errors.Is(err, format.ErrCommandNotFound) && f.AllowMissingFormatter {
 			log.Debugf("formatter command not found: %v", name)
diff --git a/docs/contributing.md b/docs/contributing.md
index 5997de1..c642a98 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -30,6 +30,15 @@ If you need to create an issue, make sure to clearly describe it, including:
 
 The cache database is stored in a `.db` file in the `~/.cache/treefmt/eval-cache` directory.
 
+## Updating go.mod/go.sum/gomod2nix.toml
+
+Whenever the go.mod or go.sum file changes, the Nix tooling also needs an
+updated `nix/packages/treefmt/gomod2nix.toml`.
+
+To sync it up, run `nix develop .#renovate -c gomod2nix:update`.
+
+
+
 ## Making changes
 
 If you want to introduce changes to the project, please follow these steps:
diff --git a/format/formatter.go b/format/formatter.go
index 3668739..58ea189 100644
--- a/format/formatter.go
+++ b/format/formatter.go
@@ -8,12 +8,13 @@ import (
 	"os/exec"
 	"time"
 
-	"git.numtide.com/numtide/treefmt/walk"
-
 	"git.numtide.com/numtide/treefmt/config"
+	"git.numtide.com/numtide/treefmt/walk"
 
 	"github.com/charmbracelet/log"
 	"github.com/gobwas/glob"
+	"mvdan.cc/sh/v3/expand"
+	"mvdan.cc/sh/v3/interp"
 )
 
 // ErrCommandNotFound is returned when the Command for a Formatter is not available.
@@ -101,6 +102,7 @@ func (f *Formatter) Wants(file *walk.File) bool {
 func NewFormatter(
 	name string,
 	treeRoot string,
+	env expand.Environ,
 	cfg *config.Formatter,
 ) (*Formatter, error) {
 	var err error
@@ -113,11 +115,9 @@ func NewFormatter(
 	f.workingDir = treeRoot
 
 	// test if the formatter is available
-	executable, err := exec.LookPath(cfg.Command)
-	if errors.Is(err, exec.ErrNotFound) {
+	executable, err := interp.LookPathDir(treeRoot, env, cfg.Command)
+	if err != nil {
 		return nil, ErrCommandNotFound
-	} else if err != nil {
-		return nil, err
 	}
 	f.executable = executable
 
diff --git a/go.mod b/go.mod
index 53fc4d1..c526de1 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
 	github.com/vmihailenco/msgpack/v5 v5.4.1
 	go.etcd.io/bbolt v1.3.11
 	golang.org/x/sync v0.8.0
+	mvdan.cc/sh/v3 v3.9.0
 )
 
 require (
@@ -35,6 +36,7 @@ require (
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-runewidth v0.0.15 // indirect
+	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/pjbgf/sha1cd v0.3.0 // indirect
@@ -48,6 +50,7 @@ require (
 	golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
 	golang.org/x/net v0.28.0 // indirect
 	golang.org/x/sys v0.24.0 // indirect
+	golang.org/x/term v0.23.0 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index f1f5d11..669b3c8 100644
--- a/go.sum
+++ b/go.sum
@@ -29,6 +29,8 @@ github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1
 github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
 github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
 github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
+github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
+github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
 github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
 github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -42,26 +44,18 @@ github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
 github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
-github.com/go-git/go-billy/v5 v5.5.1-0.20240805213603-fac749f7395a h1:E7kbQRzNItbtGVcgXR8HtmtOiyTGIjD3s/LX9e89kcA=
-github.com/go-git/go-billy/v5 v5.5.1-0.20240805213603-fac749f7395a/go.mod h1:X5ZGvb06udYfe+YUV3Hml7eWViUhjq98lqGLz53viVc=
-github.com/go-git/go-billy/v5 v5.5.1-0.20240814154544-eaed248167f1 h1:MGoBSiT4C9k32pmVplTXDBGupItZle84QUa9pVTuOSQ=
-github.com/go-git/go-billy/v5 v5.5.1-0.20240814154544-eaed248167f1/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM=
 github.com/go-git/go-billy/v5 v5.5.1-0.20240819193939-9b484184bdcc h1:fpw3vn8skBvfPwsKRq6K2o/55ZcwAid/9lubG/NyNNE=
 github.com/go-git/go-billy/v5 v5.5.1-0.20240819193939-9b484184bdcc/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM=
 github.com/go-git/go-billy/v5 v5.5.1-0.20240828070317-59c50b000c7a h1:CDPmu0p7gv6zJn35T/RtZyIq98I2SwHtLrp697pM3KI=
 github.com/go-git/go-billy/v5 v5.5.1-0.20240828070317-59c50b000c7a/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM=
 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
-github.com/go-git/go-git/v5 v5.12.1-0.20240807144107-c594bae8d75d h1:hJt4GF2BxUI1X20p6APebYu4T7TG4t3HWNwEbgFVilU=
-github.com/go-git/go-git/v5 v5.12.1-0.20240807144107-c594bae8d75d/go.mod h1:HxWls9Nx+4sukm5KsYVOz6rR+YJBB4QaVDFaXzG/weQ=
-github.com/go-git/go-git/v5 v5.12.1-0.20240809133458-4fd9979d5c29 h1:ziylW6O1GsEiHK+i7/F0ZlFRV3b6bodRsVtzdaom6EM=
-github.com/go-git/go-git/v5 v5.12.1-0.20240809133458-4fd9979d5c29/go.mod h1:tTeL/MQl8Pjm1QfKA/x/F0E04y9g5EynnXfV52kvvTw=
-github.com/go-git/go-git/v5 v5.12.1-0.20240821090644-6d583524d3e1 h1:ggoxUMCVjbfxO/CqzeYCUdUyVgCBoXjlN7aeksTylSQ=
-github.com/go-git/go-git/v5 v5.12.1-0.20240821090644-6d583524d3e1/go.mod h1:tTeL/MQl8Pjm1QfKA/x/F0E04y9g5EynnXfV52kvvTw=
 github.com/go-git/go-git/v5 v5.12.1-0.20240821195137-5c762aefcd8d h1:+KOoJFltZdLrtMrrOqaTYr8LWc7q296l6Y/+/bS9At0=
 github.com/go-git/go-git/v5 v5.12.1-0.20240821195137-5c762aefcd8d/go.mod h1:tTeL/MQl8Pjm1QfKA/x/F0E04y9g5EynnXfV52kvvTw=
 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
+github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
+github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
 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/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
@@ -88,6 +82,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
 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/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 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.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
@@ -108,8 +104,8 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
 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.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
-github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
@@ -127,8 +123,6 @@ github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV
 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
 github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0=
-go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ=
 go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
 go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -169,8 +163,6 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
-golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
 golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -204,3 +196,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 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.9.0 h1:it14fyjCdQUk4jf/aYxLO3FG8jFarR9GzMCtnlvvD7c=
+mvdan.cc/sh/v3 v3.9.0/go.mod h1:cdBk8bgoiBI7lSZqK5JhUuq7OB64VQ7fgm85xelw3Nk=
diff --git a/nix/devshells/renovate.nix b/nix/devshells/renovate.nix
index 15c7c3c..99a0bb7 100644
--- a/nix/devshells/renovate.nix
+++ b/nix/devshells/renovate.nix
@@ -10,7 +10,12 @@ pkgs.mkShellNoCC {
     (pkgs.writeShellApplication {
       name = "gomod2nix:update";
       runtimeInputs = [pkgs.git perSystem.gomod2nix.default];
-      text = "gomod2nix --outdir nix/packages/treefmt";
+      text = ''
+        set -eu
+        gomod2nix --outdir nix/packages/treefmt
+        # shellcheck disable=SC2016
+        sed -i '1i # Generated with `nix develop .#renovate -c gomod2nix:update`' nix/packages/treefmt/gomod2nix.toml
+      '';
     })
   ];
 }
diff --git a/nix/packages/treefmt/gomod2nix.toml b/nix/packages/treefmt/gomod2nix.toml
index 355935e..65ace2c 100644
--- a/nix/packages/treefmt/gomod2nix.toml
+++ b/nix/packages/treefmt/gomod2nix.toml
@@ -1,3 +1,4 @@
+# Generated with `nix develop .#renovate -c gomod2nix:update`
 schema = 3
 
 [mod]
@@ -73,6 +74,9 @@ schema = 3
   [mod."github.com/mattn/go-runewidth"]
     version = "v0.0.15"
     hash = "sha256-WP39EU2UrQbByYfnwrkBDoKN7xzXsBssDq3pNryBGm0="
+  [mod."github.com/muesli/cancelreader"]
+    version = "v0.2.2"
+    hash = "sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ="
   [mod."github.com/muesli/reflow"]
     version = "v0.3.0"
     hash = "sha256-Pou2ybE9SFSZG6YfZLVV1Eyfm+X4FuVpDPLxhpn47Cc="
@@ -127,9 +131,15 @@ schema = 3
   [mod."golang.org/x/sys"]
     version = "v0.24.0"
     hash = "sha256-P0fsA+qy9taYHWPTtCs5XmrJ1i8tWfvkno+PNuc2elw="
+  [mod."golang.org/x/term"]
+    version = "v0.23.0"
+    hash = "sha256-ECmmK3Wc0g/zUy5wAokj7ItRsUi6eoqSe9s9Uhcwu90="
   [mod."gopkg.in/warnings.v0"]
     version = "v0.1.2"
     hash = "sha256-ATVL9yEmgYbkJ1DkltDGRn/auGAjqGOfjQyBYyUo8s8="
   [mod."gopkg.in/yaml.v3"]
     version = "v3.0.1"
     hash = "sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU="
+  [mod."mvdan.cc/sh/v3"]
+    version = "v3.9.0"
+    hash = "sha256-yTQXBY9TSqRmS1Vx7k1rtiwK7xQwTPTv37dR8D4/zdU="


Test patch:
diff --git a/cli/format_test.go b/cli/format_test.go
index 86be79b..303cbfc 100644
--- a/cli/format_test.go
+++ b/cli/format_test.go
@@ -571,17 +571,17 @@ func TestPathsArg(t *testing.T) {
 	test.WriteConfig(t, configPath, cfg)
 
 	// without any path args
-	_, err = cmd(t, "-C", tempDir)
+	_, err = cmd(t)
 	as.NoError(err)
 	assertStats(t, as, 32, 32, 32, 0)
 
 	// specify some explicit paths
-	_, err = cmd(t, "-C", tempDir, "-c", "elm/elm.json", "haskell/Nested/Foo.hs")
+	_, err = cmd(t, "-c", "elm/elm.json", "haskell/Nested/Foo.hs")
 	as.NoError(err)
 	assertStats(t, as, 2, 2, 2, 0)
 
 	// specify a bad path
-	_, err = cmd(t, "-C", tempDir, "-c", "elm/elm.json", "haskell/Nested/Bar.hs")
+	_, err = cmd(t, "-c", "elm/elm.json", "haskell/Nested/Bar.hs")
 	as.ErrorContains(err, "no such file or directory")
 }
 
@@ -702,3 +702,50 @@ func TestDeterministicOrderingInPipeline(t *testing.T) {
 		}
 	}
 }
+
+func TestRunInSubdir(t *testing.T) {
+	as := require.New(t)
+
+	// capture current cwd, so we can replace it after the test is finished
+	cwd, err := os.Getwd()
+	as.NoError(err)
+
+	t.Cleanup(func() {
+		// return to the previous working directory
+		as.NoError(os.Chdir(cwd))
+	})
+
+	tempDir := test.TempExamples(t)
+	configPath := filepath.Join(tempDir, "/treefmt.toml")
+
+	// Also test that formatters are resolved relative to the treefmt root
+	echoPath, err := exec.LookPath("echo")
+	as.NoError(err)
+	echoRel := path.Join(tempDir, "echo")
+	err = os.Symlink(echoPath, echoRel)
+	as.NoError(err)
+
+	// change working directory to sub directory
+	as.NoError(os.Chdir(filepath.Join(tempDir, "elm")))
+
+	// basic config
+	cfg := config.Config{
+		Formatters: map[string]*config.Formatter{
+			"echo": {
+				Command:  "./echo",
+				Includes: []string{"*"},
+			},
+		},
+	}
+	test.WriteConfig(t, configPath, cfg)
+
+	// without any path args, should reformat the whole tree
+	_, err = cmd(t)
+	as.NoError(err)
+	assertStats(t, as, 32, 32, 32, 0)
+
+	// specify some explicit paths, relative to the elm/ sub-folder
+	_, err = cmd(t, "-c", "elm.json", "../haskell/Nested/Foo.hs")
+	as.NoError(err)
+	assertStats(t, as, 2, 2, 2, 0)
+}