├── .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 | [](LICENSE.md)
14 | [](https://travis-ci.org/maxcnunes/gaper)
15 | [](https://ci.appveyor.com/project/maxcnunes/gaper)
16 | [](https://codecov.io/gh/maxcnunes/gaper)
17 | [](http://godoc.org/github.com/maxcnunes/gaper)
18 | [](https://goreportcard.com/report/github.com/maxcnunes/gaper)
19 | [](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 |
--------------------------------------------------------------------------------