feat: support reading paths from stdin

Signed-off-by: Brian McGee <brian@bmcgee.ie>
This commit is contained in:
Brian McGee 2024-01-10 15:45:57 +00:00
parent 4dd4c55ff5
commit 0fbae06f19
Signed by: brianmcgee
GPG Key ID: D49016E76AD1E8C0
11 changed files with 243 additions and 51 deletions

View File

@ -111,6 +111,21 @@
"type": "github" "type": "github"
} }
}, },
"nix-filter": {
"locked": {
"lastModified": 1705332318,
"narHash": "sha256-kcw1yFeJe9N4PjQji9ZeX47jg0p9A0DuU4djKvg1a7I=",
"owner": "numtide",
"repo": "nix-filter",
"rev": "3449dc925982ad46246cfc36469baf66e1b64f17",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "nix-filter",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1707689078, "lastModified": 1707689078,
@ -151,6 +166,7 @@
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"flake-root": "flake-root", "flake-root": "flake-root",
"gomod2nix": "gomod2nix", "gomod2nix": "gomod2nix",
"nix-filter": "nix-filter",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"treefmt-nix": "treefmt-nix" "treefmt-nix": "treefmt-nix"
} }

View File

@ -18,6 +18,8 @@
url = "github:nix-community/gomod2nix"; url = "github:nix-community/gomod2nix";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
nix-filter.url = "github:numtide/nix-filter";
}; };
outputs = inputs @ {flake-parts, ...}: outputs = inputs @ {flake-parts, ...}:

View File

