Skip to content

feat: add jujutsu walker #601

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/init/init.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
# verbose = 2

# The method used to traverse the files within the tree root
# Currently, we support 'auto', 'git' or 'filesystem'
# Currently, we support 'auto', 'git', 'jujutsu', or 'filesystem'
# Env $TREEFMT_WALK
# walk = "filesystem"

Expand All @@ -64,4 +64,4 @@ excludes = []
# Controls the order of application when multiple formatters match the same file
# Lower the number, the higher the precedence
# Default is 0
priority = 0
priority = 0
212 changes: 212 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,218 @@ func TestGit(t *testing.T) {
)
}

func TestJujutsu(t *testing.T) {
as := require.New(t)

test.SetenvXdgConfigDir(t)
tempDir := test.TempExamples(t)
configPath := filepath.Join(tempDir, "/treefmt.toml")

test.ChangeWorkDir(t, tempDir)

// basic config
cfg := &config.Config{
FormatterConfigs: map[string]*config.Formatter{
"echo": {
Command: "echo", // will not generate any underlying changes in the file
Includes: []string{"*"},
},
},
}

test.WriteConfig(t, configPath, cfg)

// init a jujutsu repo
jjCmd := exec.Command("jj", "git", "init")
as.NoError(jjCmd.Run(), "failed to init jujutsu repository")

// run treefmt before adding anything to the jj index
// Jujutsu depends on updating the index with a `jj` command. So, until we do
// that, the treefmt should return nothing, since the walker is executed with
// `--ignore-working-copy` which does not update the index.
treefmt(t,
withConfig(configPath, cfg),
withNoError(t),
withStats(t, map[stats.Type]int{
stats.Traversed: 0,
stats.Matched: 0,
stats.Formatted: 0,
stats.Changed: 0,
}),
)

// update jujutsu's index
jjCmd = exec.Command("jj")
as.NoError(jjCmd.Run(), "failed to update the index")

// This is our first pass, since previously the files were not in the index. This should format all files.
treefmt(t,
withConfig(configPath, cfg),
withNoError(t),
withStats(t, map[stats.Type]int{
stats.Traversed: 32,
stats.Matched: 32,
stats.Formatted: 32,
stats.Changed: 0,
}),
)

// create a file which should be in .gitignore
f, err := os.CreateTemp(tempDir, "test-*.txt")
as.NoError(err, "failed to create temp file")

// update jujutsu's index
jjCmd = exec.Command("jj")
as.NoError(jjCmd.Run(), "failed to update the index")

t.Cleanup(func() {
_ = f.Close()
})

treefmt(t,
withConfig(configPath, cfg),
withNoError(t),
withStats(t, map[stats.Type]int{
stats.Traversed: 32,
stats.Matched: 32,
stats.Formatted: 0,
stats.Changed: 0,
}),
)

// remove python directory
as.NoError(os.RemoveAll(filepath.Join(tempDir, "python")), "failed to remove python directory")

// update jujutsu's index
jjCmd = exec.Command("jj")
as.NoError(jjCmd.Run(), "failed to update the index")

// we should traverse and match against fewer files, but no formatting should occur as no formatting signatures
// are impacted
treefmt(t,
withConfig(configPath, cfg),
withNoError(t),
withStats(t, map[stats.Type]int{
stats.Traversed: 29,
stats.Matched: 29,
stats.Formatted: 0,
stats.Changed: 0,
}),
)

// remove nixpkgs.toml from the filesystem but leave it in the index
as.NoError(os.Remove(filepath.Join(tempDir, "nixpkgs.toml")))

// walk with filesystem instead of with jujutsu
// the .jj folder contains 100 additional files
// when added to the 30 we started with (34 minus nixpkgs.toml which we removed from the filesystem), we should
// traverse 130 files.
treefmt(t,
withArgs("--walk", "filesystem"),
withConfig(configPath, cfg),
withNoError(t),
withStats(t, map[stats.Type]int{
stats.Traversed: 130,
stats.Matched: 130,
stats.Formatted: 102, // the echo formatter should only be applied to the new files
stats.Changed: 0,
}),
)

// format specific sub paths
// we should traverse and match against those files, but without any underlying change to their files or their
// formatting config, we will not format them

treefmt(t,
withArgs("go"),
withConfig(configPath, cfg),
withNoError(t),
withStats(t, map[stats.Type]int{
stats.Traversed: 2,
stats.Matched: 2,
stats.Formatted: 0,
stats.Changed: 0,
}),
)

treefmt(t,
withArgs("go", "haskell"),
withConfig(configPath, cfg),
withNoError(t),
withStats(t, map[stats.Type]int{
stats.Traversed: 9,
stats.Matched: 9,
stats.Formatted: 0,
stats.Changed: 0,
}),
)

treefmt(t,
withArgs("-C", tempDir, "go", "haskell", "ruby"),
withConfig(configPath, cfg),
withNoError(t),
withStats(t, map[stats.Type]int{
stats.Traversed: 10,
stats.Matched: 10,
stats.Formatted: 0,
stats.Changed: 0,
}),
)

// try with a bad path
treefmt(t,
withArgs("-C", tempDir, "haskell", "foo"),
withConfig(configPath, cfg),
withError(func(as *require.Assertions, err error) {
as.ErrorContains(err, "foo not found")
}),
)

// try with a path not in the jj index
_, err = os.Create(filepath.Join(tempDir, "foo.txt"))
as.NoError(err)

// update jujutsu's index
jjCmd = exec.Command("jj")
as.NoError(jjCmd.Run(), "failed to update the index")

treefmt(t,
withArgs("haskell", "foo.txt", "-vv"),
withConfig(configPath, cfg),
withNoError(t),
withStats(t, map[stats.Type]int{
stats.Traversed: 8,
stats.Matched: 8,
stats.Formatted: 1, // we only format foo.txt, which is new to the cache
stats.Changed: 0,
}),
)

treefmt(t,
withArgs("go", "foo.txt"),
withConfig(configPath, cfg),
withNoError(t),
withStats(t, map[stats.Type]int{
stats.Traversed: 3,
stats.Matched: 3,
stats.Formatted: 0,
stats.Changed: 0,
}),
)

treefmt(t,
withArgs("foo.txt"),
withConfig(configPath, cfg),
withNoError(t),
withStats(t, map[stats.Type]int{
stats.Traversed: 1,
stats.Matched: 1,
stats.Formatted: 0,
stats.Changed: 0,
}),
)
}

