Skip to content

Commit 8e20b5d

Browse files
authored
Merge pull request #571 from numtide/feat/tree-root-cmd
feat: find tree root via a user-specified command
2 parents 9399cd6 + b97672a commit 8e20b5d

File tree

9 files changed

+508
-86
lines changed

9 files changed

+508
-86
lines changed

cmd/init/init.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,20 @@
2828
# on-unmatched = "info"
2929

3030
# The root directory from which treefmt will start walking the filesystem
31-
# Defaults to the directory containing the config file
31+
# Defaults to the root of the current git worktree.
32+
# If not in a git repo, defaults to the directory containing the config file.
3233
# Env $TREEFMT_TREE_ROOT
3334
# tree-root = "/tmp/foo"
3435

3536
# File to search for to find the tree root (if tree-root is not set)
3637
# Env $TREEFMT_TREE_ROOT_FILE
3738
# tree-root-file = ".git/config"
3839

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

cmd/root.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,12 @@ func NewRoot() (*cobra.Command, *stats.Stats) {
4646
// add our config flags to the command's flag set
4747
config.SetFlags(fs)
4848

49-
// xor tree-root and tree-root-file flags
50-
cmd.MarkFlagsMutuallyExclusive("tree-root", "tree-root-file")
49+
// xor tree-root, tree-root-cmd and tree-root-file flags
50+
cmd.MarkFlagsMutuallyExclusive(
51+
"tree-root",
52+
"tree-root-cmd",
53+
"tree-root-file",
54+
)
5155

5256
cmd.HelpTemplate()
5357

cmd/root_test.go

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1531,6 +1531,221 @@ 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+
"--tree-root-cmd", fmt.Sprintf(
1632+
"bash -c 'echo %s && echo %s'",
1633+
tempDir, tempDir,
1634+
),
1635+
),
1636+
withStderr(func(buf []byte) {
1637+
as.Contains(string(buf), fmt.Sprintf("ERRO tree-root-cmd | stdout: \n%s\n%s\n", tempDir, tempDir))
1638+
}),
1639+
withError(func(as *require.Assertions, err error) {
1640+
as.ErrorContains(err, "tree-root-cmd cannot output multiple lines")
1641+
}),
1642+
withConfig(configPath, cfg),
1643+
)
1644+
}
1645+
1646+
func TestTreeRootExclusivity(t *testing.T) {
1647+
tempDir := test.TempExamples(t)
1648+
configPath := filepath.Join(tempDir, "/treefmt.toml")
1649+
1650+
formatterConfigs := map[string]*config.Formatter{
1651+
"echo": {
1652+
Command: "echo", // will not generate any underlying changes in the file
1653+
Includes: []string{"*"},
1654+
},
1655+
}
1656+
1657+
test.ChangeWorkDir(t, tempDir)
1658+
1659+
assertExclusiveFlag := func(as *require.Assertions, err error) {
1660+
as.ErrorContains(err,
1661+
"if any flags in the group [tree-root tree-root-cmd tree-root-file] are set none of the others can be;",
1662+
)
1663+
}
1664+
1665+
assertExclusiveConfig := func(as *require.Assertions, err error) {
1666+
as.ErrorContains(err,
1667+
"at most one of tree-root, tree-root-cmd or tree-root-file can be specified",
1668+
)
1669+
}
1670+
1671+
envValues := map[string][]string{
1672+
"tree-root": {"TREEFMT_TREE_ROOT", "bar"},
1673+
"tree-root-cmd": {"TREEFMT_TREE_ROOT_CMD", "echo /foo/bar"},
1674+
"tree-root-file": {"TREEFMT_TREE_ROOT_FILE", ".git/config"},
1675+
}
1676+
1677+
flagValues := map[string][]string{
1678+
"tree-root": {"--tree-root", "bar"},
1679+
"tree-root-cmd": {"--tree-root-cmd", "'echo /foo/bar'"},
1680+
"tree-root-file": {"--tree-root-file", ".git/config"},
1681+
}
1682+
1683+
configValues := map[string]func(*config.Config){
1684+
"tree-root": func(cfg *config.Config) {
1685+
cfg.TreeRoot = "bar"
1686+
},
1687+
"tree-root-cmd": func(cfg *config.Config) {
1688+
cfg.TreeRootCmd = "'echo /foo/bar'"
1689+
},
1690+
"tree-root-file": func(cfg *config.Config) {
1691+
cfg.TreeRootFile = ".git/config"
1692+
},
1693+
}
1694+
1695+
invalidCombinations := [][]string{
1696+
{"tree-root", "tree-root-cmd"},
1697+
{"tree-root", "tree-root-file"},
1698+
{"tree-root-cmd", "tree-root-file"},
1699+
{"tree-root", "tree-root-cmd", "tree-root-file"},
1700+
}
1701+
1702+
// TODO we should also test mixing the various methods in the same test e.g. env variable and config value
1703+
// Given that ultimately everything is being reduced into the config object after parsing from viper, I'm fairly
1704+
// confident if these tests all pass then the mixed methods should yield the same result.
1705+
1706+
// for each set of invalid args, test them with flags, environment variables, and config entries.
1707+
for _, combination := range invalidCombinations {
1708+
// test flags
1709+
var args []string
1710+
for _, key := range combination {
1711+
args = append(args, flagValues[key]...)
1712+
}
1713+
1714+
treefmt(t,
1715+
withArgs(args...),
1716+
withError(assertExclusiveFlag),
1717+
)
1718+
1719+
// test env variables
1720+
env := make(map[string]string)
1721+
1722+
for _, key := range combination {
1723+
entry := envValues[key]
1724+
env[entry[0]] = entry[1]
1725+
}
1726+
1727+
treefmt(t,
1728+
withEnv(env),
1729+
withError(assertExclusiveConfig),
1730+
)
1731+
1732+
// test config
1733+
cfg := &config.Config{
1734+
FormatterConfigs: formatterConfigs,
1735+
}
1736+
1737+
for _, key := range combination {
1738+
entry := configValues[key]
1739+
entry(cfg)
1740+
}
1741+
1742+
treefmt(t,
1743+
withConfig(configPath, cfg),
1744+
withError(assertExclusiveConfig),
1745+
)
1746+
}
1747+
}
1748+
15341749
func TestPathsArg(t *testing.T) {
15351750
as := require.New(t)
15361751

@@ -1998,6 +2213,7 @@ func treefmt(
19982213

19992214
// set env
20002215
for k, v := range opts.env {
2216+
t.Logf("setting env %s=%s", k, v)
20012217
t.Setenv(k, v)
20022218
}
20032219

0 commit comments

Comments
 (0)