Skip to content

Commit c6ed5bb

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 c6ed5bb

File tree

5 files changed

+238
-34
lines changed

5 files changed

+238
-34
lines changed

cmd/init/init.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@
3636
# Env $TREEFMT_TREE_ROOT_FILE
3737
# tree-root-file = ".git/config"
3838

39+
# Command to run to find the tree root. It is parsed using shlex, to allow quoting arguments that contain whitespace.
40+
# If you wish to pass arguments containing quotes, you should use nested quotes e.g. "'" or '"'
41+
# Env $TREEFMT_TREE_ROOT_CMD
42+
# tree-root-cmd = "git rev-parse --show-toplevel"
43+
3944
# Set the verbosity of logs
4045
# 0 = warn, 1 = info, 2 = debug
4146
# Env $TREEFMT_VERBOSE

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: 89 additions & 20 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"
@@ -108,12 +110,15 @@ func SetFlags(fs *pflag.FlagSet) {
108110
)
109111
fs.String(
110112
"tree-root", "",
111-
"The root directory from which treefmt will start walking the filesystem (defaults to the directory "+
112-
"containing the config file). (env $TREEFMT_TREE_ROOT)",
113+
"The root directory from which treefmt will start walking the filesystem. "+
114+
"Defaults to the root of the current git worktree. If not in a git repo, defaults to the directory "+
115+
"containing the config file. (env $TREEFMT_TREE_ROOT)",
113116
)
114117
fs.String(
115118
"tree-root-cmd", "",
116-
"Command to run to find the tree root. (env $TREEFMT_TREE_ROOT_CMD)",
119+
"Command to run to find the tree root. It is parsed using shlex, to allow quoting arguments that "+
120+
"contain whitespace. If you wish to pass arguments containing quotes, you should use nested quotes "+
121+
"e.g. \"'\" or '\"'. (env $TREEFMT_TREE_ROOT_CMD)",
117122
)
118123
fs.String(
119124
"tree-root-file", "",
@@ -164,6 +169,8 @@ func NewViper() (*viper.Viper, error) {
164169

165170
// FromViper takes a viper instance and produces a Config instance.
166171
func FromViper(v *viper.Viper) (*Config, error) {
172+
logger := log.WithPrefix("config")
173+
167174
configReset := map[string]any{
168175
"ci": false,
169176
"clear-cache": false,
@@ -198,7 +205,7 @@ func FromViper(v *viper.Viper) (*Config, error) {
198205
}
199206

200207
// determine tree root
201-
if err = determineTreeRoot(v, cfg); err != nil {
208+
if err = determineTreeRoot(v, cfg, logger); err != nil {
202209
return nil, fmt.Errorf("failed to determine tree root: %w", err)
203210
}
204211

@@ -258,7 +265,7 @@ func FromViper(v *viper.Viper) (*Config, error) {
258265
return cfg, nil
259266
}
260267

261-
func determineTreeRoot(v *viper.Viper, cfg *Config) error {
268+
func determineTreeRoot(v *viper.Viper, cfg *Config, logger *log.Logger) error {
262269
var err error
263270

264271
// enforce the various tree root options are mutually exclusive
@@ -279,41 +286,44 @@ func determineTreeRoot(v *viper.Viper, cfg *Config) error {
279286
}
280287

281288
if count > 1 {
282-
return errors.New("only one of tree-root, tree-root-cmd or tree-root-file can be specified")
289+
return errors.New("at most one of tree-root, tree-root-cmd or tree-root-file can be specified")
283290
}
284291

285292
// set git-based tree root command if the walker is git and no tree root has been specified
286293
if cfg.Walk == walk.Git.String() && count == 0 {
287294
cfg.TreeRootCmd = "git rev-parse --show-toplevel"
288295

289-
log.Infof(
296+
logger.Infof(
290297
"git walker enabled and tree root has not been specified: defaulting tree-root-cmd to '%s'",
291298
cfg.TreeRootCmd,
292299
)
293300
}
294301

295302
switch {
296303
case cfg.TreeRoot != "":
297-
log.Debugf("tree root specified explicitly: %s", cfg.TreeRoot)
304+
logger.Debugf("tree root specified explicitly: %s", cfg.TreeRoot)
298305

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

302309
_, cfg.TreeRoot, err = FindUp(cfg.WorkingDirectory, cfg.TreeRootFile)
303310
if err != nil {
304311
return fmt.Errorf("failed to find tree-root based on tree-root-file: %w", err)
305312
}
306313

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

310317
if cfg.TreeRoot, err = execTreeRootCmd(cfg); err != nil {
311318
return err
312319
}
313320

314321
default:
315322
// no tree root was specified
316-
log.Debugf("no tree root specified, defaulting to the directory containing the config file: %s", v.ConfigFileUsed())
323+
logger.Debugf(
324+
"no tree root specified, defaulting to the directory containing the config file: %s",
325+
v.ConfigFileUsed(),
326+
)
317327

318328
cfg.TreeRoot = filepath.Dir(v.ConfigFileUsed())
319329
}
@@ -323,7 +333,7 @@ func determineTreeRoot(v *viper.Viper, cfg *Config) error {
323333
return fmt.Errorf("failed to get absolute path for tree root: %w", err)
324334
}
325335

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

328338
return nil
329339
}
@@ -337,24 +347,83 @@ func execTreeRootCmd(cfg *Config) (string, error) {
337347

338348
// set a reasonable timeout of 2 seconds to wait for the command to return
339349
// 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)
350+
executionTimeout := 2 * time.Second
351+
352+
ctx, cancel := context.WithTimeout(context.Background(), executionTimeout)
341353
defer cancel()
342354

343355
// construct the command, setting the correct working directory
344356
//nolint:gosec
345357
cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
346358
cmd.Dir = cfg.WorkingDirectory
347359

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

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

356-
// trim the output and return
357-
return strings.TrimSpace(string(out)), nil
426+
return treeRoot, nil
358427
}
359428

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

0 commit comments

Comments
 (0)