├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── pull_request_template.md
├── .gitignore
├── .travis.yml
├── LICENSE
├── Makefile
├── README.md
├── go.mod
├── go.sum
├── logo.png
├── main.go
├── parser
├── result.go
└── result_test.go
└── platform
└── notifier.go
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | # Description
2 |
3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
4 |
5 | Fixes # (issue)
6 |
7 | ## Type of change
8 |
9 | Please delete options that are not relevant.
10 |
11 | - [ ] Bug fix (non-breaking change which fixes an issue)
12 | - [ ] New feature (non-breaking change which adds functionality)
13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
14 | - [ ] This change requires a documentation update
15 |
16 | # How Has This Been Tested?
17 |
18 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
19 |
20 | # Checklist:
21 |
22 | - [ ] My code follows the style guidelines of this project
23 | - [ ] I have performed a self-review of my own code
24 | - [ ] I have commented my code, particularly in hard-to-understand areas
25 | - [ ] I have made corresponding changes to the documentation
26 | - [ ] My changes generate no new warnings
27 | - [ ] I have added tests that prove my fix is effective or that my feature works
28 | - [ ] New and existing unit tests pass locally with my changes
29 | - [ ] Any dependent changes have been merged and published in downstream modules
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### Go ###
2 | # Binaries for programs and plugins
3 | *.exe
4 | *.exe~
5 | *.dll
6 | *.so
7 | *.dylib
8 |
9 | # Test binary, built with `go test -c`
10 | *.test
11 |
12 | # Output of the go coverage tool, specifically when used with LiteIDE
13 | *.out
14 |
15 | # Dependency directories (remove the comment below to include it)
16 | # vendor/
17 |
18 | ### Go Patch ###
19 | /vendor/
20 | /Godeps/
21 |
22 | ### macOS ###
23 | # General
24 | .DS_Store
25 | .AppleDouble
26 | .LSOverride
27 |
28 | # Icon must end with two \r
29 | Icon
30 |
31 | # Thumbnails
32 | ._*
33 |
34 | # Files that might appear in the root of a volume
35 | .DocumentRevisions-V100
36 | .fseventsd
37 | .Spotlight-V100
38 | .TemporaryItems
39 | .Trashes
40 | .VolumeIcon.icns
41 | .com.apple.timemachine.donotpresent
42 |
43 | # Directories potentially created on remote AFP share
44 | .AppleDB
45 | .AppleDesktop
46 | Network Trash Folder
47 | Temporary Items
48 | .apdisk
49 |
50 | # Binary Releases
51 | release/
52 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: go
2 | go: 1.12.x
3 | install: go get github.com/axcdnt/snitch
4 | before_script: make check
5 | script: make format test
6 | after_success: make build
7 | deploy:
8 | provider: releases
9 | api_key:
10 | secure: Hxn+EzSPz0kvd/2RScI6DMY2RYY1d7OFH21/ZlWltYmZg0uCNcF+svg8vxaJ2s1NSVgEn53Ar9pt+6wq4Dx8bDKt0ZV1IhUzkFDc7GHfRfxv/tj46GXkjgAKKfjB9nWSbMTK0I6iWcFBNtw5yxeGncq3GH/E8F+3tDRWNl/WK9DZTNFK/3U5vNI/MIcHbEHZdJszFiHQFDydNSZUcW98JkSge3sRVqE0DeUW8RL2bkSTi12yioVyIDnmjbEJQRGokeamW5GMAWDGnX/6ReFUnea7LFYUhhNVw3sPwQduyO+kfYT0Izf/HhbW3LBlE/NBIfTemo0omkW3puq2hl9ZWllI0hLnKl/QRNUmMDPd+j51AzP8gWGqFqZhwv6670SVCM1PLXVh7srsAFXtmnllkS5geSjifD/iSFd/g8/ciUE9egp5jBL/dPOBMUG24vOnLR3d1gTJhtnSHlOYzDdlXIspVXw7ByKuiebA4RZHaE8qoSbrT5NHzr9sNgofsdEeghYX103BrCHc8tFANNZHGOX/Qj5fQCTLexPyg8v91x0ON3RXeGo8ZSsw9iddsKYrLzD4Sk7MuOrFyUYkW1tIynjS7/K5PmLq0N8306VV5ZtkTSQsWEw9a5VTXg/pC3nOvFyB3IPzBMnNbvYGy3Hqv7XRVMmYygqhSa/mWxlUmkk=
11 | skip_cleanup: true
12 | file:
13 | - "release/snitch-linux-amd64"
14 | - "release/snitch-darwin-amd64"
15 | on:
16 | tags: true
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Gustavo Freitas
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 | LINUX := linux
2 | DARWIN := darwin
3 | OSARCH := amd64
4 | TRAVIS_TAG ?= $(shell git describe --abbrev=0)
5 |
6 | .PHONY: check format test build
7 |
8 | all: check format test build
9 |
10 | check:
11 | @echo ">> checking code"
12 | go vet ./...
13 |
14 | format:
15 | @echo ">> formatting code"
16 | go fmt ./...
17 |
18 | test:
19 | @echo ">> running tests"
20 | go test -v -cover ./...
21 |
22 | build:
23 | @echo ">> building binaries"
24 | GOOS=$(LINUX) GOARCH=$(OSARCH) go build -ldflags "-X main.version=$(TRAVIS_TAG)" -o release/snitch-$(LINUX)-$(OSARCH)
25 | GOOS=$(DARWIN) GOARCH=$(OSARCH) go build -ldflags "-X main.version=$(TRAVIS_TAG)" -o release/snitch-$(DARWIN)-$(OSARCH)
26 |
27 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Snitch
2 |
3 | [](https://github.com/axcdnt/snitch/issues)
4 | [](https://travis-ci.org/axcdnt/snitch)
5 | [](https://goreportcard.com/report/github.com/axcdnt/snitch)
6 |
7 |
8 |
9 | Snitch is a binary that helps your TDD cycle (or not) by watching tests and implementations of Go files.
10 | It works by scanning files, checking the modification date on save and re-running your tests.
11 |
12 | It's usual in Go projects to keep the implementation and tests under the same package, so this binary follows this _convention_.
13 |
14 | This tool focuses on Go developers. With a few LOCs we get interesting stuff.
15 |
16 | ## Inspiration
17 |
18 | It was a Friday afternoon and I was writing code, but had nothing to watch and report my tests while I changed code.
19 |
20 | Inspired by [Guard](https://github.com/guard/guard), I decided to build this and thought more people could benefit from it.
21 |
22 | ## Features
23 |
24 | - Automatically runs your tests
25 | - Re-scan new files, so no need to restart
26 | - Runs on a package basis
27 | - Shows test coverage percentage
28 | - Desktop notifications on macOS and Linux (via `notify-send`)
29 |
30 | ## Requirements
31 |
32 | Go 1.12+ :heart:
33 |
34 | The binary is _go-gettable_. Make sure you have `GOPATH` correctly set and added to the `$PATH`:
35 |
36 | `go get github.com/axcdnt/snitch`
37 |
38 | After _go-getting_ the binary, it will probably be available on your terminal.
39 |
40 | ## Run
41 |
42 | ```
43 | ▶ snitch --help
44 | Usage of snitch:
45 | -interval duration
46 | the interval (in seconds) for scanning files (default 1s)
47 | -path string
48 | the root path to be watched (default "")
49 | -v Print the current version and exit
50 | ```
51 |
52 | Feedback is welcome. I hope you enjoy it!
53 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/axcdnt/snitch
2 |
3 | go 1.12
4 |
5 | require (
6 | github.com/fatih/color v1.7.0
7 | github.com/mattn/go-colorable v0.1.2 // indirect
8 | )
9 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
2 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
3 | github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
4 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
5 | github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
6 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
7 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
8 |
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/axcdnt/snitch/4e3bf8e120046806874a36c7b01e4f5db0c7971a/logo.png
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "log"
7 | "os"
8 | "os/exec"
9 | "path"
10 | "path/filepath"
11 | "strings"
12 | "time"
13 |
14 | "github.com/axcdnt/snitch/platform"
15 | "github.com/fatih/color"
16 | )
17 |
18 | // FileInfo represents a file and its modification date
19 | type FileInfo map[string]time.Time
20 |
21 | var (
22 | notifier platform.Notifier
23 | version string
24 | pass = color.New(color.FgGreen)
25 | fail = color.New(color.FgHiRed)
26 | )
27 |
28 | func init() {
29 | notifier = platform.NewNotifier()
30 | }
31 |
32 | func main() {
33 | defaultPath, err := os.Getwd()
34 | if err != nil {
35 | log.Fatal("could not get current directory: ", err)
36 | }
37 |
38 | versionFlag := flag.Bool("v", false, "prints the current version and exit")
39 | rootPath := flag.String("path", defaultPath, "defines the root path to be watched")
40 | interval := flag.Duration("interval", 1*time.Second, "defines the interval (in seconds) for scanning files")
41 | flag.Parse()
42 |
43 | if *versionFlag {
44 | log.Printf("Build %s", version)
45 | return
46 | }
47 |
48 | if *interval < 0 {
49 | log.Fatal("invalid interval, must be > 0", *interval)
50 | }
51 |
52 | if err := os.Chdir(*rootPath); err != nil {
53 | log.Fatal("could not change directory: ", err)
54 | }
55 |
56 | log.Print("Snitch started")
57 | watchedFiles := walk(rootPath)
58 | for range time.NewTicker(*interval).C {
59 | scan(rootPath, watchedFiles)
60 | }
61 | }
62 |
63 | func scan(rootPath *string, watchedFiles FileInfo) {
64 | modifiedDirs := make(map[string]bool, 0)
65 | for filePath, mostRecentModTime := range walk(rootPath) {
66 | lastModTime, found := watchedFiles[filePath]
67 | if found {
68 | if lastModTime == mostRecentModTime {
69 | // no changes
70 | continue
71 | }
72 |
73 | watchedFiles[filePath] = mostRecentModTime
74 | if shouldRunTests(filePath, watchedFiles) {
75 | pkgDir := path.Dir(filePath)
76 | modifiedDirs[pkgDir] = true
77 | }
78 | continue
79 | }
80 |
81 | // files recently added
82 | fileInfo, err := os.Stat(filePath)
83 | if err != nil {
84 | log.Print("Stat: ", filePath, err)
85 | continue
86 | }
87 | watchedFiles[filePath] = fileInfo.ModTime()
88 | }
89 |
90 | if len(modifiedDirs) == 0 {
91 | return
92 | }
93 |
94 | dedup := make([]string, 0)
95 | for dir := range modifiedDirs {
96 | dedup = append(dedup, dir)
97 | }
98 | test(dedup)
99 | }
100 |
101 | func walk(rootPath *string) FileInfo {
102 | wf := FileInfo{}
103 | if err := filepath.Walk(*rootPath, visit(wf)); err != nil {
104 | log.Fatal("could not traverse files: ", err)
105 | }
106 |
107 | return wf
108 | }
109 |
110 | func visit(wf FileInfo) filepath.WalkFunc {
111 | return func(path string, info os.FileInfo, err error) error {
112 | if err != nil {
113 | return err
114 | }
115 |
116 | if filepath.Ext(path) == ".go" {
117 | wf[path] = info.ModTime()
118 | }
119 |
120 | return nil
121 | }
122 | }
123 |
124 | func shouldRunTests(filePath string, watchedFiles FileInfo) bool {
125 | return isTestFile(filePath) || hasTestFile(filePath, watchedFiles)
126 | }
127 |
128 | func isTestFile(fileName string) bool {
129 | return strings.HasSuffix(fileName, "_test.go")
130 | }
131 |
132 | // hasTestFile verifies if a *.go file has a test
133 | func hasTestFile(filePath string, watchedFiles FileInfo) bool {
134 | ext := filepath.Ext(filePath)
135 | testFilePath := fmt.Sprintf(
136 | "%s_test.go",
137 | filePath[0:len(filePath)-len(ext)],
138 | )
139 | _, ok := watchedFiles[testFilePath]
140 |
141 | return ok
142 | }
143 |
144 | func test(dirs []string) {
145 | clear()
146 | for _, dir := range dirs {
147 | stdout, _ := exec.Command(
148 | "go", "test", "-v", "-cover", dir).CombinedOutput()
149 | result := string(stdout)
150 | prettyPrint(result)
151 | notifier.Notify(result, filepath.Base(dir))
152 | }
153 | }
154 |
155 | func clear() {
156 | cmd := exec.Command("clear")
157 | cmd.Stdout = os.Stdout
158 | cmd.Run()
159 | }
160 |
161 | func prettyPrint(result string) {
162 | for _, line := range strings.Split(result, "\n") {
163 | trimmed := strings.TrimSpace(line)
164 | switch {
165 | case strings.HasPrefix(trimmed, "--- PASS"):
166 | pass.Println(trimmed)
167 | case strings.HasPrefix(trimmed, "--- FAIL"):
168 | fail.Println(trimmed)
169 | default:
170 | fmt.Println(trimmed)
171 | }
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/parser/result.go:
--------------------------------------------------------------------------------
1 | package parser
2 |
3 | import (
4 | "strings"
5 | )
6 |
7 | // ParseResult returns test statuses for pass and fail
8 | func ParseResult(result string) (pass, fail int) {
9 | for _, line := range strings.Split(result, "\n") {
10 | trimmed := strings.TrimSpace(line)
11 | switch {
12 | case strings.HasPrefix(trimmed, "--- PASS"):
13 | pass++
14 | case strings.HasPrefix(trimmed, "--- FAIL"):
15 | fail++
16 | }
17 | }
18 |
19 | return pass, fail
20 | }
21 |
--------------------------------------------------------------------------------
/parser/result_test.go:
--------------------------------------------------------------------------------
1 | package parser
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestParseResult(t *testing.T) {
8 | type args struct {
9 | output string
10 | }
11 | type status struct {
12 | pass, fail int
13 | }
14 | tests := []struct {
15 | name string
16 | args args
17 | want status
18 | }{
19 | {
20 | name: "it counts pass and fail",
21 | args: args{
22 | output: `
23 | === RUN TestSum
24 | === RUN TestSum/it_sums_collections_of_any_size
25 | --- PASS: TestSum (0.00s)
26 | --- PASS: TestSum/it_sums_collections_of_any_size (0.00s)
27 | === RUN TestSumAll
28 | --- FAIL: TestSumAll (0.00s)
29 | sum_test.go:26: want [3 2], got [3 6]
30 | === RUN TestSumAllTails
31 | === RUN TestSumAllTails/it_sums_the_tails_of_slices
32 | === RUN TestSumAllTails/it_sums_the_tails_for_empty_slices
33 | --- PASS: TestSumAllTails (0.00s)
34 | --- PASS: TestSumAllTails/it_sums_the_tails_of_slices (0.00s)
35 | --- PASS: TestSumAllTails/it_sums_the_tails_for_empty_slices (0.00s)
36 | `,
37 | },
38 | want: status{
39 | pass: 5,
40 | fail: 1,
41 | },
42 | },
43 | }
44 | for _, tt := range tests {
45 | t.Run(tt.name, func(t *testing.T) {
46 | pass, fail := ParseResult(tt.args.output)
47 | if pass != tt.want.pass || fail != tt.want.fail {
48 | t.Errorf("want %d %d, got %d %d", tt.want.pass, tt.want.fail, pass, fail)
49 | }
50 | })
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/platform/notifier.go:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os/exec"
7 | "runtime"
8 |
9 | "github.com/axcdnt/snitch/parser"
10 | )
11 |
12 | // NewNotifier creates a notifier
13 | func NewNotifier() Notifier {
14 | switch runtime.GOOS {
15 | case "darwin":
16 | return DarwinNotifier{}
17 | case "linux":
18 | return LinuxNotifier{}
19 | }
20 |
21 | return nil
22 | }
23 |
24 | // Notifier represents a platform notifier
25 | type Notifier interface {
26 | Notify(result, pkg string)
27 | }
28 |
29 | // DarwinNotifier represents macOS notifier
30 | type DarwinNotifier struct {
31 | }
32 |
33 | // Notify notifies desktop notifications on macOS
34 | func (d DarwinNotifier) Notify(result, pkg string) {
35 | msg := fmt.Sprintf(
36 | "display notification \"%s\" with title \"%s\" subtitle \"%s\"",
37 | statusMsg(result),
38 | "Snitch",
39 | pkg,
40 | )
41 | exec.Command("osascript", "-e", msg).Run()
42 | }
43 |
44 | // LinuxNotifier represents Linux notifier
45 | type LinuxNotifier struct {
46 | }
47 |
48 | // Notify notifies desktop notifications on Linux
49 | func (l LinuxNotifier) Notify(result, pkg string) {
50 | msg := fmt.Sprintf("%s: %s", pkg, statusMsg(result))
51 | err := exec.Command(
52 | "notify-send", "-a", "Snitch", "-c", "im", "Snitch", msg).Run()
53 | if err != nil {
54 | log.Print("Command not found: ", err)
55 | }
56 | }
57 |
58 | func statusMsg(result string) string {
59 | pass, fail := parser.ParseResult(result)
60 | return fmt.Sprintf("%d pass, %d fail", pass, fail)
61 | }
62 |
--------------------------------------------------------------------------------