From 8e02bc33486794b53a1605c8f081d1ee9b700ac2 Mon Sep 17 00:00:00 2001 From: Max Claus Nunes Date: Sat, 16 Jun 2018 21:22:21 -0300 Subject: [PATCH] Initial commit --- .editorconfig | 20 ++++ .gitignore | 6 ++ .travis.yml | 20 ++++ CONTRIBUTING.md | 51 ++++++++++ Gopkg.lock | 45 +++++++++ Gopkg.toml | 15 +++ LICENSE | 21 +++++ Makefile | 41 ++++++++ README.md | 72 ++++++++++++++ builder.go | 78 ++++++++++++++++ loggger.go | 61 ++++++++++++ main.go | 201 ++++++++++++++++++++++++++++++++++++++++ main_test.go | 10 ++ runner.go | 119 ++++++++++++++++++++++++ testdata/server/main.go | 17 ++++ watcher.go | 73 +++++++++++++++ 16 files changed, 850 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 CONTRIBUTING.md create mode 100644 Gopkg.lock create mode 100644 Gopkg.toml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 builder.go create mode 100644 loggger.go create mode 100644 main.go create mode 100644 main_test.go create mode 100644 runner.go create mode 100644 testdata/server/main.go create mode 100644 watcher.go diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c700a92 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +; top-most EditorConfig file +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.go] +indent_style = tab +indent_size = 4 + +[*.yml] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d1a0aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# binaries +*.exe +gaper +srv +vendor +coverage.out diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..dbd0c8f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,20 @@ +language: go + +go: + # - 1.7.x + # - 1.8.x + # - 1.9.x + - 1.10.x + # - master + +script: + - go version + - make setup + - make lint + - make test + +after_success: + - bash <(curl -s https://codecov.io/bash) + +notifications: + email: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0cb02ae --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing to httpfake + +:+1::tada: First off, thanks for taking the time to contribute! :tada::+1: + +There are few ways of contributing to gaper + +* Report an issue. +* Contribute to the code base. + +## Report an issue + +* Before opening the issue make sure there isn't an issue opened for the same problem +* Include the Go and Gaper version you are using +* If it is a bug, please include all info to reproduce the problem + +## Contribute to the code base + +### Pull Request + +* Please discuss the suggested changes on a issue before working on it. Just to make sure the change makes sense before you spending any time on it. + +### Setupping development + +``` +make setup +``` + +### Running gaper in development + +``` +make build && ./gaper --verbose --bin-name srv --build-path ./testdata/server +``` + +### Running lint + +``` +make lint +``` + +### Running tests + +All tests: +``` +make test +``` + +A single test: +``` +go test -run TestSimplePost ./... +``` + diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..a334fdf --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,45 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/fatih/color" + packages = ["."] + revision = "5b77d2a35fb0ede96d138fc9a99f5c9b6aef11b4" + version = "v1.7.0" + +[[projects]] + name = "github.com/mattn/go-colorable" + packages = ["."] + revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072" + version = "v0.0.9" + +[[projects]] + name = "github.com/mattn/go-isatty" + packages = ["."] + revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" + version = "v0.0.3" + +[[projects]] + name = "github.com/mattn/go-shellwords" + packages = ["."] + revision = "02e3cf038dcea8290e44424da473dd12be796a8a" + version = "v1.0.3" + +[[projects]] + name = "github.com/urfave/cli" + packages = ["."] + revision = "cfb38830724cc34fedffe9a2a29fb54fa9169cd1" + version = "v1.20.0" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = ["unix"] + revision = "6c888cc515d3ed83fc103cf1d84468aad274b0a7" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "bd09f9274a4112aeb869a026bcf3ab61443289a245d3d353da6623f3327e7b4e" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..f10c7e9 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,15 @@ +[[constraint]] + name = "github.com/fatih/color" + version = "1.7.0" + +[[constraint]] + name = "github.com/mattn/go-shellwords" + version = "1.0.3" + +[[constraint]] + name = "github.com/urfave/cli" + version = "1.20.0" + +[prune] + go-tests = true + unused-packages = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5c8b0e6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Max Claus Nunes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e7bc5e8 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +OS := $(shell uname -s) +TEST_PACKAGES := $(shell go list ./...) +COVER_PACKAGES := $(shell go list ./... | paste -sd "," -) +LINTER := $(shell command -v gometalinter 2> /dev/null) + +.PHONY: setup + +setup: +ifeq ($(OS), Darwin) + brew install dep +else + curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh +endif + dep ensure -vendor-only +ifndef LINTER + @echo "Installing linter" + @go get -u github.com/alecthomas/gometalinter + @gometalinter --install +endif + +build: + @go build . + +## lint: Validate golang code +lint: + @gometalinter \ + --deadline=120s \ + --line-length=120 \ + --enable-all \ + --vendor ./... + +test: + @go test -v -coverpkg $(COVER_PACKAGES) \ + -covermode=atomic -coverprofile=coverage.out $(TEST_PACKAGES) + +cover: test + @go tool cover -html=coverage.out + +fmt: + @find . -name '*.go' -not -wholename './vendor/*' | \ + while read -r file; do gofmt -w -s "$$file"; goimports -w "$$file"; done diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1dc9b5 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +gaper +===== + +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) +[![Build Status](https://travis-ci.org/maxcnunes/gaper.svg?branch=master)](https://travis-ci.org/maxcnunes/gaper) +[![Coverage Status](https://codecov.io/gh/maxcnunes/gaper/branch/master/graph/badge.svg)](https://codecov.io/gh/maxcnunes/gaper) +[![Go Doc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat-square)](http://godoc.org/github.com/maxcnunes/gaper) +[![Go Report Card](https://goreportcard.com/badge/github.com/maxcnunes/gaper)](https://goreportcard.com/report/github.com/maxcnunes/gaper) + +Restarts programs when they crash or a watched file changes. + +**NOT STABLE YET, STILL IN DEVELOPMENT**: Please, check out [this ticket](https://github.com/maxcnunes/gaper/issues/1) to follow its progress. + +## Installation + +``` +go get -u github.com/maxcnunes/gaper +``` + +## Changelog + +See [Releases](https://github.com/maxcnunes/gaper/releases) for detailed history changes. + +## Usage + +``` +NAME: + gaper - Used to restart programs when they crash or a watched file changes + +USAGE: + gaper [global options] command [command options] [arguments...] + +VERSION: + 0.0.0 + +COMMANDS: + help, h Shows a list of commands or help for one command + +GLOBAL OPTIONS: + --bin-name value name for the binary built by Gaper for the executed program + --build-path value path to the program source code + --build-args value build arguments passed to the program + --verbose turns on the verbose messages from Gaper + --watch value, -w value a comma-delimited list of folders or files to watch for changes + --ignore value, -i value a comma-delimited list of folders or files to ignore for changes + --poll-interval value, -p value how often in milliseconds to poll watched files for changes (default: 500) + --extensions value, -e value a comma-delimited list of file extensions to watch for changes (default: "go") + ----no-restart-on value, -n value don't automatically restart the executed program if it ends. + If "error", an exit code of 0 will still restart. + If "exit", no restart regardless of exit code. + If "success", no restart only if exit code is 0. + --help, -h show help + --version, -v print the version +``` + +## Contributing + +See the [Contributing guide](/CONTRIBUTING.md) for steps on how to contribute to this project. + +## Reference + +This package was heavily inspired by [gin](https://github.com/codegangsta/gin) and [node-supervisor](https://github.com/petruisfan/node-supervisor). + +Basically, Gaper is a mixing of those projects above. It started from **gin** code base and I rewrote it aiming to get +something similar to **node-supervisor** (but simpler). A big thanks for those projects and for the people behind it! +:clap::clap: + +### How is Gaper different of Gin + +The main difference is that Gaper removes a layer of complexity from Gin which has a proxy running on top of +the executed server. It allows to postpone a build and reload the server when the first call hits it. With Gaper +we don't care about that feature, it just restarts your server whenever a change is made. diff --git a/builder.go b/builder.go new file mode 100644 index 0000000..c87ab6f --- /dev/null +++ b/builder.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// Builder ... +type Builder interface { + Build() error + Binary() string + Errors() string +} + +type builder struct { + dir string + binary string + errors string + wd string + buildArgs []string +} + +// NewBuilder ... +func NewBuilder(dir string, bin string, wd string, buildArgs []string) Builder { + if len(bin) == 0 { + bin = "bin" + } + + // does not work on Windows without the ".exe" extension + if runtime.GOOS == OSWindows { + // check if it already has the .exe extension + if !strings.HasSuffix(bin, ".exe") { + bin += ".exe" + } + } + + return &builder{dir: dir, binary: bin, wd: wd, buildArgs: buildArgs} +} + +// Binary ... +func (b *builder) Binary() string { + return b.binary +} + +// Errors ... +func (b *builder) Errors() string { + return b.errors +} + +// Build ... +func (b *builder) Build() error { + logger.Info("Building program") + args := append([]string{"go", "build", "-o", filepath.Join(b.wd, b.binary)}, b.buildArgs...) + logger.Debug("Build command", args) + + command := exec.Command(args[0], args[1:]...) // nolint gas + command.Dir = b.dir + + output, err := command.CombinedOutput() + if err != nil { + return err + } + + if command.ProcessState.Success() { + b.errors = "" + } else { + b.errors = string(output) + } + + if len(b.errors) > 0 { + return fmt.Errorf(b.errors) + } + + return nil +} diff --git a/loggger.go b/loggger.go new file mode 100644 index 0000000..8d92b0d --- /dev/null +++ b/loggger.go @@ -0,0 +1,61 @@ +package main + +import ( + "log" + "os" + + "github.com/fatih/color" +) + +// Logger .. +type Logger struct { + verbose bool + logDebug *log.Logger + logWarn *log.Logger + logInfo *log.Logger + logError *log.Logger +} + +// NewLogger ... +func NewLogger(prefix string) *Logger { + prefix = "[" + prefix + "] " + return &Logger{ + verbose: false, + logDebug: log.New(os.Stdout, prefix, 0), + logWarn: log.New(os.Stdout, color.YellowString(prefix), 0), + logInfo: log.New(os.Stdout, color.CyanString(prefix), 0), + logError: log.New(os.Stdout, color.RedString(prefix), 0), + } +} + +// Verbose ... +func (l *Logger) Verbose(verbose bool) { + l.verbose = verbose +} + +// Debug ... +func (l *Logger) Debug(v ...interface{}) { + if l.verbose { + l.logDebug.Println(v...) + } +} + +// Warn ... +func (l *Logger) Warn(v ...interface{}) { + l.logWarn.Println(v...) +} + +// Info ... +func (l *Logger) Info(v ...interface{}) { + l.logInfo.Println(v...) +} + +// Error ... +func (l *Logger) Error(v ...interface{}) { + l.logError.Println(v...) +} + +// Errorf ... +func (l *Logger) Errorf(format string, v ...interface{}) { + l.logError.Printf(format, v...) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4592b34 --- /dev/null +++ b/main.go @@ -0,0 +1,201 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + shellwords "github.com/mattn/go-shellwords" + "github.com/urfave/cli" +) + +var logger = NewLogger("gaper") + +var defaultExtensions = cli.StringSlice{"go"} +var defaultPoolInterval = 500 + +// Config ... +type Config struct { + BinName string + BuildPath string + BuildArgs []string + BuildArgsMerged string + ProgramArgs []string + Verbose bool + WatchItems []string + IgnoreItems []string + PollInterval int + Extensions []string + NoRestartOn string +} + +func main() { + parseArgs := func(c *cli.Context) *Config { + return &Config{ + BinName: c.String("bin-name"), + BuildPath: c.String("build-path"), + BuildArgsMerged: c.String("build-args"), + ProgramArgs: c.Args(), + Verbose: c.Bool("verbose"), + WatchItems: c.StringSlice("watch"), + IgnoreItems: c.StringSlice("ignore"), + PollInterval: c.Int("poll-interval"), + Extensions: c.StringSlice("extensions"), + NoRestartOn: c.String("no-restart-on"), + } + } + + app := cli.NewApp() + app.Name = "gaper" + app.Usage = "Used to restart programs when they crash or a watched file changes" + + app.Action = func(c *cli.Context) { + args := parseArgs(c) + if err := runGaper(args); err != nil { + logger.Error(err) + os.Exit(1) + } + } + + // supported arguments + app.Flags = []cli.Flag{ + cli.StringFlag{ + Name: "bin-name", + Usage: "name for the binary built by Gaper for the executed program", + }, + cli.StringFlag{ + Name: "build-path", + Usage: "path to the program source code", + }, + cli.StringFlag{ + Name: "build-args", + Usage: "build arguments passed to the program", + }, + cli.BoolFlag{ + Name: "verbose", + Usage: "turns on the verbose messages from Gaper", + }, + cli.StringSliceFlag{ + Name: "watch, w", + Usage: "a comma-delimited list of folders or files to watch for changes", + }, + cli.StringSliceFlag{ + Name: "ignore, i", + Usage: "a comma-delimited list of folders or files to ignore for changes", + }, + cli.IntFlag{ + Name: "poll-interval, p", + Value: defaultPoolInterval, + Usage: "how often in milliseconds to poll watched files for changes", + }, + cli.StringSliceFlag{ + Name: "extensions, e", + Value: &defaultExtensions, + Usage: "a comma-delimited list of file extensions to watch for changes", + }, + cli.StringSliceFlag{ + Name: "--no-restart-on, n", + Usage: "don't automatically restart the supervised program if it ends.\n" + + "\t\tIf \"error\", an exit code of 0 will still restart.\n" + + "\t\tIf \"exit\", no restart regardless of exit code.\n" + + "\t\tIf \"success\", no restart only if exit code is 0.", + }, + } + + if err := app.Run(os.Args); err != nil { + logger.Errorf("Error running gaper: %v", err) + os.Exit(1) + } +} + +// nolint: gocyclo +func runGaper(cfg *Config) error { + var err error + logger.Verbose(cfg.Verbose) + logger.Debug("Starting gaper") + + if len(cfg.BuildArgs) == 0 && len(cfg.BuildArgsMerged) > 0 { + cfg.BuildArgs, err = shellwords.Parse(cfg.BuildArgsMerged) + if err != nil { + return err + } + } + + wd, err := os.Getwd() + if err != nil { + return err + } + + // resolve bin name by current folder name + if cfg.BinName == "" { + cfg.BinName = filepath.Base(wd) + } + + if len(cfg.WatchItems) == 0 { + cfg.WatchItems = append(cfg.WatchItems, cfg.BuildPath) + } + + logger.Debug("Settings: ") + logger.Debug(" | bin: ", cfg.BinName) + logger.Debug(" | build path: ", cfg.BuildPath) + logger.Debug(" | build args: ", cfg.BuildArgs) + logger.Debug(" | verbose: ", cfg.Verbose) + logger.Debug(" | watch: ", cfg.WatchItems) + logger.Debug(" | ignore: ", cfg.IgnoreItems) + logger.Debug(" | poll-interval: ", cfg.PollInterval) + logger.Debug(" | extensions: ", cfg.Extensions) + logger.Debug(" | working directory: ", wd) + + builder := NewBuilder(cfg.BuildPath, cfg.BinName, wd, cfg.BuildArgs) + runner := NewRunner(os.Stdout, filepath.Join(wd, builder.Binary()), cfg.ProgramArgs) + + if err = builder.Build(); err != nil { + return fmt.Errorf("build error: %v", err) + } + + shutdown(runner) + + if _, err = runner.Run(); err != nil { + return fmt.Errorf("run error: %v", err) + } + + watcher := NewWatcher(cfg.WatchItems, cfg.IgnoreItems, cfg.Extensions) + + go watcher.Watch() + for { + select { + case event := <-watcher.Events: + logger.Debug("Detected new changed file: ", event) + if err = runner.Kill(); err != nil { + return fmt.Errorf("kill error: %v", err) + } + if err = builder.Build(); err != nil { + return fmt.Errorf("build error: %v", err) + } + if _, err = runner.Run(); err != nil { + return fmt.Errorf("run error: %v", err) + } + case err := <-watcher.Errors: + return fmt.Errorf("error on watching files: %v", err) + default: + time.Sleep(time.Duration(cfg.PollInterval) * time.Millisecond) + } + } +} + +func shutdown(runner Runner) { + c := make(chan os.Signal, 2) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + s := <-c + logger.Debug("Got signal: ", s) + err := runner.Kill() + if err != nil { + logger.Error("Error killing: ", err) + } + os.Exit(1) + }() +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..a7063b3 --- /dev/null +++ b/main_test.go @@ -0,0 +1,10 @@ +package main + +import ( + "fmt" + "testing" +) + +func TestGaper(t *testing.T) { + fmt.Println("Sample test") +} diff --git a/runner.go b/runner.go new file mode 100644 index 0000000..1591bfb --- /dev/null +++ b/runner.go @@ -0,0 +1,119 @@ +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "runtime" + "time" +) + +// OSWindows ... +const OSWindows = "windows" + +// Runner ... +type Runner interface { + Run() (*exec.Cmd, error) + Kill() error +} + +type runner struct { + bin string + args []string + writer io.Writer + command *exec.Cmd + starttime time.Time +} + +// NewRunner ... +func NewRunner(writer io.Writer, bin string, args []string) Runner { + return &runner{ + bin: bin, + args: args, + writer: writer, + starttime: time.Now(), + } +} + +// Run ... +func (r *runner) Run() (*exec.Cmd, error) { + logger.Info("Starting program") + + if r.command == nil || r.Exited() { + if err := r.runBin(); err != nil { + return nil, fmt.Errorf("error running: %v", err) + } + + time.Sleep(250 * time.Millisecond) + return r.command, nil + } + + return r.command, nil +} + +// Kill ... +func (r *runner) Kill() error { + if r.command == nil || r.command.Process == nil { + return nil + } + + done := make(chan error) + go func() { + r.command.Wait() // nolint errcheck + close(done) + }() + + // Trying a "soft" kill first + if runtime.GOOS == OSWindows { + if err := r.command.Process.Kill(); err != nil { + return err + } + } else if err := r.command.Process.Signal(os.Interrupt); err != nil { + return err + } + + // Wait for our process to die before we return or hard kill after 3 sec + select { + case <-time.After(3 * time.Second): + if err := r.command.Process.Kill(); err != nil { + return fmt.Errorf("failed to kill: %v", err) + } + case <-done: + } + + r.command = nil + return nil +} + +// Exited ... +func (r *runner) Exited() bool { + return r.command != nil && r.command.ProcessState != nil && r.command.ProcessState.Exited() +} + +func (r *runner) runBin() error { + r.command = exec.Command(r.bin, r.args...) // nolint gas + stdout, err := r.command.StdoutPipe() + if err != nil { + return err + } + + stderr, err := r.command.StderrPipe() + if err != nil { + return err + } + + err = r.command.Start() + if err != nil { + return err + } + + r.starttime = time.Now() + + // TODO: handle or log errors + go io.Copy(r.writer, stdout) // nolint errcheck + go io.Copy(r.writer, stderr) // nolint errcheck + go r.command.Wait() // nolint errcheck + + return nil +} diff --git a/testdata/server/main.go b/testdata/server/main.go new file mode 100644 index 0000000..a4c49df --- /dev/null +++ b/testdata/server/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + "html" + "log" + "net/http" +) + +func main() { + http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path)) // nolint gas + }) + + log.Println("Starting server") + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/watcher.go b/watcher.go new file mode 100644 index 0000000..33bc646 --- /dev/null +++ b/watcher.go @@ -0,0 +1,73 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "time" +) + +// Watcher ... +type Watcher struct { + WatchItems []string + IgnoreItems []string + AllowedExtensions map[string]bool + Events chan string + Errors chan error +} + +// NewWatcher ... +func NewWatcher(watchItems []string, ignoreItems []string, extensions []string) *Watcher { + allowedExts := make(map[string]bool) + for _, ext := range extensions { + allowedExts["."+ext] = true + } + + return &Watcher{ + Events: make(chan string), + Errors: make(chan error), + WatchItems: watchItems, + IgnoreItems: ignoreItems, + AllowedExtensions: allowedExts, + } +} + +var startTime = time.Now() +var errDetectedChange = errors.New("done") + +// Watch ... +func (w *Watcher) Watch() { // nolint: gocyclo + for { + err := filepath.Walk(w.WatchItems[0], func(path string, info os.FileInfo, err error) error { + if path == ".git" && info.IsDir() { + return filepath.SkipDir + } + + for _, x := range w.IgnoreItems { + if x == path { + return filepath.SkipDir + } + } + + // ignore hidden files + if filepath.Base(path)[0] == '.' { + return nil + } + + ext := filepath.Ext(path) + if _, ok := w.AllowedExtensions[ext]; ok && info.ModTime().After(startTime) { + w.Events <- path + startTime = time.Now() + return errDetectedChange + } + + return nil + }) + + if err != nil && err != errDetectedChange { + w.Errors <- err + } + + time.Sleep(500 * time.Millisecond) + } +}