├── .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 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/axcdnt/snitch/issues) 4 | [![Build Status](https://travis-ci.org/axcdnt/snitch.svg?branch=master)](https://travis-ci.org/axcdnt/snitch) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/axcdnt/snitch)](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 | --------------------------------------------------------------------------------