├── .editorconfig ├── .github └── workflows │ ├── release.yml │ └── workflow.yml ├── .gitignore ├── .goreleaser.yml ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── builder.go ├── builder_test.go ├── cmd └── gaper │ └── main.go ├── gaper.go ├── gaper_test.go ├── go.mod ├── go.sum ├── gopher-gaper.png ├── logger.go ├── logger_test.go ├── runner.go ├── runner_test.go ├── testdata ├── .hidden-file ├── .hidden-folder │ └── .gitkeep ├── build-failure │ └── main.go ├── hidden-test │ ├── .hiden-file │ └── .hiden-folder │ │ └── .gitkeep ├── ignore-test-name.txt ├── ignore-test-name │ └── main.go ├── mocks.go ├── print-gaper ├── print-gaper.bat ├── server │ ├── data.txt │ ├── main.go │ └── main_test.go └── test-duplicated-paths │ ├── file-1.txt │ └── file-2.txt ├── watcher.go └── watcher_test.go /.editorconfig: -------------------------------------------------------------------------------- 1 | ; top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.go] 10 | indent_style = tab 11 | indent_size = 4 12 | 13 | [*.yml] 14 | indent_size = 2 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | 19 | [Makefile] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | # run only against tags 6 | tags: 7 | - '*' 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | goreleaser: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - 17 | name: Checkout 18 | uses: actions/checkout@v2 19 | with: 20 | fetch-depth: 0 21 | - 22 | name: Fetch all tags 23 | run: git fetch --force --tags 24 | - 25 | name: Set up Go 26 | uses: actions/setup-go@v2 27 | with: 28 | go-version: 1.19 29 | - 30 | name: Run GoReleaser 31 | uses: goreleaser/goreleaser-action@v2 32 | with: 33 | distribution: goreleaser 34 | version: latest 35 | args: release --rm-dist 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: dev-workflow 2 | 3 | on: 4 | - push 5 | jobs: 6 | run: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: 11 | - ubuntu-latest 12 | # - macos-latest 13 | # - windows-latest 14 | go: 15 | - '1.19' 16 | # - '1.18' 17 | # - '1.17' 18 | # - '1.16' 19 | # - '1.15' 20 | env: 21 | OS: ${{ matrix.os }} 22 | steps: 23 | - uses: actions/checkout@master 24 | 25 | - name: Setup Go 26 | uses: actions/setup-go@v3 27 | with: 28 | go-version: ${{ matrix.go }} 29 | 30 | - name: golangci-lint 31 | uses: golangci/golangci-lint-action@v3 32 | with: 33 | version: v1.48 34 | 35 | - name: Test 36 | run: make test 37 | 38 | - name: Upload coverage to Codecov 39 | uses: codecov/codecov-action@v3 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # binaries 2 | *.exe 3 | /gaper 4 | srv 5 | vendor 6 | coverage.out 7 | .DS_Store 8 | testdata/server/server 9 | dist 10 | test-srv 11 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - main: ./cmd/gaper/main.go 3 | binary: gaper 4 | goos: 5 | - windows 6 | - darwin 7 | - linux 8 | goarch: 9 | - amd64 10 | env: 11 | - CGO_ENABLED=0 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to httpfake 2 | 3 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: 4 | 5 | There are few ways of contributing to gaper 6 | 7 | * Report an issue. 8 | * Contribute to the code base. 9 | 10 | ## Report an issue 11 | 12 | * Before opening the issue make sure there isn't an issue opened for the same problem 13 | * Include the Go and Gaper version you are using 14 | * If it is a bug, please include all info to reproduce the problem 15 | 16 | ## Contribute to the code base 17 | 18 | ### Pull Request 19 | 20 | * 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. 21 | 22 | ### Setupping development 23 | 24 | ``` 25 | make setup 26 | ``` 27 | 28 | ### Running gaper in development 29 | 30 | ``` 31 | make build && \ 32 | ./gaper \ 33 | --verbose \ 34 | --bin-name srv \ 35 | --build-path ./testdata/server \ 36 | --build-args="-ldflags=\"-X 'main.Version=v1.0.0'\"" \ 37 | --extensions "go,txt" 38 | ``` 39 | 40 | ### Running lint 41 | 42 | ``` 43 | make lint 44 | ``` 45 | 46 | ### Running tests 47 | 48 | All tests: 49 | ``` 50 | make test 51 | ``` 52 | 53 | A single test: 54 | ``` 55 | go test -run TestSimplePost ./... 56 | ``` 57 | 58 | ### Release 59 | 60 | The release runs automatically with a Github action on pushed git tags. 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Max Claus Nunes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OS := $(shell uname -s) 2 | TEST_PACKAGES := $(shell go list ./... | grep -v cmd) 3 | COVER_PACKAGES := $(shell go list ./... | grep -v cmd | paste -sd "," -) 4 | LINTER := $(shell command -v gometalinter 2> /dev/null) 5 | 6 | build: 7 | @go build -o ./gaper cmd/gaper/main.go 8 | 9 | ## lint: Validate golang code 10 | # Install it following this doc https://golangci-lint.run/usage/install/#local-installation, 11 | # please use the same version from .github/workflows/workflow.yml. 12 | lint: 13 | @golangci-lint run 14 | 15 | test: 16 | @go test -p=1 -coverpkg $(COVER_PACKAGES) \ 17 | -covermode=atomic -coverprofile=coverage.out $(TEST_PACKAGES) 18 | 19 | cover: test 20 | @go tool cover -html=coverage.out 21 | 22 | fmt: 23 | @find . -name '*.go' -not -wholename './vendor/*' | \ 24 | while read -r file; do gofmt -w -s "$$file"; goimports -w "$$file"; done 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

gaper

4 |

5 | Used to build and restart a Go project when it crashes or some watched file changes 6 |
7 | Aimed to be used in development only. 8 |

9 |