@ -173,7 +173,7 @@ func putEntry(bucket *bolt.Bucket, path string, entry *Entry) error {
// ChangeSet is used to walk a filesystem, starting at root, and outputting any new or changed paths using pathsCh. // ChangeSet is used to walk a filesystem, starting at root, and outputting any new or changed paths using pathsCh.
// It determines if a path is new or has changed by comparing against cache entries. // It determines if a path is new or has changed by comparing against cache entries.
func ChangeSet(ctx context.Context, root string, walkerType walk.Type, pathsCh chan<- string) error { func ChangeSet(ctx context.Context, walker walk.Walker, pathsCh chan<- string) error {
var tx *bolt.Tx var tx *bolt.Tx
var bucket *bolt.Bucket var bucket *bolt.Bucket
var processed int var processed int
@ -185,12 +185,7 @@ func ChangeSet(ctx context.Context, root string, walkerType walk.Type, pathsCh c
} }
}() }()
w, err := walk.New(walkerType, root) return walker.Walk(ctx, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("%w: failed to create walker", err)
}
return w.Walk(ctx, func(path string, info fs.FileInfo, err error) error {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()

View File

@ -6,12 +6,12 @@ import (
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
) )
var Cli = Options{} var Cli = Format{}
type Options struct { type Format struct {
AllowMissingFormatter bool `default:"false" help:"Do not exit with error if a configured formatter is missing."` AllowMissingFormatter bool `default:"false" help:"Do not exit with error if a configured formatter is missing"`
WorkingDirectory kong.ChangeDirFlag `default:"." short:"C" help:"Run as if treefmt was started in the specified working directory instead of the current working directory."` WorkingDirectory kong.ChangeDirFlag `default:"." short:"C" help:"Run as if treefmt was started in the specified working directory instead of the current working directory"`
ClearCache bool `short:"c" help:"Reset the evaluation cache. Use in case the cache is not precise enough."` ClearCache bool `short:"c" help:"Reset the evaluation cache. Use in case the cache is not precise enough"`
ConfigFile string `type:"existingfile" default:"./treefmt.toml"` ConfigFile string `type:"existingfile" default:"./treefmt.toml"`
FailOnChange bool `help:"Exit with error if any changes were made. Useful for CI."` FailOnChange bool `help:"Exit with error if any changes were made. Useful for CI."`
Formatters []string `help:"Specify formatters to apply. Defaults to all formatters."` Formatters []string `help:"Specify formatters to apply. Defaults to all formatters."`
@ -19,17 +19,16 @@ type Options struct {
Walk walk.Type `enum:"auto,git,filesystem" default:"auto" help:"The method used to traverse the files within --tree-root. Currently supports 'auto', 'git' or 'filesystem'."` Walk walk.Type `enum:"auto,git,filesystem" default:"auto" help:"The method used to traverse the files within --tree-root. Currently supports 'auto', 'git' or 'filesystem'."`
Verbosity int `name:"verbose" short:"v" type:"counter" default:"0" env:"LOG_LEVEL" help:"Set the verbosity of logs e.g. -vv."` Verbosity int `name:"verbose" short:"v" type:"counter" default:"0" env:"LOG_LEVEL" help:"Set the verbosity of logs e.g. -vv."`
Format Format `cmd:"" default:"."` Paths []string `name:"paths" arg:"" type:"path" optional:"" help:"Paths to format. Defaults to formatting the whole tree."`
Stdin bool `help:"Format the context passed in via stdin"`
} }
func (c *Options) Configure() { func (f *Format) Configure() {
log.SetReportTimestamp(false) log.SetReportTimestamp(false)
if c.Verbosity == 0 { if f.Verbosity == 0 {
log.SetLevel(log.WarnLevel)
} else if c.Verbosity == 1 {
log.SetLevel(log.InfoLevel) log.SetLevel(log.InfoLevel)
} else if c.Verbosity >= 2 { } else if f.Verbosity > 0 {
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
} }
} }

View File

@ -1,6 +1,7 @@
package cli package cli
import ( import (
"bufio"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@ -10,6 +11,8 @@ import (
"syscall" "syscall"
"time" "time"
"git.numtide.com/numtide/treefmt/internal/walk"
"git.numtide.com/numtide/treefmt/internal/config" "git.numtide.com/numtide/treefmt/internal/config"
"git.numtide.com/numtide/treefmt/internal/cache" "git.numtide.com/numtide/treefmt/internal/cache"
@ -19,8 +22,6 @@ import (
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
) )
type Format struct{}
var ErrFailOnChange = errors.New("unexpected changes detected, --fail-on-change is enabled") var ErrFailOnChange = errors.New("unexpected changes detected, --fail-on-change is enabled")
func (f *Format) Run() error { func (f *Format) Run() error {
@ -201,7 +202,7 @@ func (f *Format) Run() error {
return ErrFailOnChange return ErrFailOnChange
} }
fmt.Printf("%v files changed in %v", changes, time.Now().Sub(start)) fmt.Printf("%v files changed in %v\n", changes, time.Now().Sub(start))
return nil return nil
}) })
@ -235,10 +236,24 @@ func (f *Format) Run() error {
return nil return nil
}) })
eg.Go(func() error { eg.Go(func() (err error) {
err := cache.ChangeSet(ctx, Cli.TreeRoot, Cli.Walk, pathsCh) paths := Cli.Paths
close(pathsCh)
return err if len(paths) == 0 && Cli.Stdin {
// read in all the paths
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
paths = append(paths, scanner.Text())
}
}
walker, err := walk.New(Cli.Walk, Cli.TreeRoot, paths)
if err != nil {
return fmt.Errorf("%w: failed to create walker", err)
}
defer close(pathsCh)
return cache.ChangeSet(ctx, walker, pathsCh)
}) })
// listen for shutdown and call cancel if required // listen for shutdown and call cancel if required

View File

