Skip to content

Commit 2dd1b91

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 - adapted config and docs
1 parent 7799c74 commit 2dd1b91

File tree

9 files changed

+310
-11
lines changed

9 files changed

+310
-11
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

config/config.go

Lines changed: 18 additions & 2 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,7 +114,7 @@ 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+
"Defaults to the root of the current git or jujutsu worktree. If not in a git or jujutsu repo, defaults to the directory "+
117118
"containing the config file. (env $TREEFMT_TREE_ROOT)",
118119
)
119120
fs.String(
@@ -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.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
++

walk/jujutsu.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package walk
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"io"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"strconv"
12+
13+
"github.com/charmbracelet/log"
14+
"github.com/numtide/treefmt/v2/jujutsu"
15+
"github.com/numtide/treefmt/v2/stats"
16+
"golang.org/x/sync/errgroup"
17+
)
18+
19+
type JujutsuReader struct {
20+
root string
21+
path string
22+
23+
log *log.Logger
24+
stats *stats.Stats
25+
26+
eg *errgroup.Group
27+
scanner *bufio.Scanner
28+
}
29+
30+
func (j *JujutsuReader) Read(ctx context.Context, files []*File) (n int, err error) {
31+
// ensure we record how many files we traversed
32+
defer func() {
33+
j.stats.Add(stats.Traversed, n)
34+
}()
35+
36+
if j.scanner == nil {
37+
// create a pipe to capture the command output
38+
r, w := io.Pipe()
39+
40+
// create a command which will execute from the specified sub path within root
41+
// --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.
42+
cmd := exec.Command("jj", "file", "list", "--ignore-working-copy")
43+
cmd.Dir = filepath.Join(j.root, j.path)
44+
cmd.Stdout = w
45+
46+
// execute the command in the background
47+
j.eg.Go(func() error {
48+
return w.CloseWithError(cmd.Run())
49+
})
50+
51+
// create a new scanner for reading the output
52+
j.scanner = bufio.NewScanner(r)
53+
}
54+
55+
nextLine := func() (string, error) {
56+
line := j.scanner.Text()
57+
58+
if len(line) == 0 || line[0] != '"' {
59+
return line, nil
60+
}
61+
62+
unquoted, err := strconv.Unquote(line)
63+
if err != nil {
64+
return "", fmt.Errorf("failed to unquote line %s: %w", line, err)
65+
}
66+
67+
return unquoted, nil
68+
}
69+
70+
LOOP:
71+
72+
for n < len(files) {
73+
select {
74+
// exit early if the context was cancelled
75+
case <-ctx.Done():
76+
err = ctx.Err()
77+
if err == nil {
78+
return n, fmt.Errorf("context cancelled: %w", ctx.Err())
79+
}
80+
81+
return n, nil
82+
83+
default:
84+
// read the next file
85+
if j.scanner.Scan() {
86+
entry, err := nextLine()
87+
if err != nil {
88+
return n, err
89+
}
90+
91+
path := filepath.Join(j.root, j.path, entry)
92+
93+
j.log.Debugf("processing file: %s", path)
94+
95+
info, err := os.Lstat(path)
96+
97+
switch {
98+
case os.IsNotExist(err):
99+
// the underlying file might have been removed
100+
j.log.Warnf(
101+
"Path %s is in the worktree but appears to have been removed from the filesystem", path,
102+
)
103+
104+
continue
105+
case err != nil:
106+
return n, fmt.Errorf("failed to stat %s: %w", path, err)
107+
case info.Mode()&os.ModeSymlink == os.ModeSymlink:
108+
// we skip reporting symlinks stored in Jujutsu, they should
109+
// point to local files which we would list anyway.
110+
continue
111+
}
112+
113+
files[n] = &File{
114+
Path: path,
115+
RelPath: filepath.Join(j.path, entry),
116+
Info: info,
117+
}
118+
119+
n++
120+
} else {
121+
// nothing more to read
122+
err = io.EOF
123+
124+
break LOOP
125+
}
126+
}
127+
}
128+
129+
return n, err
130+
}
131+
132+
func (g *JujutsuReader) Close() error {
133+
err := g.eg.Wait()
134+
if err != nil {
135+
return fmt.Errorf("failed to wait for jujutsu command to complete: %w", err)
136+
}
137+
138+
return nil
139+
}
140+
141+
func NewJujutsuReader(
142+
root string,
143+
path string,
144+
statz *stats.Stats,
145+
) (*JujutsuReader, error) {
146+
// check if the root is a jujutsu repository
147+
isJujutsu, err := jujutsu.IsInsideWorktree(root)
148+
if err != nil {
149+
return nil, fmt.Errorf("failed to check if %s is a jujutsu repository: %w", root, err)
150+
}
151+
152+
if !isJujutsu {
153+
return nil, fmt.Errorf("%s is not a jujutsu repository", root)
154+
}
155+
156+
return &JujutsuReader{
157+
root: root,
158+
path: path,
159+
stats: statz,
160+
eg: &errgroup.Group{},
161+
log: log.WithPrefix("walk | jujutsu"),
162+
}, nil
163+
}

walk/jujutsu_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package walk_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"io"
7+
"os/exec"
8+
"testing"
9+
"time"
10+
11+
"github.com/numtide/treefmt/v2/stats"
12+
"github.com/numtide/treefmt/v2/test"
13+
"github.com/numtide/treefmt/v2/walk"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func TestJujutsuReader(t *testing.T) {
18+
as := require.New(t)
19+
20+
tempDir := test.TempExamples(t)
21+
22+
// init a jujutsu repo (disable signing for testing)
23+
cmd := exec.Command("jj", "git", "init", "--config", "signing.backend=none")
24+
cmd.Dir = tempDir
25+
as.NoError(cmd.Run(), "failed to init jujutsu repository")
26+
27+
// read empty worktree
28+
statz := stats.New()
29+
reader, err := walk.NewJujutsuReader(tempDir, "", &statz)
30+
as.NoError(err)
31+
32+
files := make([]*walk.File, 33)
33+
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
34+
n, err := reader.Read(ctx, files)
35+
36+
cancel()
37+
as.Equal(0, n)
38+
as.ErrorIs(err, io.EOF)
39+
40+
// track new files (disable signing for testing), this step is required based on the jujutsu configuration. I prefer `snapshot.auto-track=none()`, which doesn't track new files automatically.
41+
cmd = exec.Command("jj", "file", "track", "--config", "signing.backend=none", ".")
42+
cmd.Dir = tempDir
43+
as.NoError(cmd.Run(), "failed to add everything to the index")
44+
45+
// update jujutsu's index (disable signing for testing)
46+
cmd = exec.Command("jj", "--config", "signing.backend=none")
47+
cmd.Dir = tempDir
48+
as.NoError(cmd.Run(), "failed to update the index")
49+
50+
statz = stats.New()
51+
reader, err = walk.NewJujutsuReader(tempDir, "", &statz)
52+
as.NoError(err)
53+
54+
count := 0
55+
56+
for {
57+
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
58+
59+
files := make([]*walk.File, 8)
60+
n, err := reader.Read(ctx, files)
61+
62+
count += n
63+
64+
cancel()
65+
66+
if errors.Is(err, io.EOF) {
67+
break
68+
}
69+
}
70+
71+
as.Equal(32, count)
72+
as.Equal(32, statz.Value(stats.Traversed))
73+
as.Equal(0, statz.Value(stats.Matched))
74+
as.Equal(0, statz.Value(stats.Formatted))
75+
as.Equal(0, statz.Value(stats.Changed))
76+
}

0 commit comments

Comments
 (0)