Skip to content

Commit 4b4c4f5

Browse files
committed
feat(walk): add jujutsu walker
- added jujutsu module similar to the git module, which provides the `IsInsideWorktree` function - added jujutsu walker, with the following differences to the git walker - the list command does not update the index. thus, new files are not listed, the user has to add them to the index first by running a `jj` command - added jujutsu walker test - added jujutsu walker root test - adapted config and docs
1 parent 7799c74 commit 4b4c4f5

File tree

12 files changed

+545
-13
lines changed

12 files changed

+545
-13
lines changed

cmd/init/init.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
# verbose = 2
4949

5050
# The method used to traverse the files within the tree root
51-
# Currently, we support 'auto', 'git' or 'filesystem'
51+
# Currently, we support 'auto', 'git', 'jujutsu', or 'filesystem'
5252
# Env $TREEFMT_WALK
5353
# walk = "filesystem"
5454

@@ -64,4 +64,4 @@ excludes = []
6464
# Controls the order of application when multiple formatters match the same file
6565
# Lower the number, the higher the precedence
6666
# Default is 0
67-
priority = 0
67+
priority = 0

cmd/root_test.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,6 +1536,218 @@ func TestGit(t *testing.T) {
15361536
)
15371537
}
15381538

1539+
func TestJujutsu(t *testing.T) {
1540+
as := require.New(t)
1541+
1542+
test.SetenvXdgConfigDir(t)
1543+
tempDir := test.TempExamples(t)
1544+
configPath := filepath.Join(tempDir, "/treefmt.toml")
1545+
1546+
test.ChangeWorkDir(t, tempDir)
1547+
1548+
// basic config
1549+
cfg := &config.Config{
1550+
FormatterConfigs: map[string]*config.Formatter{
1551+
"echo": {
1552+
Command: "echo", // will not generate any underlying changes in the file
1553+
Includes: []string{"*"},
1554+
},
1555+
},
1556+
}
1557+
1558+
test.WriteConfig(t, configPath, cfg)
1559+
1560+
// init a jujutsu repo
1561+
jjCmd := exec.Command("jj", "git", "init")
1562+
as.NoError(jjCmd.Run(), "failed to init jujutsu repository")
1563+
1564+
// run treefmt before adding anything to the jj index
1565+
// Jujutsu depends on updating the index with a `jj` command. So, until we do
1566+
// that, the treefmt should return nothing, since the walker is executed with
1567+
// `--ignore-working-copy` which does not update the index.
1568+
treefmt(t,
1569+
withConfig(configPath, cfg),
1570+
withNoError(t),
1571+
withStats(t, map[stats.Type]int{
1572+
stats.Traversed: 0,
1573+
stats.Matched: 0,
1574+
stats.Formatted: 0,
1575+
stats.Changed: 0,
1576+
}),
1577+
)
1578+
1579+
// update jujutsu's index
1580+
jjCmd = exec.Command("jj")
1581+
as.NoError(jjCmd.Run(), "failed to update the index")
1582+
1583+
// This is our first pass, since previously the files were not in the index. This should format all files.
1584+
treefmt(t,
1585+
withConfig(configPath, cfg),
1586+
withNoError(t),
1587+
withStats(t, map[stats.Type]int{
1588+
stats.Traversed: 32,
1589+
stats.Matched: 32,
1590+
stats.Formatted: 32,
1591+
stats.Changed: 0,
1592+
}),
1593+
)
1594+
1595+
// create a file which should be in .gitignore
1596+
f, err := os.CreateTemp(tempDir, "test-*.txt")
1597+
as.NoError(err, "failed to create temp file")
1598+
1599+
// update jujutsu's index
1600+
jjCmd = exec.Command("jj")
1601+
as.NoError(jjCmd.Run(), "failed to update the index")
1602+
1603+
t.Cleanup(func() {
1604+
_ = f.Close()
1605+
})
1606+
1607+
treefmt(t,
1608+
withConfig(configPath, cfg),
1609+
withNoError(t),
1610+
withStats(t, map[stats.Type]int{
1611+
stats.Traversed: 32,
1612+
stats.Matched: 32,
1613+
stats.Formatted: 0,
1614+
stats.Changed: 0,
1615+
}),
1616+
)
1617+
1618+
// remove python directory
1619+
as.NoError(os.RemoveAll(filepath.Join(tempDir, "python")), "failed to remove python directory")
1620+
1621+
// update jujutsu's index
1622+
jjCmd = exec.Command("jj")
1623+
as.NoError(jjCmd.Run(), "failed to update the index")
1624+
1625+
// we should traverse and match against fewer files, but no formatting should occur as no formatting signatures
1626+
// are impacted
1627+
treefmt(t,
1628+
withConfig(configPath, cfg),
1629+
withNoError(t),
1630+
withStats(t, map[stats.Type]int{
1631+
stats.Traversed: 29,
1632+
stats.Matched: 29,
1633+
stats.Formatted: 0,
1634+
stats.Changed: 0,
1635+
}),
1636+
)
1637+
1638+
// remove nixpkgs.toml from the filesystem but leave it in the index
1639+
as.NoError(os.Remove(filepath.Join(tempDir, "nixpkgs.toml")))
1640+
1641+
// walk with filesystem instead of with jujutsu
1642+
// the .jj folder contains 100 additional files
1643+
// when added to the 30 we started with (34 minus nixpkgs.toml which we removed from the filesystem), we should
1644+
// traverse 130 files.
1645+
treefmt(t,
1646+
withArgs("--walk", "filesystem"),
1647+
withConfig(configPath, cfg),
1648+
withNoError(t),
1649+
withStats(t, map[stats.Type]int{
1650+
stats.Traversed: 130,
1651+
stats.Matched: 130,
1652+
stats.Formatted: 102, // the echo formatter should only be applied to the new files
1653+
stats.Changed: 0,
1654+
}),
1655+
)
1656+
1657+
// format specific sub paths
1658+
// we should traverse and match against those files, but without any underlying change to their files or their
1659+
// formatting config, we will not format them
1660+
1661+
treefmt(t,
1662+
withArgs("go"),
1663+
withConfig(configPath, cfg),
1664+
withNoError(t),
1665+
withStats(t, map[stats.Type]int{
1666+
stats.Traversed: 2,
1667+
stats.Matched: 2,
1668+
stats.Formatted: 0,
1669+
stats.Changed: 0,
1670+
}),
1671+
)
1672+
1673+
treefmt(t,
1674+
withArgs("go", "haskell"),
1675+
withConfig(configPath, cfg),
1676+
withNoError(t),
1677+
withStats(t, map[stats.Type]int{
1678+
stats.Traversed: 9,
1679+
stats.Matched: 9,
1680+
stats.Formatted: 0,
1681+
stats.Changed: 0,
1682+
}),
1683+
)
1684+
1685+
treefmt(t,
1686+
withArgs("-C", tempDir, "go", "haskell", "ruby"),
1687+
withConfig(configPath, cfg),
1688+
withNoError(t),
1689+
withStats(t, map[stats.Type]int{
1690+
stats.Traversed: 10,
1691+
stats.Matched: 10,
1692+
stats.Formatted: 0,
1693+
stats.Changed: 0,
1694+
}),
1695+
)
1696+
1697+
// try with a bad path
1698+
treefmt(t,
1699+
withArgs("-C", tempDir, "haskell", "foo"),
1700+
withConfig(configPath, cfg),
1701+
withError(func(as *require.Assertions, err error) {
1702+
as.ErrorContains(err, "foo not found")
1703+
}),
1704+
)
1705+
1706+
// try with a path not in the jj index
1707+
_, err = os.Create(filepath.Join(tempDir, "foo.txt"))
1708+
as.NoError(err)
1709+
1710+
// update jujutsu's index
1711+
jjCmd = exec.Command("jj")
1712+
as.NoError(jjCmd.Run(), "failed to update the index")
1713+
1714+
treefmt(t,
1715+
withArgs("haskell", "foo.txt", "-vv"),
1716+
withConfig(configPath, cfg),
1717+
withNoError(t),
1718+
withStats(t, map[stats.Type]int{
1719+
stats.Traversed: 8,
1720+
stats.Matched: 8,
1721+
stats.Formatted: 1, // we only format foo.txt, which is new to the cache
1722+
stats.Changed: 0,
1723+
}),
1724+
)
1725+
1726+
treefmt(t,
1727+
withArgs("go", "foo.txt"),
1728+
withConfig(configPath, cfg),
1729+
withNoError(t),
1730+
withStats(t, map[stats.Type]int{
1731+
stats.Traversed: 3,
1732+
stats.Matched: 3,
1733+
stats.Formatted: 0,
1734+
stats.Changed: 0,
1735+
}),
1736+
)
1737+
1738+
treefmt(t,
1739+
withArgs("foo.txt"),
1740+
withConfig(configPath, cfg),
1741+
withNoError(t),
1742+
withStats(t, map[stats.Type]int{
1743+
stats.Traversed: 1,
1744+
stats.Matched: 1,
1745+
stats.Formatted: 0,
1746+
stats.Changed: 0,
1747+
}),
1748+
)
1749+
}
1750+
15391751
func TestTreeRootCmd(t *testing.T) {
15401752
as := require.New(t)
15411753

config/config.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/charmbracelet/log"
1818
"github.com/google/shlex"
1919
"github.com/numtide/treefmt/v2/git"
20+
"github.com/numtide/treefmt/v2/jujutsu"
2021
"github.com/numtide/treefmt/v2/walk"
2122
"github.com/spf13/pflag"
2223
"github.com/spf13/viper"
@@ -113,8 +114,8 @@ func SetFlags(fs *pflag.FlagSet) {
113114
fs.String(
114115
"tree-root", "",
115116
"The root directory from which treefmt will start walking the filesystem. "+
116-
"Defaults to the root of the current git worktree. If not in a git repo, defaults to the directory "+
117-
"containing the config file. (env $TREEFMT_TREE_ROOT)",
117+
"Defaults to the root of the current git or jujutsu worktree. If not in a git or jujutsu repo, defaults to the "+
118+
"directory containing the config file. (env $TREEFMT_TREE_ROOT)",
118119
)
119120
fs.String(
120121
"tree-root-cmd", "",
@@ -136,7 +137,7 @@ func SetFlags(fs *pflag.FlagSet) {
136137
fs.String(
137138
"walk", "auto",
138139
"The method used to traverse the files within the tree root. Currently supports "+
139-
"<auto|git|filesystem>. (env $TREEFMT_WALK)",
140+
"<auto|git|jujutsu|filesystem>. (env $TREEFMT_WALK)",
140141
)
141142
fs.StringP(
142143
"working-dir", "C", ".",
@@ -329,6 +330,21 @@ func determineTreeRoot(v *viper.Viper, cfg *Config, logger *log.Logger) error {
329330
}
330331
}
331332

333+
// attempt to resolve with jujutsu
334+
if cfg.TreeRoot == "" && (cfg.Walk == walk.Auto.String() || cfg.Walk == walk.Jujutsu.String()) {
335+
logger.Infof("attempting to resolve tree root using jujutsu: %s", jujutsu.TreeRootCmd)
336+
337+
// attempt to resolve the tree root with jujutsu
338+
cfg.TreeRoot, err = execTreeRootCmd(jujutsu.TreeRootCmd, cfg.WorkingDirectory)
339+
if err != nil && cfg.Walk == walk.Git.String() {
340+
return fmt.Errorf("failed to resolve tree root with jujutsu: %w", err)
341+
}
342+
343+
if err != nil {
344+
logger.Infof("failed to resolve tree root with jujutsu: %v", err)
345+
}
346+
}
347+
332348
if cfg.TreeRoot == "" {
333349
// fallback to the directory containing the config file
334350
logger.Infof(

docs/site/getting-started/configure.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,8 +317,13 @@ If you wish to pass arguments containing quotes, you should use nested quotes e.
317317
If [walk](#walk) is set to `git` and no tree root option has been defined, `tree-root-cmd` will be defaulted to
318318
`git rev-parse --show-toplevel`.
319319

320+
If [walk](#walk) is set to `jujutsu` and no tree root option has been defined, `tree-root-cmd` will be defaulted to
321+
`jj workspace root`.
322+
320323
if [walk](#walk) is set to `auto` (the default), `treefmt` will check if the [working directory](#working-dir) is
321-
inside a git worktree. If it is, `tree-root-cmd` will be defaulted as described above for `git`.
324+
inside a git worktree. If it is, `tree-root-cmd` will be defaulted as described above for `git`. If the [working
325+
directory](#working-dir) is inside a jujutsu worktree the `tree-root-cmd` will be defaulted as described above for
326+
`jujutsu`.
322327

323328
=== "Flag"
324329

@@ -391,7 +396,7 @@ Set the verbosity level of logs:
391396
### `walk`
392397

393398
The method used to traverse the files within the tree root.
394-
Currently, we support 'auto', 'git' or 'filesystem'
399+
Currently, we support 'auto', 'git', 'jujutsu' or 'filesystem'
395400

396401
=== "Flag"
397402

jujutsu/jujutsu.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package jujutsu
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os/exec"
7+
"strings"
8+
)
9+
10+
const TreeRootCmd = "jj workspace root"
11+
12+
func IsInsideWorktree(path string) (bool, error) {
13+
// check if the root is a jujutsu repository
14+
cmd := exec.Command("jj", "workspace", "root")
15+
cmd.Dir = path
16+
17+
err := cmd.Run()
18+
if err != nil {
19+
var exitErr *exec.ExitError
20+
if errors.As(err, &exitErr) && strings.Contains(string(exitErr.Stderr), "There is no jj repo in \".\"") {
21+
return false, nil
22+
}
23+
24+
return false, fmt.Errorf("failed to check if %s is a jujutsu repository: %w", path, err)
25+
}
26+
// is a jujutsu repo
27+
return true, nil
28+
}

nix/packages/treefmt/default.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ in
4646
nativeBuildInputs =
4747
(with pkgs; [
4848
git
49+
jujutsu
4950
installShellFiles
5051
])
5152
++

test/config/jj/config.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[signing]
2+
backend = "none"

test/test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ import (
1414
"golang.org/x/sys/unix"
1515
)
1616

17+
func SetenvXdgConfigDir(t *testing.T) {
18+
t.Helper()
19+
20+
configPath, err := filepath.Abs("../test/config")
21+
if err != nil {
22+
t.Fatalf("failed to get the path to the config directory: %v", err)
23+
}
24+
25+
t.Setenv("XDG_CONFIG_HOME", configPath)
26+
}
27+
1728
func WriteConfig(t *testing.T, path string, cfg *config.Config) {
1829
t.Helper()
1930

0 commit comments

Comments
 (0)