@ -481,3 +481,103 @@ func TestOrderingFormatters(t *testing.T) {
as.NoError(err) as.NoError(err)
as.Contains(string(out), "8 files changed") as.Contains(string(out), "8 files changed")
} }
func TestPathsArg(t *testing.T) {
as := require.New(t)
// capture current cwd, so we can replace it after the test is finished
cwd, err := os.Getwd()
as.NoError(err)
t.Cleanup(func() {
// return to the previous working directory
as.NoError(os.Chdir(cwd))
})
tempDir := test.TempExamples(t)
configPath := filepath.Join(tempDir, "/treefmt.toml")
// change working directory to temp root
as.NoError(os.Chdir(tempDir))
// basic config
cfg := config.Config{
Formatters: map[string]*config.Formatter{
"echo": {
Command: "echo",
Includes: []string{"*"},
},
},
}
test.WriteConfig(t, configPath, cfg)
// without any path args
out, err := cmd(t, "-C", tempDir)
as.NoError(err)
as.Contains(string(out), fmt.Sprintf("%d files changed", 29))
// specify some explicit paths
out, err = cmd(t, "-C", tempDir, "-c", "elm/elm.json", "haskell/Nested/Foo.hs")
as.NoError(err)
as.Contains(string(out), fmt.Sprintf("%d files changed", 2))
// specify a bad path
out, err = cmd(t, "-C", tempDir, "-c", "elm/elm.json", "haskell/Nested/Bar.hs")
as.ErrorContains(err, "no such file or directory")
}
func TestStdIn(t *testing.T) {
as := require.New(t)
// capture current cwd, so we can replace it after the test is finished
cwd, err := os.Getwd()
as.NoError(err)
t.Cleanup(func() {
// return to the previous working directory
as.NoError(os.Chdir(cwd))
})
tempDir := test.TempExamples(t)
configPath := filepath.Join(tempDir, "/treefmt.toml")
// change working directory to temp root
as.NoError(os.Chdir(tempDir))
// basic config
cfg := config.Config{
Formatters: map[string]*config.Formatter{
"echo": {
Command: "echo",
Includes: []string{"*"},
},
},
}
test.WriteConfig(t, configPath, cfg)
// swap out stdin
prevStdIn := os.Stdin
stdin, err := os.CreateTemp("", "stdin")
as.NoError(err)
os.Stdin = stdin
t.Cleanup(func() {
os.Stdin = prevStdIn
_ = os.Remove(stdin.Name())
})
go func() {
_, err := stdin.WriteString(`treefmt.toml
elm/elm.json
go/main.go
`)
as.NoError(err, "failed to write to stdin")
as.NoError(stdin.Sync())
_, _ = stdin.Seek(0, 0)
}()
out, err := cmd(t, "-C", tempDir, "--stdin")
as.NoError(err)
as.Contains(string(out), fmt.Sprintf("%d files changed", 3))
}

View File

