├── .gitignore ├── CHANGES-V2.md ├── Gododir └── main.go ├── LICENSE ├── Makefile ├── README.md ├── VERSION.go ├── book ├── README.md ├── SUMMARY.md ├── getting_started │ ├── README.md │ └── locating_maingo.md ├── installation │ └── README.md └── tasks │ └── README.md ├── cmd.go ├── cmd ├── example │ ├── handler.go │ └── main.go └── godo │ └── main.go ├── context.go ├── doc.go ├── env.go ├── env_test.go ├── exec.go ├── exec_test.go ├── fileWrapper.go ├── glob ├── fileAsset.go ├── glob.go ├── glob_test.go ├── test │ ├── .hidden.txt │ ├── foo.sh │ ├── foo.txt │ └── sub │ │ ├── sub │ │ ├── subsub1.txt │ │ └── subsub2.html │ │ ├── sub1.txt │ │ └── sub2.txt ├── test2 │ ├── main.css │ └── main.js ├── watchCriteria.go └── watchCriteria_test.go ├── handler.go ├── init_test.go ├── namespace_test.go ├── project.go ├── project_test.go ├── runner.go ├── task.go ├── task_options.go ├── test ├── .hidden.txt ├── bar.txt ├── foo.cmd ├── foo.sh ├── foo.txt ├── sub │ ├── foo.txt │ ├── sub │ │ ├── subsub1.txt │ │ └── subsub2.html │ ├── sub1.txt │ └── sub2.txt └── templates │ └── 1.go.html ├── util ├── doc.go ├── fs.go ├── logging.go ├── prompt.go └── utils.go ├── waitgroup.go ├── watch_test.go └── watcher ├── fileEvent.go ├── fswatch ├── ISSUES ├── LICENSE ├── README.md ├── clinotify │ └── clinotify.go ├── doc.go ├── fswatch.go ├── watch_item.go └── watcher.go └── watcher.go /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | tmp/ 3 | *.log 4 | _* 5 | node_modules 6 | example/dist 7 | godobin* 8 | /cmd/godo/godo 9 | *.iml 10 | -------------------------------------------------------------------------------- /CHANGES-V2.md: -------------------------------------------------------------------------------- 1 | v2.0.4 / 2016-01-14 2 | =================== 3 | 4 | * remove commented code 5 | * Context.Start: improve rebuild time on watch by building changed file's package only instead of using -a flag 6 | 7 | v2.0.3 / 2015-12-10 8 | =================== 9 | 10 | * update README 11 | * fix godoenv parsing on rebuild 12 | [x] Tasks have Src -> Dest to more efficiently watch and rebuild 13 | 14 | [x] Run dependencies in Parallel or Series 15 | 16 | [x] Godo will search up dir tree for nearest Gododir/main.go 17 | 18 | [x] Namespaces to better manage or import tasks 19 | 20 | [x] Optimize watch algorithm 21 | 22 | [x] Allow exec commands to be teed, print or captured 23 | 24 | [x] More efficient file watcher 25 | 26 | [x] Externalize glob 27 | 28 | [x] Deprecated 29 | 30 | In{}, 31 | D{} 32 | W{} 33 | c.Args.ZeroString -> c.Args.AsString 34 | 35 | 36 | [x] Set environment variables via key=value pairs 37 | 38 | [x] Watches Godofile (Gododir/main.go) automatically (buggy) 39 | 40 | -------------------------------------------------------------------------------- /Gododir/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | // "github.com/mgutz/goa" 7 | // f "github.com/mgutz/goa/filter" 8 | 9 | do "gopkg.in/godo.v2" 10 | ) 11 | 12 | func tasks(p *do.Project) { 13 | p.Task("test", nil, func(c *do.Context) { 14 | c.Run("go test") 15 | }) 16 | 17 | p.Task("test", do.S{"build"}, func(c *do.Context) { 18 | c.Run("go test") 19 | }) 20 | 21 | p.Task("dist", do.S{"test", "lint"}, nil) 22 | 23 | p.Task("install", nil, func(c *do.Context) { 24 | c.Run("go get github.com/golang/lint/golint") 25 | // Run("go get github.com/mgutz/goa") 26 | c.Run("go get github.com/robertkrimen/godocdown/godocdown") 27 | }) 28 | 29 | p.Task("lint", nil, func(c *do.Context) { 30 | c.Run("golint .") 31 | c.Run("gofmt -w -s .") 32 | c.Run("go vet .") 33 | }) 34 | 35 | // p.Task("readme", func() { 36 | // Run("godocdown -o README.md") 37 | // // add godoc 38 | // goa.Pipe( 39 | // f.Load("./README.md"), 40 | // f.Str(str.ReplaceF("--", "\n[godoc](https://godoc.org/gopkg.in/godo.v2)\n", 1)), 41 | // f.Write(), 42 | // ) 43 | // }) 44 | 45 | p.Task("build", nil, func(c *do.Context) { 46 | c.Run("go install", do.M{"$in": "cmd/godo"}) 47 | }) 48 | 49 | p.Task("interactive", nil, func(c *do.Context) { 50 | c.Bash(` 51 | echo name? 52 | read name 53 | echo hello $name 54 | `) 55 | }) 56 | 57 | p.Task("whoami", nil, func(c *do.Context) { 58 | c.Run("whoami") 59 | }) 60 | 61 | pass := 0 62 | p.Task("err2", nil, func(*do.Context) { 63 | if pass == 2 { 64 | do.Halt("oh oh") 65 | } 66 | }) 67 | 68 | p.Task("err", do.S{"err2"}, func(*do.Context) { 69 | pass++ 70 | if pass == 1 { 71 | return 72 | } 73 | do.Halt("foo err") 74 | }).Src("test/*.txt") 75 | 76 | p.Task("hello", nil, func(c *do.Context) { 77 | name := c.Args.AsString("default value", "name", "n") 78 | fmt.Println("Hello", name) 79 | }).Src("*.hello").Debounce(3000) 80 | 81 | p.Task("server", nil, func(c *do.Context) { 82 | c.Start("main.go", do.M{"$in": "cmd/example"}) 83 | }).Src("cmd/example/**/*.go") 84 | 85 | p.Task("change-package", nil, func(c *do.Context) { 86 | // works on mac 87 | c.Run(`find . -name "*.go" -print | xargs sed -i "" 's|gopkg.in/godo.v1|gopkg.in/godo.v2|g'`) 88 | // maybe linux? 89 | //Run(`find . -name "*.go" -print | xargs sed -i 's|gopkg.in/godo.v1|gopkg.in/godo.v2|g'`) 90 | }) 91 | } 92 | 93 | func main() { 94 | do.Godo(tasks) 95 | } 96 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2014 Mario L. Gutierrez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | 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 | .PHONY: build get 2 | 3 | build: 4 | @cd cmd/godo && go install -a 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Documentation is WIP** 2 | 3 | # godo 4 | 5 | [![GoDoc](https://godoc.org/github.com/go-godo/godo?status.svg)](https://godoc.org/github.com/go-godo/godo) 6 | 7 | godo is a task runner and file watcher for golang in the spirit of 8 | rake, gulp. 9 | 10 | To install 11 | 12 | go get -u gopkg.in/godo.v2/cmd/godo 13 | 14 | ## Godofile 15 | 16 | Godo runs `Gododir/main.go`. 17 | 18 | As an example, create a file **Gododir/main.go** with this content 19 | 20 | ```go 21 | package main 22 | 23 | import ( 24 | "fmt" 25 | do "gopkg.in/godo.v2" 26 | ) 27 | 28 | func tasks(p *do.Project) { 29 | do.Env = `GOPATH=.vendor::$GOPATH` 30 | 31 | p.Task("default", do.S{"hello", "build"}, nil) 32 | 33 | p.Task("hello", nil, func(c *do.Context) { 34 | name := c.Args.AsString("name", "n") 35 | if name == "" { 36 | c.Bash("echo Hello $USER!") 37 | } else { 38 | fmt.Println("Hello", name) 39 | } 40 | }) 41 | 42 | p.Task("assets?", nil, func(c *do.Context) { 43 | // The "?" tells Godo to run this task ONLY ONCE regardless of 44 | // how many tasks depend on it. In this case watchify watches 45 | // on its own. 46 | c.Run("watchify public/js/index.js d -o dist/js/app.bundle.js") 47 | }).Src("public/**/*.{css,js,html}") 48 | 49 | p.Task("build", do.S{"views", "assets"}, func(c *do.Context) { 50 | c.Run("GOOS=linux GOARCH=amd64 go build", do.M{"$in": "cmd/server"}) 51 | }).Src("**/*.go") 52 | 53 | p.Task("server", do.S{"views", "assets"}, func(c *do.Context) { 54 | // rebuilds and restarts when a watched file changes 55 | c.Start("main.go", do.M{"$in": "cmd/server"}) 56 | }).Src("server/**/*.go", "cmd/server/*.{go,json}"). 57 | Debounce(3000) 58 | 59 | p.Task("views", nil, func(c *do.Context) { 60 | c.Run("razor templates") 61 | }).Src("templates/**/*.go.html") 62 | } 63 | 64 | func main() { 65 | do.Godo(tasks) 66 | } 67 | ``` 68 | 69 | To run "server" task from parent dir of `Gododir/` 70 | 71 | godo server 72 | 73 | To rerun "server" and its dependencies whenever any of their watched files change 74 | 75 | godo server --watch 76 | 77 | To run the "default" task which runs "hello" and "build" 78 | 79 | godo 80 | 81 | Task names may add a "?" suffix to execute only once even when watching 82 | 83 | ```go 84 | // build once regardless of number of dependents 85 | p.Task("assets?", nil, func(*do.Context) { }) 86 | ``` 87 | 88 | Task dependencies 89 | 90 | do.S{} or do.Series{} - dependent tasks to run in series 91 | do.P{} or do.Parallel{} - dependent tasks to run in parallel 92 | 93 | For example, do.S{"clean", do.P{"stylesheets", "templates"}, "build"} 94 | 95 | 96 | ### Task Option Funcs 97 | 98 | * Task#Src() - specify watch paths or the src files for Task#Dest() 99 | 100 | Glob patterns 101 | 102 | /**/ - match zero or more directories 103 | {a,b} - match a or b, no spaces 104 | * - match any non-separator char 105 | ? - match a single non-separator char 106 | **/ - match any directory, start of pattern only 107 | /** - match any in this directory, end of pattern only 108 | ! - removes files from result set, start of pattern only 109 | 110 | * Task#Dest(globs ...string) - If globs in Src are newer than Dest, then 111 | the task is run 112 | 113 | * Task#Desc(description string) - Set task's description in usage. 114 | 115 | * Task#Debounce(duration time.Duration) - Disallow a task from running until duration 116 | has elapsed. 117 | 118 | * Task#Deps(names ...interface{}) - Can be `S, Series, P, Parallel, string` 119 | 120 | 121 | ### Task CLI Arguments 122 | 123 | Task CLI arguments follow POSIX style flag convention 124 | (unlike go's built-in flag package). Any command line arguments 125 | succeeding `--` are passed to each task. Note, arguments before `--` 126 | are reserved for `godo`. 127 | 128 | As an example, 129 | 130 | ```go 131 | p.Task("hello", nil, func(c *do.Context) { 132 | // "(none)" is the default value 133 | msg := c.Args.MayString("(none)", "message", "msg", "m") 134 | var name string 135 | if len(c.Args.NonFlags()) == 1 { 136 | name = c.Args.NonFlags()[0] 137 | } 138 | fmt.Println(msg, name) 139 | }) 140 | ``` 141 | 142 | running 143 | 144 | ```sh 145 | # prints "(none)" 146 | godo hello 147 | 148 | # prints "Hello dude" using POSIX style flags 149 | godo hello -- dude --message Hello 150 | godo hello -- dude --msg Hello 151 | godo hello -- -m Hello dude 152 | ``` 153 | 154 | Args functions are categorized as 155 | 156 | * `Must*` - Argument must be set by user or panic. 157 | 158 | ```go 159 | c.Args.MustInt("number", "n") 160 | ``` 161 | 162 | * `May*` - If argument is not set, default to first value. 163 | 164 | ```go 165 | // defaults to 100 166 | c.Args.MayInt(100, "number", "n") 167 | ``` 168 | 169 | * `As*` - If argument is not set, default to zero value. 170 | 171 | ```go 172 | // defaults to 0 173 | c.Args.AsInt("number", "n") 174 | ``` 175 | 176 | 177 | ## Modularity and Namespaces 178 | 179 | A project may include other tasks functions with `Project#Use`. `Use` requires a namespace to 180 | prevent task name conflicts with existing tasks. 181 | 182 | ```go 183 | func buildTasks(p *do.Project) { 184 | p.Task("default", S{"clean"}, nil) 185 | 186 | p.Task("clean", nil, func(*do.Context) { 187 | fmt.Println("build clean") 188 | }) 189 | } 190 | 191 | func tasks(p *do.Project) { 192 | p.Use("build", buildTasks) 193 | 194 | p.Task("clean", nil, func(*do.Context) { 195 | fmt.Println("root clean") 196 | }) 197 | 198 | p.Task("build", do.S{"build:default"}, func(*do.Context) { 199 | fmt.Println("root clean") 200 | }) 201 | } 202 | ``` 203 | 204 | Running `godo build:.` or `godo build` results in output of `build clean`. Note that 205 | it uses the `clean` task in its namespace not the `clean` in the parent project. 206 | 207 | The special name `build:.` is alias for `build:default`. 208 | 209 | Task dependencies that start with `"/"` are relative to the parent project and 210 | may be called referenced from sub projects. 211 | 212 | ## godobin 213 | 214 | `godo` compiles `Godofile.go` to `godobin-VERSION` (`godobin-VERSION.exe` on Windows) whenever 215 | `Godofile.go` changes. The binary file is built into the same directory as 216 | `Godofile.go` and should be ignored by adding the path `godobin*` to `.gitignore`. 217 | 218 | ## Exec functions 219 | 220 | All of these functions accept a `map[string]interface{}` or `M` for 221 | options. Option keys that start with `"$"` are reserved for `godo`. 222 | Other fields can be used as context for template. 223 | 224 | ### Bash 225 | 226 | Bash functions uses the bash executable and may not run on all OS. 227 | 228 | Run a bash script string. The script can be multiline line with continutation. 229 | 230 | ```go 231 | c.Bash(` 232 | echo -n $USER 233 | echo some really long \ 234 | command 235 | `) 236 | ``` 237 | 238 | Bash can use Go templates 239 | 240 | ```go 241 | c.Bash(`echo -n {{.name}}`, do.M{"name": "mario", "$in": "cmd/bar"}) 242 | ``` 243 | 244 | Run a bash script and capture STDOUT and STDERR. 245 | 246 | ```go 247 | output, err := c.BashOutput(`echo -n $USER`) 248 | ``` 249 | 250 | ### Run 251 | 252 | Run `go build` inside of cmd/app and set environment variables. 253 | 254 | ```go 255 | c.Run(`GOOS=linux GOARCH=amd64 go build`, do.M{"$in": "cmd/app"}) 256 | ``` 257 | 258 | Run can use Go templates 259 | 260 | ```go 261 | c.Run(`echo -n {{.name}}`, do.M{"name": "mario", "$in": "cmd/app"}) 262 | ``` 263 | 264 | Run and capture STDOUT and STDERR 265 | 266 | ```go 267 | output, err := c.RunOutput("whoami") 268 | ``` 269 | 270 | ### Start 271 | 272 | Start an async command. If the executable has suffix ".go" then it will be "go install"ed then executed. 273 | Use this for watching a server task. 274 | 275 | ```go 276 | c.Start("main.go", do.M{"$in": "cmd/app"}) 277 | ``` 278 | 279 | Godo tracks the process ID of started processes to restart the app gracefully. 280 | 281 | ### Inside 282 | 283 | To run many commands inside a directory, use `Inside` instead of the `$in` option. 284 | `Inside` changes the working directory. 285 | 286 | ```go 287 | do.Inside("somedir", func() { 288 | do.Run("...") 289 | do.Bash("...") 290 | }) 291 | ``` 292 | 293 | ## User Input 294 | 295 | To get plain string 296 | 297 | ```go 298 | user := do.Prompt("user: ") 299 | ``` 300 | 301 | To get password 302 | 303 | ```go 304 | password := do.PromptPassword("password: ") 305 | ``` 306 | 307 | ## Godofile Run-Time Environment 308 | 309 | ### From command-line 310 | 311 | Environment variables may be set via key-value pairs as arguments to 312 | godo. This feature was added to facilitate users on Windows. 313 | 314 | ```sh 315 | godo NAME=mario GOPATH=./vendor hello 316 | ``` 317 | 318 | ### From source code 319 | 320 | To specify whether to inherit from parent's process environment, 321 | set `InheritParentEnv`. This setting defaults to true 322 | 323 | ```go 324 | do.InheritParentEnv = false 325 | ``` 326 | 327 | To specify the base environment for your tasks, set `Env`. 328 | Separate with whitespace or newlines. 329 | 330 | ```go 331 | do.Env = ` 332 | GOPATH=.vendor::$GOPATH 333 | PG_USER=mario 334 | ` 335 | ``` 336 | 337 | Functions can add or override environment variables as part of the command string. 338 | Note that environment variables are set before the executable similar to a shell; 339 | however, the `Run` and `Start` functions do not use a shell. 340 | 341 | ```go 342 | p.Task("build", nil, func(c *do.Context) { 343 | c.Run("GOOS=linux GOARCH=amd64 go build" ) 344 | }) 345 | ``` 346 | 347 | The effective environment for exec functions is: `parent (if inherited) <- do.Env <- func parsed env` 348 | 349 | Paths should use `::` as a cross-platform path list separator. On Windows `::` is replaced with `;`. 350 | On Mac and linux `::` is replaced with `:`. 351 | 352 | ### From godoenv file 353 | 354 | For special circumstances where the GOPATH needs to be set before building the Gododir, 355 | use `Gododir/godoenv` file. 356 | 357 | TIP: Create `Gododir/godoenv` when using a dependency manager like `godep` that necessitates 358 | changing `$GOPATH` 359 | 360 | ``` 361 | # Gododir/godoenv 362 | GOPATH=$PWD/cmd/app/Godeps/_workspace::$GOPATH 363 | ``` 364 | -------------------------------------------------------------------------------- /VERSION.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | // Version is the current version 4 | var Version = "2.0.9" 5 | -------------------------------------------------------------------------------- /book/README.md: -------------------------------------------------------------------------------- 1 | # GODO 2 | 3 | Godo is a task runner and file watcher for the Go programming language. 4 | -------------------------------------------------------------------------------- /book/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | * [Introduction](README.md) 4 | * [Installation](installation/README.md) 5 | * [Getting Started](getting_started/README.md) 6 | * [main.go location](getting_started/locating_maingo.md) 7 | * [Tasks](tasks/README.md) 8 | 9 | -------------------------------------------------------------------------------- /book/getting_started/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | Create a file `Gododir/main.go` with this content 4 | 5 | ```go 6 | import ( 7 | "fmt" 8 | do "gopkg.in/godo.v2" 9 | ) 10 | 11 | func tasks(p *do.Project) { 12 | p.Task("hello", nil, func(c *do.Context) { 13 | name := c.Args.AsString("world", "name", "n") 14 | fmt.Println("Hello", name, "!") 15 | } 16 | } 17 | 18 | func main() { 19 | do.Godo(tasks) 20 | } 21 | ``` 22 | 23 | From your terminal run 24 | 25 | ``` 26 | # prints "Hello world!" 27 | godo hello 28 | 29 | # prints "Hello gopher!" 30 | godo hello -- n="gopher" 31 | 32 | # prints "Hello gopher!" 33 | godo hello -- name="gopher" 34 | ``` 35 | 36 | Let's create a non-trivial example to run tests whenever any go file changes 37 | 38 | ```go 39 | import . "gopkg.in/godo.v2" 40 | 41 | func tasks(p *do.Project) { 42 | p.Task("clean", nil, func(c *do.Context) { 43 | c.Run("rm -rf tmp") 44 | } 45 | 46 | p.Task("assets", nil, func(c *do.Context) { 47 | // Version is from external version.go 48 | versionDir := "dist/public/v" + Version 49 | c.Bash(` 50 | set -e 51 | mkdir -p {{.versionDir}} 52 | browserify . -o {{.versionDir}} 53 | `, M{"versionDir": versionDir}) 54 | } 55 | 56 | p.Task("build", nil, func(c *do.Context) { 57 | c.Run("go build", M{"$in", "cmd/app"}) 58 | }.Src("cmd/app/**/*.go") 59 | 60 | p.Task("test", nil, func(c *do.Context) { 61 | c.Run("go test") 62 | }.Src("**/*.go") 63 | 64 | // S or Series, P or Parallel 65 | p.Task("default", S{"clean", P{"build", "assets"}, "test"}, nil) 66 | } 67 | ``` 68 | 69 | From your terminal run 70 | 71 | ```sh 72 | godo -w 73 | ``` 74 | 75 | That simple statement does the following 76 | 77 | * **godo** runs "default" task. **godo** runs the "default" task in the absence of a task name. 78 | * The "default" task declares a set of dependencies qualified by the order in which they should be executed. The dependency 79 | 80 | ```go 81 | S{P{"clean", "build"}, "test"} 82 | ``` 83 | 84 | means. Run the following in a series. 85 | 86 | 1. Run "clean" and "build" in parallel 87 | 2. Then, run "test" 88 | 89 | -------------------------------------------------------------------------------- /book/getting_started/locating_maingo.md: -------------------------------------------------------------------------------- 1 | # Locating main.go 2 | 3 | Godo compiles and runs a file at the relative path `Gododir/main.go`. If the path does not exist at the current directory, parent directories are searched. 4 | 5 | For example, given this directory structure: 6 | 7 | ``` 8 | mgutz/ 9 | Gododir/ 10 | main.go 11 | project1/ 12 | Gododir/ 13 | main.go 14 | project2 15 | 16 | ``` 17 | 18 | * Running `godo` inside of project1 uses `project1/Gododir/main.go`. 19 | * Running `godo` inside of project2 uses `mgutz/Gododir/main.go` 20 | 21 | 22 | -------------------------------------------------------------------------------- /book/installation/README.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | To install or update **godo**, run the following command from a terminal 4 | 5 | ```go 6 | go get -u gopkg.in/godo.v2 7 | ``` 8 | -------------------------------------------------------------------------------- /book/tasks/README.md: -------------------------------------------------------------------------------- 1 | # Tasks 2 | 3 | A task is a named unit of work which can be executed in series or parallel. Define smaller tasks to form larger tasks. 4 | 5 | ```go 6 | 7 | var S = do.S, M = do.M, Context = do.Context 8 | 9 | func tasks(p *do.Project) { 10 | p.Task("assets", nil, func(c *Context) { 11 | c.Run("webpack") 12 | }) 13 | 14 | project.Task("build", S{"build"}, func(c *Context) { 15 | c.Run("go run main.go", M{"$in": "cmd/app"}) 16 | }) 17 | } 18 | ``` 19 | -------------------------------------------------------------------------------- /cmd.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/mgutz/ansi" 11 | "gopkg.in/godo.v2/util" 12 | ) 13 | 14 | // Processes are the processes spawned by Start() 15 | var Processes = make(map[string]*os.Process) 16 | 17 | const ( 18 | // CaptureStdout is a bitmask to capture STDOUT 19 | CaptureStdout = 1 20 | // CaptureStderr is a bitmask to capture STDERR 21 | CaptureStderr = 2 22 | // CaptureBoth captures STDOUT and STDERR 23 | CaptureBoth = CaptureStdout + CaptureStderr 24 | ) 25 | 26 | type command struct { 27 | // original command string 28 | commandstr string 29 | // parsed executable 30 | executable string 31 | // parsed argv 32 | argv []string 33 | // parsed env 34 | env []string 35 | // working directory 36 | wd string 37 | // bitmask to capture output 38 | capture int 39 | // the output buf 40 | buf bytes.Buffer 41 | } 42 | 43 | func (gcmd *command) toExecCmd() (cmd *exec.Cmd, err error) { 44 | cmd = exec.Command(gcmd.executable, gcmd.argv...) 45 | if gcmd.wd != "" { 46 | cmd.Dir = gcmd.wd 47 | } 48 | 49 | cmd.Env = EffectiveEnv(gcmd.env) 50 | cmd.Stdin = os.Stdin 51 | 52 | if gcmd.capture&CaptureStderr > 0 { 53 | cmd.Stderr = newFileWrapper(os.Stderr, &gcmd.buf, ansi.Red) 54 | } else { 55 | cmd.Stderr = os.Stderr 56 | } 57 | if gcmd.capture&CaptureStdout > 0 { 58 | cmd.Stdout = newFileWrapper(os.Stdout, &gcmd.buf, "") 59 | } else { 60 | cmd.Stdout = os.Stdout 61 | } 62 | 63 | if verbose { 64 | if Env != "" { 65 | util.Debug("#", "Env: %s\n", Env) 66 | } 67 | if gcmd.wd != "" { 68 | util.Debug("#", "Dir: %s\n", gcmd.wd) 69 | } 70 | util.Debug("#", "%s\n", gcmd.commandstr) 71 | } 72 | 73 | return cmd, nil 74 | } 75 | 76 | func (gcmd *command) run() (string, error) { 77 | var err error 78 | cmd, err := gcmd.toExecCmd() 79 | if err != nil { 80 | return "", err 81 | } 82 | 83 | err = cmd.Run() 84 | if gcmd.capture > 0 { 85 | return gcmd.buf.String(), err 86 | } 87 | return "", err 88 | 89 | } 90 | 91 | func (gcmd *command) runAsync() error { 92 | cmd, err := gcmd.toExecCmd() 93 | if err != nil { 94 | return err 95 | } 96 | 97 | id := gcmd.commandstr 98 | 99 | // kills previously spawned process (if exists) 100 | killSpawned(id) 101 | runnerWaitGroup.Add(1) 102 | waitExit = true 103 | go func() { 104 | err = cmd.Start() 105 | if err != nil { 106 | fmt.Println(err.Error()) 107 | return 108 | } 109 | Processes[id] = cmd.Process 110 | if verbose { 111 | util.Debug("#", "Processes[%q] added\n", id) 112 | } 113 | cmd.Wait() 114 | runnerWaitGroup.Done() 115 | }() 116 | return nil 117 | } 118 | 119 | func killSpawned(command string) { 120 | process := Processes[command] 121 | if process == nil { 122 | return 123 | } 124 | 125 | err := process.Kill() 126 | //err := syscall.Kill(-process.Pid, syscall.SIGKILL) 127 | delete(Processes, command) 128 | if err != nil && !strings.Contains(err.Error(), "process already finished") { 129 | util.Error("Start", "Could not kill existing process %+v\n%s\n", process, err.Error()) 130 | return 131 | } 132 | if verbose { 133 | util.Debug("#", "Processes[%q] killed\n", command) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /cmd/example/handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func handler(w http.ResponseWriter, r *http.Request) { 9 | fmt.Fprintf(w, "Hi there, I lub %s!", r.URL.Path[1:]) 10 | } 11 | -------------------------------------------------------------------------------- /cmd/example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "net/http" 4 | 5 | func main() { 6 | http.HandleFunc("/", handler) 7 | http.ListenAndServe(":8013", nil) 8 | } 9 | -------------------------------------------------------------------------------- /cmd/godo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "os/signal" 10 | "path" 11 | "path/filepath" 12 | "regexp" 13 | "runtime" 14 | "syscall" 15 | "time" 16 | 17 | // this MUST not reference any godo/v? directory 18 | 19 | "github.com/mgutz/minimist" 20 | "gopkg.in/godo.v2" 21 | "gopkg.in/godo.v2/util" 22 | "gopkg.in/godo.v2/watcher" 23 | ) 24 | 25 | var isWindows = runtime.GOOS == "windows" 26 | var isRebuild bool 27 | var isWatch bool 28 | var isVerbose bool 29 | var hasTasks bool 30 | 31 | func checkError(err error, format string, args ...interface{}) { 32 | if err != nil { 33 | util.Error("ERR", format, args...) 34 | os.Exit(1) 35 | } 36 | } 37 | 38 | func hasMain(data []byte) bool { 39 | hasMainRe := regexp.MustCompile(`\nfunc main\(`) 40 | matches := hasMainRe.Find(data) 41 | return len(matches) > 0 42 | } 43 | 44 | func isPackageMain(data []byte) bool { 45 | isMainRe := regexp.MustCompile(`(\n|^)?package main\b`) 46 | matches := isMainRe.Find(data) 47 | return len(matches) > 0 48 | } 49 | 50 | func main() { 51 | // v2 ONLY uses Gododir/main.go 52 | godoFiles := []string{"Gododir/main.go", "Gododir/Godofile.go", "tasks/Godofile.go"} 53 | src := "" 54 | for _, filename := range godoFiles { 55 | src = util.FindUp(".", filename) 56 | if src != "" { 57 | break 58 | } 59 | } 60 | 61 | if src == "" { 62 | godo.Usage("") 63 | os.Exit(0) 64 | } 65 | 66 | wd, err := os.Getwd() 67 | if err != nil { 68 | util.Error("godo", "Could not get working directory: %s\n", err.Error()) 69 | } 70 | 71 | // parent of Gododir/main.go 72 | absParentDir, err := filepath.Abs(filepath.Dir(filepath.Dir(src))) 73 | if err != nil { 74 | util.Error("godo", "Could not get absolute parent of %s: %s\n", src, err.Error()) 75 | } 76 | if wd != absParentDir { 77 | relDir, _ := filepath.Rel(wd, src) 78 | os.Chdir(absParentDir) 79 | util.Info("godo", "Using %s\n", relDir) 80 | } 81 | 82 | os.Setenv("GODOFILE", src) 83 | argm := minimist.Parse() 84 | isRebuild = argm.AsBool("rebuild") 85 | isWatch = argm.AsBool("w", "watch") 86 | isVerbose = argm.AsBool("v", "verbose") 87 | hasTasks = len(argm.NonFlags()) > 0 88 | run(src) 89 | } 90 | 91 | func run(godoFile string) { 92 | if isWatch { 93 | runAndWatch(godoFile) 94 | } else { 95 | cmd, _ := buildCommand(godoFile, false) 96 | err := cmd.Run() 97 | if err != nil { 98 | log.Fatal(err) 99 | } 100 | } 101 | } 102 | 103 | func buildCommand(godoFile string, forceBuild bool) (*exec.Cmd, string) { 104 | exe := buildMain(godoFile, forceBuild) 105 | cmd := exec.Command(exe, os.Args[1:]...) 106 | cmd.Stdout = os.Stdout 107 | cmd.Stderr = os.Stderr 108 | cmd.Stdin = os.Stdin 109 | //cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 110 | 111 | // process godoenv file 112 | env := godoenv(godoFile) 113 | if env != "" { 114 | cmd.Env = godo.EffectiveEnv(godo.ParseStringEnv(env)) 115 | } 116 | 117 | return cmd, exe 118 | } 119 | 120 | func godoenv(godoFile string) string { 121 | godoenvFile := filepath.Join(filepath.Dir(godoFile), "godoenv") 122 | if _, err := os.Stat(godoenvFile); err == nil { 123 | b, err := ioutil.ReadFile(godoenvFile) 124 | if err != nil { 125 | util.Error("godo", "Cannot read %s file", godoenvFile) 126 | os.Exit(1) 127 | } 128 | return string(b) 129 | } 130 | return "" 131 | } 132 | 133 | func runAndWatch(godoFile string) { 134 | done := make(chan bool, 1) 135 | run := func(forceBuild bool) (*exec.Cmd, string) { 136 | cmd, exe := buildCommand(godoFile, forceBuild) 137 | cmd.Start() 138 | go func() { 139 | err := cmd.Wait() 140 | done <- true 141 | if err != nil { 142 | if isVerbose { 143 | util.Debug("godo", "godo process killed\n") 144 | } 145 | } 146 | }() 147 | return cmd, exe 148 | } 149 | 150 | bufferSize := 2048 151 | watchr, err := watcher.NewWatcher(bufferSize) 152 | if err != nil { 153 | util.Panic("project", "%v\n", err) 154 | } 155 | godoDir := filepath.Dir(godoFile) 156 | watchr.WatchRecursive(godoDir) 157 | watchr.ErrorHandler = func(err error) { 158 | util.Error("godo", "Watcher error %v\n", err) 159 | } 160 | 161 | cmd, exe := run(false) 162 | // this function will block forever, Ctrl+C to quit app 163 | // var lastHappenedTime int64 164 | watchr.Start() 165 | util.Info("godo", "watching %s\n", godoDir) 166 | 167 | <-time.After(godo.GetWatchDelay() + (300 * time.Millisecond)) 168 | 169 | c := make(chan os.Signal, 1) 170 | signal.Notify(c, os.Interrupt) 171 | go func() { 172 | for range c { 173 | killGodo(cmd, false) 174 | os.Exit(0) 175 | } 176 | }() 177 | 178 | // forloop: 179 | for { 180 | select { 181 | case event := <-watchr.Event: 182 | // looks like go build starts with the output file as the dir, then 183 | // renames it to output file 184 | if event.Path == exe || event.Path == path.Join(path.Dir(exe), path.Base(path.Dir(exe))) { 185 | continue 186 | } 187 | util.Debug("watchmain", "%+v\n", event) 188 | killGodo(cmd, true) 189 | <-done 190 | cmd, _ = run(true) 191 | } 192 | } 193 | 194 | } 195 | 196 | // killGodo kills the spawned godo process. 197 | func killGodo(cmd *exec.Cmd, killProcessGroup bool) { 198 | cmd.Process.Kill() 199 | // process group may not be cross platform but on Darwin and Linux, this 200 | // is the only way to kill child processes 201 | if killProcessGroup { 202 | pgid, err := syscall.Getpgid(cmd.Process.Pid) 203 | if err != nil { 204 | panic(err) 205 | } 206 | syscall.Kill(-pgid, syscall.SIGKILL) 207 | } 208 | } 209 | 210 | func mustBeMain(src string) { 211 | data, err := ioutil.ReadFile(src) 212 | if err != nil { 213 | fmt.Fprintln(os.Stderr, err) 214 | os.Exit(1) 215 | } 216 | 217 | if !hasMain(data) { 218 | msg := `%s is not runnable. Rename package OR make it runnable by adding 219 | 220 | func main() { 221 | godo.Godo(tasks) 222 | } 223 | ` 224 | fmt.Printf(msg, src) 225 | os.Exit(1) 226 | } 227 | 228 | if !isPackageMain(data) { 229 | msg := `%s is not runnable. It must be package main` 230 | fmt.Printf(msg, src) 231 | os.Exit(1) 232 | } 233 | } 234 | 235 | func buildMain(src string, forceBuild bool) string { 236 | mustBeMain(src) 237 | dir := filepath.Dir(src) 238 | 239 | exeFile := "godobin-" + godo.Version 240 | if isWindows { 241 | exeFile += ".exe" 242 | } 243 | 244 | exe := filepath.Join(dir, exeFile) 245 | 246 | build := false 247 | reasonFormat := "" 248 | if isRebuild || forceBuild { 249 | build = true 250 | reasonFormat = "Rebuilding %s...\n" 251 | } else { 252 | build = util.Outdated([]string{dir + "/**/*.go"}, []string{exe}) 253 | reasonFormat = "Godo tasks changed. Rebuilding %s...\n" 254 | } 255 | 256 | if build { 257 | util.Debug("godo", reasonFormat, exe) 258 | env := godoenv(src) 259 | if env != "" { 260 | godo.Env = env 261 | } 262 | _, err := godo.Run("go build -a -o "+exeFile, godo.M{"$in": dir}) 263 | if err != nil { 264 | panic(fmt.Sprintf("Error building %s: %s\n", src, err.Error())) 265 | } 266 | // for some reason go build does not delete the exe named after the dir 267 | // which ends up with Gododir/Gododir 268 | if filepath.Base(dir) == "Gododir" { 269 | orphanedFile := filepath.Join(dir, filepath.Base(dir)) 270 | if _, err := os.Stat(orphanedFile); err == nil { 271 | os.Remove(orphanedFile) 272 | } 273 | } 274 | } 275 | 276 | if isRebuild { 277 | util.Info("godo", "ok\n") 278 | } 279 | 280 | return exe 281 | } 282 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "github.com/mgutz/minimist" 5 | "gopkg.in/godo.v2/util" 6 | "gopkg.in/godo.v2/watcher" 7 | ) 8 | 9 | func logVerbose(msg string, format string, args ...interface{}) { 10 | if !verbose { 11 | return 12 | } 13 | util.Debug(msg, format, args...) 14 | } 15 | 16 | // Context is the data passed to a task. 17 | type Context struct { 18 | // Task is the currently running task. 19 | Task *Task 20 | 21 | // FileEvent is an event from the watcher with change details. 22 | FileEvent *watcher.FileEvent 23 | 24 | // Task command line arguments 25 | Args minimist.ArgMap 26 | 27 | Error error 28 | } 29 | 30 | // AnyFile returns either a non-DELETe FileEvent file or the WatchGlob patterns which 31 | // can be used by goa.Load() 32 | func (context *Context) AnyFile() []string { 33 | if context.FileEvent != nil && context.FileEvent.Event != watcher.DELETED { 34 | return []string{context.FileEvent.Path} 35 | } 36 | return context.Task.SrcGlobs 37 | } 38 | 39 | // Run runs a command 40 | func (context *Context) Run(cmd string, options ...map[string]interface{}) { 41 | if context.Error != nil { 42 | logVerbose(context.Task.Name, "Context is in error. Skipping: %s\n", cmd) 43 | return 44 | } 45 | _, err := Run(cmd, options...) 46 | if err != nil { 47 | context.Error = err 48 | } 49 | } 50 | 51 | // Bash runs a bash shell. 52 | func (context *Context) Bash(cmd string, options ...map[string]interface{}) { 53 | if context.Error != nil { 54 | logVerbose(context.Task.Name, "Context is in error. Skipping: %s\n", cmd) 55 | return 56 | } 57 | _, err := Bash(cmd, options...) 58 | if err != nil { 59 | context.Error = err 60 | } 61 | } 62 | 63 | // Start run aysnchronously. 64 | func (context *Context) Start(cmd string, options ...map[string]interface{}) { 65 | if context.Error != nil { 66 | logVerbose(context.Task.Name, "Context is in error. Skipping: %s\n", cmd) 67 | return 68 | } 69 | 70 | err := startEx(context, cmd, options) 71 | if err != nil { 72 | context.Error = err 73 | } 74 | } 75 | 76 | // BashOutput executes a bash script and returns the output 77 | func (context *Context) BashOutput(script string, options ...map[string]interface{}) string { 78 | if len(options) == 0 { 79 | options = append(options, M{"$out": CaptureBoth}) 80 | } else { 81 | options[0]["$out"] = CaptureBoth 82 | } 83 | s, err := Bash(script, options...) 84 | if err != nil { 85 | context.Error = err 86 | return "" 87 | } 88 | return s 89 | } 90 | 91 | // RunOutput runs a command and returns output. 92 | func (context *Context) RunOutput(commandstr string, options ...map[string]interface{}) string { 93 | if len(options) == 0 { 94 | options = append(options, M{"$out": CaptureBoth}) 95 | } else { 96 | options[0]["$out"] = CaptureBoth 97 | } 98 | s, err := Run(commandstr, options...) 99 | if err != nil { 100 | context.Error = err 101 | return "" 102 | } 103 | return s 104 | } 105 | 106 | // Check halts the task if err is not nil. 107 | // 108 | // Do this 109 | // Check(err, "Some error occured") 110 | // 111 | // Instead of 112 | // 113 | // if err != nil { 114 | // Halt(err) 115 | // } 116 | func (context *Context) Check(err error, msg string) { 117 | if err != nil { 118 | if msg == "" { 119 | Halt(err) 120 | return 121 | } 122 | Halt(msg + ": " + err.Error()) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package godo is a task runner, file watcher in the spirit of Rake, Gulp ... 2 | // 3 | // To install 4 | // 5 | // go get -u gopkg.in/godo.v2/cmd/godo 6 | package godo 7 | -------------------------------------------------------------------------------- /env.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/mgutz/str" 9 | ) 10 | 11 | // Env is the default environment to use for all commands. That is, 12 | // the effective environment for all commands is the merged set 13 | // of (parent environment, Env, func specified environment). Whitespace 14 | // or newline separate key value pairs. $VAR interpolation is allowed. 15 | // 16 | // Env = "GOOS=linux GOARCH=amd64" 17 | // Env = ` 18 | // GOOS=linux 19 | // GOPATH=./vendor:$GOPATH 20 | // ` 21 | var Env string 22 | var environ []string 23 | 24 | // PathListSeparator is a cross-platform path list separator. On Windows, PathListSeparator 25 | // is replacd by ";". On others, PathListSeparator is replaced by ":" 26 | var PathListSeparator = "::" 27 | 28 | // InheritParentEnv whether to inherit parent's environment 29 | var InheritParentEnv bool 30 | 31 | func init() { 32 | InheritParentEnv = true 33 | } 34 | 35 | // SetEnviron sets the environment for child processes. Note that 36 | // SetEnviron(Env, InheritParentEnv) is called once automatically. 37 | func SetEnviron(envstr string, inheritParent bool) { 38 | if inheritParent { 39 | environ = os.Environ() 40 | } else { 41 | environ = []string{} 42 | } 43 | 44 | // merge in package Env 45 | if envstr != "" { 46 | for _, kv := range ParseStringEnv(envstr) { 47 | upsertenv(&environ, kv) 48 | } 49 | } 50 | } 51 | 52 | var envvarRe = regexp.MustCompile(`\$(\w+|\{(\w+)\})`) 53 | 54 | func interpolateEnv(env []string, kv string) string { 55 | if strings.Contains(kv, PathListSeparator) { 56 | kv = strings.Replace(kv, PathListSeparator, string(os.PathListSeparator), -1) 57 | } 58 | 59 | // find all key=$EXISTING_VAR:foo and interpolate from os.Environ() 60 | matches := envvarRe.FindAllStringSubmatch(kv, -1) 61 | for _, match := range matches { 62 | existingVar := match[2] 63 | if existingVar == "" { 64 | existingVar = match[1] 65 | } 66 | kv = strings.Replace(kv, match[0], getEnv(env, existingVar, true), -1) 67 | } 68 | return kv 69 | } 70 | 71 | // Getenv environment variable from a string array. 72 | func Getenv(key string) string { 73 | envvars := ParseStringEnv(Env) 74 | return getEnv(envvars, key, true) 75 | } 76 | 77 | func getEnv(env []string, key string, checkParent bool) string { 78 | for _, kv := range env { 79 | pair := splitKV(kv) 80 | if pair[0] == key { 81 | return pair[1] 82 | } 83 | } 84 | 85 | if checkParent { 86 | return os.Getenv(key) 87 | } 88 | return "" 89 | } 90 | 91 | func splitKV(kv string) []string { 92 | index := strings.Index(kv, "=") 93 | if index < 0 { 94 | return nil 95 | } 96 | 97 | return []string{ 98 | kv[0:index], 99 | kv[index+1:], 100 | } 101 | } 102 | 103 | // upsertenv updates or inserts a key=value pair into an environment. 104 | func upsertenv(env *[]string, kv string) { 105 | pair := splitKV(kv) 106 | if pair == nil { 107 | return 108 | } 109 | 110 | set := false 111 | for i, item := range *env { 112 | ipair := splitKV(item) 113 | if ipair[0] == pair[0] { 114 | (*env)[i] = interpolateEnv(*env, kv) 115 | set = true 116 | break 117 | } 118 | 119 | } 120 | 121 | if !set { 122 | *env = append(*env, interpolateEnv(*env, kv)) 123 | } 124 | } 125 | 126 | // EffectiveEnv is the effective environment for an exec function. 127 | func EffectiveEnv(funcEnv []string) []string { 128 | 129 | if environ == nil { 130 | SetEnviron(Env, InheritParentEnv) 131 | } 132 | 133 | env := make([]string, len(environ)) 134 | copy(env, environ) 135 | 136 | // merge in func's env 137 | if funcEnv != nil && len(funcEnv) > 0 { 138 | for _, kv := range funcEnv { 139 | upsertenv(&env, kv) 140 | } 141 | } 142 | return env 143 | } 144 | 145 | // ParseStringEnv parse the package Env string and converts it into an 146 | // environment slice. 147 | func ParseStringEnv(s string) []string { 148 | env := []string{} 149 | 150 | if s == "" { 151 | return env 152 | } 153 | 154 | s = str.Clean(s) 155 | argv := str.ToArgv(s) 156 | for _, kv := range argv { 157 | if !strings.Contains(kv, "=") { 158 | continue 159 | } 160 | env = append(env, kv) 161 | } 162 | return env 163 | } 164 | 165 | // parse environemnt variables from commandline 166 | func addToOSEnviron(argv []string) { 167 | for _, arg := range argv { 168 | equals := strings.IndexRune(arg, '=') 169 | if equals > 0 { 170 | os.Setenv(arg[0:equals], arg[equals+1:]) 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /env_test.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | "testing" 8 | 9 | "gopkg.in/stretchr/testify.v1/assert" 10 | 11 | "github.com/mgutz/str" 12 | ) 13 | 14 | var isWindows = runtime.GOOS == "windows" 15 | 16 | func TestEnvironment(t *testing.T) { 17 | var user string 18 | if isWindows { 19 | user = os.Getenv("USERNAME") 20 | os.Setenv("USER", user) 21 | } else { 22 | user = os.Getenv("USER") 23 | } 24 | 25 | SetEnviron("USER=$USER:godo", true) 26 | env := EffectiveEnv(nil) 27 | if !sliceContains(env, "USER="+user+":godo") { 28 | t.Error("Environment interpolation failed", env) 29 | } 30 | 31 | SetEnviron("USER=$USER:godo", false) 32 | env = EffectiveEnv(nil) 33 | if len(env) != 1 { 34 | t.Error("Disabling parent inheritance failed") 35 | } 36 | if !sliceContains(env, "USER="+user+":godo") { 37 | t.Error("Should have read parent var even if not inheriting") 38 | } 39 | 40 | // set back to defaults 41 | SetEnviron("", true) 42 | l := len(os.Environ()) 43 | env = EffectiveEnv([]string{"USER=$USER:$USER:func"}) 44 | if !sliceContains(env, "USER="+user+":"+user+":func") { 45 | t.Error("Should have been overriden by func environmnt") 46 | } 47 | if len(env) != l { 48 | t.Error("Effective environment length changed") 49 | } 50 | 51 | env = EffectiveEnv([]string{"GOSU_NEW_VAR=foo"}) 52 | if !sliceContains(env, "GOSU_NEW_VAR=foo") { 53 | t.Error("Should have new var") 54 | } 55 | if len(env) != l+1 { 56 | t.Error("Effective environment length should have increased by 1") 57 | } 58 | 59 | SetEnviron(` 60 | USER1=$USER 61 | USER2=$USER1 62 | `, true) 63 | env = EffectiveEnv([]string{"USER3=$USER2"}) 64 | if !sliceContains(env, "USER1="+user) { 65 | t.Error("Should have interpolated from parent env") 66 | } 67 | if !sliceContains(env, "USER3="+user) { 68 | t.Error("Should have interpolated from effective env") 69 | } 70 | 71 | env = EffectiveEnv([]string{"PATH=foo::bar::bah"}) 72 | if !sliceContains(env, "PATH=foo"+string(os.PathListSeparator)+"bar"+string(os.PathListSeparator)+"bah") { 73 | t.Error("Should have replaced PathSeparator, got", env) 74 | } 75 | 76 | // set back to defaults 77 | SetEnviron("", true) 78 | } 79 | 80 | func TestQuotedVar(t *testing.T) { 81 | // set back to defaults 82 | defer SetEnviron("", true) 83 | env := EffectiveEnv([]string{`FOO="a=bar b=bah c=baz"`}) 84 | v := getEnv(env, "FOO", false) 85 | if v != `"a=bar b=bah c=baz"` { 86 | t.Errorf("Quoted var failed %q", v) 87 | } 88 | } 89 | 90 | func TestExpansion(t *testing.T) { 91 | SetEnviron(` 92 | FOO=foo 93 | FAIL=$FOObar:godo 94 | OK=${FOO}bar:godo 95 | `, true) 96 | 97 | env := EffectiveEnv([]string{}) 98 | if !sliceContains(env, "FAIL=:godo") { 99 | t.Error("$FOObar should not have interpolated") 100 | } 101 | if !sliceContains(env, "OK=foobar:godo") { 102 | t.Error("${FOO}bar should have expanded", env) 103 | } 104 | } 105 | 106 | func TestInheritedRunEnv(t *testing.T) { 107 | os.Setenv("TEST_RUN_ENV", "fubar") 108 | SetEnviron("", true) 109 | 110 | var output string 111 | 112 | if isWindows { 113 | output, _ = RunOutput(`FOO=bar BAH=baz cmd /C "echo %TEST_RUN_ENV% %FOO%"`) 114 | } else { 115 | output, _ = RunOutput(`FOO=bar BAH=baz bash -c "echo -n $TEST_RUN_ENV $FOO"`) 116 | } 117 | 118 | if str.Clean(output) != "fubar bar" { 119 | t.Error("Environment was not inherited! Got", fmt.Sprintf("%q", output)) 120 | } 121 | } 122 | 123 | func TestAddToOSEnviron(t *testing.T) { 124 | others := []string{"_foo", "_test_bar=bah", "_test_opts=a=b,c=d,*="} 125 | assert.Equal(t, "", os.Getenv("_foo")) 126 | assert.Equal(t, "", os.Getenv("_test_bar")) 127 | assert.Equal(t, "", os.Getenv("_test_opts")) 128 | addToOSEnviron(others) 129 | assert.Equal(t, "", os.Getenv("_foo")) 130 | assert.Equal(t, "bah", os.Getenv("_test_bar")) 131 | assert.Equal(t, "a=b,c=d,*=", os.Getenv("_test_opts")) 132 | } 133 | 134 | func TestEnvFromArgs(t *testing.T) { 135 | tasks := func(p *Project) { 136 | p.Task("foo", nil, func(*Context) { 137 | p.Exit(0) 138 | }) 139 | } 140 | 141 | argv := []string{"foo", "a=b", "c=", "d=e=f,g=*"} 142 | godoExit(tasks, argv, func(code int) { 143 | assert.Equal(t, "b", os.Getenv("a")) 144 | assert.Equal(t, "", os.Getenv("c")) 145 | assert.Equal(t, "e=f,g=*", os.Getenv("d")) 146 | 147 | os.Setenv("a", "") 148 | os.Setenv("c", "") 149 | os.Setenv("d", "") 150 | }) 151 | } 152 | -------------------------------------------------------------------------------- /exec.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/howeyc/gopass" 12 | "github.com/mgutz/str" 13 | "github.com/nozzle/throttler" 14 | "gopkg.in/godo.v2/util" 15 | ) 16 | 17 | // Bash executes a bash script (string). 18 | func Bash(script string, options ...map[string]interface{}) (string, error) { 19 | return bash(script, options) 20 | } 21 | 22 | // BashOutput executes a bash script and returns the output 23 | func BashOutput(script string, options ...map[string]interface{}) (string, error) { 24 | if len(options) == 0 { 25 | options = append(options, M{"$out": CaptureBoth}) 26 | } else { 27 | options[0]["$out"] = CaptureBoth 28 | } 29 | return bash(script, options) 30 | } 31 | 32 | // Run runs a command. 33 | func Run(commandstr string, options ...map[string]interface{}) (string, error) { 34 | return run(commandstr, options) 35 | } 36 | 37 | // RunOutput runs a command and returns output. 38 | func RunOutput(commandstr string, options ...map[string]interface{}) (string, error) { 39 | if len(options) == 0 { 40 | options = append(options, M{"$out": CaptureBoth}) 41 | } else { 42 | options[0]["$out"] = CaptureBoth 43 | } 44 | return run(commandstr, options) 45 | } 46 | 47 | // Start starts an async command. If executable has suffix ".go" then it will 48 | // be "go install"ed then executed. Use this for watching a server task. 49 | // 50 | // If Start is called with the same command it kills the previous process. 51 | // 52 | // The working directory is optional. 53 | func Start(commandstr string, options ...map[string]interface{}) error { 54 | return startEx(nil, commandstr, options) 55 | } 56 | 57 | func rebuildPackage(filename string) error { 58 | _, err := Run("go build", M{"$in": filepath.Dir(filename)}) 59 | return err 60 | } 61 | 62 | func startEx(context *Context, commandstr string, options []map[string]interface{}) error { 63 | m, dir, _, err := parseOptions(options) 64 | if err != nil { 65 | return err 66 | } 67 | if strings.Contains(commandstr, "{{") { 68 | commandstr, err = util.StrTemplate(commandstr, m) 69 | if err != nil { 70 | return err 71 | } 72 | } 73 | executable, argv, env := splitCommand(commandstr) 74 | if context != nil && context.FileEvent != nil { 75 | event := context.FileEvent 76 | absPath, err := filepath.Abs(filepath.Join(dir, executable)) 77 | if err != nil { 78 | return err 79 | } 80 | if filepath.Ext(event.Path) == ".go" && event.Path != absPath { 81 | var p string 82 | wd, err := os.Getwd() 83 | if err != nil { 84 | return err 85 | } 86 | p, err = filepath.Rel(wd, event.Path) 87 | if err != nil { 88 | p = event.Path 89 | } 90 | util.Info(context.Task.Name, "rebuilding %s...\n", filepath.Dir(p)) 91 | rebuildPackage(event.Path) 92 | } 93 | } 94 | isGoFile := strings.HasSuffix(executable, ".go") 95 | if isGoFile { 96 | cmdstr := "go install" 97 | if context == nil || context.FileEvent == nil { 98 | util.Info(context.Task.Name, "rebuilding with -a to ensure clean build (might take awhile)\n") 99 | cmdstr += " -a" 100 | } 101 | _, err = Run(cmdstr, m) 102 | if err != nil { 103 | return err 104 | } 105 | executable = filepath.Base(dir) 106 | } 107 | cmd := &command{ 108 | executable: executable, 109 | wd: dir, 110 | env: env, 111 | argv: argv, 112 | commandstr: commandstr, 113 | } 114 | return cmd.runAsync() 115 | } 116 | 117 | func getWorkingDir(m map[string]interface{}) (string, error) { 118 | pwd, err := os.Getwd() 119 | if err != nil { 120 | return "", nil 121 | } 122 | 123 | var wd string 124 | if m != nil { 125 | if d, ok := m["$in"].(string); ok { 126 | wd = d 127 | } 128 | } 129 | if wd != "" { 130 | var path string 131 | if filepath.IsAbs(wd) { 132 | path = wd 133 | } else { 134 | path = filepath.Join(pwd, wd) 135 | } 136 | _, err := os.Stat(path) 137 | if err == nil { 138 | return path, nil 139 | } 140 | return "", fmt.Errorf("working dir does not exist: %s", path) 141 | } 142 | return pwd, nil 143 | } 144 | 145 | func parseOptions(options []map[string]interface{}) (m map[string]interface{}, dir string, capture int, err error) { 146 | if options == nil { 147 | m = map[string]interface{}{} 148 | } else { 149 | m = options[0] 150 | } 151 | 152 | dir, err = getWorkingDir(m) 153 | if err != nil { 154 | return nil, "", 0, err 155 | } 156 | 157 | if n, ok := m["$out"].(int); ok { 158 | capture = n 159 | } 160 | 161 | return m, dir, capture, nil 162 | } 163 | 164 | // Bash executes a bash string. Use backticks for multiline. To execute as shell script, 165 | // use Run("bash script.sh") 166 | func bash(script string, options []map[string]interface{}) (output string, err error) { 167 | m, dir, capture, err := parseOptions(options) 168 | if err != nil { 169 | return "", err 170 | } 171 | 172 | if strings.Contains(script, "{{") { 173 | script, err = util.StrTemplate(script, m) 174 | if err != nil { 175 | return "", err 176 | } 177 | } 178 | 179 | gcmd := &command{ 180 | executable: "bash", 181 | argv: []string{"-c", script}, 182 | wd: dir, 183 | capture: capture, 184 | commandstr: script, 185 | } 186 | 187 | return gcmd.run() 188 | } 189 | 190 | func run(commandstr string, options []map[string]interface{}) (output string, err error) { 191 | m, dir, capture, err := parseOptions(options) 192 | if err != nil { 193 | return "", err 194 | } 195 | 196 | if strings.Contains(commandstr, "{{") { 197 | commandstr, err = util.StrTemplate(commandstr, m) 198 | if err != nil { 199 | return "", err 200 | } 201 | } 202 | 203 | lines := strings.Split(commandstr, "\n") 204 | if len(lines) == 0 { 205 | return "", fmt.Errorf("Empty command string") 206 | } 207 | for i, cmdline := range lines { 208 | cmdstr := strings.Trim(cmdline, " \t") 209 | if cmdstr == "" { 210 | continue 211 | } 212 | executable, argv, env := splitCommand(cmdstr) 213 | 214 | cmd := &command{ 215 | executable: executable, 216 | wd: dir, 217 | env: env, 218 | argv: argv, 219 | capture: capture, 220 | commandstr: commandstr, 221 | } 222 | 223 | s, err := cmd.run() 224 | if err != nil { 225 | err = fmt.Errorf(err.Error()+"\nline=%d", i) 226 | return s, err 227 | } 228 | output += s 229 | } 230 | return output, nil 231 | } 232 | 233 | // func getWd(wd []In) (string, error) { 234 | // if len(wd) == 1 { 235 | // return wd[0][0], nil 236 | // } 237 | // return os.Getwd() 238 | // } 239 | 240 | func splitCommand(command string) (executable string, argv, env []string) { 241 | argv = str.ToArgv(command) 242 | for i, item := range argv { 243 | if strings.Contains(item, "=") { 244 | if env == nil { 245 | env = []string{item} 246 | continue 247 | } 248 | env = append(env, item) 249 | } else { 250 | executable = item 251 | argv = argv[i+1:] 252 | return 253 | } 254 | } 255 | 256 | executable = argv[0] 257 | argv = argv[1:] 258 | return 259 | } 260 | 261 | func toInt(s string) int { 262 | result, err := strconv.Atoi(s) 263 | if err != nil { 264 | return 0 265 | } 266 | return result 267 | } 268 | 269 | // Inside temporarily changes the working directory and restores it when lambda 270 | // finishes. 271 | func Inside(dir string, lambda func()) error { 272 | olddir, err := os.Getwd() 273 | if err != nil { 274 | return err 275 | } 276 | 277 | err = os.Chdir(dir) 278 | if err != nil { 279 | return err 280 | } 281 | 282 | defer func() { 283 | os.Chdir(olddir) 284 | }() 285 | lambda() 286 | return nil 287 | } 288 | 289 | // Prompt prompts user for input with default value. 290 | func Prompt(prompt string) string { 291 | reader := bufio.NewReader(os.Stdin) 292 | fmt.Print(prompt) 293 | text, _ := reader.ReadString('\n') 294 | return text 295 | } 296 | 297 | // PromptPassword prompts user for password input. 298 | func PromptPassword(prompt string) string { 299 | fmt.Printf(prompt) 300 | b, err := gopass.GetPasswd() 301 | if err != nil { 302 | fmt.Println(err.Error()) 303 | return "" 304 | } 305 | return string(b) 306 | } 307 | 308 | // GoThrottle starts to run the given list of fns concurrently, 309 | // at most n fns at a time. 310 | func GoThrottle(throttle int, fns ...func() error) error { 311 | var err error 312 | 313 | // Create a new Throttler that will get 2 urls at a time 314 | t := throttler.New(throttle, len(fns)) 315 | for _, fn := range fns { 316 | // Launch a goroutine to fetch the URL. 317 | go func(f func() error) { 318 | err2 := f() 319 | if err2 != nil { 320 | err = err2 321 | } 322 | 323 | // Let Throttler know when the goroutine completes 324 | // so it can dispatch another worker 325 | t.Done(err) 326 | }(fn) 327 | // Pauses until a worker is available or all jobs have been completed 328 | // Returning the total number of goroutines that have errored 329 | // lets you choose to break out of the loop without starting any more 330 | errorCount := t.Throttle() 331 | if errorCount > 0 { 332 | break 333 | } 334 | } 335 | return err 336 | } 337 | -------------------------------------------------------------------------------- /exec_test.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "runtime" 7 | "strings" 8 | "testing" 9 | 10 | "gopkg.in/stretchr/testify.v1/assert" 11 | 12 | "github.com/mgutz/str" 13 | ) 14 | 15 | var cat = "cat" 16 | 17 | func init() { 18 | if runtime.GOOS == "windows" { 19 | cat = "type" 20 | } 21 | } 22 | 23 | func TestRunMultiline(t *testing.T) { 24 | output, _ := RunOutput(` 25 | {{.cat}} test/foo.txt 26 | {{.cat}} test/bar.txt 27 | `, M{"cat": cat}) 28 | assert.Equal(t, "foo\nbar\n", output) 29 | } 30 | 31 | func TestRunError(t *testing.T) { 32 | output, err := RunOutput(` 33 | {{.cat}} test/doesnotexist.txt 34 | {{.cat}} test/bar.txt 35 | `, M{"cat": cat}) 36 | assert.Error(t, err) 37 | assert.Contains(t, err.Error(), "line=1") 38 | assert.Contains(t, output, "doesnotexist") 39 | } 40 | 41 | func TestInside(t *testing.T) { 42 | Inside("test", func() { 43 | var out string 44 | if isWindows { 45 | out, _ = RunOutput("foo.cmd") 46 | } else { 47 | out, _ = RunOutput("bash foo.sh") 48 | } 49 | 50 | if str.Clean(out) != "FOOBAR" { 51 | t.Error("Inside failed. Got", fmt.Sprintf("%q", out)) 52 | } 53 | }) 54 | 55 | version, _ := ioutil.ReadFile("./VERSION.go") 56 | if !strings.Contains(string(version), "var Version") { 57 | t.Error("Inside failed to reset work directory") 58 | } 59 | } 60 | 61 | func TestBash(t *testing.T) { 62 | if isWindows { 63 | return 64 | } 65 | out, _ := BashOutput(`echo -n foobar`) 66 | if out != "foobar" { 67 | t.Error("Simple bash failed. Got", out) 68 | } 69 | 70 | out, _ = BashOutput(` 71 | echo -n foobar 72 | echo -n bahbaz 73 | `) 74 | if out != "foobarbahbaz" { 75 | t.Error("Multiline bash failed. Got", out) 76 | } 77 | 78 | out, _ = BashOutput(` 79 | echo -n \ 80 | foobar 81 | `) 82 | if out != "foobar" { 83 | t.Error("Bash line continuation failed. Got", out) 84 | } 85 | 86 | out, _ = BashOutput(` 87 | echo -n "foobar" 88 | `) 89 | if out != "foobar" { 90 | t.Error("Bash quotes failed. Got", out) 91 | } 92 | 93 | out, _ = BashOutput(` 94 | echo -n "fo\"obar" 95 | `) 96 | if out != "fo\"obar" { 97 | t.Error("Bash quoted string failed. Got", out) 98 | } 99 | } 100 | 101 | func TestTemplatedCommands(t *testing.T) { 102 | echo := "echo" 103 | if isWindows { 104 | echo = "cmd /c echo" 105 | 106 | } 107 | // in V2 BashOutput accepts an options map 108 | out, err := RunOutput(echo+" {{.name}}", M{"name": "oy"}) 109 | assert.NoError(t, err) 110 | assert.Equal(t, "oy", str.Clean(out)) 111 | 112 | if isWindows { 113 | return 114 | } 115 | 116 | // in V2 BashOutput accepts an options map 117 | out, err = BashOutput("echo {{.name}}", M{"name": "oy"}) 118 | assert.NoError(t, err) 119 | assert.Equal(t, "oy", str.Clean(out)) 120 | } 121 | -------------------------------------------------------------------------------- /fileWrapper.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/mgutz/ansi" 9 | ) 10 | 11 | type fileWrapper struct { 12 | file *os.File 13 | buf *bytes.Buffer 14 | readLines string 15 | 16 | recorder *bytes.Buffer 17 | 18 | // Adds color to stdout & stderr if terminal supports it 19 | colorStart string 20 | } 21 | 22 | func newFileWrapper(file *os.File, recorder *bytes.Buffer, color string) *fileWrapper { 23 | streamer := &fileWrapper{ 24 | file: file, 25 | buf: bytes.NewBufferString(""), 26 | recorder: recorder, 27 | colorStart: color, 28 | } 29 | 30 | return streamer 31 | } 32 | 33 | func (l *fileWrapper) Write(p []byte) (n int, err error) { 34 | if n, err = l.recorder.Write(p); err != nil { 35 | return 36 | } 37 | 38 | err = l.out(string(p)) 39 | return 40 | } 41 | 42 | func (l *fileWrapper) WriteString(s string) (n int, err error) { 43 | if n, err = l.recorder.WriteString(s); err != nil { 44 | return 45 | } 46 | 47 | err = l.out(s) 48 | return 49 | } 50 | 51 | func (l *fileWrapper) Close() error { 52 | l.buf = bytes.NewBuffer([]byte("")) 53 | return nil 54 | } 55 | 56 | func (l *fileWrapper) out(str string) (err error) { 57 | 58 | if l.colorStart != "" { 59 | fmt.Fprint(l.file, l.colorStart) 60 | fmt.Fprint(l.file, str) 61 | fmt.Fprint(l.file, ansi.Reset) 62 | } else { 63 | fmt.Fprint(l.file, str) 64 | } 65 | 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /glob/fileAsset.go: -------------------------------------------------------------------------------- 1 | package glob 2 | 3 | import "os" 4 | 5 | // FileAsset contains file information and path from globbing. 6 | type FileAsset struct { 7 | os.FileInfo 8 | // Path to asset 9 | Path string 10 | } 11 | 12 | // Stat updates the stat of this asset. 13 | func (fa *FileAsset) Stat() (*os.FileInfo, error) { 14 | fi, err := os.Stat(fa.Path) 15 | if err != nil { 16 | return nil, err 17 | } 18 | fa.FileInfo = fi 19 | return &fa.FileInfo, nil 20 | } 21 | -------------------------------------------------------------------------------- /glob/glob.go: -------------------------------------------------------------------------------- 1 | package glob 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | //"log" 7 | "os" 8 | gpath "path" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | "sync" 13 | "unicode/utf8" 14 | 15 | "github.com/MichaelTJones/walk" 16 | ) 17 | 18 | const ( 19 | // NotSlash is any rune but path separator. 20 | notSlash = "[^/]" 21 | // AnyRune is zero or more non-path separators. 22 | anyRune = notSlash + "*" 23 | // ZeroOrMoreDirectories is used by ** patterns. 24 | zeroOrMoreDirectories = `(?:[.{}\w\-\ ]+\/)*` 25 | // TrailingStarStar matches everything inside directory. 26 | trailingStarStar = "/**" 27 | // SlashStarStarSlash maches zero or more directories. 28 | slashStarStarSlash = "/**/" 29 | ) 30 | 31 | // RegexpInfo contains additional info about the Regexp created by a glob pattern. 32 | type RegexpInfo struct { 33 | Regexp *regexp.Regexp 34 | Negate bool 35 | Path string 36 | Glob string 37 | } 38 | 39 | // MatchString matches a string with either a regexp or direct string match 40 | func (ri *RegexpInfo) MatchString(s string) bool { 41 | if ri.Regexp != nil { 42 | return ri.Regexp.MatchString(s) 43 | } else if ri.Path != "" { 44 | return strings.HasSuffix(s, ri.Path) 45 | } 46 | return false 47 | } 48 | 49 | // Globexp builds a regular express from from extended glob pattern and then 50 | // returns a Regexp object. 51 | func Globexp(glob string) *regexp.Regexp { 52 | var re bytes.Buffer 53 | 54 | re.WriteString("^") 55 | 56 | i, inGroup, L := 0, false, len(glob) 57 | 58 | for i < L { 59 | r, w := utf8.DecodeRuneInString(glob[i:]) 60 | 61 | switch r { 62 | default: 63 | re.WriteRune(r) 64 | 65 | case '\\', '$', '^', '+', '.', '(', ')', '=', '!', '|': 66 | re.WriteRune('\\') 67 | re.WriteRune(r) 68 | 69 | case '/': 70 | // TODO optimize later, string could be long 71 | rest := glob[i:] 72 | re.WriteRune('/') 73 | if strings.HasPrefix(rest, "/**/") { 74 | re.WriteString(zeroOrMoreDirectories) 75 | w *= 4 76 | } else if rest == "/**" { 77 | re.WriteString(".*") 78 | w *= 3 79 | } 80 | 81 | case '?': 82 | re.WriteRune('.') 83 | 84 | case '[', ']': 85 | re.WriteRune(r) 86 | 87 | case '{': 88 | if i < L-1 { 89 | if glob[i+1:i+2] == "{" { 90 | re.WriteString("\\{") 91 | w *= 2 92 | break 93 | } 94 | } 95 | inGroup = true 96 | re.WriteRune('(') 97 | 98 | case '}': 99 | if inGroup { 100 | inGroup = false 101 | re.WriteRune(')') 102 | } else { 103 | re.WriteRune('}') 104 | } 105 | 106 | case ',': 107 | if inGroup { 108 | re.WriteRune('|') 109 | } else { 110 | re.WriteRune('\\') 111 | re.WriteRune(r) 112 | } 113 | 114 | case '*': 115 | rest := glob[i:] 116 | if strings.HasPrefix(rest, "**/") { 117 | re.WriteString(zeroOrMoreDirectories) 118 | w *= 3 119 | } else { 120 | re.WriteString(anyRune) 121 | } 122 | } 123 | 124 | i += w 125 | } 126 | 127 | re.WriteString("$") 128 | //log.Printf("regex string %s", re.String()) 129 | return regexp.MustCompile(re.String()) 130 | } 131 | 132 | // Glob returns files and dirctories that match patterns. Patterns must use 133 | // slashes, even Windows. 134 | // 135 | // Special chars. 136 | // 137 | // /**/ - match zero or more directories 138 | // {a,b} - match a or b, no spaces 139 | // * - match any non-separator char 140 | // ? - match a single non-separator char 141 | // **/ - match any directory, start of pattern only 142 | // /** - match any this directory, end of pattern only 143 | // ! - removes files from resultset, start of pattern only 144 | // 145 | func Glob(patterns []string) ([]*FileAsset, []*RegexpInfo, error) { 146 | // TODO very inefficient and unintelligent, optimize later 147 | 148 | m := map[string]*FileAsset{} 149 | regexps := []*RegexpInfo{} 150 | 151 | for _, pattern := range patterns { 152 | remove := strings.HasPrefix(pattern, "!") 153 | if remove { 154 | pattern = pattern[1:] 155 | if hasMeta(pattern) { 156 | re := Globexp(pattern) 157 | regexps = append(regexps, &RegexpInfo{Regexp: re, Glob: pattern, Negate: true}) 158 | for path := range m { 159 | if re.MatchString(path) { 160 | m[path] = nil 161 | } 162 | } 163 | } else { 164 | path := gpath.Clean(pattern) 165 | m[path] = nil 166 | regexps = append(regexps, &RegexpInfo{Path: path, Glob: pattern, Negate: true}) 167 | } 168 | } else { 169 | if hasMeta(pattern) { 170 | re := Globexp(pattern) 171 | regexps = append(regexps, &RegexpInfo{Regexp: re, Glob: pattern}) 172 | root := PatternRoot(pattern) 173 | if root == "" { 174 | return nil, nil, fmt.Errorf("Cannot get root from pattern: %s", pattern) 175 | } 176 | fileAssets, err := walkFiles(root) 177 | if err != nil { 178 | return nil, nil, err 179 | } 180 | 181 | for _, file := range fileAssets { 182 | if re.MatchString(file.Path) { 183 | // TODO closure problem assigning &file 184 | tmp := file 185 | m[file.Path] = tmp 186 | } 187 | } 188 | } else { 189 | path := gpath.Clean(pattern) 190 | info, err := os.Stat(path) 191 | if err != nil { 192 | return nil, nil, err 193 | } 194 | regexps = append(regexps, &RegexpInfo{Path: path, Glob: pattern, Negate: false}) 195 | fa := &FileAsset{Path: path, FileInfo: info} 196 | m[path] = fa 197 | } 198 | } 199 | } 200 | 201 | //log.Printf("m %v", m) 202 | keys := []*FileAsset{} 203 | for _, it := range m { 204 | if it != nil { 205 | keys = append(keys, it) 206 | } 207 | } 208 | return keys, regexps, nil 209 | } 210 | 211 | // hasMeta determines if a path has special chars used to build a Regexp. 212 | func hasMeta(path string) bool { 213 | return strings.IndexAny(path, "*?[{") >= 0 214 | } 215 | 216 | func isDir(path string) bool { 217 | st, err := os.Stat(path) 218 | if os.IsNotExist(err) { 219 | return false 220 | } 221 | return st.IsDir() 222 | } 223 | 224 | // PatternRoot gets a real directory root from a pattern. The directory 225 | // returned is used as the start location for globbing. 226 | func PatternRoot(s string) string { 227 | if isDir(s) { 228 | return s 229 | } 230 | 231 | // No directory in pattern 232 | parts := strings.Split(s, "/") 233 | if len(parts) == 1 { 234 | return "." 235 | } 236 | // parts returns an empty string at positio 0 if the s starts with "/" 237 | root := "" 238 | 239 | // Build path until a dirname has a char used to build regex 240 | for i, part := range parts { 241 | if hasMeta(part) { 242 | break 243 | } 244 | if i > 0 { 245 | root += "/" 246 | } 247 | root += part 248 | } 249 | // Default to cwd 250 | if root == "" { 251 | root = "." 252 | } 253 | return root 254 | } 255 | 256 | // walkFiles walks a directory starting at root returning all directories and files 257 | // include those found in subdirectories. 258 | func walkFiles(root string) ([]*FileAsset, error) { 259 | fileAssets := []*FileAsset{} 260 | var lock sync.Mutex 261 | visitor := func(path string, info os.FileInfo, err error) error { 262 | // if err != nil { 263 | // fmt.Println("visitor err", err.Error(), "root", root) 264 | // } 265 | if err == nil { 266 | lock.Lock() 267 | fileAssets = append(fileAssets, &FileAsset{FileInfo: info, Path: filepath.ToSlash(path)}) 268 | lock.Unlock() 269 | } 270 | return nil 271 | } 272 | err := walk.Walk(root, visitor) 273 | if err != nil { 274 | return nil, err 275 | } 276 | return fileAssets, nil 277 | } 278 | -------------------------------------------------------------------------------- /glob/glob_test.go: -------------------------------------------------------------------------------- 1 | package glob 2 | 3 | import ( 4 | //"log" 5 | "regexp" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestMatching(t *testing.T) { 11 | var re *regexp.Regexp 12 | 13 | re = Globexp("a") 14 | if !re.MatchString("a") { 15 | t.Error("should match exactly") 16 | } 17 | 18 | re = Globexp("src/**/*.html") 19 | if !re.MatchString("src/test.html") { 20 | t.Error("/**/ should match zero directories") 21 | } 22 | 23 | re = Globexp("src/**/*.html") 24 | if !re.MatchString("src/foo/bar/test.html") { 25 | t.Error("/**/ should match intermediate directories") 26 | } 27 | 28 | re = Globexp("**/*.html") 29 | if !re.MatchString("test.html") { 30 | t.Error("**/ should match zero leading directories") 31 | } 32 | 33 | re = Globexp("**/*.html") 34 | if !re.MatchString("src/foo/bar/test.html") { 35 | t.Error("**/ should match leading directories") 36 | } 37 | 38 | re = Globexp("src/**/test.html") 39 | if !re.MatchString("src/foo/bar/test.html") { 40 | t.Error("** should match exact directories") 41 | } 42 | 43 | re = Globexp("*.js") 44 | if !re.MatchString(".config.js") { 45 | t.Error("* should match dot") 46 | } 47 | 48 | re = Globexp("*.js") 49 | if re.MatchString("foo/.config.js") { 50 | t.Error("* should not match directories") 51 | } 52 | 53 | re = Globexp("**/*.js") 54 | if !re.MatchString(".config.js") { 55 | t.Error("**/ slash should be optional") 56 | } 57 | 58 | re = Globexp("**/test.{html,js}") 59 | if !re.MatchString("src/test.html") || !re.MatchString("src/test.js") { 60 | t.Error("{} should match options") 61 | } 62 | 63 | re = Globexp("**/{{{{VERSION}}/*.foo") 64 | if !re.MatchString("src/{{VERSION}}/1.foo") { 65 | t.Error("{} should be escapable") 66 | } 67 | 68 | re = Globexp("public/**/*.uml") 69 | if !re.MatchString("public/{{VERSION}}/123/.4-5/a b/main-diagram.uml") { 70 | t.Error("should handle special chars") 71 | } 72 | 73 | re = Globexp("example/views/**/*.go.html") 74 | if !re.MatchString("example/views/admin/layout.go.html") { 75 | t.Error("should handle multiple subdirs admin") 76 | } 77 | if !re.MatchString("example/views/front/indexl.go.html") { 78 | t.Error("should handle multiple subdirs admin") 79 | } 80 | } 81 | 82 | func TestGlob(t *testing.T) { 83 | files, regexps, _ := Glob([]string{"./test/foo.txt"}) 84 | if len(files) != 1 { 85 | t.Log("files", files) 86 | t.Error("should return file with no patterns") 87 | } 88 | if len(files) != len(regexps) { 89 | t.Error("Unequal amount of files and regexps") 90 | } 91 | 92 | files, _, _ = Glob([]string{"test/**/*.txt"}) 93 | if len(files) != 5 { 94 | t.Log("files", files) 95 | t.Error("should return all txt files") 96 | } 97 | 98 | files, _, _ = Glob([]string{"test/**/*.txt", "!**/*sub1.txt"}) 99 | if len(files) != 3 { 100 | t.Error("should return all go files but those suffixed with sub1.txt") 101 | } 102 | for _, file := range files { 103 | if strings.HasSuffix(file.Path, "sub1.txt") { 104 | t.Error("should have excluded a negated pattern") 105 | } 106 | } 107 | } 108 | 109 | func TestPatternRoot(t *testing.T) { 110 | s := PatternRoot("example/views/**/*.go.html") 111 | if s != "example/views" { 112 | t.Error("did not calculate root dir from pattern") 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /glob/test/.hidden.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-godo/godo/b0de4ae4bf6fb493425e157c54f8cf2049c4931c/glob/test/.hidden.txt -------------------------------------------------------------------------------- /glob/test/foo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo -n FOOBAR 3 | -------------------------------------------------------------------------------- /glob/test/foo.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-godo/godo/b0de4ae4bf6fb493425e157c54f8cf2049c4931c/glob/test/foo.txt -------------------------------------------------------------------------------- /glob/test/sub/sub/subsub1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-godo/godo/b0de4ae4bf6fb493425e157c54f8cf2049c4931c/glob/test/sub/sub/subsub1.txt -------------------------------------------------------------------------------- /glob/test/sub/sub/subsub2.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-godo/godo/b0de4ae4bf6fb493425e157c54f8cf2049c4931c/glob/test/sub/sub/subsub2.html -------------------------------------------------------------------------------- /glob/test/sub/sub1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-godo/godo/b0de4ae4bf6fb493425e157c54f8cf2049c4931c/glob/test/sub/sub1.txt -------------------------------------------------------------------------------- /glob/test/sub/sub2.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-godo/godo/b0de4ae4bf6fb493425e157c54f8cf2049c4931c/glob/test/sub/sub2.txt -------------------------------------------------------------------------------- /glob/test2/main.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-godo/godo/b0de4ae4bf6fb493425e157c54f8cf2049c4931c/glob/test2/main.css -------------------------------------------------------------------------------- /glob/test2/main.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-godo/godo/b0de4ae4bf6fb493425e157c54f8cf2049c4931c/glob/test2/main.js -------------------------------------------------------------------------------- /glob/watchCriteria.go: -------------------------------------------------------------------------------- 1 | package glob 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/mgutz/str" 10 | ) 11 | 12 | // WatchCriterion is the criteria needed to test if a file 13 | // matches a pattern. 14 | type WatchCriterion struct { 15 | // Root is the root directory to start watching. 16 | Root string 17 | // Includes are the regexp for including files 18 | IncludesRegexp []*regexp.Regexp 19 | // Excludes are the regexp for excluding files 20 | ExcludesRegexp []*regexp.Regexp 21 | Includes []string 22 | Excludes []string 23 | } 24 | 25 | func newWatchCriterion(r string) *WatchCriterion { 26 | return &WatchCriterion{ 27 | Root: r, 28 | IncludesRegexp: []*regexp.Regexp{}, 29 | ExcludesRegexp: []*regexp.Regexp{}, 30 | Includes: []string{}, 31 | Excludes: []string{}, 32 | } 33 | } 34 | 35 | // WatchCriteria is the set of criterion to watch one or more glob patterns. 36 | type WatchCriteria struct { 37 | Items []*WatchCriterion 38 | } 39 | 40 | func newWatchCriteria() *WatchCriteria { 41 | return &WatchCriteria{ 42 | Items: []*WatchCriterion{}, 43 | } 44 | } 45 | 46 | func (cr *WatchCriteria) findParent(root string) *WatchCriterion { 47 | for _, item := range cr.Items { 48 | if item.Root == root || strings.Contains(item.Root, root) { 49 | return item 50 | } 51 | } 52 | return nil 53 | } 54 | 55 | func (cr *WatchCriteria) add(glob string) error { 56 | var err error 57 | 58 | if glob == "" || glob == "!" { 59 | return nil 60 | } 61 | 62 | isExclude := strings.HasPrefix(glob, "!") 63 | if isExclude { 64 | glob = glob[1:] 65 | } 66 | 67 | // determine if the root of pattern already exists 68 | root := PatternRoot(glob) 69 | root, err = filepath.Abs(root) 70 | if err != nil { 71 | return err 72 | } 73 | root = filepath.ToSlash(root) 74 | cri := cr.findParent(root) 75 | if cri == nil { 76 | cri = newWatchCriterion(root) 77 | cr.Items = append(cr.Items, cri) 78 | } 79 | 80 | glob, err = filepath.Abs(glob) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | // add glob to {in,ex}cludes 86 | if isExclude { 87 | if str.SliceIndexOf(cri.Excludes, glob) < 0 { 88 | re := Globexp(glob) 89 | cri.ExcludesRegexp = append(cri.ExcludesRegexp, re) 90 | cri.Excludes = append(cri.Excludes, glob) 91 | } 92 | } else { 93 | if str.SliceIndexOf(cri.Includes, glob) < 0 { 94 | re := Globexp(glob) 95 | cri.IncludesRegexp = append(cri.IncludesRegexp, re) 96 | cri.Includes = append(cri.Includes, glob) 97 | } 98 | } 99 | 100 | return nil 101 | } 102 | 103 | // Roots returns the root paths of all criteria. 104 | func (cr *WatchCriteria) Roots() []string { 105 | if cr.Items == nil || len(cr.Items) == 0 { 106 | return nil 107 | } 108 | 109 | roots := make([]string, len(cr.Items)) 110 | for i, it := range cr.Items { 111 | roots[i] = it.Root 112 | } 113 | return roots 114 | } 115 | 116 | // Matches determines if pth is matched by internal criteria. 117 | func (cr *WatchCriteria) Matches(pth string) bool { 118 | match := false 119 | pth = filepath.ToSlash(pth) 120 | for _, it := range cr.Items { 121 | // if sub path 122 | if strings.HasPrefix(pth, it.Root) { 123 | // check if matches an include pattern 124 | for _, re := range it.IncludesRegexp { 125 | if re.MatchString(pth) { 126 | match = true 127 | break 128 | } 129 | } 130 | // when found, check if it is excluded 131 | if match { 132 | for _, re := range it.ExcludesRegexp { 133 | if re.MatchString(pth) { 134 | match = false 135 | break 136 | } 137 | } 138 | if match { 139 | return true 140 | } 141 | } 142 | } 143 | } 144 | 145 | return false 146 | } 147 | 148 | // EffectiveCriteria is the minimum set of criteria to watch the 149 | // items in patterns 150 | func EffectiveCriteria(globs ...string) (*WatchCriteria, error) { 151 | if len(globs) == 0 { 152 | return nil, nil 153 | } 154 | result := newWatchCriteria() 155 | for _, glob := range globs { 156 | err := result.add(glob) 157 | if err != nil { 158 | fmt.Println(err.Error()) 159 | return nil, err 160 | } 161 | } 162 | 163 | return result, nil 164 | } 165 | -------------------------------------------------------------------------------- /glob/watchCriteria_test.go: -------------------------------------------------------------------------------- 1 | package glob 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestEffectiveCriteria(t *testing.T) { 9 | result, _ := EffectiveCriteria("xtest/*.txt", "xtest2/**/*.html", "xtest/*.js", "!xtest/*.html") 10 | 11 | if len(result.Roots()) != 2 { 12 | t.Error("expected 2 items in result set") 13 | } 14 | 15 | success := 0 16 | for _, c := range result.Items { 17 | if strings.HasSuffix(c.Root, "/xtest") { 18 | success++ 19 | } 20 | if strings.HasSuffix(c.Root, "/xtest2") { 21 | success++ 22 | } 23 | } 24 | 25 | if success != 2 { 26 | t.Error("should calc effective criteria") 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | // Handler is the interface which all task handlers eventually implement. 4 | type Handler interface { 5 | Handle(*Context) 6 | } 7 | 8 | // // HandlerFunc is Handler adapter. 9 | // type handlerFunc func() error 10 | 11 | // // Handle implements Handler. 12 | // func (f handlerFunc) Handle(*Context) error { 13 | // return f() 14 | // } 15 | 16 | // // VoidHandlerFunc is a Handler adapter. 17 | // type voidHandlerFunc func() 18 | 19 | // // Handle implements Handler. 20 | // func (v voidHandlerFunc) Handle(*Context) error { 21 | // v() 22 | // return nil 23 | // } 24 | 25 | // // ContextHandlerFunc is a Handler adapter. 26 | // type contextHandlerFunc func(*Context) error 27 | 28 | // // Handle implements Handler. 29 | // func (c contextHandlerFunc) Handle(ctx *Context) error { 30 | // return c(ctx) 31 | // } 32 | 33 | // HandlerFunc is a Handler adapter. 34 | type HandlerFunc func(*Context) 35 | 36 | // Handle implements Handler. 37 | func (f HandlerFunc) Handle(ctx *Context) { 38 | f(ctx) 39 | } 40 | -------------------------------------------------------------------------------- /init_test.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "strconv" 9 | "time" 10 | ) 11 | 12 | // amount of time to wait before a test project is ready 13 | var testProjectDelay = 150 * time.Millisecond 14 | var testWatchDelay time.Duration 15 | 16 | func init() { 17 | // devnull, err := os.Open(os.DevNull) 18 | // if err != nil { 19 | // panic(err) 20 | // } 21 | // util.LogWriter = devnull 22 | 23 | // WatchDelay is the time to poll the file system 24 | SetWatchDelay(150 * time.Millisecond) 25 | //testWatchDelay = watchDelay + 250*time.Millisecond 26 | testWatchDelay = watchDelay*2 + 50 27 | 28 | // Debounce should be less han watch delay 29 | Debounce = 100 * time.Millisecond 30 | verbose = false 31 | } 32 | 33 | // Runs a project returning the error value from the task. 34 | func runTask(tasksFn func(*Project), name string) (*Project, error) { 35 | proj := NewProject(tasksFn, func(status int) { 36 | //fmt.Println("exited with", status) 37 | panic("exited with code" + strconv.Itoa(status)) 38 | }, nil) 39 | return proj, proj.Run(name) 40 | } 41 | 42 | // Runs tasksFn with command line arguments. 43 | func execCLI(tasksFn func(*Project), argv []string, customExitFn func(int)) int { 44 | var code int 45 | var exitFn func(code int) 46 | if customExitFn == nil { 47 | exitFn = func(status int) { 48 | code = status 49 | } 50 | } else { 51 | exitFn = func(status int) { 52 | code = status 53 | customExitFn(status) 54 | } 55 | } 56 | godoExit(tasksFn, argv, exitFn) 57 | return code 58 | } 59 | 60 | func touch(path string, delta time.Duration) { 61 | if _, err := os.Stat(path); err == nil { 62 | tn := time.Now() 63 | tn = tn.Add(delta) 64 | err := os.Chtimes(path, tn, tn) 65 | if err != nil { 66 | fmt.Printf("Err touching %s\n", err.Error()) 67 | } 68 | //fmt.Printf("touched %s %s\n", path, time.Now()) 69 | return 70 | } 71 | os.MkdirAll(filepath.Dir(path), 0755) 72 | ioutil.WriteFile(path, []byte{}, 0644) 73 | } 74 | 75 | func touchTil(filename string, timeout time.Duration, cquit chan bool) { 76 | filename, _ = filepath.Abs(filename) 77 | forloop: 78 | for { 79 | select { 80 | case <-cquit: 81 | break forloop 82 | case <-time.After(timeout): 83 | touch(filename, 1*time.Minute) 84 | } 85 | } 86 | } 87 | 88 | func sliceContains(slice []string, val string) bool { 89 | for _, it := range slice { 90 | if it == val { 91 | return true 92 | } 93 | } 94 | return false 95 | } 96 | -------------------------------------------------------------------------------- /namespace_test.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "testing" 5 | 6 | "gopkg.in/stretchr/testify.v1/assert" 7 | ) 8 | 9 | func TestMultiProject(t *testing.T) { 10 | result := "" 11 | 12 | otherTasks := func(p *Project) { 13 | p.Task("foo", S{"bar"}, func(c *Context) { 14 | result += "B" 15 | }) 16 | 17 | p.Task("bar", nil, func(c *Context) { 18 | result += "C" 19 | }) 20 | } 21 | 22 | tasks := func(p *Project) { 23 | p.Use("other", otherTasks) 24 | 25 | p.Task("foo", nil, func(c *Context) { 26 | result += "A" 27 | }) 28 | 29 | p.Task("bar", S{"foo", "other:foo"}, nil) 30 | } 31 | runTask(tasks, "bar") 32 | if result != "ACB" { 33 | t.Error("should have run dependent project") 34 | } 35 | } 36 | 37 | func TestNestedNamespaces(t *testing.T) { 38 | levels := "" 39 | var subsubTasks = func(p *Project) { 40 | p.Task("A", S{"B"}, func(*Context) { 41 | levels += "2:" 42 | }) 43 | p.Task("B", nil, func(*Context) { 44 | levels += "2B:" 45 | }) 46 | } 47 | var subTasks = func(p *Project) { 48 | p.Use("sub", subsubTasks) 49 | p.Task("A", S{"sub:A"}, func(*Context) { 50 | levels += "1:" 51 | }) 52 | } 53 | var tasks = func(p *Project) { 54 | p.Use("sub", subTasks) 55 | p.Task("A", S{"sub:A"}, func(*Context) { 56 | levels += "0:" 57 | }) 58 | } 59 | 60 | runTask(tasks, "A") 61 | assert.Equal(t, levels, "2B:2:1:0:") 62 | } 63 | 64 | func TestNestedNamespaceDependency(t *testing.T) { 65 | levels := "" 66 | var subsubTasks = func(p *Project) { 67 | p.Task("A", S{"B"}, func(*Context) { 68 | levels += "2:" 69 | }) 70 | p.Task1("B", func(*Context) { 71 | levels += "2B:" 72 | }) 73 | } 74 | var subTasks = func(p *Project) { 75 | p.Use("sub", subsubTasks) 76 | p.Task("A", S{"sub:A"}, func(*Context) { 77 | levels += "1:" 78 | }) 79 | } 80 | var tasks = func(p *Project) { 81 | p.Use("sub", subTasks) 82 | p.Task("A", S{"sub:sub:A"}, func(*Context) { 83 | levels += "0:" 84 | }) 85 | } 86 | 87 | runTask(tasks, "A") 88 | assert.Equal(t, levels, "2B:2:0:") 89 | } 90 | 91 | func TestRelativeNamespace(t *testing.T) { 92 | levels := "" 93 | var subsubTasks = func(p *Project) { 94 | p.Task("A", S{"/sub:A"}, func(*Context) { 95 | levels += "2:" 96 | }) 97 | } 98 | var subTasks = func(p *Project) { 99 | p.Use("sub", subsubTasks) 100 | p.Task("A", S{"sub:A"}, func(*Context) { 101 | levels += "1:" 102 | }) 103 | } 104 | var tasks = func(p *Project) { 105 | p.Use("sub", subTasks) 106 | p.Task("A", S{"sub:sub:A"}, func(*Context) { 107 | levels += "0:" 108 | }) 109 | } 110 | 111 | runTask(tasks, "A") 112 | assert.Equal(t, "1:2:0:", levels) 113 | } 114 | -------------------------------------------------------------------------------- /project.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "path/filepath" 7 | "sort" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/mgutz/minimist" 14 | "gopkg.in/godo.v2/glob" 15 | "gopkg.in/godo.v2/util" 16 | "gopkg.in/godo.v2/watcher" 17 | ) 18 | 19 | // softPanic is used to check for errors within a task handler. 20 | type softPanic struct { 21 | // msg is the original error that caused the panic 22 | err error 23 | } 24 | 25 | func (sp *softPanic) Error() string { 26 | return sp.err.Error() 27 | } 28 | 29 | // Halt is a soft panic and stops a task. 30 | func Halt(v interface{}) { 31 | if v == nil { 32 | panic("No reason provided") 33 | } else if err, ok := v.(error); ok { 34 | panic(&softPanic{err}) 35 | } 36 | 37 | panic(&softPanic{fmt.Errorf("%v", v)}) 38 | } 39 | 40 | // M is generic string to interface alias 41 | type M map[string]interface{} 42 | 43 | // Project is a container for tasks. 44 | type Project struct { 45 | sync.Mutex 46 | Tasks map[string]*Task 47 | Namespace map[string]*Project 48 | lastRun map[string]time.Time 49 | exitFn func(code int) 50 | ns string 51 | contextArgm minimist.ArgMap 52 | cwatchTasks map[chan bool]bool 53 | 54 | parent *Project 55 | } 56 | 57 | // NewProject creates am empty project ready for tasks. 58 | func NewProject(tasksFunc func(*Project), exitFn func(code int), argm minimist.ArgMap) *Project { 59 | project := &Project{Tasks: map[string]*Task{}, lastRun: map[string]time.Time{}} 60 | project.Namespace = map[string]*Project{} 61 | project.Namespace[""] = project 62 | project.ns = "root" 63 | project.exitFn = exitFn 64 | project.contextArgm = argm 65 | project.Define(tasksFunc) 66 | project.cwatchTasks = map[chan bool]bool{} 67 | return project 68 | } 69 | 70 | // reset resets project state 71 | func (project *Project) reset() { 72 | for _, task := range project.Tasks { 73 | task.Complete = false 74 | } 75 | project.lastRun = map[string]time.Time{} 76 | } 77 | 78 | func (project *Project) mustTask(name string) (*Project, *Task, string) { 79 | if name == "" { 80 | panic("Cannot get task for empty string") 81 | } 82 | 83 | proj := project 84 | 85 | // use root 86 | if strings.HasPrefix(name, "/") { 87 | name = name[1:] 88 | for true { 89 | if proj.parent != nil { 90 | proj = proj.parent 91 | } else { 92 | break 93 | } 94 | } 95 | } else { 96 | proj = project 97 | } 98 | 99 | taskName := "default" 100 | parts := strings.Split(name, ":") 101 | 102 | if len(parts) == 1 { 103 | taskName = parts[0] 104 | } else { 105 | namespace := "" 106 | 107 | for i := 0; i < len(parts)-1; i++ { 108 | 109 | if namespace != "" { 110 | namespace += ":" 111 | } 112 | ns := parts[i] 113 | namespace += ns 114 | 115 | proj = proj.Namespace[ns] 116 | if proj == nil { 117 | util.Panic("ERR", "Could not find project having namespace \"%s\"\n", namespace) 118 | } 119 | } 120 | taskName = parts[len(parts)-1] 121 | } 122 | 123 | task := proj.Tasks[taskName] 124 | if task == nil { 125 | util.Panic("ERR", `"%s" task is not defined`+"\n", name) 126 | } 127 | return proj, task, taskName 128 | } 129 | 130 | func (project *Project) debounce(task *Task) bool { 131 | if task.Name == "" { 132 | panic("task name should not be empty") 133 | } 134 | debounce := task.debounce 135 | if debounce == 0 { 136 | debounce = Debounce 137 | } 138 | 139 | now := time.Now() 140 | project.Lock() 141 | defer project.Unlock() 142 | 143 | oldRun := project.lastRun[task.Name] 144 | if oldRun.IsZero() { 145 | project.lastRun[task.Name] = now 146 | return false 147 | } 148 | 149 | if oldRun.Add(debounce).After(now) { 150 | project.lastRun[task.Name] = now 151 | return true 152 | } 153 | return false 154 | } 155 | 156 | // Run runs a task by name. 157 | func (project *Project) Run(name string) error { 158 | return project.run(name, name, nil) 159 | } 160 | 161 | // RunWithEvent runs a task by name and adds FileEvent e to the context. 162 | func (project *Project) runWithEvent(name string, logName string, e *watcher.FileEvent) error { 163 | return project.run(name, logName, e) 164 | } 165 | 166 | func (project *Project) runTask(depName string, parentName string, e *watcher.FileEvent) error { 167 | proj, _, taskName := project.mustTask(depName) 168 | 169 | if proj == nil { 170 | return fmt.Errorf("Project was not loaded for \"%s\" task", parentName) 171 | } 172 | return proj.runWithEvent(taskName, parentName+">"+depName, e) 173 | } 174 | 175 | func (project *Project) runParallel(steps []interface{}, parentName string, e *watcher.FileEvent) error { 176 | var funcs = []func() error{} 177 | for _, step := range steps { 178 | switch t := step.(type) { 179 | default: 180 | panic(parentName + ": Parallel flow can only have types: (string | Series | Parallel)") 181 | case string: 182 | funcs = append(funcs, func() error { 183 | return project.runTask(t, parentName, e) 184 | }) 185 | case S: 186 | funcs = append(funcs, func() error { 187 | return project.runSeries(t, parentName, e) 188 | }) 189 | case Series: 190 | funcs = append(funcs, func() error { 191 | return project.runSeries(t, parentName, e) 192 | }) 193 | case P: 194 | funcs = append(funcs, func() error { 195 | return project.runParallel(t, parentName, e) 196 | }) 197 | case Parallel: 198 | funcs = append(funcs, func() error { 199 | return project.runParallel(t, parentName, e) 200 | }) 201 | } 202 | } 203 | err := GoThrottle(3, funcs...) 204 | return err 205 | } 206 | 207 | func (project *Project) runSeries(steps []interface{}, parentName string, e *watcher.FileEvent) error { 208 | var err error 209 | for _, step := range steps { 210 | switch t := step.(type) { 211 | default: 212 | panic(parentName + ": Series can only have types: (string | Series | Parallel)") 213 | case string: 214 | err = project.runTask(t, parentName, e) 215 | case S: 216 | err = project.runSeries(t, parentName, e) 217 | case Series: 218 | err = project.runSeries(t, parentName, e) 219 | case P: 220 | err = project.runParallel(t, parentName, e) 221 | case Parallel: 222 | err = project.runParallel(t, parentName, e) 223 | } 224 | if err != nil { 225 | return err 226 | } 227 | } 228 | return nil 229 | } 230 | 231 | // run runs the project, executing any tasks named on the command line. 232 | func (project *Project) run(name string, logName string, e *watcher.FileEvent) error { 233 | proj, task, _ := project.mustTask(name) 234 | 235 | if !task.shouldRun(e) { 236 | return nil 237 | } 238 | 239 | // debounce needs to be separate from shouldRun, so we can enqueue 240 | // a file event that arrives between debounce intervals 241 | if proj.debounce(task) { 242 | if task.shouldRun(e) { 243 | task.Lock() 244 | if !task.ignoreEvents { 245 | task.ignoreEvents = true 246 | // fmt.Printf("DBG: ENQUEUE fileevent in between debounce\n") 247 | time.AfterFunc(task.debounceValue(), func() { 248 | // fmt.Printf("DBG: Running ENQUEUED\n") 249 | task.Lock() 250 | task.ignoreEvents = false 251 | task.Unlock() 252 | project.run(name, logName, e) 253 | }) 254 | } 255 | task.Unlock() 256 | } 257 | 258 | return nil 259 | } 260 | 261 | // run dependencies first 262 | err := proj.runSeries(task.dependencies, name, e) 263 | if err != nil { 264 | return err 265 | } 266 | 267 | // then run the task itself 268 | return task.RunWithEvent(logName, e) 269 | } 270 | 271 | // usage returns a string for usage screen 272 | func (project *Project) usage() string { 273 | tasks := "Tasks:\n" 274 | names := []string{} 275 | m := map[string]*Task{} 276 | for ns, proj := range project.Namespace { 277 | if ns != "" { 278 | ns += ":" 279 | } 280 | for _, task := range proj.Tasks { 281 | names = append(names, ns+task.Name) 282 | m[ns+task.Name] = task 283 | } 284 | } 285 | sort.Strings(names) 286 | longest := 0 287 | for _, name := range names { 288 | l := len(name) 289 | if l > longest { 290 | longest = l 291 | } 292 | } 293 | 294 | for _, name := range names { 295 | task := m[name] 296 | description := task.description 297 | if description == "" { 298 | if len(task.dependencies) > 0 { 299 | description = fmt.Sprintf("Runs %v %s", task.DependencyNames(), name) 300 | } else { 301 | description = "Runs " + name 302 | } 303 | } 304 | tasks += fmt.Sprintf(" %-"+strconv.Itoa(longest)+"s %s\n", name, description) 305 | } 306 | 307 | return tasks 308 | } 309 | 310 | // Use uses another project's task within a namespace. 311 | func (project *Project) Use(namespace string, tasksFunc func(*Project)) { 312 | namespace = strings.Trim(namespace, ":") 313 | proj := NewProject(tasksFunc, project.exitFn, project.contextArgm) 314 | proj.ns = project.ns + ":" + namespace 315 | project.Namespace[namespace] = proj 316 | proj.parent = project 317 | } 318 | 319 | // Task adds a task to the project with dependencies and handler. 320 | func (project *Project) Task(name string, dependencies Dependency, handler func(*Context)) *Task { 321 | task := NewTask(name, project.contextArgm) 322 | 323 | if handler == nil && dependencies == nil { 324 | util.Panic("godo", "Task %s requires a dependency or handler\n", name) 325 | } 326 | 327 | if handler != nil { 328 | task.Handler = HandlerFunc(handler) 329 | } 330 | if dependencies != nil { 331 | task.dependencies = append(task.dependencies, dependencies) 332 | } 333 | 334 | project.Tasks[task.Name] = task 335 | return task 336 | } 337 | 338 | // Task1 adds a simple task to the project. 339 | func (project *Project) Task1(name string, handler func(*Context)) *Task { 340 | task := NewTask(name, project.contextArgm) 341 | 342 | if handler == nil { 343 | util.Panic("godo", "Task %s requires a dependency or handler\n", name) 344 | } 345 | 346 | task.Handler = HandlerFunc(handler) 347 | 348 | project.Tasks[task.Name] = task 349 | return task 350 | } 351 | 352 | // TaskD adds a task which runs other dependencies with no handler. 353 | func (project *Project) TaskD(name string, dependencies Dependency) *Task { 354 | task := NewTask(name, project.contextArgm) 355 | 356 | if dependencies == nil { 357 | util.Panic("godo", "Task %s requires a dependency or handler\n", name) 358 | } 359 | 360 | task.dependencies = append(task.dependencies, dependencies) 361 | project.Tasks[task.Name] = task 362 | return task 363 | } 364 | 365 | func (project *Project) watchTask(task *Task, root string, logName string, handler func(e *watcher.FileEvent)) { 366 | ignorePathFn := func(p string) bool { 367 | return watcher.DefaultIgnorePathFn(p) || !task.isWatchedFile(p) 368 | } 369 | 370 | const bufferSize = 2048 371 | watchr, err := watcher.NewWatcher(bufferSize) 372 | if err != nil { 373 | util.Panic("project", "%v\n", err) 374 | } 375 | watchr.IgnorePathFn = ignorePathFn 376 | watchr.ErrorHandler = func(err error) { 377 | util.Error("project", "Watcher error %v\n", err) 378 | } 379 | watchr.WatchRecursive(root) 380 | 381 | // this function will block forever, Ctrl+C to quit app 382 | abs, err := filepath.Abs(root) 383 | if err != nil { 384 | fmt.Println("Could not get absolute path", err) 385 | return 386 | } 387 | util.Info(logName, "watching %s\n", abs) 388 | 389 | // not sure why this need to be unbuffered, but it was blocking 390 | // on cquit <- true 391 | cquit := make(chan bool, 1) 392 | project.Lock() 393 | project.cwatchTasks[cquit] = true 394 | project.Unlock() 395 | watchr.Start() 396 | forloop: 397 | for { 398 | select { 399 | case event := <-watchr.Event: 400 | if event.Path != "" { 401 | util.InfoColorful("godo", "%s changed\n", event.Path) 402 | } 403 | handler(event) 404 | case <-cquit: 405 | watchr.Stop() 406 | break forloop 407 | } 408 | } 409 | } 410 | 411 | // Define defines tasks 412 | func (project *Project) Define(fn func(*Project)) { 413 | fn(project) 414 | } 415 | 416 | func calculateWatchPaths(patterns []string) []string { 417 | //fmt.Println("DBG:calculateWatchPaths patterns", patterns) 418 | paths := map[string]bool{} 419 | for _, pat := range patterns { 420 | if pat == "" { 421 | continue 422 | } 423 | path := glob.PatternRoot(pat) 424 | abs, err := filepath.Abs(path) 425 | if err != nil { 426 | fmt.Println("Error calculating watch paths", err) 427 | } 428 | paths[abs] = true 429 | } 430 | 431 | var keys []string 432 | for key := range paths { 433 | keys = append(keys, key) 434 | } 435 | sort.Strings(keys) 436 | //fmt.Println("DBG:calculateWatchPaths keys", keys) 437 | 438 | // skip any directories that overlap each other, eg test/sub should be 439 | // ignored if test/ is in paths 440 | var skip = map[string]bool{} 441 | for i, dir := range keys { 442 | dirSlash := dir + "/" 443 | for _, dirj := range keys[i+1:] { 444 | if strings.HasPrefix(dirj, dirSlash) { 445 | skip[dirj] = true 446 | } 447 | } 448 | } 449 | 450 | var keep = []string{} 451 | for _, dir := range keys { 452 | if skip[dir] { 453 | continue 454 | } 455 | rel, err := filepath.Rel(wd, dir) 456 | if err != nil { 457 | fmt.Println("Error calculating relative path", err) 458 | continue 459 | } 460 | keep = append(keep, rel) 461 | } 462 | 463 | //fmt.Println("DBG:calculateWatchPaths keep", keep) 464 | return keep 465 | } 466 | 467 | // gatherWatchInfo updates globs and regexps for the task based on its dependencies 468 | func (project *Project) gatherWatchInfo(task *Task) (globs []string, regexps []*glob.RegexpInfo) { 469 | globs = task.SrcGlobs 470 | regexps = task.SrcRegexps 471 | 472 | if len(task.dependencies) > 0 { 473 | names := task.DependencyNames() 474 | 475 | proj := project 476 | for _, depname := range names { 477 | var task *Task 478 | proj, task, _ = project.mustTask(depname) 479 | tglobs, tregexps := proj.gatherWatchInfo(task) 480 | task.EffectiveWatchRegexps = tregexps 481 | globs = append(globs, tglobs...) 482 | regexps = append(regexps, tregexps...) 483 | } 484 | } 485 | task.EffectiveWatchRegexps = regexps 486 | task.EffectiveWatchGlobs = globs 487 | return 488 | } 489 | 490 | // Watch watches the Files of a task and reruns the task on a watch event. Any 491 | // direct dependency is also watched. Returns true if watching. 492 | // 493 | // 494 | // TODO: 495 | // 1. Only the parent task watches, but it gathers wath info from all dependencies. 496 | // 497 | // 2. Anything without src files always run when a dependency is triggered by a glob match. 498 | // 499 | // build [generate{*.go} compile] => go file changes => build, generate and compile 500 | // 501 | // 3. Tasks with src only run if it matches a src 502 | // 503 | // build [generate{*.go} css{*.scss} compile] => go file changes => build, generate and compile 504 | // css does not need to run since no SCSS files ran 505 | // 506 | // X depends on [A:txt, B] => txt changes A runs, X runs without deps 507 | // X:txt on [A, B] => txt changes A, B, X runs 508 | // 509 | func (project *Project) Watch(names []string, isParent bool) bool { 510 | // fixes a bug where the first debounce prevents the task from running because 511 | // all tasks are run once before Watch() is called 512 | project.reset() 513 | 514 | funcs := []func(){} 515 | 516 | taskClosure := func(project *Project, task *Task, taskname string, logName string) func() { 517 | paths := calculateWatchPaths(task.EffectiveWatchGlobs) 518 | return func() { 519 | if len(paths) == 0 { 520 | return 521 | } 522 | for _, pth := range paths { 523 | go func(path string) { 524 | project.watchTask(task, path, logName, func(e *watcher.FileEvent) { 525 | err := project.run(taskname, taskname, e) 526 | if err != nil { 527 | util.Error("ERR", "%s\n", err.Error()) 528 | } 529 | }) 530 | }(pth) 531 | } 532 | } 533 | } 534 | 535 | for _, taskname := range names { 536 | proj, task, _ := project.mustTask(taskname) 537 | // updates effectiveWatchGlobs 538 | proj.gatherWatchInfo(task) 539 | if len(task.EffectiveWatchGlobs) > 0 { 540 | funcs = append(funcs, taskClosure(project, task, taskname, taskname)) 541 | } 542 | } 543 | 544 | if len(funcs) > 0 { 545 | <-all(funcs) 546 | return true 547 | } 548 | return false 549 | } 550 | 551 | // Dumps information about the project to the console 552 | func (project *Project) dump(buf io.Writer, prefix string, indent string) { 553 | fmt.Fprintln(buf, "") 554 | fmt.Fprintln(buf, prefix, project.ns, " =>") 555 | fmt.Fprintln(buf, indent, "Tasks:") 556 | for _, task := range project.Tasks { 557 | task.dump(buf, indent+indent) 558 | } 559 | 560 | for key, proj := range project.Namespace { 561 | if key == "" { 562 | continue 563 | } 564 | proj.dump(buf, prefix, indent) 565 | } 566 | } 567 | 568 | func (project *Project) quit(isParent bool) { 569 | for ns, proj := range project.Namespace { 570 | if ns != "" { 571 | proj.quit(false) 572 | } 573 | } 574 | // kill all watchTasks 575 | for cquit := range project.cwatchTasks { 576 | cquit <- true 577 | } 578 | if isParent { 579 | runnerWaitGroup.Stop() 580 | for _, process := range Processes { 581 | if process != nil { 582 | process.Kill() 583 | } 584 | } 585 | } 586 | //fmt.Printf("DBG: QUITTED\n") 587 | } 588 | 589 | // Exit quits the project. 590 | func (project *Project) Exit(code int) { 591 | project.quit(true) 592 | } 593 | 594 | // all runs the functions in fns concurrently. 595 | func all(fns []func()) (done <-chan bool) { 596 | var wg sync.WaitGroup 597 | wg.Add(len(fns)) 598 | 599 | ch := make(chan bool, 1) 600 | for _, fn := range fns { 601 | go func(f func()) { 602 | f() 603 | wg.Done() 604 | }(fn) 605 | } 606 | go func() { 607 | wg.Wait() 608 | doneSig(ch, true) 609 | }() 610 | return ch 611 | } 612 | 613 | func doneSig(ch chan bool, val bool) { 614 | ch <- val 615 | close(ch) 616 | } 617 | -------------------------------------------------------------------------------- /project_test.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "sort" 5 | "testing" 6 | "time" 7 | 8 | "gopkg.in/stretchr/testify.v1/assert" 9 | 10 | "github.com/mgutz/str" 11 | ) 12 | 13 | func TestSimpleTask(t *testing.T) { 14 | result := "" 15 | tasks := func(p *Project) { 16 | p.Task1("foo", func(c *Context) { 17 | result = "A" 18 | }) 19 | } 20 | 21 | runTask(tasks, "foo") 22 | if result != "A" { 23 | t.Error("should have run simple task") 24 | } 25 | } 26 | 27 | func TestErrorReturn(t *testing.T) { 28 | result := "" 29 | tasks := func(p *Project) { 30 | p.Task1("err", func(*Context) { 31 | Halt("error caught") 32 | // should not get here 33 | result += "ERR" 34 | }) 35 | 36 | p.Task("foo", S{"err"}, func(*Context) { 37 | result = "A" 38 | }) 39 | } 40 | 41 | _, err := runTask(tasks, "foo") 42 | if result == "A" { 43 | t.Error("parent task should not run on error") 44 | } 45 | if err.Error() != `"foo>err": error caught` { 46 | t.Error("dependency errors should stop parent") 47 | } 48 | 49 | _, err = runTask(tasks, "err") 50 | if err.Error() != `"err": error caught` { 51 | t.Error("error was not handle properly") 52 | } 53 | } 54 | 55 | func TestTaskArgs(t *testing.T) { 56 | assert := assert.New(t) 57 | result := "" 58 | tasks := func(p *Project) { 59 | p.Task1("foo", func(c *Context) { 60 | name := c.Args.MustString("name") 61 | result = name 62 | }) 63 | } 64 | 65 | execCLI(tasks, []string{"foo", "--", "--name=gopher"}, nil) 66 | assert.Equal("gopher", result) 67 | assert.Panics(func() { 68 | runTask(tasks, "foo") 69 | }) 70 | } 71 | 72 | func TestDependency(t *testing.T) { 73 | result := "" 74 | tasks := func(p *Project) { 75 | p.Task1("foo", func(c *Context) { 76 | result = "A" 77 | }) 78 | 79 | p.Task("bar", S{"foo"}, nil) 80 | } 81 | runTask(tasks, "bar") 82 | if result != "A" { 83 | t.Error("should have run task's dependency") 84 | } 85 | } 86 | 87 | func TestShouldExpandGlobs(t *testing.T) { 88 | result := "" 89 | tasks := func(p *Project) { 90 | p.Task("foo", nil, func(c *Context) { 91 | result = "A" 92 | }).Src("test/**/*.txt") 93 | 94 | p.Task("bar", S{"foo"}, nil).Src("test/**/*.html") 95 | } 96 | proj, err := runTask(tasks, "bar") 97 | assert.NoError(t, err) 98 | if len(proj.Tasks["bar"].SrcFiles) != 2 { 99 | t.Error("bar should have 2 HTML file") 100 | } 101 | if len(proj.Tasks["foo"].SrcFiles) != 7 { 102 | t.Error("foo should have 7 txt files, one is hidden, got", 103 | len(proj.Tasks["foo"].SrcFiles)) 104 | } 105 | } 106 | 107 | func TestCalculateWatchPaths(t *testing.T) { 108 | // test wildcards, should watch current directory 109 | paths := []string{ 110 | "example/views/**/*.go.html", 111 | "example.html", 112 | } 113 | paths = calculateWatchPaths(paths) 114 | if len(paths) != 1 { 115 | t.Error("Expected exact elements") 116 | } 117 | sort.Strings(paths) 118 | if paths[0] != "." { 119 | t.Error("Expected exact file paths got", paths[0]) 120 | } 121 | 122 | // should only watch current directory 123 | paths = []string{ 124 | "**/*.go.html", 125 | "example.html", 126 | } 127 | paths = calculateWatchPaths(paths) 128 | 129 | if len(paths) != 1 { 130 | t.Error("Expected exact elements") 131 | } 132 | if paths[0] != "." { 133 | t.Error("Expected . got", paths[0]) 134 | } 135 | } 136 | 137 | func TestLegacyIn(t *testing.T) { 138 | 139 | var cat = "cat" 140 | if isWindows { 141 | cat = "cmd /c type" 142 | } 143 | //// Run 144 | 145 | // in V2 BashOutput accepts an options map 146 | 147 | out, err := RunOutput(cat+" foo.txt", M{"$in": "test"}) 148 | assert.NoError(t, err) 149 | assert.Equal(t, "foo", str.Clean(out)) 150 | 151 | if isWindows { 152 | return 153 | } 154 | 155 | //// Bash 156 | 157 | // in V2 BashOutput accepts an options map 158 | out, err = BashOutput("cat foo.txt", M{"$in": "test"}) 159 | assert.NoError(t, err) 160 | assert.Equal(t, "foo", str.Clean(out)) 161 | } 162 | 163 | func TestInvalidTask(t *testing.T) { 164 | tasks := func(p *Project) { 165 | } 166 | 167 | assert.Panics(t, func() { 168 | runTask(tasks, "dummy") 169 | }) 170 | } 171 | 172 | func TestParallel(t *testing.T) { 173 | var result string 174 | tasks := func(p *Project) { 175 | p.Task1("A", func(*Context) { 176 | result += "A" 177 | }) 178 | p.Task1("B", func(*Context) { 179 | time.Sleep(10 * time.Millisecond) 180 | result += "B" 181 | }) 182 | p.Task("C", nil, func(*Context) { 183 | result += "C" 184 | }) 185 | p.Task("D", nil, func(*Context) { 186 | result += "D" 187 | }) 188 | p.Task("default", P{"A", "B", "C", "D"}, nil) 189 | } 190 | 191 | argv := []string{} 192 | ch := make(chan int) 193 | go func() { 194 | execCLI(tasks, argv, func(code int) { 195 | assert.Equal(t, code, 0) 196 | assert.True(t, len(result) == 4) 197 | ch <- code 198 | close(ch) 199 | }) 200 | }() 201 | <-ch 202 | } 203 | 204 | func TestTaskD(t *testing.T) { 205 | trace := "" 206 | tasks := func(p *Project) { 207 | p.Task1("A", func(*Context) { 208 | trace += "A" 209 | }) 210 | 211 | p.TaskD("default", S{"A"}) 212 | } 213 | runTask(tasks, "default") 214 | assert.Equal(t, "A", trace) 215 | } 216 | 217 | func TestRunOnce(t *testing.T) { 218 | trace := "" 219 | tasks := func(p *Project) { 220 | p.Task("once?", nil, func(*Context) { 221 | trace += "1" 222 | }) 223 | 224 | p.Task("A", S{"once"}, func(*Context) { 225 | trace += "A" 226 | }) 227 | 228 | p.Task("B", S{"once"}, func(*Context) { 229 | trace += "B" 230 | }) 231 | } 232 | 233 | execCLI(tasks, []string{"A", "B"}, nil) 234 | assert.Equal(t, "1AB", trace) 235 | } 236 | -------------------------------------------------------------------------------- /runner.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "strings" 8 | "syscall" 9 | "time" 10 | 11 | "github.com/mgutz/minimist" 12 | "gopkg.in/godo.v2/util" 13 | "gopkg.in/godo.v2/watcher" 14 | ) 15 | 16 | // Message are sent on the Events channel 17 | type Message struct { 18 | Event string 19 | Data string 20 | } 21 | 22 | const defaultWatchDelay = 1200 * time.Millisecond 23 | 24 | var watching bool 25 | var help bool 26 | var verbose bool 27 | var version bool 28 | var deprecatedWarnings bool 29 | 30 | // DebounceMs is the default time (1500 ms) to debounce task events in watch mode. 31 | var Debounce time.Duration 32 | var runnerWaitGroup = &WaitGroupN{} 33 | var waitExit bool 34 | var argm minimist.ArgMap 35 | var wd string 36 | var watchDelay = defaultWatchDelay 37 | 38 | // SetWatchDelay sets the time duration between watches. 39 | func SetWatchDelay(delay time.Duration) { 40 | if delay == 0 { 41 | delay = defaultWatchDelay 42 | } 43 | watchDelay = delay 44 | watcher.SetWatchDelay(watchDelay) 45 | } 46 | 47 | // GetWatchDelay gets the watch delay 48 | func GetWatchDelay() time.Duration { 49 | return watchDelay 50 | } 51 | 52 | func init() { 53 | // WatchDelay is the time to poll the file system 54 | SetWatchDelay(watchDelay) 55 | Debounce = 2000 * time.Millisecond 56 | var err error 57 | wd, err = os.Getwd() 58 | if err != nil { 59 | panic(err) 60 | } 61 | 62 | } 63 | 64 | // Usage prints a usage screen with task descriptions. 65 | func Usage(tasks string) { 66 | // go's flag package prints ugly screen 67 | format := `godo %s - do task(s) 68 | 69 | Usage: godo [flags] [task...] 70 | -D Print deprecated warnings 71 | --dump Dump debug info about the project 72 | -h, --help This screen 73 | -i, --install Install Godofile dependencies 74 | --rebuild Rebuild Godofile 75 | -v --verbose Log verbosely 76 | -V, --version Print version 77 | -w, --watch Watch task and dependencies` 78 | 79 | if tasks == "" { 80 | fmt.Printf(format, Version) 81 | } else { 82 | format += "\n\n%s" 83 | fmt.Printf(format, Version, tasks) 84 | } 85 | } 86 | 87 | // Godo runs a project of tasks. 88 | func Godo(tasksFunc func(*Project)) { 89 | godo(tasksFunc, nil) 90 | } 91 | 92 | func godo(tasksFn func(*Project), argv []string) { 93 | godoExit(tasksFn, argv, os.Exit) 94 | } 95 | 96 | // used for testing to switch out exitFn 97 | func godoExit(tasksFunc func(*Project), argv []string, exitFn func(int)) { 98 | if argv == nil { 99 | argm = minimist.Parse() 100 | } else { 101 | argm = minimist.ParseArgv(argv) 102 | } 103 | 104 | dump := argm.AsBool("dump") 105 | help = argm.AsBool("help", "h", "?") 106 | verbose = argm.AsBool("verbose", "v") 107 | version = argm.AsBool("version", "V") 108 | watching = argm.AsBool("watch", "w") 109 | deprecatedWarnings = argm.AsBool("D") 110 | contextArgm := minimist.ParseArgv(argm.Unparsed()) 111 | 112 | project := NewProject(tasksFunc, exitFn, contextArgm) 113 | 114 | if help { 115 | Usage(project.usage()) 116 | exitFn(0) 117 | } 118 | 119 | if version { 120 | fmt.Printf("godo %s\n", Version) 121 | exitFn(0) 122 | } 123 | 124 | if dump { 125 | project.dump(os.Stdout, "", " ") 126 | exitFn(0) 127 | } 128 | 129 | // env vars are any nonflag key=value pair 130 | addToOSEnviron(argm.NonFlags()) 131 | 132 | // Run each task including their dependencies. 133 | args := []string{} 134 | for _, s := range argm.NonFlags() { 135 | // skip env vars 136 | if !strings.Contains(s, "=") { 137 | args = append(args, s) 138 | } 139 | } 140 | 141 | if len(args) == 0 { 142 | if project.Tasks["default"] != nil { 143 | args = append(args, "default") 144 | } else { 145 | Usage(project.usage()) 146 | exitFn(0) 147 | } 148 | } 149 | 150 | for _, name := range args { 151 | err := project.Run(name) 152 | if err != nil { 153 | util.Error("ERR", "%s\n", err.Error()) 154 | exitFn(1) 155 | } 156 | } 157 | 158 | if watching { 159 | if project.Watch(args, true) { 160 | runnerWaitGroup.Add(1) 161 | waitExit = true 162 | } else { 163 | fmt.Println("Nothing to watch. Use Task#Src() to specify watch patterns") 164 | exitFn(0) 165 | } 166 | } 167 | 168 | if waitExit { 169 | // Ctrl+C handler 170 | csig := make(chan os.Signal, 1) 171 | signal.Notify(csig, syscall.SIGQUIT) 172 | go func() { 173 | for sig := range csig { 174 | fmt.Println("SIG caught") 175 | if sig == syscall.SIGQUIT { 176 | fmt.Println("SIG caught B") 177 | project.Exit(0) 178 | break 179 | } 180 | } 181 | }() 182 | 183 | runnerWaitGroup.Wait() 184 | } 185 | exitFn(0) 186 | } 187 | 188 | // MustNotError checks if error is not nil. If it is not nil it will panic. 189 | func mustNotError(err error) { 190 | if err != nil { 191 | panic(err) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /task.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "path/filepath" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/mgutz/minimist" 12 | "github.com/mgutz/str" 13 | "gopkg.in/godo.v2/glob" 14 | "gopkg.in/godo.v2/util" 15 | "gopkg.in/godo.v2/watcher" 16 | ) 17 | 18 | // TaskFunction is the signature of the function used to define a type. 19 | // type TaskFunc func(string, ...interface{}) *Task 20 | // type UseFunc func(string, interface{}) 21 | 22 | // A Task is an operation performed on a user's project directory. 23 | type Task struct { 24 | Name string 25 | description string 26 | Handler Handler 27 | dependencies Series 28 | argm minimist.ArgMap 29 | 30 | // Watches are watches files. On change the task is rerun. For example `**/*.less` 31 | // Usually Watches and Sources are the same. 32 | // WatchFiles []*FileAsset 33 | // WatchGlobs []string 34 | // WatchRegexps []*RegexpInfo 35 | 36 | // computed based on dependencies 37 | EffectiveWatchRegexps []*glob.RegexpInfo 38 | EffectiveWatchGlobs []string 39 | 40 | // Complete indicates whether this task has already ran. This flag is 41 | // ignored in watch mode. 42 | Complete bool 43 | debounce time.Duration 44 | RunOnce bool 45 | 46 | SrcFiles []*glob.FileAsset 47 | SrcGlobs []string 48 | SrcRegexps []*glob.RegexpInfo 49 | 50 | DestFiles []*glob.FileAsset 51 | DestGlobs []string 52 | DestRegexps []*glob.RegexpInfo 53 | 54 | // used when a file event is received between debounce intervals, the file event 55 | // will queue itself and set this flag and force debounce to run it 56 | // when time has elapsed 57 | sync.Mutex 58 | ignoreEvents bool 59 | } 60 | 61 | // NewTask creates a new Task. 62 | func NewTask(name string, argm minimist.ArgMap) *Task { 63 | runOnce := false 64 | if strings.HasSuffix(name, "?") { 65 | runOnce = true 66 | name = str.ChompRight(name, "?") 67 | } 68 | return &Task{Name: name, RunOnce: runOnce, dependencies: Series{}, argm: argm} 69 | } 70 | 71 | // Expands glob patterns. 72 | func (task *Task) expandGlobs() { 73 | 74 | // runs once lazily 75 | if len(task.SrcFiles) > 0 { 76 | return 77 | } 78 | 79 | files, regexps, err := glob.Glob(task.SrcGlobs) 80 | if err != nil { 81 | util.Error(task.Name, "%v", err) 82 | return 83 | } 84 | 85 | task.SrcRegexps = regexps 86 | task.SrcFiles = files 87 | 88 | if len(task.DestGlobs) > 0 { 89 | files, regexps, err := glob.Glob(task.DestGlobs) 90 | if err != nil { 91 | util.Error(task.Name, "%v", err) 92 | return 93 | } 94 | task.DestRegexps = regexps 95 | task.DestFiles = files 96 | } 97 | } 98 | 99 | // Run runs all the dependencies of this task and when they have completed, 100 | // runs this task. 101 | func (task *Task) Run() error { 102 | if !watching && task.Complete { 103 | util.Debug(task.Name, "Already ran\n") 104 | return nil 105 | } 106 | return task.RunWithEvent(task.Name, nil) 107 | } 108 | 109 | // isWatchedFile determines if a FileEvent's file is a watched file 110 | func (task *Task) isWatchedFile(path string) bool { 111 | filename, err := filepath.Rel(wd, path) 112 | if err != nil { 113 | return false 114 | } 115 | 116 | filename = filepath.ToSlash(filename) 117 | //util.Debug("task", "checking for match %s\n", filename) 118 | 119 | matched := false 120 | for _, info := range task.EffectiveWatchRegexps { 121 | if info.Negate { 122 | if matched { 123 | matched = !info.MatchString(filename) 124 | //util.Debug("task", "negated match? %s %s\n", filename, matched) 125 | continue 126 | } 127 | } else if info.MatchString(filename) { 128 | matched = true 129 | //util.Debug("task", "matched %s %s\n", filename, matched) 130 | continue 131 | } 132 | } 133 | return matched 134 | } 135 | 136 | // RunWithEvent runs this task when triggered from a watch. 137 | // *e* FileEvent contains information about the file/directory which changed 138 | // in watch mode. 139 | func (task *Task) RunWithEvent(logName string, e *watcher.FileEvent) (err error) { 140 | if task.RunOnce && task.Complete { 141 | util.Debug(task.Name, "Already ran\n") 142 | return nil 143 | } 144 | 145 | task.expandGlobs() 146 | if !task.shouldRun(e) { 147 | util.Info(logName, "up-to-date 0ms\n") 148 | return nil 149 | } 150 | 151 | start := time.Now() 152 | if len(task.SrcGlobs) > 0 && len(task.SrcFiles) == 0 { 153 | util.Error("task", "\""+task.Name+"\" '%v' did not match any files\n", task.SrcGlobs) 154 | } 155 | 156 | // Run this task only if the file matches watch Regexps 157 | rebuilt := "" 158 | if e != nil { 159 | rebuilt = "rebuilt " 160 | if !task.isWatchedFile(e.Path) && len(task.SrcGlobs) > 0 { 161 | return nil 162 | } 163 | if verbose { 164 | util.Debug(logName, "%s\n", e.String()) 165 | } 166 | } 167 | 168 | log := true 169 | if task.Handler != nil { 170 | context := Context{Task: task, Args: task.argm, FileEvent: e} 171 | defer func() { 172 | if p := recover(); p != nil { 173 | sp, ok := p.(*softPanic) 174 | if !ok { 175 | panic(p) 176 | } 177 | err = fmt.Errorf("%q: %s", logName, sp) 178 | } 179 | }() 180 | 181 | task.Handler.Handle(&context) 182 | if context.Error != nil { 183 | return fmt.Errorf("%q: %s", logName, context.Error.Error()) 184 | } 185 | } else if len(task.dependencies) > 0 { 186 | // no need to log if just dependency 187 | log = false 188 | } else { 189 | util.Info(task.Name, "Ignored. Task does not have a handler or dependencies.\n") 190 | return nil 191 | } 192 | 193 | if log { 194 | if rebuilt != "" { 195 | util.InfoColorful(logName, "%s%vms\n", rebuilt, time.Since(start).Nanoseconds()/1e6) 196 | } else { 197 | util.Info(logName, "%s%vms\n", rebuilt, time.Since(start).Nanoseconds()/1e6) 198 | } 199 | } 200 | 201 | task.Complete = true 202 | 203 | return nil 204 | } 205 | 206 | // DependencyNames gets the flattened dependency names. 207 | func (task *Task) DependencyNames() []string { 208 | if len(task.dependencies) == 0 { 209 | return nil 210 | } 211 | deps := []string{} 212 | for _, dep := range task.dependencies { 213 | switch d := dep.(type) { 214 | default: 215 | panic("dependencies can only be Serial or Parallel") 216 | case Series: 217 | deps = append(deps, d.names()...) 218 | case Parallel: 219 | deps = append(deps, d.names()...) 220 | case S: 221 | deps = append(deps, Series(d).names()...) 222 | case P: 223 | deps = append(deps, Parallel(d).names()...) 224 | } 225 | } 226 | return deps 227 | } 228 | 229 | func (task *Task) dump(buf io.Writer, indent string) { 230 | fmt.Fprintln(buf, indent, task.Name) 231 | fmt.Fprintln(buf, indent+indent, "EffectiveWatchGlobs", task.EffectiveWatchGlobs) 232 | fmt.Fprintln(buf, indent+indent, "SrcFiles", task.SrcFiles) 233 | fmt.Fprintln(buf, indent+indent, "SrcGlobs", task.SrcGlobs) 234 | 235 | } 236 | 237 | func (task *Task) shouldRun(e *watcher.FileEvent) bool { 238 | if e == nil || len(task.SrcFiles) == 0 { 239 | return true 240 | } else if !task.isWatchedFile(e.Path) { 241 | // fmt.Printf("received a file so it should return immediately\n") 242 | return false 243 | } 244 | 245 | // lazily expand globs 246 | task.expandGlobs() 247 | 248 | if len(task.SrcFiles) == 0 || len(task.DestFiles) == 0 { 249 | // fmt.Printf("no source files %s %#v\n", task.Name, task.SrcFiles) 250 | // fmt.Printf("no source files %s %#v\n", task.Name, task.DestFiles) 251 | return true 252 | } 253 | 254 | // TODO figure out intelligent way to cache this instead of stating 255 | // each time 256 | for _, src := range task.SrcFiles { 257 | // refresh stat 258 | src.Stat() 259 | for _, dest := range task.DestFiles { 260 | // refresh stat 261 | dest.Stat() 262 | if filepath.Base(src.Path) == "foo.txt" { 263 | fmt.Printf("src %s %#v\n", src.Path, src.ModTime().UnixNano()) 264 | fmt.Printf("dest %s %#v\n", dest.Path, dest.ModTime().UnixNano()) 265 | } 266 | if src.ModTime().After(dest.ModTime()) { 267 | return true 268 | } 269 | } 270 | } 271 | 272 | fmt.Printf("FileEvent ignored %#v\n", e) 273 | 274 | return false 275 | } 276 | 277 | func (task *Task) debounceValue() time.Duration { 278 | if task.debounce == 0 { 279 | // use default Debounce 280 | return Debounce 281 | } 282 | return task.debounce 283 | } 284 | -------------------------------------------------------------------------------- /task_options.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "time" 5 | 6 | "gopkg.in/godo.v2/util" 7 | "github.com/mgutz/str" 8 | ) 9 | 10 | // Dependency marks an interface as a dependency. 11 | type Dependency interface { 12 | markAsDependency() 13 | } 14 | 15 | // Series are dependent tasks which must run in series. 16 | type Series []interface{} 17 | 18 | func (s Series) names() []string { 19 | names := []string{} 20 | for _, step := range s { 21 | switch t := step.(type) { 22 | case string: 23 | if str.SliceIndexOf(names, t) < 0 { 24 | names = append(names, t) 25 | } 26 | case Series: 27 | names = append(names, t.names()...) 28 | case Parallel: 29 | names = append(names, t.names()...) 30 | } 31 | 32 | } 33 | return names 34 | } 35 | 36 | func (s Series) markAsDependency() {} 37 | 38 | // Parallel runs tasks in parallel 39 | type Parallel []interface{} 40 | 41 | func (p Parallel) names() []string { 42 | names := []string{} 43 | for _, step := range p { 44 | switch t := step.(type) { 45 | case string: 46 | if str.SliceIndexOf(names, t) < 0 { 47 | names = append(names, t) 48 | } 49 | case Series: 50 | names = append(names, t.names()...) 51 | case Parallel: 52 | names = append(names, t.names()...) 53 | } 54 | 55 | } 56 | return names 57 | } 58 | 59 | func (p Parallel) markAsDependency() {} 60 | 61 | // S is alias for Series 62 | type S []interface{} 63 | 64 | func (s S) markAsDependency() {} 65 | 66 | // P is alias for Parallel 67 | type P []interface{} 68 | 69 | func (p P) markAsDependency() {} 70 | 71 | // Debounce is minimum milliseconds before task can run again 72 | func (task *Task) Debounce(duration time.Duration) *Task { 73 | if duration > 0 { 74 | task.debounce = duration 75 | } 76 | return task 77 | } 78 | 79 | // Deps are task dependencies and must specify how to run tasks in series or in parallel. 80 | func (task *Task) Deps(names ...interface{}) { 81 | for _, name := range names { 82 | switch dep := name.(type) { 83 | default: 84 | util.Error(task.Name, "Dependency types must be (string | P | Parallel | S | Series)") 85 | case string: 86 | task.dependencies = append(task.dependencies, dep) 87 | case P: 88 | task.dependencies = append(task.dependencies, Parallel(dep)) 89 | case Parallel: 90 | task.dependencies = append(task.dependencies, dep) 91 | case S: 92 | task.dependencies = append(task.dependencies, Series(dep)) 93 | case Series: 94 | task.dependencies = append(task.dependencies, dep) 95 | } 96 | } 97 | } 98 | 99 | // Description sets the description for the task. 100 | func (task *Task) Description(desc string) *Task { 101 | if desc != "" { 102 | task.description = desc 103 | } 104 | return task 105 | } 106 | 107 | // Desc is alias for Description. 108 | func (task *Task) Desc(desc string) *Task { 109 | return task.Description(desc) 110 | } 111 | 112 | // Dest adds target globs which are used to calculated outdated files. 113 | // The tasks is not run unless ANY file Src are newer than ANY 114 | // in DestN. 115 | func (task *Task) Dest(globs ...string) *Task { 116 | if len(globs) > 0 { 117 | task.DestGlobs = globs 118 | } 119 | return task 120 | } 121 | 122 | // Src adds a source globs to this task. The task is 123 | // not run unless files are outdated between Src and Dest globs. 124 | func (task *Task) Src(globs ...string) *Task { 125 | if len(globs) > 0 { 126 | task.SrcGlobs = globs 127 | } 128 | return task 129 | } 130 | -------------------------------------------------------------------------------- /test/.hidden.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-godo/godo/b0de4ae4bf6fb493425e157c54f8cf2049c4931c/test/.hidden.txt -------------------------------------------------------------------------------- /test/bar.txt: -------------------------------------------------------------------------------- 1 | bar 2 | -------------------------------------------------------------------------------- /test/foo.cmd: -------------------------------------------------------------------------------- 1 | @echo FOOBAR -------------------------------------------------------------------------------- /test/foo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo -n FOOBAR 3 | -------------------------------------------------------------------------------- /test/foo.txt: -------------------------------------------------------------------------------- 1 | foo 2 | -------------------------------------------------------------------------------- /test/sub/foo.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-godo/godo/b0de4ae4bf6fb493425e157c54f8cf2049c4931c/test/sub/foo.txt -------------------------------------------------------------------------------- /test/sub/sub/subsub1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-godo/godo/b0de4ae4bf6fb493425e157c54f8cf2049c4931c/test/sub/sub/subsub1.txt -------------------------------------------------------------------------------- /test/sub/sub/subsub2.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-godo/godo/b0de4ae4bf6fb493425e157c54f8cf2049c4931c/test/sub/sub/subsub2.html -------------------------------------------------------------------------------- /test/sub/sub1.txt: -------------------------------------------------------------------------------- 1 | sfd 2 | -------------------------------------------------------------------------------- /test/sub/sub2.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-godo/godo/b0de4ae4bf6fb493425e157c54f8cf2049c4931c/test/sub/sub2.txt -------------------------------------------------------------------------------- /test/templates/1.go.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-godo/godo/b0de4ae4bf6fb493425e157c54f8cf2049c4931c/test/templates/1.go.html -------------------------------------------------------------------------------- /util/doc.go: -------------------------------------------------------------------------------- 1 | // Package util contains general purpose utility and logging functions. 2 | package util 3 | -------------------------------------------------------------------------------- /util/fs.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "gopkg.in/godo.v2/glob" 9 | ) 10 | 11 | // FileExists determines if path exists 12 | func FileExists(filename string) bool { 13 | _, err := os.Stat(filename) 14 | return err == nil 15 | } 16 | 17 | // FindUp finds a path up the tree. On sucess, it returns found path, else "". 18 | func FindUp(start, path string) string { 19 | absStart, err := filepath.Abs(start) 20 | if err != nil { 21 | return "" 22 | } 23 | 24 | filename := filepath.Join(absStart, path) 25 | if _, err := os.Stat(filename); err == nil { 26 | return filename 27 | } 28 | 29 | parent := filepath.Dir(absStart) 30 | if parent != absStart { 31 | return FindUp(parent, path) 32 | } 33 | return "" 34 | } 35 | 36 | // Outdated determines if ANY src has been modified after ANY dest. 37 | // 38 | // For example: *.go.html -> *.go 39 | // 40 | // If any go.html has changed then generate go files. 41 | func Outdated(srcGlobs, destGlobs []string) bool { 42 | srcFiles, _, err := glob.Glob(srcGlobs) 43 | if err != nil { 44 | if strings.Contains(err.Error(), "no such file") { 45 | return true 46 | } 47 | Error("godo", "Outdated src error: %s", err.Error()) 48 | return true 49 | } 50 | destFiles, _, err := glob.Glob(destGlobs) 51 | if err != nil { 52 | if strings.Contains(err.Error(), "no such file") { 53 | return true 54 | } 55 | Error("godo", "Outdated dest error: %s", err.Error()) 56 | return true 57 | } 58 | 59 | for _, src := range srcFiles { 60 | for _, dest := range destFiles { 61 | if src.ModTime().After(dest.ModTime()) { 62 | return true 63 | } 64 | } 65 | } 66 | return false 67 | } 68 | 69 | // TODO outdated 1-1 mapping 70 | -------------------------------------------------------------------------------- /util/logging.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "sync" 7 | 8 | "github.com/mattn/go-colorable" 9 | "github.com/mgutz/ansi" 10 | ) 11 | 12 | var cyan func(string) string 13 | var red func(string) string 14 | var yellow func(string) string 15 | var redInverse func(string) string 16 | var gray func(string) string 17 | var magenta func(string) string 18 | 19 | var colorfulMap = map[string]int{} 20 | var colorfulMutex = &sync.Mutex{} 21 | var colorfulFormats = []func(string) string{ 22 | ansi.ColorFunc("+h"), 23 | ansi.ColorFunc("green"), 24 | ansi.ColorFunc("yellow"), 25 | ansi.ColorFunc("magenta"), 26 | ansi.ColorFunc("green+h"), 27 | ansi.ColorFunc("yellow+h"), 28 | ansi.ColorFunc("magenta+h"), 29 | } 30 | 31 | // LogWriter is the writer to which the logs are written 32 | var LogWriter io.Writer 33 | 34 | func init() { 35 | ansi.DisableColors(false) 36 | cyan = ansi.ColorFunc("cyan") 37 | red = ansi.ColorFunc("red+b") 38 | yellow = ansi.ColorFunc("yellow+b") 39 | redInverse = ansi.ColorFunc("white:red") 40 | gray = ansi.ColorFunc("black+h") 41 | magenta = ansi.ColorFunc("magenta+h") 42 | LogWriter = colorable.NewColorableStdout() 43 | } 44 | 45 | // Debug writes a debug statement to stdout. 46 | func Debug(group string, format string, any ...interface{}) { 47 | fmt.Fprint(LogWriter, gray(group)+" ") 48 | fmt.Fprintf(LogWriter, gray(format), any...) 49 | } 50 | 51 | // Info writes an info statement to stdout. 52 | func Info(group string, format string, any ...interface{}) { 53 | fmt.Fprint(LogWriter, cyan(group)+" ") 54 | fmt.Fprintf(LogWriter, format, any...) 55 | } 56 | 57 | // InfoColorful writes an info statement to stdout changing colors 58 | // on succession. 59 | func InfoColorful(group string, format string, any ...interface{}) { 60 | colorfulMutex.Lock() 61 | colorfulMap[group]++ 62 | colorFn := colorfulFormats[colorfulMap[group]%len(colorfulFormats)] 63 | colorfulMutex.Unlock() 64 | 65 | fmt.Fprint(LogWriter, cyan(group)+" ") 66 | s := colorFn(fmt.Sprintf(format, any...)) 67 | fmt.Fprint(LogWriter, s) 68 | } 69 | 70 | // Error writes an error statement to stdout. 71 | func Error(group string, format string, any ...interface{}) error { 72 | fmt.Fprintf(LogWriter, red(group)+" ") 73 | fmt.Fprintf(LogWriter, red(format), any...) 74 | return fmt.Errorf(format, any...) 75 | } 76 | 77 | // Panic writes an error statement to stdout. 78 | func Panic(group string, format string, any ...interface{}) { 79 | fmt.Fprintf(LogWriter, redInverse(group)+" ") 80 | fmt.Fprintf(LogWriter, redInverse(format), any...) 81 | panic("") 82 | } 83 | 84 | // Deprecate writes a deprecation warning. 85 | func Deprecate(message string) { 86 | fmt.Fprintf(LogWriter, yellow("godo")+" "+message) 87 | } 88 | -------------------------------------------------------------------------------- /util/prompt.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/howeyc/gopass" 9 | ) 10 | 11 | // Prompt prompts user for input with default value. 12 | func Prompt(prompt string) string { 13 | reader := bufio.NewReader(os.Stdin) 14 | fmt.Print(prompt) 15 | text, _ := reader.ReadString('\n') 16 | return text 17 | } 18 | 19 | // PromptPassword prompts user for password input. 20 | func PromptPassword(prompt string) string { 21 | fmt.Printf(prompt) 22 | b, err := gopass.GetPasswd() 23 | if err != nil { 24 | fmt.Println(err.Error()) 25 | return "" 26 | } 27 | return string(b) 28 | } 29 | -------------------------------------------------------------------------------- /util/utils.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "io" 8 | "io/ioutil" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "text/template" 13 | 14 | "github.com/mgutz/str" 15 | ) 16 | 17 | // PackageName determines the package name from sourceFile if it is within $GOPATH 18 | func PackageName(sourceFile string) (string, error) { 19 | if filepath.Ext(sourceFile) != ".go" { 20 | return "", errors.New("sourcefile must end with .go") 21 | } 22 | sourceFile, err := filepath.Abs(sourceFile) 23 | if err != nil { 24 | Panic("util", "Could not convert to absolute path: %s", sourceFile) 25 | } 26 | 27 | gopath := os.Getenv("GOPATH") 28 | if gopath == "" { 29 | return "", errors.New("Environment variable GOPATH is not set") 30 | } 31 | paths := strings.Split(gopath, string(os.PathListSeparator)) 32 | for _, path := range paths { 33 | srcDir := filepath.Join(path, "src") 34 | srcDir, err := filepath.Abs(srcDir) 35 | if err != nil { 36 | continue 37 | } 38 | 39 | //log.Printf("srcDir %s sourceFile %s\n", srcDir, sourceFile) 40 | rel, err := filepath.Rel(srcDir, sourceFile) 41 | if err != nil { 42 | continue 43 | } 44 | return filepath.Dir(rel), nil 45 | } 46 | return "", errors.New("sourceFile not reachable from GOPATH") 47 | } 48 | 49 | // Template reads a go template and writes it to dist given data. 50 | func Template(src string, dest string, data map[string]interface{}) { 51 | content, err := ioutil.ReadFile(src) 52 | if err != nil { 53 | Panic("template", "Could not read file %s\n%v\n", src, err) 54 | } 55 | 56 | tpl := template.New("t") 57 | tpl, err = tpl.Parse(string(content)) 58 | if err != nil { 59 | Panic("template", "Could not parse template %s\n%v\n", src, err) 60 | } 61 | 62 | f, err := os.Create(dest) 63 | if err != nil { 64 | Panic("template", "Could not create file for writing %s\n%v\n", dest, err) 65 | } 66 | defer f.Close() 67 | err = tpl.Execute(f, data) 68 | if err != nil { 69 | Panic("template", "Could not execute template %s\n%v\n", src, err) 70 | } 71 | } 72 | 73 | // StrTemplate reads a go template and writes it to dist given data. 74 | func StrTemplate(src string, data map[string]interface{}) (string, error) { 75 | tpl := template.New("t") 76 | tpl, err := tpl.Parse(src) 77 | if err != nil { 78 | return "", err 79 | } 80 | 81 | var buf bytes.Buffer 82 | err = tpl.Execute(&buf, data) 83 | if err != nil { 84 | return "", err 85 | } 86 | 87 | return buf.String(), nil 88 | } 89 | 90 | // PartitionKV partitions a reader then parses key-value meta using an assignment string. 91 | // 92 | // Example 93 | // 94 | // PartitionKV(buf.NewBufferString(` 95 | // --@ key=SelectUser 96 | // SELECT * FROM users; 97 | // `, "--@", "=") => [{"_kind": "key", "key": "SelectUser", "_body": "SELECT * FROM users;"}] 98 | func PartitionKV(r io.Reader, prefix string, assignment string) ([]map[string]string, error) { 99 | scanner := bufio.NewScanner(r) 100 | var buf bytes.Buffer 101 | var kv string 102 | var text string 103 | var result []map[string]string 104 | collect := false 105 | 106 | parseKV := func(kv string) { 107 | argv := str.ToArgv(kv) 108 | body := buf.String() 109 | for i, arg := range argv { 110 | m := map[string]string{} 111 | var key string 112 | var value string 113 | if strings.Contains(arg, assignment) { 114 | parts := strings.Split(arg, assignment) 115 | key = parts[0] 116 | value = parts[1] 117 | } else { 118 | key = arg 119 | value = "" 120 | } 121 | m[key] = value 122 | m["_body"] = body 123 | if i == 0 { 124 | m["_kind"] = key 125 | } 126 | result = append(result, m) 127 | } 128 | } 129 | 130 | for scanner.Scan() { 131 | text = scanner.Text() 132 | if strings.HasPrefix(text, prefix) { 133 | if kv != "" { 134 | parseKV(kv) 135 | } 136 | kv = text[len(prefix):] 137 | collect = true 138 | buf.Reset() 139 | continue 140 | } 141 | if collect { 142 | buf.WriteString(text) 143 | buf.WriteRune('\n') 144 | } 145 | } 146 | if err := scanner.Err(); err != nil { 147 | return nil, err 148 | } 149 | 150 | if kv != "" && buf.Len() > 0 { 151 | parseKV(kv) 152 | } 153 | 154 | if collect { 155 | return result, nil 156 | } 157 | 158 | return nil, nil 159 | } 160 | -------------------------------------------------------------------------------- /waitgroup.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import "sync" 4 | 5 | // WaitGroupN is a custom wait group that tracks the number added 6 | // so it can be stopped. 7 | type WaitGroupN struct { 8 | sync.WaitGroup 9 | sync.Mutex 10 | N int 11 | } 12 | 13 | // Add adds to counter. 14 | func (wg *WaitGroupN) Add(n int) { 15 | wg.Lock() 16 | wg.N += n 17 | wg.Unlock() 18 | wg.WaitGroup.Add(n) 19 | } 20 | 21 | // Done removes from counter. 22 | func (wg *WaitGroupN) Done() { 23 | wg.Lock() 24 | wg.N-- 25 | wg.Unlock() 26 | 27 | wg.WaitGroup.Done() 28 | } 29 | 30 | // Stop calls done on remaining counter. 31 | func (wg *WaitGroupN) Stop() { 32 | wg.Lock() 33 | for i := 0; i < wg.N; i++ { 34 | wg.WaitGroup.Done() 35 | } 36 | wg.N = 0 37 | wg.Unlock() 38 | } 39 | -------------------------------------------------------------------------------- /watch_test.go: -------------------------------------------------------------------------------- 1 | package godo 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | 8 | "gopkg.in/stretchr/testify.v1/assert" 9 | ) 10 | 11 | /* 12 | NOTE: Watching tests 13 | 14 | // touch initial modtime of files 15 | touch("tmp/foo.txt", 0) 16 | 17 | // start project 18 | func tasks(p *Project) { 19 | // ... 20 | } 21 | execClI(tasks, ...) 22 | 23 | // give the project time to initialize 24 | <- time.After(testProjectDelay) 25 | 26 | // use touch to change files. Modtime has to be after the initial 27 | // modtime, so the watcher can pick up the change. To make one file 28 | // newer than the other: 29 | touch("tmp/older.txt", 1*time.Second) 30 | touch("tmp/newer.txt", 2*time.Second) 31 | 32 | // wait for at least the watch delay, which is the interval 33 | // the watchers polls the file system 34 | <- time.After(testWatchDelay) 35 | 36 | // finally, do assertions 37 | */ 38 | 39 | // default [templates:*go.html compile] 40 | // 41 | // if any go.html changes then run "templates", "compile", "default" 42 | func TestWatchTasksWithoutSrcShouldAlwaysRun(t *testing.T) { 43 | trace := "" 44 | pass := 1 45 | tasks := func(p *Project) { 46 | p.Task("A", nil, func(*Context) { 47 | trace += "A" 48 | }) 49 | p.Task("B", nil, func(*Context) { 50 | trace += "B" 51 | }) 52 | p.Task("C", nil, func(*Context) { 53 | trace += "C" 54 | }).Src("test/sub/foo.txt") 55 | 56 | p.Task("default", S{"A", "B", "C"}, func(*Context) { 57 | // on watch, the task is run once before watching 58 | if pass == 2 { 59 | p.Exit(0) 60 | } 61 | pass++ 62 | }) 63 | } 64 | 65 | go func() { 66 | execCLI(tasks, []string{"-w"}, nil) 67 | }() 68 | 69 | <-time.After(testProjectDelay) 70 | 71 | touch("test/sub/foo.txt", 0) 72 | 73 | <-time.After(testWatchDelay) 74 | 75 | assert.Equal(t, "ABCABC", trace) 76 | } 77 | 78 | // default [templates:*go.html styles:*.scss compile ] 79 | // 80 | // if any go.html changes then run "templates", "compile", "default". 81 | // styles is not run since no changes to SCSS files occurred. 82 | func TestWatchWithSrc(t *testing.T) { 83 | trace := "" 84 | pass := 1 85 | tasks := func(p *Project) { 86 | p.Task("compile", nil, func(*Context) { 87 | trace += "C" 88 | }) 89 | 90 | p.Task("styles", nil, func(*Context) { 91 | trace += "S" 92 | }).Src("test/styles/*scss") 93 | 94 | p.Task("templates", nil, func(*Context) { 95 | trace += "T" 96 | }).Src("test/templates/*go.html") 97 | 98 | p.Task("default", S{"templates", "styles", "compile"}, func(*Context) { 99 | // on watch, the task is run once before watching 100 | if pass == 2 { 101 | p.Exit(0) 102 | } 103 | pass++ 104 | }) 105 | } 106 | 107 | go func() { 108 | execCLI(tasks, []string{"-w"}, nil) 109 | }() 110 | 111 | <-time.After(testProjectDelay) 112 | 113 | touch("test/templates/1.go.html", 100*time.Millisecond) 114 | 115 | <-time.After(testWatchDelay) 116 | 117 | assert.Equal(t, "TSCTC", trace) 118 | } 119 | 120 | func TestWatchShouldWatchNamespaceTasks(t *testing.T) { 121 | done := make(chan bool) 122 | trace := "" 123 | pass := 0 124 | 125 | dbTasks := func(p *Project) { 126 | p.Task("default", S{"models"}, func(*Context) { 127 | trace += "D" 128 | pass++ 129 | if pass == 2 { 130 | p.Exit(0) 131 | } 132 | }) 133 | p.Task("models", nil, func(*Context) { 134 | trace += "M" 135 | }).Src("test/sub/*.txt") 136 | } 137 | 138 | tasks := func(p *Project) { 139 | p.Use("db", dbTasks) 140 | } 141 | 142 | go func() { 143 | execCLI(tasks, []string{"db:default", "-w"}, func(code int) { 144 | done <- true 145 | }) 146 | }() 147 | 148 | touchTil("test/sub/sub1.txt", 200*time.Millisecond, done) 149 | assert.Equal(t, "MDMD", trace) 150 | 151 | // test non-watch 152 | trace = "" 153 | runTask(tasks, "db:default") 154 | assert.Equal(t, "MD", trace) 155 | } 156 | 157 | func TestWatch(t *testing.T) { 158 | done := make(chan bool) 159 | ran := 0 160 | tasks := func(p *Project) { 161 | // this should run twice, watch always runs all tasks first then 162 | // the touch below 163 | p.Task("txt", nil, func(*Context) { 164 | ran++ 165 | if ran == 2 { 166 | p.Exit(0) 167 | } 168 | }).Src("test/*.txt") 169 | } 170 | 171 | status := -1 172 | go func() { 173 | argv := []string{"txt", "-w"} 174 | execCLI(tasks, argv, func(code int) { 175 | status = code 176 | done <- true 177 | }) 178 | }() 179 | 180 | touchTil("test/bar.txt", 100*time.Millisecond, done) 181 | assert.Equal(t, 2, ran) 182 | } 183 | 184 | func TestOutdatedNoDest(t *testing.T) { 185 | done := make(chan bool) 186 | ran := "" 187 | // each task will run once before watch is called 188 | tasks := func(p *Project) { 189 | p.Task("txt", nil, func(*Context) { 190 | ran += "T" 191 | }). 192 | Src("test/*.txt"). 193 | Dest("tmp/*.foo2") 194 | 195 | p.Task("parent", S{"txt"}, func(*Context) { 196 | ran += "P" 197 | if strings.Count(ran, "P") == 2 { 198 | p.Exit(0) 199 | } 200 | }).Src("test/sub/*.txt") 201 | } 202 | 203 | status := -1 204 | go func() { 205 | argv := []string{"parent", "-w"} 206 | execCLI(tasks, argv, func(code int) { 207 | status = code 208 | done <- true 209 | }) 210 | }() 211 | 212 | touchTil("test/sub/sub1.txt", 100*time.Millisecond, done) 213 | assert.Equal(t, "TPP", ran) 214 | } 215 | 216 | func TestOutdated(t *testing.T) { 217 | // force txt to be newer than foo, which should run txt 218 | touch("tmp/sub/1.foo", -1*time.Second) 219 | touch("tmp/sub/foo.txt", 0) 220 | 221 | ran := "" 222 | var project *Project 223 | tasks := func(p *Project) { 224 | project = p 225 | 226 | p.Task("txt", nil, func(*Context) { 227 | ran += "T" 228 | }). 229 | Src("tmp/sub/*.txt"). 230 | Dest("tmp/sub/*.foo") 231 | 232 | p.Task("parent", S{"txt"}, func(*Context) { 233 | ran += "P" 234 | }).Src("tmp/*.txt") 235 | } 236 | 237 | go func() { 238 | argv := []string{"parent", "-w"} 239 | execCLI(tasks, argv, func(code int) { 240 | assert.Fail(t, "should not have exited") 241 | }) 242 | }() 243 | 244 | // give the task enough time to setup watches 245 | <-time.After(testProjectDelay) 246 | 247 | // force txt to have older modtime than foo which should not run "text but run "parent" 248 | touch("tmp/1.foo", 3*time.Second) // is not watched 249 | touch("tmp/foo.txt", 1*time.Second) // txt is watched 250 | 251 | // wait at least watchDelay which is the interval for updating watches 252 | <-time.After(testWatchDelay) 253 | 254 | assert.Equal(t, "TPP", ran) 255 | } 256 | -------------------------------------------------------------------------------- /watcher/fileEvent.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | import "fmt" 4 | 5 | //"log" 6 | 7 | const ( 8 | // NONE means no event, initial state. 9 | NONE = iota 10 | // CREATED means file was created. 11 | CREATED 12 | // DELETED means file was deleted. 13 | DELETED 14 | // MODIFIED means file was modified. 15 | MODIFIED 16 | // PERM means changed permissions 17 | PERM 18 | // NOEXIST means file does not exist. 19 | NOEXIST 20 | // NOPERM means no permissions for the file (see const block comment). 21 | NOPERM 22 | // INVALID means any type of error not represented above. 23 | INVALID 24 | ) 25 | 26 | // FileEvent is a wrapper around github.com/howeyc/fsnotify.FileEvent 27 | type FileEvent struct { 28 | Event int 29 | Path string 30 | UnixNano int64 31 | } 32 | 33 | // newFileEvent creates a new file event. 34 | func newFileEvent(op int, path string, unixNano int64) *FileEvent { 35 | //log.Printf("to channel %+v\n", originEvent) 36 | return &FileEvent{Event: op, Path: path, UnixNano: unixNano} 37 | } 38 | 39 | // String returns an eye friendly version of this event. 40 | func (fe *FileEvent) String() string { 41 | var status string 42 | switch fe.Event { 43 | case CREATED: 44 | status = "was created" 45 | case DELETED: 46 | status = "was deleted" 47 | case MODIFIED: 48 | status = "was modified" 49 | case PERM: 50 | status = "permissions changed" 51 | case NOEXIST: 52 | status = "does not exist" 53 | case NOPERM: 54 | status = "is not accessible (permission)" 55 | case INVALID: 56 | status = "is invalid" 57 | } 58 | return fmt.Sprintf("%s %s", fe.Path, status) 59 | } 60 | -------------------------------------------------------------------------------- /watcher/fswatch/ISSUES: -------------------------------------------------------------------------------- 1 | * directory deletions not recognised as events 2 | * wi should use filepath.Abs 3 | -------------------------------------------------------------------------------- /watcher/fswatch/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Kyle Isom 2 | 3 | Permission to use, copy, modify, and distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /watcher/fswatch/README.md: -------------------------------------------------------------------------------- 1 | # fswatch 2 | ## go library for simple UNIX file system watching 3 | 4 | fswatch provides simple UNIX file system watching in Go. It is based around 5 | the Watcher struct, which should be initialised with either NewWatcher or 6 | NewAutoWatcher. Both functions accept a variable number of string arguments 7 | specfying the paths to be loaded, which may be globbed, and return a pointer 8 | to a Watcher. This value can be started and stopped with the Start() and 9 | Stop() methods. The Watcher will automatically stop if all the files it is 10 | watching have been deleted. 11 | 12 | The Start() method returns a read-only channel that receives Notification 13 | values. The Stop() method closes the channel, and no files will be watched 14 | from that point. 15 | 16 | The list of files being watched may be retrieved with the Watch() method and 17 | the current state of the files being watched may be retrieved with the 18 | State() method. See the go docs for more information. 19 | 20 | In synchronous mode (i.e. Watchers obtained from NewWatcher()), deleted files 21 | will not be removed from the watch list, allowing the user to watch for files 22 | that might be created at a future time, or to allow notification of files that 23 | are deleted and then recreated. The auto-watching mode (i.e. from 24 | NewAutoWatcher()) will remove deleted files from the watch list, as it 25 | automatically adds new files to the watch list. 26 | 27 | ## Usage 28 | There are two types of Watchers: 29 | 30 | * static watchers watch a limited set of files; they do not purge deleted 31 | files from the watch list. 32 | * auto watchers watch a set of files and directories; directories are 33 | watched for new files. New files are automatically added, and deleted 34 | files are removed from the watch list. 35 | 36 | Take a look at the provided `clinotify/clinotify.go` for an example; the 37 | package is also well-documented. See the godocs for more specifics. 38 | 39 | ## License 40 | 41 | `fswatch` is licensed under the ISC license. 42 | -------------------------------------------------------------------------------- /watcher/fswatch/clinotify/clinotify.go: -------------------------------------------------------------------------------- 1 | // clinotify provides an example file system watching command line app. It 2 | // scans the file system, and every 15 seconds prints out the files being 3 | // watched and their current state. 4 | package main 5 | 6 | import ( 7 | "flag" 8 | "fmt" 9 | "github.com/gokyle/fswatch" 10 | "os" 11 | "time" 12 | ) 13 | 14 | var dur time.Duration 15 | 16 | func init() { 17 | if len(os.Args) == 1 { 18 | fmt.Println("[+] not watching anything, exiting.") 19 | os.Exit(1) 20 | } 21 | dur, _ = time.ParseDuration("15s") 22 | } 23 | 24 | func main() { 25 | var w *fswatch.Watcher 26 | 27 | auto_watch := flag.Bool("a", false, "auto add new files in directories") 28 | flag.Parse() 29 | paths := flag.Args() 30 | if *auto_watch { 31 | w = fswatch.NewAutoWatcher(paths...) 32 | } else { 33 | w = fswatch.NewWatcher(paths...) 34 | } 35 | fmt.Println("[+] listening...") 36 | 37 | l := w.Start() 38 | go func() { 39 | for { 40 | n, ok := <-l 41 | if !ok { 42 | return 43 | } 44 | var status_text string 45 | switch n.Event { 46 | case fswatch.CREATED: 47 | status_text = "was created" 48 | case fswatch.DELETED: 49 | status_text = "was deleted" 50 | case fswatch.MODIFIED: 51 | status_text = "was modified" 52 | case fswatch.PERM: 53 | status_text = "permissions changed" 54 | case fswatch.NOEXIST: 55 | status_text = "doesn't exist" 56 | case fswatch.NOPERM: 57 | status_text = "has invalid permissions" 58 | case fswatch.INVALID: 59 | status_text = "is invalid" 60 | } 61 | fmt.Printf("[+] %s %s\n", n.Path, status_text) 62 | } 63 | }() 64 | go func() { 65 | for { 66 | <-time.After(dur) 67 | if !w.Active() { 68 | fmt.Println("[!] not watching anything") 69 | os.Exit(1) 70 | } 71 | fmt.Printf("[-] watching: %+v\n", w.State()) 72 | } 73 | }() 74 | time.Sleep(60 * time.Second) 75 | fmt.Println("[+] stopping...") 76 | w.Stop() 77 | time.Sleep(5 * time.Second) 78 | } 79 | -------------------------------------------------------------------------------- /watcher/fswatch/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2012 Kyle Isom 3 | 4 | Permission to use, copy, modify, and distribute this software for any 5 | purpose with or without fee is hereby granted, provided that the 6 | above copyright notice and this permission notice appear in all 7 | copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL 10 | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED 11 | WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE 12 | AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL 13 | DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA 14 | OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 15 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 16 | PERFORMANCE OF THIS SOFTWARE. 17 | */ 18 | 19 | /* 20 | Package fswatch provides simple UNIX file system watching in Go. It is based around 21 | the Watcher struct, which should be initialised with either NewWatcher or 22 | NewAutoWatcher. Both functions accept a variable number of string arguments 23 | specfying the paths to be loaded, which may be globbed, and return a pointer 24 | to a Watcher. This value can be started and stopped with the Start() and 25 | Stop() methods. The Watcher will automatically stop if all the files it is 26 | watching have been deleted. 27 | 28 | The Start() method returns a read-only channel that receives Notification 29 | values. The Stop() method closes the channel, and no files will be watched 30 | from that point. 31 | 32 | The list of files being watched may be retrieved with the Watch() method and 33 | the current state of the files being watched may be retrieved with the 34 | State() method. See the go docs for more information. 35 | 36 | In synchronous mode (i.e. Watchers obtained from NewWatcher()), deleted files 37 | will not be removed from the watch list, allowing the user to watch for files 38 | that might be created at a future time, or to allow notification of files that 39 | are deleted and then recreated. The auto-watching mode (i.e. from 40 | NewAutoWatcher()) will remove deleted files from the watch list, as it 41 | automatically adds new files to the watch list. 42 | 43 | If "." is not specified explicitly in the list of files to watch, new 44 | directories created in the current directory will not be seen (as per the 45 | behaviour of filepath.Match); any directories being watched will, however. 46 | If you wish to watch for changes in the current directory, be sure to specify 47 | ".". 48 | */ 49 | package fswatch 50 | -------------------------------------------------------------------------------- /watcher/fswatch/fswatch.go: -------------------------------------------------------------------------------- 1 | package fswatch 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // These values represent the events fswatch knows about. fswatch uses a 8 | // stat(2) call to look up file information; a file will only have a NOPERM 9 | // event if the parent directory has no search permission (i.e. parent 10 | // directory doesn't have executable permissions for the current user). 11 | const ( 12 | NONE = iota // No event, initial state. 13 | CREATED // File was created. 14 | DELETED // File was deleted. 15 | MODIFIED // File was modified. 16 | PERM // Changed permissions 17 | NOEXIST // File does not exist. 18 | NOPERM // No permissions for the file (see const block comment). 19 | INVALID // Any type of error not represented above. 20 | ) 21 | 22 | // NotificationBufLen is the number of notifications that should be buffered 23 | // in the channel. 24 | var NotificationBufLen = 16 25 | 26 | // WatchDelay is the duration between path scans. It defaults to 100ms. 27 | var WatchDelay time.Duration 28 | 29 | func init() { 30 | del, err := time.ParseDuration("100ms") 31 | if err != nil { 32 | panic("couldn't set up fswatch: " + err.Error()) 33 | } 34 | WatchDelay = del 35 | } 36 | -------------------------------------------------------------------------------- /watcher/fswatch/watch_item.go: -------------------------------------------------------------------------------- 1 | package fswatch 2 | 3 | import "os" 4 | 5 | type watchItem struct { 6 | Path string 7 | StatInfo os.FileInfo 8 | LastEvent int 9 | } 10 | 11 | func watchPath(path string) (wi *watchItem) { 12 | wi = new(watchItem) 13 | wi.Path = path 14 | wi.LastEvent = NONE 15 | 16 | fi, err := os.Stat(path) 17 | if err == nil { 18 | wi.StatInfo = fi 19 | } else if os.IsNotExist(err) { 20 | wi.LastEvent = NOEXIST 21 | } else if os.IsPermission(err) { 22 | wi.LastEvent = NOPERM 23 | } else { 24 | wi.LastEvent = INVALID 25 | } 26 | return 27 | } 28 | 29 | func (wi *watchItem) Update() bool { 30 | fi, err := os.Stat(wi.Path) 31 | 32 | if err != nil { 33 | if os.IsNotExist(err) { 34 | if wi.LastEvent == NOEXIST { 35 | return false 36 | } else if wi.LastEvent == DELETED { 37 | wi.LastEvent = NOEXIST 38 | return false 39 | } else { 40 | wi.LastEvent = DELETED 41 | return true 42 | } 43 | } else if os.IsPermission(err) { 44 | if wi.LastEvent == NOPERM { 45 | return false 46 | } 47 | wi.LastEvent = NOPERM 48 | return true 49 | } else { 50 | wi.LastEvent = INVALID 51 | return false 52 | } 53 | } 54 | 55 | if wi.LastEvent == NOEXIST { 56 | wi.LastEvent = CREATED 57 | wi.StatInfo = fi 58 | return true 59 | } else if fi.ModTime().After(wi.StatInfo.ModTime()) { 60 | wi.StatInfo = fi 61 | switch wi.LastEvent { 62 | case NONE, CREATED, NOPERM, INVALID: 63 | wi.LastEvent = MODIFIED 64 | case DELETED, NOEXIST: 65 | wi.LastEvent = CREATED 66 | } 67 | return true 68 | } else if fi.Mode() != wi.StatInfo.Mode() { 69 | wi.LastEvent = PERM 70 | wi.StatInfo = fi 71 | return true 72 | } 73 | return false 74 | } 75 | 76 | // Notification represents a file state change. The Path field indicates 77 | // the file that was changed, while last event corresponds to one of the 78 | // event type constants. 79 | type Notification struct { 80 | Path string 81 | Event int 82 | } 83 | 84 | // Notification returns a notification from a watchItem. 85 | func (wi *watchItem) Notification() *Notification { 86 | return &Notification{wi.Path, wi.LastEvent} 87 | } 88 | -------------------------------------------------------------------------------- /watcher/fswatch/watcher.go: -------------------------------------------------------------------------------- 1 | package fswatch 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "time" 8 | 9 | "github.com/mgutz/str" 10 | ) 11 | 12 | // Watcher represents a file system watcher. It should be initialised 13 | // with NewWatcher or NewAutoWatcher, and started with Watcher.Start(). 14 | type Watcher struct { 15 | paths map[string]*watchItem 16 | cnotify chan *Notification 17 | cadd chan *watchItem 18 | autoWatch bool 19 | 20 | // ignoreFn is used to ignore paths 21 | IgnorePathFn func(path string) bool 22 | } 23 | 24 | // newWatcher is the internal function for properly setting up a new watcher. 25 | func newWatcher(dirNotify bool, initpaths ...string) (w *Watcher) { 26 | w = new(Watcher) 27 | w.autoWatch = dirNotify 28 | w.paths = make(map[string]*watchItem, 0) 29 | w.IgnorePathFn = ignorePathDefault 30 | 31 | var paths []string 32 | for _, path := range initpaths { 33 | matches, err := filepath.Glob(path) 34 | if err != nil { 35 | continue 36 | } 37 | paths = append(paths, matches...) 38 | } 39 | if dirNotify { 40 | w.syncAddPaths(paths...) 41 | } else { 42 | for _, path := range paths { 43 | w.paths[path] = watchPath(path) 44 | } 45 | } 46 | return 47 | } 48 | 49 | // NewWatcher initialises a new Watcher with an initial set of paths. It 50 | // does not start listening, and this Watcher will not automatically add 51 | // files created under any directories it is watching. 52 | func NewWatcher(paths ...string) *Watcher { 53 | return newWatcher(false, paths...) 54 | } 55 | 56 | // NewAutoWatcher initialises a new Watcher with an initial set of paths. 57 | // It behaves the same as NewWatcher, except it will automatically add 58 | // files created in directories it is watching, including adding any 59 | // subdirectories. 60 | func NewAutoWatcher(paths ...string) *Watcher { 61 | return newWatcher(true, paths...) 62 | } 63 | 64 | // Start begins watching the files, sending notifications when files change. 65 | // It returns a channel that notifications are sent on. 66 | func (w *Watcher) Start() <-chan *Notification { 67 | if w.cnotify != nil { 68 | return w.cnotify 69 | } 70 | if w.autoWatch { 71 | w.cadd = make(chan *watchItem, NotificationBufLen) 72 | go w.watchItemListener() 73 | } 74 | w.cnotify = make(chan *Notification, NotificationBufLen) 75 | go w.watch(w.cnotify) 76 | return w.cnotify 77 | } 78 | 79 | // Stop listening for changes to the files. 80 | func (w *Watcher) Stop() { 81 | if w.cnotify != nil { 82 | close(w.cnotify) 83 | } 84 | 85 | if w.cadd != nil { 86 | close(w.cadd) 87 | } 88 | } 89 | 90 | // Active returns true if the Watcher is actively looking for changes. 91 | func (w *Watcher) Active() bool { 92 | return w.paths != nil && len(w.paths) > 0 93 | } 94 | 95 | // Add method takes a variable number of string arguments and adds those 96 | // files to the watch list, returning the number of files added. 97 | func (w *Watcher) Add(inpaths ...string) { 98 | var paths []string 99 | for _, path := range inpaths { 100 | matches, err := filepath.Glob(path) 101 | if err != nil { 102 | continue 103 | } 104 | paths = append(paths, matches...) 105 | } 106 | if w.autoWatch && w.cnotify != nil { 107 | for _, path := range paths { 108 | wi := watchPath(path) 109 | w.addPaths(wi) 110 | } 111 | } else if w.autoWatch { 112 | w.syncAddPaths(paths...) 113 | } else { 114 | for _, path := range paths { 115 | w.paths[path] = watchPath(path) 116 | } 117 | } 118 | } 119 | 120 | // goroutine that cycles through the list of paths and checks for updates. 121 | func (w *Watcher) watch(sndch chan<- *Notification) { 122 | defer func() { 123 | recover() 124 | }() 125 | 126 | for { 127 | //fmt.Printf("updating watch info %s\n", time.Now()) 128 | <-time.After(WatchDelay) 129 | 130 | for _, wi := range w.paths { 131 | //fmt.Printf("cheecking %#v\n", wi.Path) 132 | 133 | if wi.Update() && w.shouldNotify(wi) { 134 | sndch <- wi.Notification() 135 | } 136 | 137 | if wi.LastEvent == NOEXIST && w.autoWatch { 138 | delete(w.paths, wi.Path) 139 | } 140 | 141 | if len(w.paths) == 0 { 142 | w.Stop() 143 | } 144 | // if filepath.Base(wi.Path) == "sub1.txt" { 145 | // fmt.Printf("%s\n", wi.Path) 146 | // } 147 | } 148 | } 149 | } 150 | 151 | func (w *Watcher) shouldNotify(wi *watchItem) bool { 152 | if w.autoWatch && wi.StatInfo.IsDir() && 153 | !(wi.LastEvent == DELETED || wi.LastEvent == NOEXIST) { 154 | go w.addPaths(wi) 155 | return false 156 | } 157 | return true 158 | } 159 | 160 | func (w *Watcher) addPaths(wi *watchItem) { 161 | walker := getWalker(w, wi.Path, w.cadd) 162 | go filepath.Walk(wi.Path, walker) 163 | } 164 | 165 | func (w *Watcher) watchItemListener() { 166 | defer func() { 167 | recover() 168 | }() 169 | for { 170 | wi := <-w.cadd 171 | if wi == nil { 172 | continue 173 | } else if _, watching := w.paths[wi.Path]; watching { 174 | continue 175 | } 176 | w.paths[wi.Path] = wi 177 | } 178 | } 179 | 180 | func getWalker(w *Watcher, root string, addch chan<- *watchItem) func(string, os.FileInfo, error) error { 181 | walker := func(path string, info os.FileInfo, err error) error { 182 | if w.IgnorePathFn(path) { 183 | if info.IsDir() { 184 | //fmt.Println("SKIPPING dir", path) 185 | return filepath.SkipDir 186 | } 187 | return nil 188 | } 189 | if err != nil { 190 | return err 191 | } 192 | if path == root { 193 | return nil 194 | } 195 | wi := watchPath(path) 196 | if wi == nil { 197 | return nil 198 | } else if _, watching := w.paths[wi.Path]; !watching { 199 | wi.LastEvent = CREATED 200 | w.cnotify <- wi.Notification() 201 | addch <- wi 202 | if !wi.StatInfo.IsDir() { 203 | return nil 204 | } 205 | w.addPaths(wi) 206 | } 207 | return nil 208 | } 209 | return walker 210 | } 211 | 212 | // DefaultIsIgnorePath checks whether a path is ignored. Currently defaults 213 | // to hidden files on *nix systems, ie they start with a ".". 214 | func ignorePathDefault(path string) bool { 215 | if strings.HasPrefix(path, ".") || strings.Contains(path, "/.") { 216 | return true 217 | } 218 | 219 | // ignore node 220 | if strings.HasPrefix(path, "node_modules") || strings.Contains(path, "/node_modules") { 221 | return true 222 | } 223 | 224 | // vim creates random numeric files 225 | base := filepath.Base(path) 226 | if str.IsNumeric(base) { 227 | return true 228 | } 229 | return false 230 | } 231 | 232 | func (w *Watcher) syncAddPaths(paths ...string) { 233 | for _, path := range paths { 234 | if w.IgnorePathFn(path) { 235 | //fmt.Println("SKIPPING path", path) 236 | continue 237 | } 238 | wi := watchPath(path) 239 | if wi == nil { 240 | continue 241 | } else if wi.LastEvent == NOEXIST { 242 | continue 243 | } else if _, watching := w.paths[wi.Path]; watching { 244 | continue 245 | } 246 | w.paths[wi.Path] = wi 247 | if wi.StatInfo.IsDir() { 248 | w.syncAddDir(wi) 249 | } 250 | } 251 | } 252 | 253 | func (w *Watcher) syncAddDir(wi *watchItem) { 254 | walker := func(path string, info os.FileInfo, err error) error { 255 | if w.IgnorePathFn(path) { 256 | if info.IsDir() { 257 | //fmt.Println("SKIPPING dir", path) 258 | return filepath.SkipDir 259 | } 260 | return nil 261 | } 262 | 263 | if err != nil { 264 | return err 265 | } 266 | if path == wi.Path { 267 | return nil 268 | } 269 | newWI := watchPath(path) 270 | if newWI != nil { 271 | w.paths[path] = newWI 272 | if !newWI.StatInfo.IsDir() { 273 | return nil 274 | } 275 | if _, watching := w.paths[newWI.Path]; !watching { 276 | w.syncAddDir(newWI) 277 | } 278 | } 279 | return nil 280 | } 281 | filepath.Walk(wi.Path, walker) 282 | } 283 | 284 | // Watching returns a list of the files being watched. 285 | func (w *Watcher) Watching() (paths []string) { 286 | paths = make([]string, 0) 287 | for path := range w.paths { 288 | paths = append(paths, path) 289 | } 290 | return 291 | } 292 | 293 | // State returns a slice of Notifications representing the files being watched 294 | // and their last event. 295 | func (w *Watcher) State() (state []Notification) { 296 | state = make([]Notification, 0) 297 | if w.paths == nil { 298 | return 299 | } 300 | for _, wi := range w.paths { 301 | state = append(state, *wi.Notification()) 302 | } 303 | return 304 | } 305 | -------------------------------------------------------------------------------- /watcher/watcher.go: -------------------------------------------------------------------------------- 1 | // Package watcher implements filesystem notification,. 2 | package watcher 3 | 4 | import ( 5 | //"fmt" 6 | 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/mgutz/str" 14 | "gopkg.in/godo.v2/watcher/fswatch" 15 | ) 16 | 17 | const ( 18 | // IgnoreThresholdRange is the amount of time in ns to ignore when 19 | // receiving watch events for the same file 20 | IgnoreThresholdRange = 50 * 1000000 // convert to ms 21 | ) 22 | 23 | // SetWatchDelay sets the watch delay 24 | func SetWatchDelay(delay time.Duration) { 25 | fswatch.WatchDelay = delay 26 | } 27 | 28 | // Watcher is a wrapper around which adds some additional features: 29 | // 30 | // - recursive directory watch 31 | // - buffer to even chan 32 | // - even time 33 | // 34 | // Original work from https://github.com/bronze1man/kmg 35 | type Watcher struct { 36 | *fswatch.Watcher 37 | Event chan *FileEvent 38 | Error chan error 39 | //default ignore all file start with "." 40 | IgnorePathFn func(path string) bool 41 | //default is nil,if is nil ,error send through Error chan,if is not nil,error handle by this func 42 | ErrorHandler func(err error) 43 | isClosed bool 44 | quit chan bool 45 | cache map[string]*os.FileInfo 46 | mu sync.Mutex 47 | } 48 | 49 | // NewWatcher creates an instance of watcher. 50 | func NewWatcher(bufferSize int) (watcher *Watcher, err error) { 51 | 52 | fswatcher := fswatch.NewAutoWatcher() 53 | 54 | if err != nil { 55 | return nil, err 56 | } 57 | watcher = &Watcher{ 58 | Watcher: fswatcher, 59 | Error: make(chan error, 10), 60 | Event: make(chan *FileEvent, bufferSize), 61 | IgnorePathFn: DefaultIgnorePathFn, 62 | cache: map[string]*os.FileInfo{}, 63 | } 64 | return 65 | } 66 | 67 | // Close closes the watcher channels. 68 | func (w *Watcher) Close() error { 69 | if w.isClosed { 70 | return nil 71 | } 72 | w.Watcher.Stop() 73 | w.quit <- true 74 | w.isClosed = true 75 | return nil 76 | } 77 | 78 | func (w *Watcher) eventLoop() { 79 | // cache := map[string]*os.FileInfo{} 80 | // mu := &sync.Mutex{} 81 | 82 | coutput := w.Watcher.Start() 83 | for { 84 | event, ok := <-coutput 85 | if !ok { 86 | return 87 | } 88 | 89 | // fmt.Printf("event %+v\n", event) 90 | if w.IgnorePathFn(event.Path) { 91 | continue 92 | } 93 | 94 | // you can not stat a delete file... 95 | if event.Event == fswatch.DELETED || event.Event == fswatch.NOEXIST { 96 | // adjust with arbitrary value because it was deleted 97 | // before it got here 98 | //fmt.Println("sending fi wevent", event) 99 | w.Event <- newFileEvent(event.Event, event.Path, time.Now().UnixNano()-10) 100 | continue 101 | } 102 | 103 | fi, err := os.Stat(event.Path) 104 | if os.IsNotExist(err) { 105 | //fmt.Println("not exists", event) 106 | continue 107 | } 108 | 109 | // fsnotify is sending multiple MODIFY events for the same 110 | // file which is likely OS related. The solution here is to 111 | // compare the current stats of a file against its last stats 112 | // (if any) and if it falls within a nanoseconds threshold, 113 | // ignore it. 114 | w.mu.Lock() 115 | oldFI := w.cache[event.Path] 116 | w.cache[event.Path] = &fi 117 | 118 | // if oldFI != nil { 119 | // fmt.Println("new FI", fi.ModTime().UnixNano()) 120 | // fmt.Println("old FI", (*oldFI).ModTime().UnixNano()+IgnoreThresholdRange) 121 | // } 122 | 123 | if oldFI != nil && fi.ModTime().UnixNano() < (*oldFI).ModTime().UnixNano()+IgnoreThresholdRange { 124 | w.mu.Unlock() 125 | continue 126 | } 127 | w.mu.Unlock() 128 | 129 | //fmt.Println("sending Event", fi.Name()) 130 | 131 | //fmt.Println("sending fi wevent", event) 132 | w.Event <- newFileEvent(event.Event, event.Path, fi.ModTime().UnixNano()) 133 | 134 | if err != nil { 135 | //rename send two events,one old file,one new file,here ignore old one 136 | if os.IsNotExist(err) { 137 | continue 138 | } 139 | w.errorHandle(err) 140 | continue 141 | } 142 | // case err := <-w.Watcher.Errors: 143 | // w.errorHandle(err) 144 | // case _ = <-w.quit: 145 | // break 146 | // } 147 | } 148 | } 149 | func (w *Watcher) errorHandle(err error) { 150 | if w.ErrorHandler == nil { 151 | w.Error <- err 152 | return 153 | } 154 | w.ErrorHandler(err) 155 | } 156 | 157 | // GetErrorChan gets error chan. 158 | func (w *Watcher) GetErrorChan() chan error { 159 | return w.Error 160 | } 161 | 162 | // GetEventChan gets event chan. 163 | func (w *Watcher) GetEventChan() chan *FileEvent { 164 | return w.Event 165 | } 166 | 167 | // WatchRecursive watches a directory recursively. If a dir is created 168 | // within directory it is also watched. 169 | func (w *Watcher) WatchRecursive(path string) error { 170 | path, err := filepath.Abs(path) 171 | if err != nil { 172 | return err 173 | } 174 | 175 | _, err = os.Stat(path) 176 | if err != nil { 177 | return err 178 | } 179 | 180 | w.Watcher.Add(path) 181 | 182 | //util.Debug("watcher", "watching %s %s\n", path, time.Now()) 183 | return nil 184 | } 185 | 186 | // Start starts the watcher 187 | func (w *Watcher) Start() { 188 | go w.eventLoop() 189 | } 190 | 191 | // func (w *Watcher) getSubFolders(path string) (paths []string, err error) { 192 | // err = filepath.Walk(path, func(newPath string, info os.FileInfo, err error) error { 193 | // if err != nil { 194 | // return err 195 | // } 196 | 197 | // if !info.IsDir() { 198 | // return nil 199 | // } 200 | // if w.IgnorePathFn(newPath) { 201 | // return filepath.SkipDir 202 | // } 203 | // paths = append(paths, newPath) 204 | // return nil 205 | // }) 206 | // return paths, err 207 | // } 208 | 209 | // DefaultIgnorePathFn checks whether a path is ignored. Currently defaults 210 | // to hidden files on *nix systems, ie they start with a ".". 211 | func DefaultIgnorePathFn(path string) bool { 212 | if strings.HasPrefix(path, ".") || strings.Contains(path, "/.") { 213 | return true 214 | } 215 | 216 | // ignore node 217 | if strings.HasPrefix(path, "node_modules") || strings.Contains(path, "/node_modules") { 218 | return true 219 | } 220 | 221 | // vim creates random numeric files 222 | base := filepath.Base(path) 223 | if str.IsNumeric(base) { 224 | return true 225 | } 226 | return false 227 | } 228 | 229 | // SetIgnorePathFn sets the function which determines if a path should be 230 | // skipped when watching. 231 | func (w *Watcher) SetIgnorePathFn(fn func(string) bool) { 232 | w.Watcher.IgnorePathFn = fn 233 | } 234 | --------------------------------------------------------------------------------