From 64d3ceec13bd5f159e72776cef9daba6e366e654 Mon Sep 17 00:00:00 2001 From: Dana Woodman Date: Mon, 19 Feb 2024 11:31:27 -0800 Subject: [PATCH] Big rework and expanded test suite --- Makefile | 38 +++++++--- README.md | 2 +- cmd/cng/main.go | 16 ++--- internal/domain/skip.go | 50 +++++++++++++ internal/domain/skip_test.go | 102 ++++++++++++++++++++++++++ internal/types.go | 22 ------ internal/watch.go | 134 ++++++++++++++++------------------- test/e2e_test.go | 51 ++++++++++--- 8 files changed, 293 insertions(+), 122 deletions(-) create mode 100644 internal/domain/skip.go create mode 100644 internal/domain/skip_test.go delete mode 100644 internal/types.go diff --git a/Makefile b/Makefile index 6420408..7c64396 100644 --- a/Makefile +++ b/Makefile @@ -1,25 +1,47 @@ -.PHONY: dev build install test watch-test +#------------------------------------------------------------------------------ +# TESTS +#------------------------------------------------------------------------------ -dev: - @cng -ik '**/*.go' -- make build +.PHONY: test +test: + @make -j test-unit test-e2e -# @cng -ik '**/*.go' -- go run ./cmd/cng $(ARGS) +.PHONY: test-e2e +test-e2e: + @go test -v ./test/... -test: - @go test -v ./... +.PHONY: test-unit +test-unit: + @go test -v ./internal/... +.PHONY: watch-test watch-test: - @cng -ik '**/*.go' -- make install test + @make -j watch-unit-test watch-e2e-test + +.PHONY: watch-unit-test +watch-unit-test: + @cng -ik -e 'test' '**/*.go' -- make test-unit + +.PHONY: watch-e2e-test +watch-e2e-test: + @cng -ik '**/*.go' -- make install test-e2e + +#------------------------------------------------------------------------------ +# BUILDING +#------------------------------------------------------------------------------ +.PHONY: build build: @CGO_ENABLED=0 go build -a -gcflags=all="-l -B" -ldflags="-s -w" -o bin/cng ./cmd/cng @echo "🎉 cng built to bin/cng" +.PHONY: install install: @go install ./cmd/cng @echo "🎉 cng installed to: $(shell which cng)" +.PHONY: watch-install watch-install: @cng -ik '**/*.go' -- make install -.DEFAULT_GOAL := dev \ No newline at end of file +.DEFAULT_GOAL := watch-test \ No newline at end of file diff --git a/README.md b/README.md index 00f1857..98c936c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ go install github.com/danawoodman/cng cng -i -k '**/*.go' 'templates/**/*.html' -- go run ./cmd/myapp # Run tests when your source or tests change: -cng 'app/**/*.tsx?' '**/*.test.ts' -- npm test +cng 'app/**/*.{ts,tsx}' '**/*.test.ts' -- npm test # Wait 500ms before running the command: cng -d 500 '*.md' -- echo "changed!" diff --git a/cmd/cng/main.go b/cmd/cng/main.go index e4eedd9..4ceef15 100644 --- a/cmd/cng/main.go +++ b/cmd/cng/main.go @@ -71,14 +71,14 @@ func execute(cmd *cobra.Command, args []string) { watchedPaths := args[:cmdIndex] - internal.NewWatcher(&internal.WatcherConfig{ - Command: cmdToRun, - Paths: watchedPaths, - Verbose: verbose, - Initial: initial, - Kill: kill, - Exclude: exclude, - Delay: delay, + internal.NewWatcher(internal.WatcherConfig{ + Command: cmdToRun, + ExcludePaths: watchedPaths, + Verbose: verbose, + Initial: initial, + Kill: kill, + Exclude: exclude, + Delay: delay, }).Start() } diff --git a/internal/domain/skip.go b/internal/domain/skip.go new file mode 100644 index 0000000..dfad37a --- /dev/null +++ b/internal/domain/skip.go @@ -0,0 +1,50 @@ +package domain + +import ( + "fmt" + "path/filepath" + + "github.com/bmatcuk/doublestar/v4" +) + +type Skipper interface { + ShouldExclude(path string) bool +} + +type skipper struct { + workDir string + exclude []string +} + +func NewSkipper(workDir string, exclude []string) Skipper { + return skipper{workDir: workDir, exclude: exclude} +} + +// shouldExclude returns true if the given path should be excluded from +// triggering a command run based on the `-e, --exclude` flag. +func (s skipper) ShouldExclude(path string) bool { + // skip common things like .git and node_modules dirs + // todo: make this configurable + ignores := []string{".git", "node_modules"} + for _, ignore := range ignores { + if matches, _ := doublestar.PathMatch(fmt.Sprintf("**/%s/**", ignore), path); matches { + return true + } + } + + // fmt.Println("EXCLUDE:", s.exclude) + // fmt.Println("PATH:", path) + + // check if the path matches any of the exclude patterns + for _, pattern := range s.exclude { + // s.log("Checking exclude pattern", "pattern", pattern, "path", path) + expandedPattern := filepath.Join(s.workDir, pattern) + // fmt.Println("EXPANDED:", expandedPattern) + if matches, _ := doublestar.PathMatch(expandedPattern, path); matches { + // s.log("File in exclude path, skipping", "exclude", pattern) + return true + } + } + + return false +} diff --git a/internal/domain/skip_test.go b/internal/domain/skip_test.go new file mode 100644 index 0000000..13e6302 --- /dev/null +++ b/internal/domain/skip_test.go @@ -0,0 +1,102 @@ +package domain_test + +import ( + "path/filepath" + "testing" + + "github.com/danawoodman/cng/internal/domain" + "github.com/stretchr/testify/assert" +) + +func Test(t *testing.T) { + tests := []struct { + name string + workDir string + exclusions []string + expected map[string]bool + }{ + { + name: "ignore common directories", + workDir: "/foo", + exclusions: []string{"**/*.txt"}, + expected: map[string]bool{ + "/foo/.git/test.txt": true, + "/foo/bar/.git/test.txt": true, + "/foo/node_modules/test.txt": true, + "/foo/bar/node_modules/test.txt": true, + }, + }, + { + name: "absolute paths", + workDir: "/foo", + exclusions: []string{"**/*.txt"}, + expected: map[string]bool{ + "/foo/bar/baz/test.txt": true, + "/biz/bang/bop/test.md": false, + }, + }, + { + name: "relative paths", + workDir: "/foo/bar", + exclusions: []string{"**/*.txt"}, + expected: map[string]bool{ + "/test.txt": false, + "/foo/test.txt": false, + "/foo/bar/test.txt": true, + "/test.md": false, + "/foo/test.md": false, + "/foo/bar/test.md": false, + }, + }, + { + name: "current dir", + workDir: "/foo", + exclusions: []string{"*.txt"}, + expected: map[string]bool{ + "/foo/test.txt": true, + "/foo/foo.txt": true, + "/test.txt": false, + "/test.md": false, + "/foo/test.md": false, + }, + }, + { + name: "fragment pattern", + workDir: "/", + exclusions: []string{"foo_*.txt"}, + expected: map[string]bool{ + "/test.txt": false, + "/foo.txt": false, + "/foo_bar.txt": true, + "/foo_1.txt": true, + }, + }, + { + name: "optional ending", + workDir: "/foo", + exclusions: []string{"*.{js,jsx}"}, + expected: map[string]bool{ + "/foo/a.js": true, + "/foo/b.jsx": true, + "/foo/c.j": false, + "/foo/c.jsxx": false, + }, + }, + // todo: test windows \ paths + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var exclusions []string + for _, e := range test.exclusions { + exclusions = append(exclusions, filepath.Join(test.workDir, e)) + } + s := domain.NewSkipper(test.workDir, exclusions) + for path, val := range test.expected { + p := filepath.Join(test.workDir, path) + assert.Equal(t, val, s.ShouldExclude(p), "exclude patterns %v should skip path '%s' but didn't", test.exclusions, path) + } + }) + } +} diff --git a/internal/types.go b/internal/types.go deleted file mode 100644 index 2cc7c1a..0000000 --- a/internal/types.go +++ /dev/null @@ -1,22 +0,0 @@ -package internal - -import ( - "os/exec" - "time" -) - -type WatcherConfig struct { - Paths []string - Command []string - Initial bool - Verbose bool - Kill bool - Exclude []string - Delay int -} - -type Watcher struct { - config *WatcherConfig - cmd *exec.Cmd - lastCmdStart time.Time -} diff --git a/internal/watch.go b/internal/watch.go index 06ef849..059d555 100644 --- a/internal/watch.go +++ b/internal/watch.go @@ -1,17 +1,16 @@ package internal import ( - "fmt" "os" "os/exec" "os/signal" "path/filepath" - "strings" "syscall" "time" "github.com/bmatcuk/doublestar/v4" "github.com/charmbracelet/log" + "github.com/danawoodman/cng/internal/domain" "github.com/fsnotify/fsnotify" ) @@ -21,16 +20,53 @@ var logger = log.NewWithOptions(os.Stderr, log.Options{ // todo: inject logging using WithLogger -func NewWatcher(config *WatcherConfig) *Watcher { +type WatcherConfig struct { + ExcludePaths []string + Command []string + Initial bool + Verbose bool + Kill bool + Exclude []string + Delay int +} + +type Watcher struct { + config WatcherConfig + cmd *exec.Cmd + lastCmdStart time.Time + log func(msg string, args ...interface{}) + skipper domain.Skipper + workDir string +} + +func NewWatcher(config WatcherConfig) Watcher { if config.Verbose { logger.Info("Starting watcher with config:", "config", config) } - return &Watcher{config: config} + + workDir, err := os.Getwd() + if err != nil { + panic("Could not get working directory: " + err.Error()) + } + + return Watcher{ + config: config, + // todo: make this injectable + workDir: workDir, + skipper: domain.NewSkipper(workDir, config.Exclude), + log: func(msg string, args ...interface{}) { + if config.Verbose { + logger.Info(msg, args...) + } + }, + } } -func (w *Watcher) Start() { +func (w Watcher) Start() { + // fmt.Println("WORKING DIRECTORY:", wd) + w.log("Command to run:", "cmd", w.config.Command) - w.log("Watched paths:", "paths", w.config.Paths) + w.log("Watched paths:", "paths", w.config.ExcludePaths) watcher, err := fsnotify.NewWatcher() if err != nil { @@ -38,40 +74,25 @@ func (w *Watcher) Start() { } defer watcher.Close() - w.log("Adding watched paths:", "paths", w.config.Paths) - for _, pattern := range w.config.Paths { - // todo: validate patterns - if strings.HasPrefix(pattern, ".") || strings.HasPrefix(pattern, "*") { - dir, err := os.Getwd() - if err != nil { - log.Fatal(err) - } - w.log("Expanding path to current dir", "path", pattern, "dir", dir) - w.log("Adding current dir to watcher", "dir", dir) - if err := watcher.Add(dir); err != nil { - w.exit("Could not watch directory:", "dir", dir, " error:", err) - } - pattern = filepath.Join(dir, pattern) - } else { - // if path starts with . or *, expand to current dir: - // todo: this is really dumb... - dir := filepath.Dir(pattern) - if dir != "" { - w.log("Adding dir to watcher", "dir", dir) - if err := watcher.Add(dir); err != nil { - w.exit("Could not watch dir", dir, " error:", err) - } - } + w.log("Adding watched paths:", "paths", w.config.ExcludePaths) + for _, pattern := range w.config.ExcludePaths { + expandedPath := filepath.Join(w.workDir, pattern) + // fmt.Println("EXPANDED PATH:", expandedPath) + + rootDir, _ := doublestar.SplitPattern(expandedPath) + // fmt.Println("ROOT DIR:", rootDir) + + if err := watcher.Add(rootDir); err != nil { + w.exit("Could not watch root directory:", "dir", rootDir, " error:", err) } - matches, err := doublestar.FilepathGlob(pattern) + matches, err := doublestar.FilepathGlob(expandedPath) if err != nil { w.exit("Could not watch glob pattern", pattern, " error: ", err) } w.log("Glob matches", "pattern", pattern, "matches", matches) for _, path := range matches { - w.log("Watching", "path", path) if err := watcher.Add(path); err != nil { @@ -112,7 +133,7 @@ func (w *Watcher) Start() { case event := <-watcher.Events: w.log("Detected fsnotify event", "op", event.Op, "name", event.Name) - if w.shouldExclude(event.Name) { + if w.skipper.ShouldExclude(event.Name) { w.log("File in exclude path, skipping", "path", event.Name) continue } @@ -128,7 +149,10 @@ func (w *Watcher) Start() { continue } - if event.Op&fsnotify.Create == fsnotify.Create { + if event.Op&fsnotify.Create == fsnotify.Create { // || event.Op&fsnotify.Remove == fsnotify.Remove { + // Attempt to remove in case it's deleted + // watcher.Remove(event.Name) + // Check if the created item is a directory; if so, add it and its contents to the watcher w.log("File created, adding to watcher", "path", event.Name) fileInfo, err := os.Stat(event.Name) @@ -164,7 +188,7 @@ func (w *Watcher) Start() { <-done } -func (w *Watcher) runCmd() { +func (w Watcher) runCmd() { w.log("Running command...") cmd := exec.Command(w.config.Command[0], w.config.Command[1:]...) cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} @@ -187,7 +211,7 @@ func (w *Watcher) runCmd() { // the child processes of cmd which, in the case of something like a // web server, would mean that we can't re-bind to the given port. // We then wait for the task to exit cleanly before continuing. -func (w *Watcher) kill() { +func (w Watcher) kill() { if w.cmd == nil { return } @@ -199,47 +223,13 @@ func (w *Watcher) kill() { w.cmd.Wait() } -// shouldExclude returns true if the given path should be excluded from -// triggering a command run based on the `-e, --exclude` flag. -func (w *Watcher) shouldExclude(path string) bool { - // skip common things like .git and node_modules dirs - dir := filepath.Dir(path) - if dir != "" { - // todo: make this configurable - ignores := []string{".git", "node_modules"} - for _, ignore := range ignores { - if matches, _ := doublestar.Match(fmt.Sprintf("**/%s/**", ignore), path); matches { - return true - } - } - } - - // check if the path matches any of the exclude patterns - for _, pattern := range w.config.Exclude { - w.log("Checking exclude pattern", "pattern", pattern, "path", path) - if matches, _ := doublestar.Match(pattern, path); matches { - w.log("File in exclude path, skipping", "exclude", pattern) - return true - } - } - - return false -} - // exit logs a fatal message and exits the program because of // some invalid condition. -func (w *Watcher) exit(msg string, args ...interface{}) { +func (w Watcher) exit(msg string, args ...interface{}) { logger.Fatal(msg, args...) } -// log logs a message if verbose mode is enabled. -func (w *Watcher) log(msg string, args ...interface{}) { - if w.config.Verbose { - logger.Info(msg, args...) - } -} - -func (w *Watcher) addFiles(watcher *fsnotify.Watcher, rootPath string) { +func (w Watcher) addFiles(watcher *fsnotify.Watcher, rootPath string) { w.log("Adding files in directory to watcher", "path", rootPath) err := filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error { if err != nil { diff --git a/test/e2e_test.go b/test/e2e_test.go index f872e41..813d814 100644 --- a/test/e2e_test.go +++ b/test/e2e_test.go @@ -2,7 +2,6 @@ package test import ( "bytes" - "fmt" "io" "os" "os/exec" @@ -49,6 +48,16 @@ func TestCng(t *testing.T) { }, stdout: "hello\n", }, + { + name: "ignores default excluded dirs", + pattern: "*.txt", + exclude: "*.md", + steps: func(write func(string)) { + write(".git/foo.txt") + write("node_modules/foo.txt") + }, + stdout: "", + }, // todo: should report helpful error if missing pattern // { // name: "adds renamed files", @@ -65,12 +74,18 @@ func TestCng(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - t.Parallel() + // t.Parallel() if test.skip { t.Skip() } dir := t.TempDir() + // fmt.Println("TEMP DIR:", dir) + err := os.Chdir(dir) + assert.NoError(t, err) + // wd, err := os.Getwd() + // assert.NoError(t, err) + // fmt.Println("WORK DIR:", wd) var stdoutBuf, stderrBuf bytes.Buffer conf := conf{ @@ -84,12 +99,12 @@ func TestCng(t *testing.T) { } // t.Logf("CONF: %+v", conf) cmd := command(t, &stdoutBuf, &stderrBuf, conf) - err := cmd.Start() + err = cmd.Start() assert.NoError(t, err) // wait for the process to start // anything less than about 100ms and the process won't have time to start - time.Sleep(200 * time.Millisecond) + time.Sleep(300 * time.Millisecond) if test.steps != nil { test.steps(func(path string) { @@ -97,7 +112,14 @@ func TestCng(t *testing.T) { }) } - time.Sleep(100 * time.Millisecond) + // List all files in dir: + // files, err := os.ReadDir(dir) + // assert.NoError(t, err) + // for _, file := range files { + // fmt.Println("FILE:", file.Name()) + // } + + time.Sleep(300 * time.Millisecond) // Send SIGINT to the process err = cmd.Process.Signal(os.Interrupt) @@ -135,13 +157,20 @@ func write(t *testing.T, dir, path, content string, waitMs int) /**os.File*/ { t.Helper() var f *os.File + fullPath := filepath.Join(dir, path) + fullDir := filepath.Dir(fullPath) + fileName := filepath.Base(fullPath) + // Create the file if it doesn't exist yet: - _, err := os.Stat(filepath.Join(dir, path)) + _, err := os.Stat(path) if os.IsNotExist(err) { - f, err = os.CreateTemp(dir, path) + // recursively create the file's parent directories: + err = os.MkdirAll(fullDir, 0o755) + assert.NoError(t, err) + f, err = os.CreateTemp(fullDir, fileName) assert.NoError(t, err) } else { - f, err = os.OpenFile(filepath.Join(dir, path), os.O_RDWR, 0o644) + f, err = os.OpenFile(fullPath, os.O_RDWR, 0o644) assert.NoError(t, err) } @@ -170,12 +199,12 @@ func command(t *testing.T, stdout, stderr io.Writer, conf conf) *exec.Cmd { parts = append(parts, "-k") } if conf.exclude != "" { - parts = append(parts, "-e", fmt.Sprintf("%s/%s", conf.dir, conf.exclude)) + parts = append(parts, "-e", conf.exclude) } - parts = append(parts, fmt.Sprintf("%s/%s", conf.dir, conf.pattern)) + parts = append(parts, conf.pattern) parts = append(parts, "--", "echo", "hello") cmd := exec.Command("cng", parts...) - t.Log("CMD:", cmd.String()) + // t.Log("CMD:", cmd.String()) cmd.Stdout = stdout cmd.Stderr = stderr return cmd