Skip to content

Commit e2067dd

Browse files
feat: find tree root via a user-specified command
Signed-off-by: Brian McGee <[email protected]> Co-authored-by: Matt Sturgeon <[email protected]>
1 parent ea56597 commit e2067dd

File tree

4 files changed

+226
-30
lines changed

4 files changed

+226
-30
lines changed

cmd/root_test.go

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1531,6 +1531,116 @@ func TestGit(t *testing.T) {
15311531
)
15321532
}
15331533

1534+
func TestTreeRootCmd(t *testing.T) {
1535+
as := require.New(t)
1536+
1537+
tempDir := test.TempExamples(t)
1538+
configPath := filepath.Join(tempDir, "/treefmt.toml")
1539+
1540+
test.ChangeWorkDir(t, tempDir)
1541+
1542+
// basic config
1543+
cfg := &config.Config{
1544+
FormatterConfigs: map[string]*config.Formatter{
1545+
"echo": {
1546+
Command: "echo", // will not generate any underlying changes in the file
1547+
Includes: []string{"*"},
1548+
},
1549+
},
1550+
}
1551+
1552+
test.WriteConfig(t, configPath, cfg)
1553+
1554+
// construct a tree root command with some error logging and dumping output on stdout
1555+
treeRootCmd := func(output string) string {
1556+
return fmt.Sprintf("bash -c '>&2 echo -e \"some error text\nsome more error text\" && echo %s'", output)
1557+
}
1558+
1559+
// helper for checking the contents of stderr matches our expected debug output
1560+
checkStderr := func(buf []byte) {
1561+
output := string(buf)
1562+
as.Contains(output, "DEBU tree-root-cmd | stderr: some error text\n")
1563+
as.Contains(output, "DEBU tree-root-cmd | stderr: some more error text\n")
1564+
}
1565+
1566+
// run treefmt with DEBUG logging enabled and with tree root cmd being the root of the temp directory
1567+
treefmt(t,
1568+
withArgs("-vv", "--tree-root-cmd", treeRootCmd(tempDir)),
1569+
withNoError(t),
1570+
withStderr(checkStderr),
1571+
withConfig(configPath, cfg),
1572+
withStats(t, map[stats.Type]int{
1573+
stats.Traversed: 32,
1574+
stats.Matched: 32,
1575+
stats.Formatted: 32,
1576+
stats.Changed: 0,
1577+
}),
1578+
)
1579+
1580+
// run from a subdirectory, mixing things up by specifying the command via an env variable
1581+
treefmt(t,
1582+
withArgs("-vv"),
1583+
withEnv(map[string]string{
1584+
"TREEFMT_TREE_ROOT_CMD": treeRootCmd(filepath.Join(tempDir, "go")),
1585+
}),
1586+
withNoError(t),
1587+
withStderr(checkStderr),
1588+
withConfig(configPath, cfg),
1589+
withStats(t, map[stats.Type]int{
1590+
stats.Traversed: 2,
1591+
stats.Matched: 2,
1592+
stats.Formatted: 2,
1593+
stats.Changed: 0,
1594+
}),
1595+
)
1596+
1597+
// run from a subdirectory, mixing things up by specifying the command via config
1598+
cfg.TreeRootCmd = treeRootCmd(filepath.Join(tempDir, "haskell"))
1599+
1600+
treefmt(t,
1601+
withArgs("-vv"),
1602+
withNoError(t),
1603+
withStderr(checkStderr),
1604+
withConfig(configPath, cfg),
1605+
withStats(t, map[stats.Type]int{
1606+
stats.Traversed: 7,
1607+
stats.Matched: 7,
1608+
stats.Formatted: 7,
1609+
stats.Changed: 0,
1610+
}),
1611+
)
1612+
1613+
// run with a long-running command (2 seconds or more)
1614+
treefmt(t,
1615+
withArgs(
1616+
"-vv",
1617+
"--tree-root-cmd", fmt.Sprintf(
1618+
"bash -c 'sleep 2 && echo %s'",
1619+
tempDir,
1620+
),
1621+
),
1622+
withError(func(as *require.Assertions, err error) {
1623+
as.ErrorContains(err, "tree-root-cmd was killed after taking more than 2s to execute")
1624+
}),
1625+
withConfig(configPath, cfg),
1626+
)
1627+
1628+
// run with a command that outputs multiple lines
1629+
treefmt(t,
1630+
withArgs(
1631+
"-vv",
1632+
"--tree-root-cmd", fmt.Sprintf(
1633+
"bash -c 'echo %s && echo %s'",
1634+
tempDir, tempDir,
1635+
),
1636+
),
1637+
withError(func(as *require.Assertions, err error) {
1638+
as.ErrorContains(err, "tree-root-cmd cannot output multiple lines")
1639+
}),
1640+
withConfig(configPath, cfg),
1641+
)
1642+
}
1643+
15341644
func TestTreeRootExclusivity(t *testing.T) {
15351645
tempDir := test.TempExamples(t)
15361646
configPath := filepath.Join(tempDir, "/treefmt.toml")
@@ -1580,7 +1690,7 @@ func TestTreeRootExclusivity(t *testing.T) {
15801690
},
15811691
}
15821692

