Repository: numtide/treefmt
Pull request #601 (2025-06-19 13:45:10)

feat: add jujutsu walker
This PR introduces a new Jujutsu walker to numtide/treefmt, modeled after the existing Git walker but tailored to Jujutsu repositories. Key differences include:

- The list command does not update the index automatically, so untracked files must be explicitly added via `jj` commands.

This addition enables proper handling of Jujutsu ignore rules without requiring co-located Git repos, improving integration and file traversal accuracy for Jujutsu users.

I lack Go experience and primarily copied, and adapted the existing Git walker by adjusting commands. I would welcome guidance or collaboration to develop a more idiomatic and robust implementation.



Patch:
diff --git a/cmd/init/init.toml b/cmd/init/init.toml
index d0e29b1..375686b 100644
--- a/cmd/init/init.toml
+++ b/cmd/init/init.toml
@@ -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"
 
@@ -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
\ No newline at end of file
+priority = 0
diff --git a/config/config.go b/config/config.go
index 4cb2d5e..b3ce635 100644
--- a/config/config.go
+++ b/config/config.go
@@ -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"
@@ -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", "",
@@ -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", ".",
@@ -329,6 +330,21 @@ func determineTreeRoot(v *viper.Viper, cfg *Config, logger *log.Logger) error {
 			}
 		}
 
+		// attempt to resolve with jujutsu
+		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(
diff --git a/docs/site/getting-started/configure.md b/docs/site/getting-started/configure.md
index 844cb0f..a64fffd 100644
--- a/docs/site/getting-started/configure.md
+++ b/docs/site/getting-started/configure.md
@@ -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"
 
@@ -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"
 
diff --git a/jujutsu/jujutsu.go b/jujutsu/jujutsu.go
new file mode 100644
index 0000000..e68c7bb
--- /dev/null
+++ b/jujutsu/jujutsu.go
@@ -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
+}
diff --git a/nix/packages/treefmt/default.nix b/nix/packages/treefmt/default.nix
index 2db73ee..6fdab2b 100644
--- a/nix/packages/treefmt/default.nix
+++ b/nix/packages/treefmt/default.nix
@@ -46,6 +46,7 @@ in
     nativeBuildInputs =
       (with pkgs; [
         git
+        jujutsu
         installShellFiles
       ])
       ++