@ -2,11 +2,13 @@ package walk
import ( import (
"context" "context"
"os"
"path/filepath" "path/filepath"
) )
type filesystemWalker struct { type filesystemWalker struct {
root string root string
paths []string
} }
func (f filesystemWalker) Root() string { func (f filesystemWalker) Root() string {
@ -14,9 +16,24 @@ func (f filesystemWalker) Root() string {
} }
func (f filesystemWalker) Walk(_ context.Context, fn filepath.WalkFunc) error { func (f filesystemWalker) Walk(_ context.Context, fn filepath.WalkFunc) error {
return filepath.Walk(f.root, fn) if len(f.paths) == 0 {
return filepath.Walk(f.root, fn)
}
for _, path := range f.paths {
info, err := os.Stat(path)
if err = filepath.Walk(path, fn); err != nil {
return err
}
if err = fn(path, info, err); err != nil {
return err
}
}
return nil
} }
func NewFilesystem(root string) (Walker, error) { func NewFilesystem(root string, paths []string) (Walker, error) {
return filesystemWalker{root}, nil return filesystemWalker{root, paths}, nil
} }

View File

@ -2,16 +2,22 @@ package walk
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"github.com/charmbracelet/log"
"github.com/go-git/go-git/v5/plumbing/format/index"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
) )
type gitWalker struct { type gitWalker struct {
root string root string
repo *git.Repository paths []string
repo *git.Repository
} }
func (g *gitWalker) Root() string { func (g *gitWalker) Root() string {
@ -24,28 +30,56 @@ func (g *gitWalker) Walk(ctx context.Context, fn filepath.WalkFunc) error {
return fmt.Errorf("%w: failed to open index", err) return fmt.Errorf("%w: failed to open index", err)
} }
for _, entry := range idx.Entries { if len(g.paths) > 0 {
select { for _, path := range g.paths {
case <-ctx.Done():
return ctx.Err()
default:
path := filepath.Join(g.root, entry.Name)
// stat the file err = filepath.Walk(path, func(path string, info fs.FileInfo, err error) error {
info, err := os.Lstat(path) if info.IsDir() {
if err = fn(path, info, err); err != nil { return nil
}
relPath, err := filepath.Rel(g.root, path)
if err != nil {
return err
}
if _, err = idx.Entry(relPath); errors.Is(err, index.ErrEntryNotFound) {
// we skip this path as it's not staged
log.Debugf("Path not found in git index, skipping: %v, %v", relPath, path)
return nil
}
return fn(path, info, err)
})
if err != nil {
return err return err
} }
}
} else {
for _, entry := range idx.Entries {
select {
case <-ctx.Done():
return ctx.Err()
default:
path := filepath.Join(g.root, entry.Name)
// stat the file
info, err := os.Lstat(path)
if err = fn(path, info, err); err != nil {
return err
}
}
} }
} }
return nil return nil
} }
func NewGit(root string) (Walker, error) { func NewGit(root string, paths []string) (Walker, error) {
repo, err := git.PlainOpen(root) repo, err := git.PlainOpen(root)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: failed to open git repo", err) return nil, fmt.Errorf("%w: failed to open git repo", err)
} }
return &gitWalker{root, repo}, nil return &gitWalker{root, paths, repo}, nil
} }

View File

@ -19,24 +19,24 @@ type Walker interface {
Walk(ctx context.Context, fn filepath.WalkFunc) error Walk(ctx context.Context, fn filepath.WalkFunc) error
} }
func New(walkerType Type, root string) (Walker, error) { func New(walkerType Type, root string, paths []string) (Walker, error) {
switch walkerType { switch walkerType {
case Git: case Git:
return NewGit(root) return NewGit(root, paths)
case Auto: case Auto:
return Detect(root) return Detect(root, paths)
case Filesystem: case Filesystem:
return NewFilesystem(root) return NewFilesystem(root, paths)
default: default:
return nil, fmt.Errorf("unknown walker type: %v", walkerType) return nil, fmt.Errorf("unknown walker type: %v", walkerType)
} }
} }
func Detect(root string) (Walker, error) { func Detect(root string, paths []string) (Walker, error) {
// for now, we keep it simple and try git first, filesystem second // for now, we keep it simple and try git first, filesystem second
w, err := NewGit(root) w, err := NewGit(root, paths)
if err == nil { if err == nil {
return w, err return w, err
} }
return NewFilesystem(root) return NewFilesystem(root, paths)
} }

View File

@ -18,7 +18,14 @@
# ensure we are using the same version of go to build with # ensure we are using the same version of go to build with
inherit (pkgs) go; inherit (pkgs) go;
src = ../.; src = let
filter = inputs.nix-filter.lib;
in
filter {
root = ../.;
exclude = [./nix];
};
modules = ../gomod2nix.toml; modules = ../gomod2nix.toml;
ldflags = [ ldflags = [

View File

@ -2,11 +2,18 @@
imports = [ imports = [
inputs.treefmt-nix.flakeModule inputs.treefmt-nix.flakeModule
]; ];
perSystem = {config, ...}: { perSystem = {
config,
self',
...
}: {
treefmt.config = { treefmt.config = {
inherit (config.flake-root) projectRootFile; inherit (config.flake-root) projectRootFile;
flakeCheck = true; flakeCheck = true;
flakeFormatter = true; flakeFormatter = true;
package = self'.packages.default;
programs = { programs = {
alejandra.enable = true; alejandra.enable = true;
deadnix.enable = true; deadnix.enable = true;