1583-
permutations := [][]string{
1693+
invalidCombinations := [][]string{
15841694
{"tree-root", "tree-root-cmd"},
15851695
{"tree-root", "tree-root-file"},
15861696
{"tree-root-cmd", "tree-root-file"},
@@ -1591,11 +1701,11 @@ func TestTreeRootExclusivity(t *testing.T) {
15911701
// Given that ultimately everything is being reduced into the config object after parsing from viper, I'm fairly
15921702
// confident if these tests all pass then the mixed methods should yield the same result.
15931703

1594-
// test permutations of the same type
1595-
for _, perm := range permutations {
1704+
// for each set of invalid args, test them with flags, environment variables, and config entries.
1705+
for _, combination := range invalidCombinations {
15961706
// test flags
15971707
var args []string
1598-
for _, key := range perm {
1708+
for _, key := range combination {
15991709
args = append(args, flagValues[key]...)
16001710
}
16011711

@@ -1607,7 +1717,7 @@ func TestTreeRootExclusivity(t *testing.T) {
16071717
// test env variables
16081718
env := make(map[string]string)
16091719

1610-
for _, key := range perm {
1720+
for _, key := range combination {
16111721
entry := envValues[key]
16121722
env[entry[0]] = entry[1]
16131723
}
@@ -1622,7 +1732,7 @@ func TestTreeRootExclusivity(t *testing.T) {
16221732
FormatterConfigs: formatterConfigs,
16231733
}
16241734

1625-
for _, key := range perm {
1735+
for _, key := range combination {
16261736
entry := configValues[key]
16271737
entry(cfg)
16281738
}

config/config.go

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package config
22

33
import (
4+
"bufio"
45
"context"
56
"errors"
67
"fmt"
8+
"io"
79
"os"
810
"os/exec"
911
"path/filepath"
@@ -164,6 +166,8 @@ func NewViper() (*viper.Viper, error) {
164166

165167
// FromViper takes a viper instance and produces a Config instance.
166168
func FromViper(v *viper.Viper) (*Config, error) {
169+
logger := log.WithPrefix("config")
170+
167171
configReset := map[string]any{
168172
"ci": false,
169173
"clear-cache": false,
@@ -198,7 +202,7 @@ func FromViper(v *viper.Viper) (*Config, error) {
198202
}
199203

200204
// determine tree root
201-
if err = determineTreeRoot(v, cfg); err != nil {
205+
if err = determineTreeRoot(v, cfg, logger); err != nil {
202206
return nil, fmt.Errorf("failed to determine tree root: %w", err)
203207
}
204208

@@ -258,7 +262,7 @@ func FromViper(v *viper.Viper) (*Config, error) {
258262
return cfg, nil
259263
}
260264

261-
func determineTreeRoot(v *viper.Viper, cfg *Config) error {
265+
func determineTreeRoot(v *viper.Viper, cfg *Config, logger *log.Logger) error {
262266
var err error
263267

264268
// enforce the various tree root options are mutually exclusive
@@ -286,34 +290,37 @@ func determineTreeRoot(v *viper.Viper, cfg *Config) error {
286290
if cfg.Walk == walk.Git.String() && count == 0 {
287291
cfg.TreeRootCmd = "git rev-parse --show-toplevel"
288292

289-
log.Infof(
293+
logger.Infof(
290294
"git walker enabled and tree root has not been specified: defaulting tree-root-cmd to '%s'",
291295
cfg.TreeRootCmd,
292296
)
293297
}
294298

295299
switch {
296300
case cfg.TreeRoot != "":
297-
log.Debugf("tree root specified explicitly: %s", cfg.TreeRoot)
301+
logger.Debugf("tree root specified explicitly: %s", cfg.TreeRoot)
298302

299303
case cfg.TreeRootFile != "":
300-
log.Debugf("searching for tree root using --tree-root-file: %s", cfg.TreeRootFile)
304+
logger.Debugf("searching for tree root using tree-root-file: %s", cfg.TreeRootFile)
301305

302306
_, cfg.TreeRoot, err = FindUp(cfg.WorkingDirectory, cfg.TreeRootFile)
303307
if err != nil {
304308
return fmt.Errorf("failed to find tree-root based on tree-root-file: %w", err)
305309
}
306310

307311
case cfg.TreeRootCmd != "":
308-
log.Debugf("searching for tree root using --tree-root-cmd: %s", cfg.TreeRootCmd)
312+
logger.Debugf("searching for tree root using tree-root-cmd: %s", cfg.TreeRootCmd)
309313

310314
if cfg.TreeRoot, err = execTreeRootCmd(cfg); err != nil {
311315
return err
312316
}
313317

314318
default:
315319
// no tree root was specified
316-
log.Debugf("no tree root specified, defaulting to the directory containing the config file: %s", v.ConfigFileUsed())
320+
logger.Debugf(
321+
"no tree root specified, defaulting to the directory containing the config file: %s",
322+
v.ConfigFileUsed(),
323+
)
317324

318325
cfg.TreeRoot = filepath.Dir(v.ConfigFileUsed())
319326
}
@@ -323,7 +330,7 @@ func determineTreeRoot(v *viper.Viper, cfg *Config) error {
323330
return fmt.Errorf("failed to get absolute path for tree root: %w", err)
324331
}
325332

326-
log.Debugf("tree root: %s", cfg.TreeRoot)
333+
logger.Debugf("tree root: %s", cfg.TreeRoot)
327334

328335
return nil
329336
}
@@ -337,24 +344,83 @@ func execTreeRootCmd(cfg *Config) (string, error) {
337344

338345
// set a reasonable timeout of 2 seconds to wait for the command to return
339346
// it shouldn't take anywhere near this amount of time unless there's a problem
340-
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
347+
executionTimeout := 2 * time.Second
348+
349+
ctx, cancel := context.WithTimeout(context.Background(), executionTimeout)
341350
defer cancel()
342351

343352
// construct the command, setting the correct working directory
344353
//nolint:gosec
345354
cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
346355
cmd.Dir = cfg.WorkingDirectory
347356

348-
// execute
349-
out, cmdErr := cmd.CombinedOutput()
350-
if cmdErr != nil {
351-
log.Errorf("tree-root-cmd output: \n%s", out)
357+
// setup some pipes to capture stdout and stderr
358+
stdout, err := cmd.StdoutPipe()
359+
if err != nil {
360+
return "", fmt.Errorf("failed to create stdout pipe for tree-root-cmd: %w", err)
361+
}
362+
363+
stderr, err := cmd.StderrPipe()
364+
if err != nil {
365+
return "", fmt.Errorf("failed to create stderr pipe for tree-root-cmd: %w", err)
366+
}
367+
368+
// start processing stderr before we begin executing the command
369+
go func() {
370+
// capture stderr line by line and log
371+
l := log.WithPrefix("tree-root-cmd | stderr")
372+
373+
scanner := bufio.NewScanner(stderr)
374+
for scanner.Scan() {
375+
l.Debugf("%s", scanner.Text())
376+
}
377+
}()
378+
379+
// start executing without waiting
380+
if cmdErr := cmd.Start(); cmdErr != nil {
381+
return "", fmt.Errorf("failed to start tree-root-cmd: %w", cmdErr)
382+
}
383+
384+
// read stdout until it is closed (command exits)
385+
output, err := io.ReadAll(stdout)
386+
if err != nil {
387+
return "", fmt.Errorf("failed to read stdout from tree-root-cmd: %w", err)
388+
}
389+
390+
log.WithPrefix("tree-root-cmd | stdout").Debugf("%s", output)
391+
392+
// check execution error
393+
if cmdErr := cmd.Wait(); cmdErr != nil {
394+
var exitErr *exec.ExitError
395+
396+
// by experimenting, I noticed that sometimes we received the deadline exceeded error first, other times
397+
// the exit error indicating the process was killed, therefore, we look for both
398+
tookTooLong := errors.Is(cmdErr, context.DeadlineExceeded)
399+
tookTooLong = tookTooLong || (errors.As(cmdErr, &exitErr) && exitErr.ProcessState.String() == "signal: killed")
400+
401+
if tookTooLong {
402+
return "", fmt.Errorf(
403+
"tree-root-cmd was killed after taking more than %v to execute",
404+
executionTimeout,
405+
)
406+
}
407+
408+
// otherwise, some other kind of error occurred
409+
return "", fmt.Errorf("failed to execute tree-root-cmd: %w", cmdErr)
410+
}
411+
412+
// trim the output and check it's not empty
413+
treeRoot := strings.TrimSpace(string(output))
414+
415+
if treeRoot == "" {
416+
return "", fmt.Errorf("empty output received after executing tree-root-cmd: %s", cfg.TreeRootCmd)
417+
}
352418

353-
return "", fmt.Errorf("failed to run tree-root-cmd: %w", cmdErr)
419+
if strings.Contains(treeRoot, "\n") {
420+
return "", fmt.Errorf("tree-root-cmd cannot output multiple lines: %s", cfg.TreeRootCmd)
354421
}
355422

356-
// trim the output and return
357-
return strings.TrimSpace(string(out)), nil
423+
return treeRoot, nil
358424
}
359425

360426
func Find(searchDir string, fileNames ...string) (path string, err error) {

config/config_test.go

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func newViper(t *testing.T) (*viper.Viper, *pflag.FlagSet) {
4848
return v, flags
4949
}
5050

51-
func readValue(t *testing.T, v *viper.Viper, cfg *config.Config, test func(*config.Config)) {
51+
func writeAndReadBack(t *testing.T, v *viper.Viper, cfg *config.Config) {
5252
t.Helper()
5353

5454
// serialise the config and read it into viper
@@ -60,6 +60,25 @@ func readValue(t *testing.T, v *viper.Viper, cfg *config.Config, test func(*conf
6060
} else if err = v.ReadConfig(bufio.NewReader(buf)); err != nil {
6161
t.Fatal(fmt.Errorf("failed to read config: %w", err))
6262
}
63+
}
64+
65+
func readError(t *testing.T, v *viper.Viper, cfg *config.Config, test func(error)) {
66+
t.Helper()
67+
68+
writeAndReadBack(t, v, cfg)
69+
70+
_, err := config.FromViper(v)
71+
if err == nil {
72+
t.Fatal("error was expected but none was thrown")
73+
}
74+
75+
test(err)
76+
}
77+
78+
func readValue(t *testing.T, v *viper.Viper, cfg *config.Config, test func(*config.Config)) {
79+
t.Helper()
80+
81+
writeAndReadBack(t, v, cfg)
6382

6483
//
6584
decodedCfg, err := config.FromViper(v)
@@ -503,14 +522,12 @@ func TestTreeRootCmd(t *testing.T) {
503522
as.NoError(flags.Set("tree-root-cmd", fmt.Sprintf("echo '%s/bar'", tempDir)))
504523
checkValue(filepath.Join(tempDir, "bar"))
505524

506-
// empty tree-root-cmd value
507-
// should be the same as the current working directory
508-
// we switch to a new temp directory to test this
509-
tempDir2 := t.TempDir()
510-
t.Chdir(tempDir2)
511-
525+
// empty output from tree-root-cmd
526+
// should throw an error
512527
as.NoError(flags.Set("tree-root-cmd", "echo ''"))
513-
checkValue(tempDir2)
528+
readError(t, v, cfg, func(err error) {
529+
as.ErrorContains(err, "empty output received after executing tree-root-cmd: echo ''")
530+
})
514531
}
515532

516533
func TestVerbosity(t *testing.T) {

0 commit comments

Comments
 (0)