diff --git a/walk/jujutsu.go b/walk/jujutsu.go
new file mode 100644
index 0000000..8bdcf37
--- /dev/null
+++ b/walk/jujutsu.go
@@ -0,0 +1,159 @@
+package walk
+
+import (
+	"bufio"
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"path/filepath"
+
+	"github.com/charmbracelet/log"
+	"github.com/numtide/treefmt/v2/jujutsu"
+	"github.com/numtide/treefmt/v2/stats"
+	"golang.org/x/sync/errgroup"
+)
+
+type JujutsuReader struct {
+	root string
+	path string
+
+	log   *log.Logger
+	stats *stats.Stats
+
+	eg      *errgroup.Group
+	scanner *bufio.Scanner
+}
+
+func (j *JujutsuReader) Read(ctx context.Context, files []*File) (n int, err error) {
+	// ensure we record how many files we traversed
+	defer func() {
+		j.stats.Add(stats.Traversed, n)
+	}()
+
+	if j.scanner == nil {
+		// create a pipe to capture the command output
+		r, w := io.Pipe()
+
+		// create a command which will execute from root
+		// --ignore-working-copy: Don't snapshot the working copy, and don't update it. This prevents that the user has to
+		// enter a password for singning the commit. New files also won't be added to the index and not displayed in the
+		// output.
+		// Add the subpath as a fileset displaying only files matching this prefix. If
+		// the subpath is empty ignore it since it interferese with command
+		args := []string{"file", "list", "--ignore-working-copy"}
+		if j.path != "" {
+			args = append(args, j.path)
+		}
+
+		cmd := exec.Command("jj", args...)
+		cmd.Dir = j.root
+		cmd.Stdout = w
+
+		// execute the command in the background
+		j.eg.Go(func() error {
+			return w.CloseWithError(cmd.Run())
+		})
+
+		// create a new scanner for reading the output
+		j.scanner = bufio.NewScanner(r)
+	}
+
+	nextLine := func() string {
+		line := j.scanner.Text()
+
+		return line
+	}
+
+LOOP:
+
+	for n < len(files) {
+		select {
+		// exit early if the context was cancelled
+		case <-ctx.Done():
+			err = ctx.Err()
+			if err == nil {
+				return n, fmt.Errorf("context cancelled: %w", ctx.Err())
+			}
+
+			return n, nil
+
+		default:
+			// read the next file
+			if j.scanner.Scan() {
+				entry := nextLine()
+
+				path := filepath.Join(j.root, entry)
+
+				j.log.Debugf("processing file: %s", path)
+
+				info, err := os.Lstat(path)
+
+				switch {
+				case os.IsNotExist(err):
+					// the underlying file might have been removed
+					j.log.Warnf(
+						"Path %s is in the worktree but appears to have been removed from the filesystem", path,
+					)
+
+					continue
+				case err != nil:
+					return n, fmt.Errorf("failed to stat %s: %w", path, err)
+				case info.Mode()&os.ModeSymlink == os.ModeSymlink:
+					// we skip reporting symlinks stored in Jujutsu, they should
+					// point to local files which we would list anyway.
+					continue
+				}
+
+				files[n] = &File{
+					Path:    path,
+					RelPath: entry,
+					Info:    info,
+				}
+
+				n++
+			} else {
+				// nothing more to read
+				err = io.EOF
+
+				break LOOP
+			}
+		}
+	}
+
+	return n, err
+}
+
+func (j *JujutsuReader) Close() error {
+	err := j.eg.Wait()
+	if err != nil {
+		return fmt.Errorf("failed to wait for jujutsu command to complete: %w", err)
+	}
+
+	return nil
+}
+
+func NewJujutsuReader(
+	root string,
+	path string,
+	statz *stats.Stats,
+) (*JujutsuReader, error) {
+	// check if the root is a jujutsu repository
+	isJujutsu, err := jujutsu.IsInsideWorktree(root)
+	if err != nil {
+		return nil, fmt.Errorf("failed to check if %s is a jujutsu repository: %w", root, err)
+	}
+
+	if !isJujutsu {
+		return nil, fmt.Errorf("%s is not a jujutsu repository", root)
+	}
+
+	return &JujutsuReader{
+		root:  root,
+		path:  path,
+		stats: statz,
+		eg:    &errgroup.Group{},
+		log:   log.WithPrefix("walk | jujutsu"),
+	}, nil
+}
diff --git a/walk/type_enum.go b/walk/type_enum.go
index 4550804..8cec561 100644
--- a/walk/type_enum.go
+++ b/walk/type_enum.go
@@ -7,11 +7,11 @@ import (
 	"strings"
 )
 
-const _TypeName = "autostdinfilesystemgit"
+const _TypeName = "autostdinfilesystemgitjujutsu"
 
-var _TypeIndex = [...]uint8{0, 4, 9, 19, 22}
+var _TypeIndex = [...]uint8{0, 4, 9, 19, 22, 29}
 