func TestTreeRootCmd(t *testing.T) {
as := require.New(t)

Expand Down
22 changes: 19 additions & 3 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/charmbracelet/log"
"github.com/google/shlex"
"github.com/numtide/treefmt/v2/git"
"github.com/numtide/treefmt/v2/jujutsu"
"github.com/numtide/treefmt/v2/walk"
"github.com/spf13/pflag"
"github.com/spf13/viper"
Expand Down Expand Up @@ -113,8 +114,8 @@ func SetFlags(fs *pflag.FlagSet) {
fs.String(
"tree-root", "",
"The root directory from which treefmt will start walking the filesystem. "+
"Defaults to the root of the current git worktree. If not in a git repo, defaults to the directory "+
"containing the config file. (env $TREEFMT_TREE_ROOT)",
"Defaults to the root of the current git or jujutsu worktree. If not in a git or jujutsu repo, defaults to the "+
"directory containing the config file. (env $TREEFMT_TREE_ROOT)",
)
fs.String(
"tree-root-cmd", "",
Expand All @@ -136,7 +137,7 @@ func SetFlags(fs *pflag.FlagSet) {
fs.String(
"walk", "auto",
"The method used to traverse the files within the tree root. Currently supports "+
"<auto|git|filesystem>. (env $TREEFMT_WALK)",
"<auto|git|jujutsu|filesystem>. (env $TREEFMT_WALK)",
)
fs.StringP(
"working-dir", "C", ".",
Expand Down Expand Up @@ -329,6 +330,21 @@ func determineTreeRoot(v *viper.Viper, cfg *Config, logger *log.Logger) error {
}
}

// attempt to resolve with jujutsu
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If walk == auto, do we first attempt to resolve with git, and then attempt to resolve with jujutsu? That feels silly to me: we should not keep searching after we find a tree route. In other words, guard this with cfg.TreeRoot == "" (as we do below).

I suspect there's a flatter refactor of all this that looks less like the current case with a big default block and is instead:

  1. if no tree root, search with tree root file
  2. if (still) no tree root, search with tree root cmd
  3. if (still) no tree root (and walk is auto or git), search with git
  4. if (still) no tree root (and walk is auto or jujutsu), search with jujutsu
  5. if (still) no tree root, use the directory containing the config file

If you do opt to make this refactor, please split it into a separate initial commit that we could merge first.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, I added cfg.TreeRoot == "" as a guard:

if cfg.TreeRoot == "" && (cfg.Walk == walk.Auto.String() || cfg.Walk == walk.Jujutsu.String()) {
  logger.Infof("attempting to resolve tree root using jujutsu: %s", jujutsu.TreeRootCmd)
  ...

if cfg.TreeRoot == "" && (cfg.Walk == walk.Auto.String() || cfg.Walk == walk.Jujutsu.String()) {
logger.Infof("attempting to resolve tree root using jujutsu: %s", jujutsu.TreeRootCmd)

// attempt to resolve the tree root with jujutsu
cfg.TreeRoot, err = execTreeRootCmd(jujutsu.TreeRootCmd, cfg.WorkingDirectory)
if err != nil && cfg.Walk == walk.Git.String() {
return fmt.Errorf("failed to resolve tree root with jujutsu: %w", err)
}

if err != nil {
logger.Infof("failed to resolve tree root with jujutsu: %v", err)
}
}

if cfg.TreeRoot == "" {
// fallback to the directory containing the config file
logger.Infof(
Expand Down
9 changes: 7 additions & 2 deletions docs/site/getting-started/configure.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,13 @@ If you wish to pass arguments containing quotes, you should use nested quotes e.
If [walk](#walk) is set to `git` and no tree root option has been defined, `tree-root-cmd` will be defaulted to
`git rev-parse --show-toplevel`.

If [walk](#walk) is set to `jujutsu` and no tree root option has been defined, `tree-root-cmd` will be defaulted to
`jj workspace root`.

if [walk](#walk) is set to `auto` (the default), `treefmt` will check if the [working directory](#working-dir) is
inside a git worktree. If it is, `tree-root-cmd` will be defaulted as described above for `git`.
inside a git worktree. If it is, `tree-root-cmd` will be defaulted as described above for `git`. If the [working
directory](#working-dir) is inside a jujutsu worktree the `tree-root-cmd` will be defaulted as described above for
`jujutsu`.

=== "Flag"

Expand Down Expand Up @@ -391,7 +396,7 @@ Set the verbosity level of logs:
### `walk`

The method used to traverse the files within the tree root.
Currently, we support 'auto', 'git' or 'filesystem'
Currently, we support 'auto', 'git', 'jujutsu' or 'filesystem'

=== "Flag"

Expand Down
28 changes: 28 additions & 0 deletions jujutsu/jujutsu.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package jujutsu

import (
"errors"
"fmt"
"os/exec"
"strings"
)

const TreeRootCmd = "jj workspace root"

func IsInsideWorktree(path string) (bool, error) {
// check if the root is a jujutsu repository
cmd := exec.Command("jj", "workspace", "root")
cmd.Dir = path

err := cmd.Run()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && strings.Contains(string(exitErr.Stderr), "There is no jj repo in \".\"") {
return false, nil
}

return false, fmt.Errorf("failed to check if %s is a jujutsu repository: %w", path, err)
}
// is a jujutsu repo
return true, nil
}
1 change: 1 addition & 0 deletions nix/packages/treefmt/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ in
nativeBuildInputs =
(with pkgs; [
git
jujutsu
installShellFiles
])
++
Expand Down
2 changes: 2 additions & 0 deletions test/config/jj/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[signing]
backend = "none"
11 changes: 11 additions & 0 deletions test/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ import (
"golang.org/x/sys/unix"
)

func SetenvXdgConfigDir(t *testing.T) {
t.Helper()

configPath, err := filepath.Abs("../test/config")
if err != nil {
t.Fatalf("failed to get the path to the config directory: %v", err)
}

t.Setenv("XDG_CONFIG_HOME", configPath)
}

func WriteConfig(t *testing.T, path string, cfg *config.Config) {
t.Helper()

Expand Down
Loading