10 | 11 | --- 12 | 13 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 14 | [![Linux - Build Status](https://travis-ci.org/maxcnunes/gaper.svg?branch=master)](https://travis-ci.org/maxcnunes/gaper) 15 | [![Windows - Build status](https://ci.appveyor.com/api/projects/status/e0g00kmxwv44?svg=true)](https://ci.appveyor.com/project/maxcnunes/gaper) 16 | [![Coverage Status](https://codecov.io/gh/maxcnunes/gaper/branch/master/graph/badge.svg)](https://codecov.io/gh/maxcnunes/gaper) 17 | [![Go Doc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat-square)](http://godoc.org/github.com/maxcnunes/gaper) 18 | [![Go Report Card](https://goreportcard.com/badge/github.com/maxcnunes/gaper)](https://goreportcard.com/report/github.com/maxcnunes/gaper) 19 | [![Powered By: GoReleaser](https://img.shields.io/badge/powered%20by-goreleaser-green.svg?style=flat-square)](https://github.com/goreleaser) 20 | 21 | ## Changelog 22 | 23 | See [Releases](https://github.com/maxcnunes/gaper/releases) for detailed history changes. 24 | 25 | ## Installation 26 | 27 | Using go tooling: 28 | 29 | ``` 30 | go get -u github.com/maxcnunes/gaper/cmd/gaper 31 | ``` 32 | 33 | Or, downloading the binary instead (example for version 1.1.0, make sure you are using the latest version though): 34 | 35 | ``` 36 | curl -SL https://github.com/maxcnunes/gaper/releases/download/v1.1.0/gaper_1.1.0_linux_amd64.tar.gz | tar -xvzf - -C "${GOPATH}/bin" 37 | ``` 38 | 39 | ## Usage 40 | 41 | ``` 42 | NAME: 43 | gaper - Used to build and restart a Go project when it crashes or some watched file changes 44 | 45 | USAGE: 46 | gaper [global options] command [command options] [arguments...] 47 | 48 | VERSION: 49 | version 50 | 51 | COMMANDS: 52 | help, h Shows a list of commands or help for one command 53 | 54 | GLOBAL OPTIONS: 55 | --bin-name value name for the binary built by gaper for the executed program (default current directory name) 56 | --build-path value path to the program source code (default: ".") 57 | --build-args value arguments used on building the program 58 | --program-args value arguments used on executing the program 59 | --verbose turns on the verbose messages from gaper 60 | --disable-default-ignore turns off default ignore for hidden files and folders, "*_test.go" files, and vendor folder 61 | --watch value, -w value list of folders or files to watch for changes 62 | --ignore value, -i value list of folders or files to ignore for changes 63 | --poll-interval value, -p value how often in milliseconds to poll watched files for changes (default: 500) 64 | --extensions value, -e value extensions to watch for changes (default: "go") 65 | --no-restart-on value, -n value don't automatically restart the supervised program if it ends: 66 | if "error", an exit code of 0 will still restart. 67 | if "exit", no restart regardless of exit code. 68 | if "success", no restart only if exit code is 0. 69 | --help, -h show help 70 | --version, -v print the version 71 | ``` 72 | 73 | ### Watch and Ignore paths 74 | 75 | For those options Gaper supports static paths (e.g. `build/`, `seed.go`) or glob paths (e.g. `migrations/**/up.go`, `*_test.go`). 76 | 77 | On using a path to a directory please add a `/` at the end (e.g. `build/`) to make sure Gaper won't include other matches that starts with that same value (e.g. `build/`, `build_settings.go`). 78 | 79 | ### Default ignore settings 80 | 81 | Since in most projects there is no need to watch changes for: 82 | 83 | * hidden files and folders 84 | * test files (`*_test.go`) 85 | * vendor folder 86 | 87 | Gaper by default ignores those cases already. Although, if you need Gaper to watch those files anyway it is possible to disable this setting with `--disable-default-ignore` argument. 88 | 89 | ### Watch method 90 | 91 | Currently Gaper uses polling to watch file changes. We have plans to [support fs events](https://github.com/maxcnunes/gaper/issues/12) though in a near future. 92 | 93 | ### Examples 94 | 95 | Using all defaults provided by Gaper: 96 | 97 | ``` 98 | gaper 99 | ``` 100 | 101 | Example providing a few custom configurations: 102 | 103 | ``` 104 | gaper \ 105 | --bin-name build/api-dev \ 106 | --build-path cmd/server \ 107 | --build-args "-ldflags=\"-X 'main.Version=dev'" \ 108 | -w 'public/**' -w '*.go' \ 109 | -e js -e css -e html \ 110 | --ignore './**/*_mock.go' \ 111 | --program-args "-arg1 ok -arg2=nope" \ 112 | --watch . 113 | ``` 114 | 115 | ## Contributing 116 | 117 | See the [Contributing guide](/CONTRIBUTING.md) for steps on how to contribute to this project. 118 | 119 | ## Reference 120 | 121 | This package was heavily inspired by [gin](https://github.com/codegangsta/gin) and [node-supervisor](https://github.com/petruisfan/node-supervisor). 122 | 123 | Basically, Gaper is a mixing of those projects above. It started from **gin** code base and I rewrote it aiming to get 124 | something similar to **node-supervisor** (but simpler). A big thanks for those projects and for the people behind it! 125 | :clap::clap: 126 | 127 | ### How is Gaper different of Gin 128 | 129 | The main difference is that Gaper removes a layer of complexity from Gin which has a proxy running on top of 130 | the executed server. It allows to postpone a build and reload the server when the first call hits it. With Gaper 131 | we don't care about that feature, it just restarts your server whenever a change is made. 132 | -------------------------------------------------------------------------------- /builder.go: -------------------------------------------------------------------------------- 1 | package gaper 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | ) 10 | 11 | // Builder is a interface for the build process 12 | type Builder interface { 13 | Build() error 14 | Binary() string 15 | } 16 | 17 | type builder struct { 18 | dir string 19 | binary string 20 | wd string 21 | buildArgs []string 22 | } 23 | 24 | // NewBuilder creates a new builder 25 | func NewBuilder(dir string, bin string, wd string, buildArgs []string) Builder { 26 | // resolve bin name by current folder name 27 | if bin == "" { 28 | bin = filepath.Base(wd) 29 | } 30 | 31 | // does not work on Windows without the ".exe" extension 32 | if runtime.GOOS == OSWindows { 33 | // check if it already has the .exe extension 34 | if !strings.HasSuffix(bin, ".exe") { 35 | bin += ".exe" 36 | } 37 | } 38 | 39 | return &builder{dir: dir, binary: bin, wd: wd, buildArgs: buildArgs} 40 | } 41 | 42 | // Binary returns its build binary's path 43 | func (b *builder) Binary() string { 44 | return b.binary 45 | } 46 | 47 | // Build the Golang project set for this builder 48 | func (b *builder) Build() error { 49 | logger.Info("Building program") 50 | args := append([]string{"go", "build", "-o", filepath.Join(b.wd, b.binary)}, b.buildArgs...) 51 | logger.Debug("Build command", args) 52 | 53 | command := exec.Command(args[0], args[1:]...) // nolint gas 54 | command.Dir = b.dir 55 | 56 | output, err := command.CombinedOutput() 57 | if err != nil { 58 | return fmt.Errorf("build failed with %v\n%s", err, output) 59 | } 60 | 61 | if !command.ProcessState.Success() { 62 | return fmt.Errorf("error building: %s", output) 63 | } 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /builder_test.go: -------------------------------------------------------------------------------- 1 | package gaper 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestBuilderSuccessBuild(t *testing.T) { 13 | bArgs := []string{} 14 | bin := resolveBinNameByOS("srv") 15 | dir := filepath.Join("testdata", "server") 16 | wd, err := os.Getwd() 17 | if err != nil { 18 | t.Fatalf("couldn't get current working directory: %v", err) 19 | } 20 | 21 | b := NewBuilder(dir, bin, wd, bArgs) 22 | err = b.Build() 23 | assert.Nil(t, err, "build error") 24 | 25 | file, err := os.Open(filepath.Join(wd, bin)) 26 | if err != nil { 27 | t.Fatalf("couldn't open open built binary: %v", err) 28 | } 29 | assert.NotNil(t, file, "binary not written properly") 30 | } 31 | 32 | func TestBuilderFailureBuild(t *testing.T) { 33 | bArgs := []string{} 34 | bin := "srv" 35 | dir := filepath.Join("testdata", "build-failure") 36 | wd, err := os.Getwd() 37 | if err != nil { 38 | t.Fatalf("couldn't get current working directory: %v", err) 39 | } 40 | 41 | b := NewBuilder(dir, bin, wd, bArgs) 42 | err = b.Build() 43 | assert.NotNil(t, err, "build error") 44 | assert.Equal(t, err.Error(), "build failed with exit status 2\n"+ 45 | "# github.com/maxcnunes/gaper/testdata/build-failure\n"+ 46 | "./main.go:4:6: func main must have no arguments and no return values\n"+ 47 | "./main.go:5:1: missing return\n") 48 | } 49 | 50 | func TestBuilderDefaultBinName(t *testing.T) { 51 | bin := "" 52 | dir := filepath.Join("testdata", "server") 53 | wd := "/src/projects/project-name" 54 | b := NewBuilder(dir, bin, wd, nil) 55 | assert.Equal(t, b.Binary(), resolveBinNameByOS("project-name")) 56 | } 57 | 58 | func resolveBinNameByOS(name string) string { 59 | if runtime.GOOS == OSWindows { 60 | name += ".exe" 61 | } 62 | return name 63 | } 64 | -------------------------------------------------------------------------------- /cmd/gaper/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/maxcnunes/gaper" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | // build info 11 | var ( 12 | // Version is hardcoded because when installing it through "go get/install" 13 | // the build tags are not available to override it. 14 | // Update it after every release. 15 | version = "1.0.3-dev" 16 | ) 17 | 18 | func main() { 19 | logger := gaper.Logger() 20 | loggerVerbose := false 21 | 22 | parseArgs := func(c *cli.Context) *gaper.Config { 23 | loggerVerbose = c.Bool("verbose") 24 | 25 | return &gaper.Config{ 26 | BinName: c.String("bin-name"), 27 | BuildPath: c.String("build-path"), 28 | BuildArgsMerged: c.String("build-args"), 29 | ProgramArgsMerged: c.String("program-args"), 30 | DisableDefaultIgnore: c.Bool("disable-default-ignore"), 31 | WatchItems: c.StringSlice("watch"), 32 | IgnoreItems: c.StringSlice("ignore"), 33 | PollInterval: c.Int("poll-interval"), 34 | Extensions: c.StringSlice("extensions"), 35 | NoRestartOn: c.String("no-restart-on"), 36 | } 37 | } 38 | 39 | app := cli.NewApp() 40 | app.Name = "gaper" 41 | app.Usage = "Used to build and restart a Go project when it crashes or some watched file changes" 42 | app.Version = version 43 | 44 | app.Action = func(c *cli.Context) error { 45 | args := parseArgs(c) 46 | chOSSiginal := make(chan os.Signal, 2) 47 | logger.Verbose(loggerVerbose) 48 | 49 | return gaper.Run(args, chOSSiginal) 50 | } 51 | 52 | // supported arguments 53 | app.Flags = []cli.Flag{ 54 | &cli.StringFlag{ 55 | Name: "bin-name", 56 | Usage: "name for the binary built by gaper for the executed program (default current directory name)", 57 | }, 58 | &cli.StringFlag{ 59 | Name: "build-path", 60 | Value: gaper.DefaultBuildPath, 61 | Usage: "path to the program source code", 62 | }, 63 | &cli.StringFlag{ 64 | Name: "build-args", 65 | Usage: "arguments used on building the program", 66 | }, 67 | &cli.StringFlag{ 68 | Name: "program-args", 69 | Usage: "arguments used on executing the program", 70 | }, 71 | &cli.BoolFlag{ 72 | Name: "verbose", 73 | Usage: "turns on the verbose messages from gaper", 74 | }, 75 | &cli.BoolFlag{ 76 | Name: "disable-default-ignore", 77 | Usage: "turns off default ignore for hidden files and folders, \"*_test.go\" files, and vendor folder", 78 | }, 79 | &cli.StringSliceFlag{ 80 | Name: "watch, w", 81 | Usage: "list of folders or files to watch for changes", 82 | }, 83 | &cli.StringSliceFlag{ 84 | Name: "ignore, i", 85 | Usage: "list of folders or files to ignore for changes\n" + 86 | "\t\t(always ignores all hidden files and directories)", 87 | }, 88 | &cli.IntFlag{ 89 | Name: "poll-interval, p", 90 | Value: gaper.DefaultPoolInterval, 91 | Usage: "how often in milliseconds to poll watched files for changes", 92 | }, 93 | &cli.StringSliceFlag{ 94 | Name: "extensions, e", 95 | Value: cli.NewStringSlice(gaper.DefaultExtensions...), 96 | Usage: "a comma-delimited list of file extensions to watch for changes", 97 | }, 98 | &cli.StringFlag{ 99 | Name: "no-restart-on, n", 100 | Usage: "don't automatically restart the supervised program if it ends:\n" + 101 | "\t\tif \"error\", an exit code of 0 will still restart.\n" + 102 | "\t\tif \"exit\", no restart regardless of exit code.\n" + 103 | "\t\tif \"success\", no restart only if exit code is 0.", 104 | }, 105 | } 106 | 107 | if err := app.Run(os.Args); err != nil { 108 | logger.Errorf("Error running gaper: %v", err) 109 | os.Exit(1) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /gaper.go: -------------------------------------------------------------------------------- 1 | // Package gaper implements a supervisor restarts a go project 2 | // when it crashes or a watched file changes 3 | package gaper 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "path/filepath" 10 | "strings" 11 | "syscall" 12 | "time" 13 | 14 | shellwords "github.com/mattn/go-shellwords" 15 | ) 16 | 17 | // DefaultBuildPath is the default build and watched path 18 | var DefaultBuildPath = "." 19 | 20 | // DefaultExtensions is the default watched extension 21 | var DefaultExtensions = []string{"go"} 22 | 23 | // DefaultPoolInterval is the time in ms used by the watcher to wait between scans 24 | var DefaultPoolInterval = 500 25 | 26 | // No restart types 27 | var ( 28 | NoRestartOnError = "error" 29 | NoRestartOnSuccess = "success" 30 | NoRestartOnExit = "exit" 31 | ) 32 | 33 | // exit statuses 34 | var exitStatusSuccess = 0 35 | var exitStatusError = 1 36 | 37 | // Config contains all settings supported by gaper 38 | type Config struct { 39 | BinName string 40 | BuildPath string 41 | BuildArgs []string 42 | BuildArgsMerged string 43 | ProgramArgs []string 44 | ProgramArgsMerged string 45 | WatchItems []string 46 | IgnoreItems []string 47 | PollInterval int 48 | Extensions []string 49 | NoRestartOn string 50 | DisableDefaultIgnore bool 51 | WorkingDirectory string 52 | } 53 | 54 | // Run starts the whole gaper process watching for file changes or exit codes 55 | // and restarting the program 56 | func Run(cfg *Config, chOSSiginal chan os.Signal) error { 57 | logger.Debug("Starting gaper") 58 | 59 | if err := setupConfig(cfg); err != nil { 60 | return err 61 | } 62 | 63 | logger.Debugf("Config: %+v", cfg) 64 | 65 | wCfg := WatcherConfig{ 66 | DefaultIgnore: !cfg.DisableDefaultIgnore, 67 | PollInterval: cfg.PollInterval, 68 | WatchItems: cfg.WatchItems, 69 | IgnoreItems: cfg.IgnoreItems, 70 | Extensions: cfg.Extensions, 71 | } 72 | 73 | builder := NewBuilder(cfg.BuildPath, cfg.BinName, cfg.WorkingDirectory, cfg.BuildArgs) 74 | runner := NewRunner(os.Stdout, os.Stderr, filepath.Join(cfg.WorkingDirectory, builder.Binary()), cfg.ProgramArgs) 75 | watcher, err := NewWatcher(wCfg) 76 | if err != nil { 77 | return fmt.Errorf("watcher error: %v", err) 78 | } 79 | 80 | return run(cfg, chOSSiginal, builder, runner, watcher) 81 | } 82 | 83 | // nolint: gocyclo 84 | func run(cfg *Config, chOSSiginal chan os.Signal, builder Builder, runner Runner, watcher Watcher) error { 85 | if err := builder.Build(); err != nil { 86 | return fmt.Errorf("build error: %v", err) 87 | } 88 | 89 | // listen for OS signals 90 | signal.Notify(chOSSiginal, os.Interrupt, syscall.SIGTERM) 91 | 92 | if _, err := runner.Run(); err != nil { 93 | return fmt.Errorf("run error: %v", err) 94 | } 95 | 96 | // flag to know if an exit was caused by a restart from a file changing 97 | changeRestart := false 98 | 99 | go watcher.Watch() 100 | for { 101 | select { 102 | case event := <-watcher.Events(): 103 | logger.Debug("Detected new changed file:", event) 104 | if changeRestart { 105 | logger.Debug("Skip restart due to existing on going restart") 106 | continue 107 | } 108 | 109 | changeRestart = runner.IsRunning() 110 | 111 | if err := restart(builder, runner); err != nil { 112 | return err 113 | } 114 | case err := <-watcher.Errors(): 115 | return fmt.Errorf("error on watching files: %v", err) 116 | case err := <-runner.Errors(): 117 | logger.Debug("Detected program exit:", err) 118 | 119 | // ignore exit by change 120 | if changeRestart { 121 | changeRestart = false 122 | continue 123 | } 124 | 125 | if err = handleProgramExit(builder, runner, err, cfg.NoRestartOn); err != nil { 126 | return err 127 | } 128 | case signal := <-chOSSiginal: 129 | logger.Debug("Got signal:", signal) 130 | 131 | if err := runner.Kill(); err != nil { 132 | logger.Error("Error killing:", err) 133 | } 134 | 135 | return fmt.Errorf("OS signal: %v", signal) 136 | default: 137 | time.Sleep(time.Duration(cfg.PollInterval) * time.Millisecond) 138 | } 139 | } 140 | } 141 | 142 | func restart(builder Builder, runner Runner) error { 143 | logger.Debug("Restarting program") 144 | 145 | // kill process if it is running 146 | if !runner.Exited() { 147 | if err := runner.Kill(); err != nil { 148 | return fmt.Errorf("kill error: %v", err) 149 | } 150 | } 151 | 152 | if err := builder.Build(); err != nil { 153 | logger.Error("Error building binary during a restart:", err) 154 | return nil 155 | } 156 | 157 | if _, err := runner.Run(); err != nil { 158 | logger.Error("Error starting process during a restart:", err) 159 | return nil 160 | } 161 | 162 | return nil 163 | } 164 | 165 | func handleProgramExit(builder Builder, runner Runner, err error, noRestartOn string) error { 166 | exitStatus := runner.ExitStatus(err) 167 | 168 | // if "error", an exit code of 0 will still restart. 169 | if noRestartOn == NoRestartOnError && exitStatus == exitStatusError { 170 | return nil 171 | } 172 | 173 | // if "success", no restart only if exit code is 0. 174 | if noRestartOn == NoRestartOnSuccess && exitStatus == exitStatusSuccess { 175 | return nil 176 | } 177 | 178 | // if "exit", no restart regardless of exit code. 179 | if noRestartOn == NoRestartOnExit { 180 | return nil 181 | } 182 | 183 | return restart(builder, runner) 184 | } 185 | 186 | func setupConfig(cfg *Config) error { 187 | var err error 188 | 189 | if len(cfg.BuildPath) == 0 { 190 | cfg.BuildPath = DefaultBuildPath 191 | } 192 | 193 | cfg.BuildArgs, err = parseInnerArgs(cfg.BuildArgs, cfg.BuildArgsMerged) 194 | if err != nil { 195 | return err 196 | } 197 | 198 | cfg.ProgramArgs, err = parseInnerArgs(cfg.ProgramArgs, cfg.ProgramArgsMerged) 199 | if err != nil { 200 | return err 201 | } 202 | 203 | cfg.WorkingDirectory, err = os.Getwd() 204 | if err != nil { 205 | return err 206 | } 207 | 208 | if len(cfg.WatchItems) == 0 { 209 | cfg.WatchItems = append(cfg.WatchItems, cfg.BuildPath) 210 | } 211 | 212 | var extensions []string 213 | for i := range cfg.Extensions { 214 | values := strings.Split(cfg.Extensions[i], ",") 215 | extensions = append(extensions, values...) 216 | } 217 | cfg.Extensions = extensions 218 | 219 | return nil 220 | } 221 | 222 | func parseInnerArgs(args []string, argsm string) ([]string, error) { 223 | if len(args) > 0 || len(argsm) == 0 { 224 | return args, nil 225 | } 226 | 227 | return shellwords.Parse(argsm) 228 | } 229 | -------------------------------------------------------------------------------- /gaper_test.go: -------------------------------------------------------------------------------- 1 | package gaper 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "syscall" 9 | "testing" 10 | "time" 11 | 12 | "github.com/maxcnunes/gaper/testdata" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestGaperRunStopOnSGINT(t *testing.T) { 17 | args := &Config{ 18 | BuildPath: filepath.Join("testdata", "server"), 19 | } 20 | 21 | chOSSiginal := make(chan os.Signal, 2) 22 | go func() { 23 | time.Sleep(1 * time.Second) 24 | chOSSiginal <- syscall.SIGINT 25 | }() 26 | 27 | err := Run(args, chOSSiginal) 28 | assert.NotNil(t, err, "build error") 29 | assert.Equal(t, "OS signal: interrupt", err.Error()) 30 | } 31 | 32 | func TestGaperSetupConfigNoParams(t *testing.T) { 33 | cwd, _ := os.Getwd() 34 | args := &Config{} 35 | err := setupConfig(args) 36 | assert.Nil(t, err, "build error") 37 | assert.Equal(t, args.BuildPath, ".") 38 | assert.Equal(t, args.WorkingDirectory, cwd) 39 | assert.Equal(t, args.WatchItems, []string{"."}) 40 | } 41 | 42 | func TestGaperBuildError(t *testing.T) { 43 | mockBuilder := new(testdata.MockBuilder) 44 | mockBuilder.On("Build").Return(errors.New("build-error")) 45 | mockRunner := new(testdata.MockRunner) 46 | mockWatcher := new(testdata.MockWacther) 47 | 48 | cfg := &Config{} 49 | 50 | chOSSiginal := make(chan os.Signal, 2) 51 | err := run(cfg, chOSSiginal, mockBuilder, mockRunner, mockWatcher) 52 | assert.NotNil(t, err, "build error") 53 | assert.Equal(t, "build error: build-error", err.Error()) 54 | } 55 | 56 | func TestGaperRunError(t *testing.T) { 57 | mockBuilder := new(testdata.MockBuilder) 58 | mockBuilder.On("Build").Return(nil) 59 | mockRunner := new(testdata.MockRunner) 60 | mockRunner.On("Run").Return(nil, errors.New("runner-error")) 61 | mockWatcher := new(testdata.MockWacther) 62 | 63 | cfg := &Config{} 64 | 65 | chOSSiginal := make(chan os.Signal, 2) 66 | err := run(cfg, chOSSiginal, mockBuilder, mockRunner, mockWatcher) 67 | assert.NotNil(t, err, "runner error") 68 | assert.Equal(t, "run error: runner-error", err.Error()) 69 | } 70 | 71 | func TestGaperWatcherError(t *testing.T) { 72 | mockBuilder := new(testdata.MockBuilder) 73 | mockBuilder.On("Build").Return(nil) 74 | 75 | mockRunner := new(testdata.MockRunner) 76 | cmd := &exec.Cmd{} 77 | runnerErrorsChan := make(chan error) 78 | mockRunner.On("Run").Return(cmd, nil) 79 | mockRunner.On("Errors").Return(runnerErrorsChan) 80 | 81 | mockWatcher := new(testdata.MockWacther) 82 | watcherErrorsChan := make(chan error) 83 | watcherEvetnsChan := make(chan string) 84 | mockWatcher.On("Errors").Return(watcherErrorsChan) 85 | mockWatcher.On("Events").Return(watcherEvetnsChan) 86 | 87 | dir := filepath.Join("testdata", "server") 88 | 89 | cfg := &Config{ 90 | BinName: "test-srv", 91 | BuildPath: dir, 92 | } 93 | 94 | go func() { 95 | time.Sleep(3 * time.Second) 96 | watcherErrorsChan <- errors.New("watcher-error") 97 | }() 98 | chOSSiginal := make(chan os.Signal, 2) 99 | err := run(cfg, chOSSiginal, mockBuilder, mockRunner, mockWatcher) 100 | assert.NotNil(t, err, "build error") 101 | assert.Equal(t, "error on watching files: watcher-error", err.Error()) 102 | mockBuilder.AssertExpectations(t) 103 | mockRunner.AssertExpectations(t) 104 | mockWatcher.AssertExpectations(t) 105 | } 106 | 107 | func TestGaperProgramExit(t *testing.T) { 108 | testCases := []struct { 109 | name string 110 | exitStatus int 111 | noRestartOn string 112 | restart bool 113 | }{ 114 | { 115 | name: "no restart on exit error with no-restart-on=error", 116 | exitStatus: exitStatusError, 117 | noRestartOn: NoRestartOnError, 118 | restart: false, 119 | }, 120 | { 121 | name: "no restart on exit success with no-restart-on=success", 122 | exitStatus: exitStatusSuccess, 123 | noRestartOn: NoRestartOnSuccess, 124 | restart: false, 125 | }, 126 | { 127 | name: "no restart on exit error with no-restart-on=exit", 128 | exitStatus: exitStatusError, 129 | noRestartOn: NoRestartOnExit, 130 | restart: false, 131 | }, 132 | { 133 | name: "no restart on exit success with no-restart-on=exit", 134 | exitStatus: exitStatusSuccess, 135 | noRestartOn: NoRestartOnExit, 136 | restart: false, 137 | }, 138 | { 139 | name: "restart on exit error with disabled no-restart-on", 140 | exitStatus: exitStatusError, 141 | restart: true, 142 | }, 143 | { 144 | name: "restart on exit success with disabled no-restart-on", 145 | exitStatus: exitStatusSuccess, 146 | restart: true, 147 | }, 148 | } 149 | 150 | for _, tc := range testCases { 151 | t.Run(tc.name, func(t *testing.T) { 152 | mockBuilder := new(testdata.MockBuilder) 153 | mockBuilder.On("Build").Return(nil) 154 | 155 | mockRunner := new(testdata.MockRunner) 156 | cmd := &exec.Cmd{} 157 | runnerErrorsChan := make(chan error) 158 | mockRunner.On("Run").Return(cmd, nil) 159 | mockRunner.On("Kill").Return(nil) 160 | mockRunner.On("Errors").Return(runnerErrorsChan) 161 | mockRunner.On("ExitStatus").Return(tc.exitStatus) 162 | if tc.restart { 163 | mockRunner.On("Exited").Return(true) 164 | } 165 | 166 | mockWatcher := new(testdata.MockWacther) 167 | watcherErrorsChan := make(chan error) 168 | watcherEvetnsChan := make(chan string) 169 | mockWatcher.On("Errors").Return(watcherErrorsChan) 170 | mockWatcher.On("Events").Return(watcherEvetnsChan) 171 | 172 | dir := filepath.Join("testdata", "server") 173 | 174 | cfg := &Config{ 175 | BinName: "test-srv", 176 | BuildPath: dir, 177 | NoRestartOn: tc.noRestartOn, 178 | } 179 | 180 | chOSSiginal := make(chan os.Signal, 2) 181 | go func() { 182 | time.Sleep(1 * time.Second) 183 | runnerErrorsChan <- errors.New("runner-error") 184 | time.Sleep(1 * time.Second) 185 | chOSSiginal <- syscall.SIGINT 186 | }() 187 | err := run(cfg, chOSSiginal, mockBuilder, mockRunner, mockWatcher) 188 | assert.NotNil(t, err, "build error") 189 | assert.Equal(t, "OS signal: interrupt", err.Error()) 190 | mockBuilder.AssertExpectations(t) 191 | mockRunner.AssertExpectations(t) 192 | mockWatcher.AssertExpectations(t) 193 | }) 194 | } 195 | } 196 | 197 | func TestGaperRestartExited(t *testing.T) { 198 | mockBuilder := new(testdata.MockBuilder) 199 | mockBuilder.On("Build").Return(nil) 200 | 201 | mockRunner := new(testdata.MockRunner) 202 | cmd := &exec.Cmd{} 203 | mockRunner.On("Run").Return(cmd, nil) 204 | mockRunner.On("Exited").Return(true) 205 | 206 | err := restart(mockBuilder, mockRunner) 207 | assert.Nil(t, err, "restart error") 208 | mockBuilder.AssertExpectations(t) 209 | mockRunner.AssertExpectations(t) 210 | } 211 | 212 | func TestGaperRestartNotExited(t *testing.T) { 213 | mockBuilder := new(testdata.MockBuilder) 214 | mockBuilder.On("Build").Return(nil) 215 | 216 | mockRunner := new(testdata.MockRunner) 217 | cmd := &exec.Cmd{} 218 | mockRunner.On("Run").Return(cmd, nil) 219 | mockRunner.On("Kill").Return(nil) 220 | mockRunner.On("Exited").Return(false) 221 | 222 | err := restart(mockBuilder, mockRunner) 223 | assert.Nil(t, err, "restart error") 224 | mockBuilder.AssertExpectations(t) 225 | mockRunner.AssertExpectations(t) 226 | } 227 | 228 | func TestGaperRestartNotExitedKillFail(t *testing.T) { 229 | mockBuilder := new(testdata.MockBuilder) 230 | 231 | mockRunner := new(testdata.MockRunner) 232 | mockRunner.On("Kill").Return(errors.New("kill-error")) 233 | mockRunner.On("Exited").Return(false) 234 | 235 | err := restart(mockBuilder, mockRunner) 236 | assert.NotNil(t, err, "restart error") 237 | assert.Equal(t, "kill error: kill-error", err.Error()) 238 | mockBuilder.AssertExpectations(t) 239 | mockRunner.AssertExpectations(t) 240 | } 241 | 242 | func TestGaperRestartBuildFail(t *testing.T) { 243 | mockBuilder := new(testdata.MockBuilder) 244 | mockBuilder.On("Build").Return(errors.New("build-error")) 245 | 246 | mockRunner := new(testdata.MockRunner) 247 | mockRunner.On("Exited").Return(true) 248 | 249 | err := restart(mockBuilder, mockRunner) 250 | assert.Nil(t, err, "restart error") 251 | mockBuilder.AssertExpectations(t) 252 | mockRunner.AssertExpectations(t) 253 | } 254 | 255 | func TestGaperRestartRunFail(t *testing.T) { 256 | mockBuilder := new(testdata.MockBuilder) 257 | mockBuilder.On("Build").Return(nil) 258 | 259 | mockRunner := new(testdata.MockRunner) 260 | cmd := &exec.Cmd{} 261 | mockRunner.On("Run").Return(cmd, errors.New("run-error")) 262 | mockRunner.On("Exited").Return(true) 263 | 264 | err := restart(mockBuilder, mockRunner) 265 | assert.Nil(t, err, "restart error") 266 | mockBuilder.AssertExpectations(t) 267 | mockRunner.AssertExpectations(t) 268 | } 269 | 270 | func TestGaperFailBadBuildArgsMerged(t *testing.T) { // nolint: dupl 271 | args := &Config{ 272 | BuildArgsMerged: "foo '", 273 | } 274 | chOSSiginal := make(chan os.Signal, 2) 275 | 276 | err := Run(args, chOSSiginal) 277 | assert.NotNil(t, err, "run error") 278 | assert.Equal(t, "invalid command line string", err.Error()) 279 | } 280 | 281 | func TestGaperFailBadProgramArgsMerged(t *testing.T) { // nolint: dupl 282 | args := &Config{ 283 | ProgramArgsMerged: "foo '", 284 | } 285 | chOSSiginal := make(chan os.Signal, 2) 286 | 287 | err := Run(args, chOSSiginal) 288 | assert.NotNil(t, err, "run error") 289 | assert.Equal(t, "invalid command line string", err.Error()) 290 | } 291 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/maxcnunes/gaper 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/fatih/color v1.7.0 7 | github.com/mattn/go-colorable v0.0.9 // indirect 8 | github.com/mattn/go-isatty v0.0.3 // indirect 9 | github.com/mattn/go-shellwords v1.0.3 10 | github.com/mattn/go-zglob v0.0.0-20180607075734-49693fbb3fe3 11 | github.com/stretchr/objx v0.1.1 // indirect 12 | github.com/stretchr/testify v1.4.0 13 | github.com/urfave/cli/v2 v2.11.1 14 | golang.org/x/sys v0.0.0-20180616030259-6c888cc515d3 // indirect 15 | gopkg.in/yaml.v2 v2.4.0 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 2 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 4 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= 7 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 8 | github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= 9 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 10 | github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI= 11 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 12 | github.com/mattn/go-shellwords v1.0.3 h1:K/VxK7SZ+cvuPgFSLKi5QPI9Vr/ipOf4C1gN+ntueUk= 13 | github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= 14 | github.com/mattn/go-zglob v0.0.0-20180607075734-49693fbb3fe3 h1:GWnsQiFbiQ7lREZbKkiJC6xxbymvny8GKtpdkPxjB6o= 15 | github.com/mattn/go-zglob v0.0.0-20180607075734-49693fbb3fe3/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= 16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 17 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 18 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 19 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 20 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 21 | github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= 22 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 23 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 24 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 25 | github.com/urfave/cli/v2 v2.11.1 h1:UKK6SP7fV3eKOefbS87iT9YHefv7iB/53ih6e+GNAsE= 26 | github.com/urfave/cli/v2 v2.11.1/go.mod h1:f8iq5LtQ/bLxafbdBSLPPNsgaW0l/2fYYEHhAyPlwvo= 27 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= 28 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 29 | golang.org/x/sys v0.0.0-20180616030259-6c888cc515d3 h1:FCfAlbS73+IQQJktaKGHldMdL2bGDVpm+OrCEbVz1f4= 30 | golang.org/x/sys v0.0.0-20180616030259-6c888cc515d3/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 31 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 32 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 33 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 35 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 36 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 37 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 38 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 39 | -------------------------------------------------------------------------------- /gopher-gaper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxcnunes/gaper/c44dd5995248f02773cfacf3964c2b45544ed963/gopher-gaper.png -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package gaper 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/fatih/color" 8 | ) 9 | 10 | // logger use by the whole package 11 | var logger = newLogger("gaper") 12 | 13 | // Logger give access to external packages to use gaper logger 14 | func Logger() *LoggerEntity { 15 | return logger 16 | } 17 | 18 | // LoggerEntity used by gaper 19 | type LoggerEntity struct { 20 | verbose bool 21 | logDebug *log.Logger 22 | logInfo *log.Logger 23 | logError *log.Logger 24 | } 25 | 26 | // newLogger creates a new logger 27 | func newLogger(prefix string) *LoggerEntity { 28 | prefix = "[" + prefix + "] " 29 | return &LoggerEntity{ 30 | verbose: false, 31 | logDebug: log.New(os.Stdout, prefix, 0), 32 | logInfo: log.New(os.Stdout, color.CyanString(prefix), 0), 33 | logError: log.New(os.Stdout, color.RedString(prefix), 0), 34 | } 35 | } 36 | 37 | // Verbose toggle this logger verbosity 38 | func (l *LoggerEntity) Verbose(verbose bool) { 39 | l.verbose = verbose 40 | } 41 | 42 | // Debug logs a debug message 43 | func (l *LoggerEntity) Debug(v ...interface{}) { 44 | if l.verbose { 45 | l.logDebug.Println(v...) 46 | } 47 | } 48 | 49 | // Debugf logs a debug message with format 50 | func (l *LoggerEntity) Debugf(format string, v ...interface{}) { 51 | if l.verbose { 52 | l.logDebug.Printf(format, v...) 53 | } 54 | } 55 | 56 | // Info logs a info message 57 | func (l *LoggerEntity) Info(v ...interface{}) { 58 | l.logInfo.Println(v...) 59 | } 60 | 61 | // Error logs an error message 62 | func (l *LoggerEntity) Error(v ...interface{}) { 63 | l.logError.Println(v...) 64 | } 65 | 66 | // Errorf logs and error message with format 67 | func (l *LoggerEntity) Errorf(format string, v ...interface{}) { 68 | l.logError.Printf(format, v...) 69 | } 70 | -------------------------------------------------------------------------------- /logger_test.go: -------------------------------------------------------------------------------- 1 | package gaper 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestLoggerDefault(t *testing.T) { 10 | l := newLogger("gaper-test") 11 | assert.Equal(t, l.verbose, false) 12 | } 13 | 14 | func TestLoggerEnableVerbose(t *testing.T) { 15 | l := newLogger("gaper-test") 16 | l.Verbose(true) 17 | assert.Equal(t, l.verbose, true) 18 | } 19 | 20 | func TestLoggerRunAllLogsWithoutVerbose(t *testing.T) { 21 | // no asserts, just checking it doesn't crash 22 | l := newLogger("gaper-test") 23 | l.Debug("debug") 24 | l.Debugf("%s", "debug") 25 | l.Info("info") 26 | l.Error("error") 27 | l.Errorf("%s", "error") 28 | } 29 | 30 | func TestLoggerRunAllLogsWithVerbose(t *testing.T) { 31 | // no asserts, just checking it doesn't crash 32 | l := newLogger("gaper-test") 33 | l.Verbose(true) 34 | l.Debug("debug") 35 | l.Debugf("%s", "debug") 36 | l.Info("info") 37 | l.Error("error") 38 | l.Errorf("%s", "error") 39 | } 40 | -------------------------------------------------------------------------------- /runner.go: -------------------------------------------------------------------------------- 1 | package gaper 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "runtime" 10 | "syscall" 11 | "time" 12 | ) 13 | 14 | // OSWindows is used to check if current OS is a Windows 15 | const OSWindows = "windows" 16 | 17 | // os errors 18 | var errFinished = errors.New("os: process already finished") 19 | 20 | // Runner is a interface for the run process 21 | type Runner interface { 22 | Run() (*exec.Cmd, error) 23 | Kill() error 24 | Errors() chan error 25 | Exited() bool 26 | IsRunning() bool 27 | ExitStatus(err error) int 28 | } 29 | 30 | type runner struct { 31 | bin string 32 | args []string 33 | writerStdout io.Writer 34 | writerStderr io.Writer 35 | command *exec.Cmd 36 | starttime time.Time 37 | errors chan error 38 | end chan bool // used internally by Kill to wait a process die 39 | } 40 | 41 | // NewRunner creates a new runner 42 | func NewRunner(wStdout io.Writer, wStderr io.Writer, bin string, args []string) Runner { 43 | return &runner{ 44 | bin: bin, 45 | args: args, 46 | writerStdout: wStdout, 47 | writerStderr: wStderr, 48 | starttime: time.Now(), 49 | errors: make(chan error), 50 | end: make(chan bool), 51 | } 52 | } 53 | 54 | // Run executes the project binary 55 | func (r *runner) Run() (*exec.Cmd, error) { 56 | logger.Info("Starting program") 57 | 58 | if r.command != nil && !r.Exited() { 59 | return r.command, nil 60 | } 61 | 62 | if err := r.runBin(); err != nil { 63 | return nil, fmt.Errorf("error running: %v", err) 64 | } 65 | 66 | return r.command, nil 67 | } 68 | 69 | // Kill the current process running for the Golang project 70 | func (r *runner) Kill() error { // nolint gocyclo 71 | if r.command == nil || r.command.Process == nil { 72 | return nil 73 | } 74 | 75 | done := make(chan error) 76 | go func() { 77 | <-r.end 78 | close(done) 79 | }() 80 | 81 | // Trying a "soft" kill first 82 | if runtime.GOOS == OSWindows { 83 | if err := r.command.Process.Kill(); err != nil { 84 | return err 85 | } 86 | } else if err := r.command.Process.Signal(os.Interrupt); err != nil { 87 | return err 88 | } 89 | 90 | // Wait for our process to die before we return or hard kill after 3 sec 91 | select { 92 | case <-time.After(3 * time.Second): 93 | if err := r.command.Process.Kill(); err != nil { 94 | errMsg := err.Error() 95 | // ignore error if the processed has been killed already 96 | if errMsg != errFinished.Error() && errMsg != os.ErrInvalid.Error() { 97 | return fmt.Errorf("failed to kill: %v", err) 98 | } 99 | } 100 | case <-done: 101 | } 102 | 103 | r.command = nil 104 | return nil 105 | } 106 | 107 | // Exited checks if the process has exited 108 | func (r *runner) Exited() bool { 109 | return r.command != nil && r.command.ProcessState != nil && r.command.ProcessState.Exited() 110 | } 111 | 112 | // IsRunning returns if the process is running 113 | func (r *runner) IsRunning() bool { 114 | return r.command != nil && r.command.Process != nil && r.command.Process.Pid > 0 115 | } 116 | 117 | // Errors get errors occurred during the build 118 | func (r *runner) Errors() chan error { 119 | return r.errors 120 | } 121 | 122 | // ExitStatus resolves the exit status 123 | func (r *runner) ExitStatus(err error) int { 124 | var exitStatus int 125 | if exiterr, ok := err.(*exec.ExitError); ok { 126 | if status, oks := exiterr.Sys().(syscall.WaitStatus); oks { 127 | exitStatus = status.ExitStatus() 128 | } 129 | } 130 | 131 | return exitStatus 132 | } 133 | 134 | func (r *runner) runBin() error { 135 | r.command = exec.Command(r.bin, r.args...) // nolint gas 136 | stdout, err := r.command.StdoutPipe() 137 | if err != nil { 138 | return err 139 | } 140 | 141 | stderr, err := r.command.StderrPipe() 142 | if err != nil { 143 | return err 144 | } 145 | 146 | // TODO: handle or log errors 147 | go io.Copy(r.writerStdout, stdout) // nolint errcheck 148 | go io.Copy(r.writerStderr, stderr) // nolint errcheck 149 | 150 | err = r.command.Start() 151 | if err != nil { 152 | return err 153 | } 154 | 155 | r.starttime = time.Now() 156 | 157 | // wait for exit errors 158 | go func() { 159 | r.errors <- r.command.Wait() 160 | r.end <- true 161 | }() 162 | 163 | return nil 164 | } 165 | -------------------------------------------------------------------------------- /runner_test.go: -------------------------------------------------------------------------------- 1 | package gaper 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "runtime" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestRunnerSuccessRun(t *testing.T) { 16 | stdout := bytes.NewBufferString("") 17 | stderr := bytes.NewBufferString("") 18 | pArgs := []string{} 19 | bin := filepath.Join("testdata", "print-gaper") 20 | if runtime.GOOS == OSWindows { 21 | bin += ".bat" 22 | } 23 | 24 | runner := NewRunner(stdout, stderr, bin, pArgs) 25 | 26 | cmd, err := runner.Run() 27 | assert.Nil(t, err, "error running binary") 28 | assert.NotNil(t, cmd.Process, "process has not started") 29 | 30 | errCmd := <-runner.Errors() 31 | assert.Nil(t, errCmd, "async error running binary") 32 | assert.Contains(t, stdout.String(), "Gaper Test Message") 33 | assert.Equal(t, stderr.String(), "") 34 | } 35 | 36 | func TestRunnerSuccessKill(t *testing.T) { 37 | bin := filepath.Join("testdata", "print-gaper") 38 | if runtime.GOOS == OSWindows { 39 | bin += ".bat" 40 | } 41 | 42 | runner := NewRunner(os.Stdout, os.Stderr, bin, nil) 43 | 44 | _, err := runner.Run() 45 | assert.Nil(t, err, "error running binary") 46 | 47 | err = runner.Kill() 48 | assert.Nil(t, err, "error killing program") 49 | 50 | errCmd := <-runner.Errors() 51 | assert.NotNil(t, errCmd, "kill program") 52 | } 53 | 54 | func TestRunnerExitedNotStarted(t *testing.T) { 55 | runner := NewRunner(os.Stdout, os.Stderr, "", nil) 56 | assert.Equal(t, runner.Exited(), false) 57 | } 58 | 59 | func TestRunnerExitStatusNonExitError(t *testing.T) { 60 | runner := NewRunner(os.Stdout, os.Stderr, "", nil) 61 | err := errors.New("non exec.ExitError") 62 | assert.Equal(t, runner.ExitStatus(err), 0) 63 | } 64 | 65 | func testExit() { 66 | os.Exit(1) 67 | } 68 | 69 | func TestRunnerExitStatusExitError(t *testing.T) { 70 | if os.Getenv("TEST_EXIT") == "1" { 71 | testExit() 72 | return 73 | } 74 | 75 | cmd := exec.Command(os.Args[0], "-test.run=TestRunnerExitStatusExitError") 76 | cmd.Env = append(os.Environ(), "TEST_EXIT=1") 77 | err := cmd.Run() 78 | 79 | runner := NewRunner(os.Stdout, os.Stderr, "", nil) 80 | assert.Equal(t, runner.ExitStatus(err), 1) 81 | } 82 | -------------------------------------------------------------------------------- /testdata/.hidden-file: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxcnunes/gaper/c44dd5995248f02773cfacf3964c2b45544ed963/testdata/.hidden-file -------------------------------------------------------------------------------- /testdata/.hidden-folder/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxcnunes/gaper/c44dd5995248f02773cfacf3964c2b45544ed963/testdata/.hidden-folder/.gitkeep -------------------------------------------------------------------------------- /testdata/build-failure/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // nolint 4 | func main() error { 5 | } 6 | -------------------------------------------------------------------------------- /testdata/hidden-test/.hiden-file: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxcnunes/gaper/c44dd5995248f02773cfacf3964c2b45544ed963/testdata/hidden-test/.hiden-file -------------------------------------------------------------------------------- /testdata/hidden-test/.hiden-folder/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxcnunes/gaper/c44dd5995248f02773cfacf3964c2b45544ed963/testdata/hidden-test/.hiden-folder/.gitkeep -------------------------------------------------------------------------------- /testdata/ignore-test-name.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxcnunes/gaper/c44dd5995248f02773cfacf3964c2b45544ed963/testdata/ignore-test-name.txt -------------------------------------------------------------------------------- /testdata/ignore-test-name/main.go: -------------------------------------------------------------------------------- 1 | package ignoretestname 2 | -------------------------------------------------------------------------------- /testdata/mocks.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | import ( 4 | "os/exec" 5 | 6 | "github.com/stretchr/testify/mock" 7 | ) 8 | 9 | // MockBuilder ... 10 | type MockBuilder struct { 11 | mock.Mock 12 | } 13 | 14 | // Build ... 15 | func (m *MockBuilder) Build() error { 16 | args := m.Called() 17 | return args.Error(0) 18 | } 19 | 20 | // Binary ... 21 | func (m *MockBuilder) Binary() string { 22 | args := m.Called() 23 | return args.String(0) 24 | } 25 | 26 | // MockRunner ... 27 | type MockRunner struct { 28 | mock.Mock 29 | } 30 | 31 | // Run ... 32 | func (m *MockRunner) Run() (*exec.Cmd, error) { 33 | args := m.Called() 34 | cmdArg := args.Get(0) 35 | if cmdArg == nil { 36 | return nil, args.Error(1) 37 | } 38 | 39 | return cmdArg.(*exec.Cmd), args.Error(1) 40 | } 41 | 42 | // Kill ... 43 | func (m *MockRunner) Kill() error { 44 | args := m.Called() 45 | return args.Error(0) 46 | } 47 | 48 | // Errors ... 49 | func (m *MockRunner) Errors() chan error { 50 | args := m.Called() 51 | return args.Get(0).(chan error) 52 | } 53 | 54 | // Exited ... 55 | func (m *MockRunner) Exited() bool { 56 | args := m.Called() 57 | return args.Bool(0) 58 | } 59 | 60 | // IsRunning ... 61 | func (m *MockRunner) IsRunning() bool { 62 | args := m.Called() 63 | return args.Bool(0) 64 | } 65 | 66 | // ExitStatus ... 67 | func (m *MockRunner) ExitStatus(err error) int { 68 | args := m.Called() 69 | return args.Int(0) 70 | } 71 | 72 | // MockWacther ... 73 | type MockWacther struct { 74 | mock.Mock 75 | } 76 | 77 | // Watch ... 78 | func (m *MockWacther) Watch() {} 79 | 80 | // Events ... 81 | func (m *MockWacther) Events() chan string { 82 | args := m.Called() 83 | return args.Get(0).(chan string) 84 | } 85 | 86 | // Errors ... 87 | func (m *MockWacther) Errors() chan error { 88 | args := m.Called() 89 | return args.Get(0).(chan error) 90 | } 91 | -------------------------------------------------------------------------------- /testdata/print-gaper: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | sleep 3 3 | echo "Gaper Test Message" 4 | -------------------------------------------------------------------------------- /testdata/print-gaper.bat: -------------------------------------------------------------------------------- 1 | timeout 3 2>NUL 2 | @echo Gaper Test Message 3 | -------------------------------------------------------------------------------- /testdata/server/data.txt: -------------------------------------------------------------------------------- 1 | test 2 | -------------------------------------------------------------------------------- /testdata/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "html" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | var Version string 11 | 12 | func main() { 13 | http.HandleFunc("/foo", func(w http.ResponseWriter, r *http.Request) { 14 | fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path)) // nolint gas 15 | }) 16 | 17 | http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) { 18 | log.Fatal("Forced failure") 19 | }) 20 | 21 | log.Println("Starting server: Version", Version) 22 | log.Fatal(http.ListenAndServe(":8080", nil)) 23 | } 24 | -------------------------------------------------------------------------------- /testdata/server/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // an empty test file just to check during tests we can ignore _test.go files 4 | -------------------------------------------------------------------------------- /testdata/test-duplicated-paths/file-1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxcnunes/gaper/c44dd5995248f02773cfacf3964c2b45544ed963/testdata/test-duplicated-paths/file-1.txt -------------------------------------------------------------------------------- /testdata/test-duplicated-paths/file-2.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxcnunes/gaper/c44dd5995248f02773cfacf3964c2b45544ed963/testdata/test-duplicated-paths/file-2.txt -------------------------------------------------------------------------------- /watcher.go: -------------------------------------------------------------------------------- 1 | package gaper 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "regexp" 9 | "strings" 10 | "time" 11 | 12 | zglob "github.com/mattn/go-zglob" 13 | ) 14 | 15 | // Watcher is a interface for the watch process 16 | type Watcher interface { 17 | Watch() 18 | Errors() chan error 19 | Events() chan string 20 | } 21 | 22 | // watcher is a interface for the watch process 23 | type watcher struct { 24 | defaultIgnore bool 25 | pollInterval int 26 | watchItems map[string]bool 27 | ignoreItems map[string]bool 28 | allowedExtensions map[string]bool 29 | events chan string 30 | errors chan error 31 | } 32 | 33 | // WatcherConfig defines the settings available for the watcher 34 | type WatcherConfig struct { 35 | DefaultIgnore bool 36 | PollInterval int 37 | WatchItems []string 38 | IgnoreItems []string 39 | Extensions []string 40 | } 41 | 42 | // NewWatcher creates a new watcher 43 | func NewWatcher(cfg WatcherConfig) (Watcher, error) { 44 | if cfg.PollInterval == 0 { 45 | cfg.PollInterval = DefaultPoolInterval 46 | } 47 | 48 | if len(cfg.Extensions) == 0 { 49 | cfg.Extensions = DefaultExtensions 50 | } 51 | 52 | allowedExts := make(map[string]bool) 53 | for _, ext := range cfg.Extensions { 54 | allowedExts["."+ext] = true 55 | } 56 | 57 | watchPaths, err := resolvePaths(cfg.WatchItems, allowedExts) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | ignorePaths, err := resolvePaths(cfg.IgnoreItems, allowedExts) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | logger.Debugf("Resolved watch paths: %v", watchPaths) 68 | logger.Debugf("Resolved ignore paths: %v", ignorePaths) 69 | return &watcher{ 70 | events: make(chan string), 71 | errors: make(chan error), 72 | defaultIgnore: cfg.DefaultIgnore, 73 | pollInterval: cfg.PollInterval, 74 | watchItems: watchPaths, 75 | ignoreItems: ignorePaths, 76 | allowedExtensions: allowedExts, 77 | }, nil 78 | } 79 | 80 | var startTime = time.Now() 81 | var errDetectedChange = errors.New("done") 82 | 83 | // Watch starts watching for file changes 84 | func (w *watcher) Watch() { 85 | for { 86 | for watchPath := range w.watchItems { 87 | fileChanged, err := w.scanChange(watchPath) 88 | if err != nil { 89 | w.errors <- err 90 | return 91 | } 92 | 93 | if fileChanged != "" { 94 | w.events <- fileChanged 95 | startTime = time.Now() 96 | } 97 | } 98 | 99 | time.Sleep(time.Duration(w.pollInterval) * time.Millisecond) 100 | } 101 | } 102 | 103 | // Events get events occurred during the watching 104 | // these events are emitted only a file changing is detected 105 | func (w *watcher) Events() chan string { 106 | return w.events 107 | } 108 | 109 | // Errors get errors occurred during the watching 110 | func (w *watcher) Errors() chan error { 111 | return w.errors 112 | } 113 | 114 | func (w *watcher) scanChange(watchPath string) (string, error) { 115 | logger.Debug("Watching ", watchPath) 116 | 117 | var fileChanged string 118 | 119 | err := filepath.Walk(watchPath, func(path string, info os.FileInfo, err error) error { 120 | if err != nil { 121 | // Ignore attempt to acess go temporary unmask 122 | if strings.Contains(err.Error(), "-go-tmp-umask") { 123 | return filepath.SkipDir 124 | } 125 | 126 | return fmt.Errorf("couldn't walk to path \"%s\": %v", path, err) 127 | } 128 | 129 | if w.ignoreFile(path, info) { 130 | return skipFile(info) 131 | } 132 | 133 | ext := filepath.Ext(path) 134 | if _, ok := w.allowedExtensions[ext]; ok && info.ModTime().After(startTime) { 135 | fileChanged = path 136 | return errDetectedChange 137 | } 138 | 139 | return nil 140 | }) 141 | 142 | if err != nil && err != errDetectedChange { 143 | return "", err 144 | } 145 | 146 | return fileChanged, nil 147 | } 148 | 149 | func (w *watcher) ignoreFile(path string, info os.FileInfo) bool { 150 | // if a file has been deleted after gaper was watching it 151 | // info will be nil in the other iterations 152 | if info == nil { 153 | return true 154 | } 155 | 156 | // check if preset ignore is enabled 157 | if w.defaultIgnore { 158 | // check for hidden files and directories 159 | if name := info.Name(); name[0] == '.' && name != "." { 160 | return true 161 | } 162 | 163 | // check if it is a Go testing file 164 | if strings.HasSuffix(path, "_test.go") { 165 | return true 166 | } 167 | 168 | // check if it is the vendor folder 169 | if info.IsDir() && info.Name() == "vendor" { 170 | return true 171 | } 172 | } 173 | 174 | if _, ignored := w.ignoreItems[path]; ignored { 175 | return true 176 | } 177 | 178 | return false 179 | } 180 | 181 | func resolvePaths(paths []string, extensions map[string]bool) (map[string]bool, error) { 182 | result := map[string]bool{} 183 | 184 | for _, path := range paths { 185 | matches := []string{path} 186 | 187 | isGlob := strings.Contains(path, "*") 188 | if isGlob { 189 | var err error 190 | matches, err = zglob.Glob(path) 191 | if err != nil { 192 | return nil, fmt.Errorf("couldn't resolve glob path \"%s\": %v", path, err) 193 | } 194 | } 195 | 196 | for _, match := range matches { 197 | // ignore existing files that don't match the allowed extensions 198 | if f, err := os.Stat(match); !os.IsNotExist(err) && !f.IsDir() { 199 | if ext := filepath.Ext(match); ext != "" { 200 | if _, ok := extensions[ext]; !ok { 201 | continue 202 | } 203 | } 204 | } 205 | 206 | if _, ok := result[match]; !ok { 207 | result[match] = true 208 | } 209 | } 210 | } 211 | 212 | removeOverlappedPaths(result) 213 | 214 | return result, nil 215 | } 216 | 217 | // remove overlapped paths so it makes the scan for changes later faster and simpler 218 | func removeOverlappedPaths(mapPaths map[string]bool) { 219 | startDot := regexp.MustCompile(`^\./`) 220 | 221 | for p1 := range mapPaths { 222 | p1 = startDot.ReplaceAllString(p1, "") 223 | 224 | // skip to next item if this path has already been checked 225 | if v, ok := mapPaths[p1]; ok && !v { 226 | continue 227 | } 228 | 229 | for p2 := range mapPaths { 230 | p2 = startDot.ReplaceAllString(p2, "") 231 | 232 | if p1 == p2 { 233 | continue 234 | } 235 | 236 | if strings.HasPrefix(p2, p1) { 237 | mapPaths[p2] = false 238 | } else if strings.HasPrefix(p1, p2) { 239 | mapPaths[p1] = false 240 | } 241 | } 242 | } 243 | 244 | // cleanup path list 245 | for p := range mapPaths { 246 | if !mapPaths[p] { 247 | delete(mapPaths, p) 248 | } 249 | } 250 | } 251 | 252 | func skipFile(info os.FileInfo) error { 253 | if info.IsDir() { 254 | return filepath.SkipDir 255 | } 256 | return nil 257 | } 258 | -------------------------------------------------------------------------------- /watcher_test.go: -------------------------------------------------------------------------------- 1 | package gaper 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestWatcherDefaultValues(t *testing.T) { 14 | pollInterval := 0 15 | watchItems := []string{filepath.Join("testdata", "server")} 16 | var ignoreItems []string 17 | var extensions []string 18 | 19 | wCfg := WatcherConfig{ 20 | DefaultIgnore: true, 21 | PollInterval: pollInterval, 22 | WatchItems: watchItems, 23 | IgnoreItems: ignoreItems, 24 | Extensions: extensions, 25 | } 26 | wt, err := NewWatcher(wCfg) 27 | 28 | expectedPath := "testdata/server" 29 | if runtime.GOOS == OSWindows { 30 | expectedPath = "testdata\\server" 31 | } 32 | 33 | w := wt.(*watcher) 34 | assert.Nil(t, err, "wacher error") 35 | assert.Equal(t, 500, w.pollInterval) 36 | assert.Equal(t, map[string]bool{expectedPath: true}, w.watchItems) 37 | assert.Len(t, w.ignoreItems, 0) 38 | assert.Equal(t, map[string]bool{".go": true}, w.allowedExtensions) 39 | } 40 | 41 | func TestWatcherGlobPath(t *testing.T) { 42 | pollInterval := 0 43 | watchItems := []string{filepath.Join("testdata", "server")} 44 | ignoreItems := []string{"./testdata/**/*_test.go"} 45 | var extensions []string 46 | 47 | wCfg := WatcherConfig{ 48 | DefaultIgnore: true, 49 | PollInterval: pollInterval, 50 | WatchItems: watchItems, 51 | IgnoreItems: ignoreItems, 52 | Extensions: extensions, 53 | } 54 | wt, err := NewWatcher(wCfg) 55 | assert.Nil(t, err, "wacher error") 56 | w := wt.(*watcher) 57 | assert.Equal(t, map[string]bool{"testdata/server/main_test.go": true}, w.ignoreItems) 58 | } 59 | 60 | func TestWatcherRemoveOverlapdPaths(t *testing.T) { 61 | pollInterval := 0 62 | watchItems := []string{filepath.Join("testdata", "server")} 63 | ignoreItems := []string{"./testdata/server/**/*", "./testdata/server"} 64 | var extensions []string 65 | 66 | wCfg := WatcherConfig{ 67 | DefaultIgnore: true, 68 | PollInterval: pollInterval, 69 | WatchItems: watchItems, 70 | IgnoreItems: ignoreItems, 71 | Extensions: extensions, 72 | } 73 | wt, err := NewWatcher(wCfg) 74 | assert.Nil(t, err, "wacher error") 75 | w := wt.(*watcher) 76 | assert.Equal(t, map[string]bool{"./testdata/server": true}, w.ignoreItems) 77 | } 78 | 79 | func TestWatcherWatchChange(t *testing.T) { 80 | srvdir := filepath.Join("testdata", "server") 81 | hiddendir := filepath.Join("testdata", "hidden-test") 82 | 83 | hiddenfile1 := filepath.Join("testdata", ".hidden-file") 84 | hiddenfile2 := filepath.Join("testdata", ".hidden-folder", ".gitkeep") 85 | mainfile := filepath.Join("testdata", "server", "main.go") 86 | testfile := filepath.Join("testdata", "server", "main_test.go") 87 | 88 | pollInterval := 0 89 | watchItems := []string{srvdir, hiddendir} 90 | ignoreItems := []string{testfile} 91 | extensions := []string{"go"} 92 | 93 | wCfg := WatcherConfig{ 94 | DefaultIgnore: true, 95 | PollInterval: pollInterval, 96 | WatchItems: watchItems, 97 | IgnoreItems: ignoreItems, 98 | Extensions: extensions, 99 | } 100 | w, err := NewWatcher(wCfg) 101 | assert.Nil(t, err, "wacher error") 102 | 103 | go w.Watch() 104 | time.Sleep(time.Millisecond * 500) 105 | 106 | // update hidden files and dirs to check builtin hidden ignore is working 107 | err = os.Chtimes(hiddenfile1, time.Now(), time.Now()) 108 | assert.Nil(t, err, "chtimes error") 109 | 110 | err = os.Chtimes(hiddenfile2, time.Now(), time.Now()) 111 | assert.Nil(t, err, "chtimes error") 112 | 113 | // update testfile first to check ignore is working 114 | err = os.Chtimes(testfile, time.Now(), time.Now()) 115 | assert.Nil(t, err, "chtimes error") 116 | 117 | time.Sleep(time.Millisecond * 500) 118 | err = os.Chtimes(mainfile, time.Now(), time.Now()) 119 | assert.Nil(t, err, "chtimes error") 120 | 121 | select { 122 | case event := <-w.Events(): 123 | assert.Equal(t, mainfile, event) 124 | case err := <-w.Errors(): 125 | assert.Nil(t, err, "wacher event error") 126 | } 127 | } 128 | 129 | func TestWatcherIgnoreFile(t *testing.T) { 130 | testCases := []struct { 131 | name, file, ignoreFile string 132 | defaultIgnore, expectIgnore bool 133 | }{ 134 | { 135 | name: "with default ignore enabled it ignores vendor folder", 136 | file: "vendor", 137 | defaultIgnore: true, 138 | expectIgnore: true, 139 | }, 140 | { 141 | name: "without default ignore enabled it does not ignore vendor folder", 142 | file: "vendor", 143 | defaultIgnore: false, 144 | expectIgnore: false, 145 | }, 146 | { 147 | name: "with default ignore enabled it ignores test file", 148 | file: filepath.Join("testdata", "server", "main_test.go"), 149 | defaultIgnore: true, 150 | expectIgnore: true, 151 | }, 152 | { 153 | name: "with default ignore enabled it does no ignore non test files which have test in the name", 154 | file: filepath.Join("testdata", "ignore-test-name.txt"), 155 | defaultIgnore: true, 156 | expectIgnore: false, 157 | }, 158 | { 159 | name: "without default ignore enabled it does not ignore test file", 160 | file: filepath.Join("testdata", "server", "main_test.go"), 161 | defaultIgnore: false, 162 | expectIgnore: false, 163 | }, 164 | { 165 | name: "with default ignore enabled it ignores ignored items", 166 | file: filepath.Join("testdata", "server", "main.go"), 167 | ignoreFile: filepath.Join("testdata", "server", "main.go"), 168 | defaultIgnore: true, 169 | expectIgnore: true, 170 | }, 171 | { 172 | name: "without default ignore enabled it ignores ignored items", 173 | file: filepath.Join("testdata", "server", "main.go"), 174 | ignoreFile: filepath.Join("testdata", "server", "main.go"), 175 | defaultIgnore: false, 176 | expectIgnore: true, 177 | }, 178 | } 179 | 180 | // create vendor folder for testing 181 | if err := os.MkdirAll("vendor", os.ModePerm); err != nil { 182 | t.Fatal(err) 183 | } 184 | 185 | for _, tc := range testCases { 186 | t.Run(tc.name, func(t *testing.T) { 187 | srvdir := "." 188 | 189 | watchItems := []string{srvdir} 190 | ignoreItems := []string{} 191 | if len(tc.ignoreFile) > 0 { 192 | ignoreItems = append(ignoreItems, tc.ignoreFile) 193 | } 194 | extensions := []string{"go"} 195 | 196 | wCfg := WatcherConfig{ 197 | DefaultIgnore: tc.defaultIgnore, 198 | WatchItems: watchItems, 199 | IgnoreItems: ignoreItems, 200 | Extensions: extensions, 201 | } 202 | w, err := NewWatcher(wCfg) 203 | assert.Nil(t, err, "wacher error") 204 | 205 | wt := w.(*watcher) 206 | 207 | filePath := tc.file 208 | file, err := os.Open(filePath) 209 | if err != nil { 210 | t.Fatal(err) 211 | } 212 | 213 | fileInfo, err := file.Stat() 214 | if err != nil { 215 | t.Fatal(err) 216 | } 217 | 218 | assert.Equal(t, tc.expectIgnore, wt.ignoreFile(filePath, fileInfo)) 219 | }) 220 | } 221 | } 222 | 223 | func TestWatcherResolvePaths(t *testing.T) { 224 | testCases := []struct { 225 | name string 226 | paths []string 227 | extensions, expectPaths map[string]bool 228 | err error 229 | }{ 230 | { 231 | name: "remove duplicated paths", 232 | paths: []string{"testdata/test-duplicated-paths", "testdata/test-duplicated-paths"}, 233 | extensions: map[string]bool{".txt": true}, 234 | expectPaths: map[string]bool{"testdata/test-duplicated-paths": true}, 235 | }, 236 | { 237 | name: "remove duplicated paths from glob", 238 | paths: []string{"testdata/test-duplicated-paths", "testdata/test-duplicated-paths/**/*"}, 239 | extensions: map[string]bool{".txt": true}, 240 | expectPaths: map[string]bool{"testdata/test-duplicated-paths": true}, 241 | }, 242 | { 243 | name: "remove duplicated paths from glob with inverse order", 244 | paths: []string{"testdata/test-duplicated-paths/**/*", "testdata/test-duplicated-paths"}, 245 | extensions: map[string]bool{".txt": true}, 246 | expectPaths: map[string]bool{"testdata/test-duplicated-paths": true}, 247 | }, 248 | } 249 | 250 | for _, tc := range testCases { 251 | t.Run(tc.name, func(t *testing.T) { 252 | paths, err := resolvePaths(tc.paths, tc.extensions) 253 | if tc.err == nil { 254 | assert.Nil(t, err, "resolve path error") 255 | assert.Equal(t, tc.expectPaths, paths) 256 | } else { 257 | assert.Equal(t, tc.err, err) 258 | } 259 | }) 260 | } 261 | } 262 | --------------------------------------------------------------------------------