-const _TypeLowerName = "autostdinfilesystemgit"
+const _TypeLowerName = "autostdinfilesystemgitjujutsu"
 
 func (i Type) String() string {
 	if i < 0 || i >= Type(len(_TypeIndex)-1) {
@@ -28,9 +28,10 @@ func _TypeNoOp() {
 	_ = x[Stdin-(1)]
 	_ = x[Filesystem-(2)]
 	_ = x[Git-(3)]
+	_ = x[Jujutsu-(4)]
 }
 
-var _TypeValues = []Type{Auto, Stdin, Filesystem, Git}
+var _TypeValues = []Type{Auto, Stdin, Filesystem, Git, Jujutsu}
 
 var _TypeNameToValueMap = map[string]Type{
 	_TypeName[0:4]:        Auto,
@@ -41,6 +42,8 @@ var _TypeNameToValueMap = map[string]Type{
 	_TypeLowerName[9:19]:  Filesystem,
 	_TypeName[19:22]:      Git,
 	_TypeLowerName[19:22]: Git,
+	_TypeName[22:29]:      Jujutsu,
+	_TypeLowerName[22:29]: Jujutsu,
 }
 
 var _TypeNames = []string{
@@ -48,6 +51,7 @@ var _TypeNames = []string{
 	_TypeName[4:9],
 	_TypeName[9:19],
 	_TypeName[19:22],
+	_TypeName[22:29],
 }
 
 // TypeString retrieves an enum value from the enum constants string name.
diff --git a/walk/walk.go b/walk/walk.go
index fdb73af..195e678 100644
--- a/walk/walk.go
+++ b/walk/walk.go
@@ -25,6 +25,7 @@ const (
 	Stdin
 	Filesystem
 	Git
+	Jujutsu
 
 	BatchSize = 1024
 )
@@ -205,10 +206,13 @@ func NewReader(
 
 	switch walkType {
 	case Auto:
-		// for now, we keep it simple and try git first, filesystem second
+		// for now, we keep it simple and try git first, jujutsu second, and filesystem last
 		reader, err = NewReader(Git, root, path, db, statz)
 		if err != nil {
-			reader, err = NewReader(Filesystem, root, path, db, statz)
+			reader, err = NewReader(Jujutsu, root, path, db, statz)
+			if err != nil {
+				reader, err = NewReader(Filesystem, root, path, db, statz)
+			}
 		}
 
 		return reader, err
@@ -218,6 +222,8 @@ func NewReader(
 		reader = NewFilesystemReader(root, path, statz, BatchSize)
 	case Git:
 		reader, err = NewGitReader(root, path, statz)
+	case Jujutsu:
+		reader, err = NewJujutsuReader(root, path, statz)
 
 	default:
 		return nil, fmt.Errorf("unknown walk type: %v", walkType)


Test patch:
diff --git a/cmd/root_test.go b/cmd/root_test.go
index 766c74b..97c6b7c 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -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)
 
diff --git a/test/config/jj/config.toml b/test/config/jj/config.toml
new file mode 100644
index 0000000..4b55649
--- /dev/null
+++ b/test/config/jj/config.toml
@@ -0,0 +1,2 @@
+[signing]
+backend = "none"
diff --git a/test/test.go b/test/test.go
index 957fbd1..65f2664 100644
--- a/test/test.go
+++ b/test/test.go
@@ -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()
 
diff --git a/walk/jujutsu_test.go b/walk/jujutsu_test.go
new file mode 100644
index 0000000..abe777f
--- /dev/null
+++ b/walk/jujutsu_test.go
@@ -0,0 +1,75 @@
+package walk_test
+
+import (
+	"context"
+	"errors"
+	"io"
+	"os/exec"
+	"testing"
+	"time"
+
+	"github.com/numtide/treefmt/v2/stats"
+	"github.com/numtide/treefmt/v2/test"
+	"github.com/numtide/treefmt/v2/walk"
+	"github.com/stretchr/testify/require"
+)
+
+func TestJujutsuReader(t *testing.T) {
+	as := require.New(t)
+
+	test.SetenvXdgConfigDir(t)
+	tempDir := test.TempExamples(t)
+
+	// init a jujutsu repo
+	cmd := exec.Command("jj", "git", "init")
+	cmd.Dir = tempDir
+	as.NoError(cmd.Run(), "failed to init jujutsu repository")
+
+	// read empty worktree
+	statz := stats.New()
+	reader, err := walk.NewJujutsuReader(tempDir, "", &statz)
+	as.NoError(err)
+
+	files := make([]*walk.File, 33) // The number of files in `test/examples` used for testing
+	ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
+	n, err := reader.Read(ctx, files)
+
+	// Jujutsu depends on updating the index with a `jj` command. So, until we do
+	// that, the walker should return nothing, since the walker is executed with
+	// `--ignore-working-copy` which does not update the index.
+	cancel()
+	as.Equal(0, n)
+	as.ErrorIs(err, io.EOF)
+
+	// update jujutsu's index
+	cmd = exec.Command("jj")
+	cmd.Dir = tempDir
+	as.NoError(cmd.Run(), "failed to update the index")
+
+	statz = stats.New()
+	reader, err = walk.NewJujutsuReader(tempDir, "", &statz)
+	as.NoError(err)
+
+	count := 0
+
+	for {
+		ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
+
+		files := make([]*walk.File, 8)
+		n, err := reader.Read(ctx, files)
+
+		count += n
+
+		cancel()
+
+		if errors.Is(err, io.EOF) {
+			break
+		}
+	}
+
+	as.Equal(32, count)
+	as.Equal(32, statz.Value(stats.Traversed))
+	as.Equal(0, statz.Value(stats.Matched))
+	as.Equal(0, statz.Value(stats.Formatted))
+	as.Equal(0, statz.Value(stats.Changed))
+}