├── .github └── workflows │ ├── build.yml │ └── tests.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── START_HERE.md ├── go.mod ├── go.sum ├── main.go ├── main_test.go ├── testdata ├── args │ └── main.go ├── build_flags │ ├── bar.go │ └── main.go ├── dir │ ├── .settings │ │ └── foo │ │ │ └── bar.txt │ ├── foo │ │ └── bar.txt │ ├── node_modules │ │ └── foo │ │ │ └── bar.txt │ └── subdir │ │ └── foo │ │ └── bar.txt ├── env │ ├── env.sh │ └── main.go ├── file_event │ ├── main.go │ └── run.bat ├── hello_world │ └── main.go ├── polling │ └── main.go ├── server │ └── main.go ├── signal │ └── main.go └── stdin │ └── main.go ├── util_unix.go ├── util_unix_test.go ├── util_windows.go ├── util_windows_test.go ├── wgo_cmd.go └── wgo_cmd_test.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: 'build' 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | jobs: 7 | build_and_release: 8 | runs-on: 'ubuntu-latest' 9 | steps: 10 | - name: 'Clone repo' 11 | uses: 'actions/checkout@v4' 12 | - name: 'Install go' 13 | uses: 'actions/setup-go@v5' 14 | with: 15 | go-version: 'stable' 16 | - run: 'GOOS=linux GOARCH=amd64 go build -ldflags "-w" -o wgo-linux .' 17 | - run: 'GOOS=linux GOARCH=arm64 go build -ldflags "-w" -o wgo-linux-arm .' 18 | - run: 'GOOS=darwin GOARCH=amd64 go build -ldflags "-w" -o wgo-macos .' 19 | - run: 'GOOS=darwin GOARCH=arm64 go build -ldflags "-w" -o wgo-macos-apple-silicon .' 20 | - run: 'GOOS=windows GOARCH=amd64 go build -ldflags "-w" -o wgo-windows.exe .' 21 | - name: 'Create release' 22 | uses: 'svenstaro/upload-release-action@v2' 23 | with: 24 | repo_token: '${{ secrets.GITHUB_TOKEN }}' 25 | tag: '${{ github.ref }}' 26 | file: 'wgo-*' 27 | file_glob: 'true' 28 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: 'tests' 2 | on: 3 | push: 4 | branches: ['main'] 5 | pull_request: 6 | branches: ['main'] 7 | jobs: 8 | run_wgo_tests: 9 | runs-on: 'ubuntu-latest' 10 | steps: 11 | - name: 'Clone repo' 12 | uses: 'actions/checkout@v3' 13 | - name: 'Install go' 14 | uses: 'actions/setup-go@v4' 15 | with: 16 | go-version: '1.16.0' 17 | - run: 'go install github.com/mattn/goveralls@latest' 18 | - run: 'go test . -coverprofile=coverage -race' 19 | - run: 'goveralls -coverprofile=coverage -service=github || true' 20 | env: 21 | COVERALLS_TOKEN: '${{ secrets.GITHUB_TOKEN }}' 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .idea/ 3 | testdata/hello_world/timeout_on 4 | testdata/hello_world/timeout_off 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # docker build --tag wgotest . 2 | # docker run --interactive --tty --volume "$(pwd)":/wgo wgotest 3 | 4 | FROM golang:latest 5 | 6 | RUN apt-get update && apt-get install --yes bash 7 | 8 | WORKDIR /wgo 9 | 10 | CMD ["/bin/bash"] 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Chua Bok Woon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![tests](https://github.com/bokwoon95/sq/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/bokwoon95/wgo/actions) 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/bokwoon95/wgo)](https://goreportcard.com/report/github.com/bokwoon95/wgo) 3 | [![Coverage Status](https://shields.io/coverallsCoverage/github/bokwoon95/wgo?branch=main)](https://coveralls.io/github/bokwoon95/wgo?branch=main) 4 | 5 |

wgo – watcher-go

6 |

Live reload for Go apps (and more)

7 |
8 | 9 | ## Installation 10 | 11 | You can [download](#download-the-latest-release) binaries from [the release page](https://github.com/bokwoon95/wgo/releases/latest), or use the Go command: 12 | 13 | ```shell 14 | go install github.com/bokwoon95/wgo@latest 15 | ``` 16 | 17 | If you are on macOS, you may install it with [Homebrew](https://brew.sh). 18 | 19 | ```shell 20 | brew install wgo 21 | ``` 22 | 23 | ```text 24 | Usage: 25 | wgo [FLAGS] [ARGUMENTS...] 26 | wgo gcc -o main main.c 27 | wgo go build -o main main.go 28 | wgo -file .c gcc -o main main.c 29 | wgo -file=.go go build -o main main.go 30 | 31 | wgo run [FLAGS] [GO_BUILD_FLAGS] [ARGUMENTS...] 32 | wgo run main.go 33 | wgo run -file .html main.go arg1 arg2 arg3 34 | wgo run -file .html . arg1 arg2 arg3 35 | wgo run -file=.css -file=.js -tags=fts5 ./cmd/my_project arg1 arg2 arg3 36 | 37 | Pass in the -h flag to the wgo/wgo run to learn what flags there are i.e. wgo -h, wgo run -h 38 | 39 | Core documentation resides at https://github.com/bokwoon95/wgo#quickstart 40 | ``` 41 | 42 | ## Why this exists 43 | 44 | Too many file watchers either force you to wrap your commands into strings, require config files or log tons of noisy output to your stdout. In contrast, `wgo` is [dead simple](#quickstart) and silent by default. The implementation is also really short, most of it resides in just two files ([wgo\_cmd.go](https://github.com/bokwoon95/wgo/blob/main/wgo_cmd.go) and [main.go](https://github.com/bokwoon95/wgo/blob/main/main.go)). You can read the entire codebase in one sitting, [start here](https://github.com/bokwoon95/wgo/blob/main/START_HERE.md). 45 | 46 | It can be used like [`go run`](#wgo-run). 47 | 48 | ## Quickstart 49 | 50 | *You already know how to use wgo*. Simply slap `wgo` in front of any command in order to have it rerun whenever a file changes. 51 | 52 | ```shell 53 | # Run gcc. 54 | $ gcc -Wall -o main main.c 55 | 56 | # Run gcc whenever a file changes. 57 | $ wgo gcc -Wall -o main main.c 58 | ``` 59 | 60 | ### wgo run 61 | 62 | `wgo run` behaves exactly like `go run` except it runs again the moment any Go file changes. This can be used to live-reload Go servers. `wgo run` accepts the same flags as `go run`. 63 | 64 | By default `wgo run` only watches .go files. To include additional file types such as .html, use the [-file flag](#including-and-excluding-files). 65 | 66 | ```shell 67 | # Run main.go. 68 | $ go run main.go 69 | 70 | # Run main.go whenever a .go file changes. 71 | $ wgo run main.go 72 | 73 | # Any flag that can be passed to `go run` can also be passed to `wgo run`. 74 | $ wgo run -tags=fts5 -race -trimpath main.go 75 | ``` 76 | 77 | ## Flags 78 | 79 | `wgo`/`wgo run` take in additional flags. These flags must be passed in directly after `wgo`/`wgo run`, before invoking your command. 80 | 81 | - [-file/-xfile](#including-and-excluding-files) - Include/exclude files. 82 | - [-dir/-xdir](#including-and-excluding-directories) - Include/exclude directories. 83 | - [-cd](#running-commands-in-a-different-directory) - Change to a different directory to run the commands. 84 | - [-root](#specify-additional-root-directories-to-watch) - Specify additional root directories to watch. 85 | - [-exit](#exit-when-the-last-command-exits) - Exit when the last command exits. 86 | - [-stdin](#enable-stdin) - Enable stdin for the last command. 87 | - [-verbose](#log-file-events) - Log file events. 88 | - [-debounce](#debounce-duration) - How quickly to react to file events. Lower debounce values will react quicker. 89 | - [-postpone](#postpone-the-first-execution-of-the-command-until-a-file-is-modified) - Postpone the first execution of the command until a file is modified. 90 | - [-poll](#use-polling-to-detect-file-changes) - How often to poll for file changes. Zero or no value means no polling. 91 | 92 | ## Advanced Usage 93 | 94 | - [Chaining commands](#chaining-commands) 95 | - [Clear terminal on restart](#clear-terminal-on-restart) 96 | - [Running parallel wgo commands](#running-parallel-wgo-commands) 97 | - [Don't stop the application on compile errors](#dont-stop-the-application-on-compile-errors) 98 | - [Debug Go code using GoLand or VSCode with wgo](#debug-go-code-using-goland-or-vscode-with-wgo) 99 | 100 | ## Including and excluding files 101 | 102 | [*back to flags index*](#flags) 103 | 104 | To include specific files eligible for triggering a reload, use the -file flag. It takes in a regex, and files whose paths (relative to the root directory) match the regex are included. You can provide multiple -file flags. 105 | 106 | Path separators are always forward slash, even on Windows. 107 | 108 | If no -file flag is provided, every file is included by default unless it is explicitly excluded by the -xfile flag. 109 | 110 | ```shell 111 | # Run sass whenever an .scss file changes. 112 | $ wgo -file .scss sass assets/styles.scss assets/styles.css 113 | 114 | # Run main.go whenever a .go or .html or .css file changes. 115 | $ wgo run -file .html -file .css main.go 116 | 117 | # Run main.go when foo/bar/baz.go changes. 118 | $ wgo run -file '^foo/bar/baz.go$' main.go 119 | ``` 120 | 121 | To exclude specific files, use the -xfile flag. It takes in a regex (like -file) but if it matches with a file path that file is excluded. 122 | 123 | The -xfile flag takes higher precedence than the -file flag so you can include a large group of files using a -file flag and surgically ignore specific files from that group using an -xfile flag. 124 | 125 | ```shell 126 | # `go test` writes to coverage.out, which counts as a changed file which triggers 127 | # `go test` to run again which generates coverage.out again in an infinite loop. 128 | # Avoid this by excluding any file matching 'coverage.out'. 129 | $ wgo -xfile coverage.out go test . -race -coverprofile=coverage.out 130 | 131 | # Probably better to specify `-file .go` in order to rerun `go test` only when 132 | # .go files change. 133 | $ wgo -file .go go test . -race -coverprofile=coverage.out 134 | ``` 135 | 136 | ## Regex dot literals 137 | 138 | The [-file](#including-and-excluding-files) flag takes in regexes like `.html` or `.css`. 139 | 140 | ```shell 141 | $ wgo run -file .html -file .css main.go 142 | ``` 143 | 144 | Technically the dot `.` matches any character, but file extensions are such a common pattern that wgo includes a slight modification to the regex matching rules: 145 | 146 | *Any dot `.` immediately followed by an alphabet `[a-zA-Z]` is treated as a dot literal i.e. `\.`* 147 | 148 | So `.css` really means `\.css`, but `.*` still means `.*` because `*` is not an alphabet. If you really want to use the dot `.` wildcard followed by an alphabet, wrap it in (a bracket group) so that it is not immediately followed by an alphabet i.e. `(.)abc`. 149 | 150 | ## Including and excluding directories 151 | 152 | [*back to flags index*](#flags) 153 | 154 | To only watch specific directories, use the -dir flag. It takes in a regex, and directories whose paths (relative to the root directory) match the regex are included. You can provide multiple -dir flags. 155 | 156 | Path separators are always forward slash, even on Windows. 157 | 158 | ```shell 159 | # Run sass whenever an .scss file in the assets directory changes. 160 | $ wgo -dir assets -file .scss sass assets/styles.scss assets/styles.css 161 | 162 | # Run main.go whenever something in the foo directory or bar/baz directory changes. 163 | $ wgo run -dir foo -dir bar/baz main.go 164 | 165 | # Run main.go whenever something in the foo/bar/baz directory changes. 166 | $ wgo run -dir '^foo/bar/baz$' main.go 167 | ``` 168 | 169 | To exclude specific directories, use the -xdir flag. It takes in a regex (like -dir) but if it matches with a directory path that directory is excluded from being watched. 170 | 171 | ```shell 172 | # Run main.go whenever a file changes, ignoring any directory called node_modules. 173 | $ wgo run -xdir node_modules main.go 174 | ``` 175 | 176 | In practice you don't have to exclude `node_modules` because it's already excluded by default (together with `.git`, `.hg`, `.svn`, `.idea`, `.vscode` and `.settings`). If you do want to watch any of those directories, you should explicitly include it with the -dir flag. 177 | 178 | ## Chaining commands 179 | 180 | Commands can be chained using the `::` separator. Subsequent commands are executed only when the previous command succeeds. 181 | 182 | ```shell 183 | # Run `make build` followed by `make run` whenever a file changes. 184 | $ wgo make build :: make run 185 | 186 | # Run `go build` followed by the built binary whenever a .go file changes. 187 | $ wgo -file .go go build -o main main.go :: ./main 188 | 189 | # Clear the screen with `clear` before running `go test`. 190 | # Windows users should use `cls` instead of `clear`. 191 | $ wgo -file .go clear :: go test . -race -coverprofile=coverage.out 192 | ``` 193 | 194 | ### Escaping the command separator 195 | 196 | Since `::` designates the command separator, if you actually need to pass in a `::` string an an argument to a command you should escape it by appending an extra `:` to it. So `::` is escaped to `:::`, `:::` is escaped to `::::`, and so on. 197 | 198 | ### Shell wrapping 199 | 200 | Chained commands execute in their own independent environment and are not implicitly wrapped in a shell. This can be a problem if you want to use shell specific commands like `if-else` statements or if you want to pass data between commands. In this case you should explicitly wrap the command(s) in a shell using the form `sh -c ''` (or `pwsh.exe -command ''` if you're on Windows). 201 | 202 | ```shell 203 | # bash: echo "passed" or "failed" depending on whether the program succeeds. 204 | $ wgo -file .go go build -o main main.go :: bash -c 'if ./main; then echo "passed"; else echo "failed"; fi' 205 | 206 | # powershell: echo "passed" or "failed" depending on whether the program succeeds. 207 | $ wgo -file .go go build -o main main.go :: pwsh.exe -command './main; if ($LastExitCode -eq 0) { echo "passed" } else { echo "failed" }' 208 | ``` 209 | 210 | ### Clear terminal on restart 211 | 212 | You can chain the `clear` command (or the `cls` command if you're on Windows) so that the terminal is cleared before everything restarts. You will not be able to use the `wgo run` command, instead you'll have to use the `wgo` command as a general-purpose file watcher to rerun `go run main.go` when a .go file changes. 213 | 214 | ```shell 215 | # Clears the screen. 216 | $ clear 217 | 218 | # When a .go file changes, clear the screen and run go run main.go. 219 | $ wgo -file .go clear :: go run main.go 220 | 221 | # If you're on Windows: 222 | $ wgo -file .go cls :: go run main.go 223 | ``` 224 | 225 | ## Running parallel wgo commands 226 | 227 | If a [command separator `::`](#chaining-commands) is followed by `wgo`, a new `wgo` command is started (which runs in parallel). 228 | 229 | ```shell 230 | # Run the echo commands sequentially. 231 | $ wgo echo foo :: echo bar :: echo baz 232 | 233 | # Run the echo commands in parallel. 234 | $ wgo echo foo :: wgo echo bar :: wgo echo baz 235 | ``` 236 | 237 | This allows you to reload a server when .go files change, but also do things like rebuild .scss and .ts files whenever they change at the same time. 238 | 239 | ```shell 240 | $ wgo run main.go \ 241 | :: wgo -file .scss sass assets/styles.scss assets/styles.css \ 242 | :: wgo -file .ts tsc 'assets/*.ts' --outfile assets/index.js 243 | ``` 244 | 245 | ### NOTE: It specifically has to be "wgo" directly after "::" for a parallel command to be started 246 | 247 | Even if wgo was invoked with `go tool wgo` or its full binary path (e.g. `/usr/local/bin/wgo`), wgo only recognizes the magic string `"wgo"` after the `"::"` as the signal to start a new parallel command (otherwise it is treated as a chained command). 248 | 249 | 250 | ```shell 251 | # ❌ These do not start parallel wgo commands. Do not nest wgo commands like this. 252 | $ go tool wgo echo 1 :: go tool wgo echo 2 :: go tool wgo echo 3 253 | $ /usr/local/bin/wgo echo 1 :: /usr/local/bin/wgo echo 2 :: /usr/local/bin/wgo echo 3 254 | 255 | # ✅ These start parallel wgo commands. 256 | $ wgo echo 1 :: wgo echo 2 :: wgo echo 3 257 | $ go tool wgo echo 1 :: wgo echo 2 :: wgo echo 3 258 | $ /usr/local/bin/wgo echo 1 :: wgo echo 2 :: wgo echo 3 259 | # └┬┘ └┬┘ 260 | # └──────┬──────┘ 261 | # This is not spawning a separate wgo process, it is a placeholder string 262 | # recognized by the wgo binary, which is in turn responsible for running all the 263 | # specified commands in parallel. There is only ever one parent wgo process. 264 | ``` 265 | 266 | ## Running commands in a different directory 267 | 268 | [*back to flags index*](#flags) 269 | 270 | If you want to run commands in a different directory from where `wgo` was invoked, used the -cd flag. 271 | 272 | ```shell 273 | # Run main.go whenever a file changes. 274 | $ wgo run main.go 275 | 276 | # Run main.go from the 'app' directory whenever a file changes. 277 | $ wgo run -cd app main.go 278 | ``` 279 | 280 | ## Specify additional root directories to watch 281 | 282 | [*back to flags index*](#flags) 283 | 284 | By default, the root being watched is the current directory. You can watch additional roots with the -root flag. Note that the [-file/-xfile](#including-and-excluding-files) and [-dir/-xdir](#including-and-excluding-directories) filters apply equally across all roots. 285 | 286 | ```shell 287 | # Run main.go whenever a file in the current directory or the parent directory changes. 288 | $ wgo run -root .. main.go 289 | 290 | # Run main.go whenever a file in the current directory or the /env_secrets directory changes. 291 | $ wgo run -root /env_secrets main.go 292 | ``` 293 | 294 | You may also be interested in the [-cd flag](#running-commands-in-a-different-directory), which lets you watch a directory but run commands from a different directory. 295 | 296 | ## Exit when the last command exits 297 | 298 | [*back to flags index*](#flags) 299 | 300 | If the -exit flag is provided, `wgo` exits once the last command exits. This is useful if your program is something like a server which should block forever, so file changes can trigger a reload as usual but if the server ever dies on its own (e.g. a panic or log.Fatal) wgo should exit to signal to some supervisor process that the server has been terminated. 301 | 302 | ```shell 303 | # Exit once main.go exits. 304 | $ wgo run -exit main.go 305 | 306 | # Exit once ./main exits. 307 | $ wgo -exit -file .go go build -o main main.go :: ./main 308 | ``` 309 | 310 | ## Enable stdin 311 | 312 | [*back to flags index*](#flags) 313 | 314 | By default, stdin to wgo is ignored. You can enable it for the last command using the -stdin flag. This is useful for reloading programs that read from stdin. 315 | 316 | ```shell 317 | # Allow main.go to read from stdin. 318 | $ wgo run -stdin main.go 319 | 320 | # Only ./main (the last command) gets to read from stdin, `go build` does not get stdin. 321 | $ wgo -stdin -file .go go build -o main main.go :: ./main 322 | ``` 323 | 324 | ## Log file events 325 | 326 | [*back to flags index*](#flags) 327 | 328 | If the -verbose flag is provided, file events are logged. 329 | 330 | Without -verbose: 331 | 332 | ```shell 333 | $ wgo run ./server 334 | Listening on localhost:8080 335 | Listening on localhost:8080 # <-- file edited. 336 | ``` 337 | 338 | With -verbose: 339 | 340 | ```shell 341 | $ wgo run -verbose ./server 342 | [wgo] WATCH /Users/bokwoon/Documents/wgo/testdata 343 | [wgo] WATCH args 344 | [wgo] WATCH build_flags 345 | [wgo] WATCH dir 346 | [wgo] WATCH dir/foo 347 | [wgo] WATCH dir/subdir 348 | [wgo] WATCH dir/subdir/foo 349 | [wgo] WATCH env 350 | [wgo] WATCH file_event 351 | [wgo] WATCH hello_world 352 | [wgo] WATCH server 353 | [wgo] WATCH signal 354 | [wgo] WATCH stdin 355 | Listening on localhost:8080 356 | [wgo] CREATE server/main.go~ 357 | [wgo] CREATE server/main.go 358 | [wgo] WRITE server/main.go 359 | Listening on localhost:8080 360 | ``` 361 | 362 | ## Debounce duration 363 | 364 | [*back to flags index*](#flags) 365 | 366 | File events are debounced to prevent a command from repeatedly restarting when a string of file events occur in rapid succession. The default debounce duration is 300ms. Change the duration with the -postpone flag. Lower values will react quicker. 367 | 368 | ```shell 369 | # Wait 10 milliseconds before restarting. 370 | $ wgo run -debounce 10ms main.go 371 | 372 | # Wait 1 second before restarting. 373 | $ wgo -debounce 1s go build -o out main.go :: ./out 374 | ``` 375 | 376 | ## Postpone the first execution of the command until a file is modified 377 | 378 | [*back to flags index*](#flags) 379 | 380 | By default, wgo runs the command immediately. If you wish to postpone running the command until a file is modified, use the -postpone flag. 381 | 382 | ```shell 383 | # Prints hello immediately, and whenever file is modified. 384 | $ wgo echo hello 385 | 386 | # Prints hello only when a file is modified. 387 | $ wgo -postpone echo hello 388 | ``` 389 | 390 | ## Use polling to detect file changes 391 | 392 | [*back to flags index*](#flags) 393 | 394 | If you wish to poll for file changes instead of using system's builtin file watcher, use the -poll flag. This may sometimes be necessary if the system file watcher is unable to pick up file changes e.g. for files on a mounted network drive. 395 | 396 | ```shell 397 | # Polls files every 500ms for changes. 398 | $ wgo run -poll 500ms main.go 399 | 400 | # Polls files every 1s for changes. 401 | $ wgo -poll 1s echo hello 402 | ``` 403 | 404 | ## Don't stop the application on compile errors 405 | 406 | To keep the old application running even while there are compile errors, separate wgo into two [parallel commands](#running-parallel-wgo-commands): one to compile the application, another to rerun the application whenever the application executable file changes. When there is a syntax error, only the compile step will fail (the run step will keep running). When the compile step succeeds, it will rebuild the executable file which retriggers the run step to rerun the executable. 407 | 408 | ```shell 409 | $ wgo -file .go go build -o my_app.exe ./my_app :: wgo -postpone -file my_app.exe ./my_app.exe 410 | ``` 411 | 412 | ## Debug Go code using GoLand or VSCode with wgo 413 | 414 | You need to ensure the [delve debugger](https://github.com/go-delve/delve) is installed. 415 | 416 | ```shell 417 | go install github.com/go-delve/delve/cmd/dlv@latest 418 | ``` 419 | 420 | ### Start the delve debugger on port 2345 using wgo. 421 | 422 | ```shell 423 | $ wgo -file .go go build -o my_binary_name . :: sh -c 'while true; do dlv exec my_binary_name --headless --listen :2345 --api-version 2; done' 424 | 425 | # If you're on Windows: 426 | $ wgo -file .go go build -o my_binary_name.exe . :: pwsh.exe -command 'while (1) { dlv exec my_binary_name.exe --headless --listen :2345 --api-version 2 }' 427 | ``` 428 | 429 | You should see something like this 430 | 431 | ```shell 432 | $ wgo -file .go go build -o my_binary_name . :: sh -c 'while true; do dlv exec my_binary_name --headless --listen :2345 --api-version 2; done' 433 | API server listening at: [::]:2345 434 | 2024-12-01T01:18:13+08:00 warning layer=rpc Listening for remote connections (connections are not authenticated nor encrypted) 435 | ``` 436 | 437 | ### For GoLand users, add a new "Go Remote" configuration 438 | 439 | In the menu bar, Click on Run > Edit Configurations > Add New Configuration > Go Remote. Then fill in these values. 440 | 441 | ``` 442 | Name: Attach Debugger 443 | Host: localhost 444 | Port: 2345 445 | On disconnect: Stop remote Delve process 446 | ``` 447 | 448 | Click OK. Now you can add breakpoints and then click Debug using this configuration. [Make sure the Delve debugger server is first started!](#start-the-delve-debugger-on-port-2345-using-wgo) 449 | 450 | ### For VSCode users, add a new launch.json configuration 451 | 452 | ```json 453 | { 454 | "version": "0.2.0", 455 | "configurations": [ 456 | { 457 | "name": "Attach Debugger", 458 | "type": "go", 459 | "request": "attach", 460 | "mode": "remote", 461 | "host": "localhost", 462 | "port": 2345 463 | } 464 | ] 465 | } 466 | ``` 467 | 468 | Save the launch.json file. Now you can add breakpoints and Start Debugging using this configuration. [Make sure the Delve debugger server is first started!](#start-the-delve-debugger-on-port-2345-using-wgo) 469 | 470 | ## Why should I use this over other file watchers? 471 | 472 | Nothing! File watchers honestly all do the same things, if you find a file watcher that works for you there's no reason to change. Maybe wgo has a [lower bar to entry](#quickstart)? Or maybe you're allergic to unnecessary config files like I am. Or if you want a feature like [chained commands](#chaining-commands) or [parallel commands](#running-parallel-wgo-commands), consider using `wgo`. 473 | 474 | ## How do I pronounce wgo? 475 | 476 | I've been calling it wi-go or wuh-go inside my head. 477 | 478 | ## Contributing 479 | 480 | See [START\_HERE.md](https://github.com/bokwoon95/wgo/blob/main/START_HERE.md). 481 | 482 | ## Download the latest release 483 | 484 | [Release page](https://github.com/bokwoon95/wgo/releases/latest) 485 | 486 | ### Linux 487 | 488 | [https://github.com/bokwoon95/wgo/releases/latest/download/wgo-linux](https://github.com/bokwoon95/wgo/releases/latest/download/wgo-linux) 489 | 490 | ```shell 491 | curl --location --output wgo 'https://github.com/bokwoon95/wgo/releases/latest/download/wgo-linux' 492 | ``` 493 | 494 | ### Linux (ARM) 495 | 496 | [https://github.com/bokwoon95/wgo/releases/latest/download/wgo-linux-arm](https://github.com/bokwoon95/wgo/releases/latest/download/wgo-linux-arm) 497 | 498 | ```shell 499 | curl --location --output wgo "https://github.com/bokwoon95/wgo/releases/latest/download/wgo-linux-arm" 500 | ``` 501 | 502 | ### macOS 503 | 504 | [https://github.com/bokwoon95/wgo/releases/latest/download/wgo-macos](https://github.com/bokwoon95/wgo/releases/latest/download/wgo-macos) 505 | 506 | ```shell 507 | curl --location --output wgo "https://github.com/bokwoon95/wgo/releases/latest/download/wgo-macos" 508 | ``` 509 | 510 | ### macOS (Apple Silicon) 511 | 512 | [https://github.com/bokwoon95/wgo/releases/latest/download/wgo-macos-apple-silicon](https://github.com/bokwoon95/wgo/releases/latest/download/wgo-macos-apple-silicon) 513 | 514 | ```shell 515 | curl --location --output wgo "https://github.com/bokwoon95/wgo/releases/latest/download/wgo-macos-apple-silicon" 516 | ``` 517 | 518 | ### Windows 519 | 520 | [https://github.com/bokwoon95/wgo/releases/latest/download/wgo-windows.exe](https://github.com/bokwoon95/wgo/releases/latest/download/wgo-windows.exe) 521 | 522 | ```bat 523 | curl --location --output wgo.exe "https://github.com/bokwoon95/wgo/releases/latest/download/wgo-windows.exe" 524 | ``` 525 | -------------------------------------------------------------------------------- /START_HERE.md: -------------------------------------------------------------------------------- 1 | This document describes how the codebase is organized. It is meant for people who are contributing to the codebase (or are just casually browsing). 2 | 3 | Files are written in such a way that each successive file in the list below only depends on files that come before it. This makes it easy to rewrite the codebase from scratch file-by-file, complete with working tests at every step of the way. Please adhere to this file order when submitting pull requests. 4 | 5 | - [**util_unix.go**](https://github.com/bokwoon95/wgo/blob/main/util_unix.go) 6 | - unix-specific `stop(cmd)` (which stops an \*exec.Cmd cleanly) and `joinArgs(args)` (which joins an args slice into a string that can be evaluated by bash). 7 | - [**util_windows.go**](https://github.com/bokwoon95/wgo/blob/main/util_windows.go) 8 | - windows-specific `stop(cmd)` (which stops an \*exec.Cmd cleanly) and `joinArgs(args)` (which joins an args slice into a string that can be evaluated by powershell). 9 | - [**wgo_cmd.go**](https://github.com/bokwoon95/wgo/blob/main/wgo_cmd.go) 10 | - `type WgoCmd struct` 11 | - `WgoCommand(ctx, args)`, which initializes a new WgoCmd. `WgoCommands(ctx, args)` instantiates a slice of WgoCmds. 12 | - `(*WgoCmd).Run()` runs the WgoCmd. 13 | - [**main.go**](https://github.com/bokwoon95/wgo/blob/main/main.go) 14 | - `main()` instantiates a slice of WgoCmds from `os.Args` and runs them in parallel. 15 | 16 | ## Testing 17 | 18 | Add tests if you add code. 19 | 20 | To run tests, use: 21 | 22 | ```shell 23 | $ go test . -race # -shuffle=on -coverprofile=coverage 24 | ``` 25 | 26 | PS: I noticed TestWgoCmd\_FileEvent() was consistently failing when running it on an ancient laptop, I've been using a faster laptop to circumvent the issue. If you're using a slow computer you might encounter the same thing. It's a very flaky test due to using time.Sleep, but I'm not sure how else to test it currently. 27 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bokwoon95/wgo 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/fsnotify/fsnotify v1.6.0 7 | github.com/google/go-cmp v0.5.9 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 2 | github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 3 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 4 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 5 | golang.org/x/sys v0.0.0-20220908164124-27713097b956 h1:XeJjHH1KiLpKGb6lvMiksZ9l0fVUh+AmGcm0nOMEBOY= 6 | golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 7 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "os" 10 | "os/signal" 11 | "sync" 12 | "syscall" 13 | ) 14 | 15 | const helptext = `Usage: 16 | wgo [FLAGS] [ARGUMENTS...] 17 | wgo gcc -o main main.c 18 | wgo go build -o main main.go 19 | wgo -file .c gcc -o main main.c 20 | wgo -file=.go go build -o main main.go 21 | 22 | wgo run [FLAGS] [GO_BUILD_FLAGS] [ARGUMENTS...] 23 | wgo run main.go 24 | wgo run -file .html main.go arg1 arg2 arg3 25 | wgo run -file .html . arg1 arg2 arg3 26 | wgo run -file=.css -file=.js -tags=fts5 ./cmd/my_project arg1 arg2 arg3 27 | 28 | Pass in the -h flag to the wgo/wgo run to learn what flags there are i.e. wgo -h, wgo run -h 29 | 30 | Core documentation resides at https://github.com/bokwoon95/wgo#quickstart 31 | ` 32 | 33 | func main() { 34 | if len(os.Args) == 1 { 35 | fmt.Print(helptext) 36 | return 37 | } 38 | 39 | userInterrupt := make(chan os.Signal, 1) 40 | signal.Notify(userInterrupt, syscall.SIGTERM, syscall.SIGINT) 41 | ctx, cancel := context.WithCancel(context.Background()) 42 | defer cancel() 43 | go func() { 44 | <-userInterrupt // Soft interrupt. 45 | cancel() 46 | <-userInterrupt // Hard interrupt. 47 | os.Exit(1) 48 | }() 49 | 50 | // Construct the list of WgoCmds from os.Args. 51 | wgoCmds, err := WgoCommands(ctx, os.Args) 52 | if err != nil { 53 | if errors.Is(err, flag.ErrHelp) { 54 | return 55 | } 56 | log.Fatal(err) 57 | } 58 | 59 | // Run the WgoCmds in parallel. 60 | results := make(chan error, len(wgoCmds)) 61 | var wg sync.WaitGroup 62 | for _, wgoCmd := range wgoCmds { 63 | wgoCmd := wgoCmd 64 | wg.Add(1) 65 | go func() { 66 | defer wg.Done() 67 | results <- wgoCmd.Run() 68 | }() 69 | } 70 | go func() { 71 | wg.Wait() 72 | close(results) 73 | }() 74 | 75 | // Wait for results. 76 | ok := true 77 | for err := range results { 78 | if err != nil { 79 | fmt.Println(err) 80 | ok = false 81 | } 82 | } 83 | if !ok { 84 | os.Exit(1) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestMain(m *testing.M) { 9 | temp := os.Args 10 | os.Args = []string{ 11 | "wgo", "-exit", "echo", "foo", 12 | "::", "wgo", "-exit", "echo", "bar", 13 | "::", "wgo", "-exit", "echo", "baz", 14 | } 15 | main() 16 | os.Args = temp 17 | os.Exit(m.Run()) 18 | } 19 | -------------------------------------------------------------------------------- /testdata/args/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func main() { 9 | fmt.Println(os.Args[1:]) 10 | } 11 | -------------------------------------------------------------------------------- /testdata/build_flags/bar.go: -------------------------------------------------------------------------------- 1 | //go:build bar 2 | // +build bar 3 | 4 | package main 5 | 6 | func init() { 7 | words = append(words, "bar") 8 | } 9 | -------------------------------------------------------------------------------- /testdata/build_flags/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | var words = []string{"foo"} 6 | 7 | func main() { 8 | fmt.Println(words) 9 | } 10 | -------------------------------------------------------------------------------- /testdata/dir/.settings/foo/bar.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bokwoon95/wgo/20c1fdbed32356be254a75a8ba8f3b79d4b99957/testdata/dir/.settings/foo/bar.txt -------------------------------------------------------------------------------- /testdata/dir/foo/bar.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bokwoon95/wgo/20c1fdbed32356be254a75a8ba8f3b79d4b99957/testdata/dir/foo/bar.txt -------------------------------------------------------------------------------- /testdata/dir/node_modules/foo/bar.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bokwoon95/wgo/20c1fdbed32356be254a75a8ba8f3b79d4b99957/testdata/dir/node_modules/foo/bar.txt -------------------------------------------------------------------------------- /testdata/dir/subdir/foo/bar.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bokwoon95/wgo/20c1fdbed32356be254a75a8ba8f3b79d4b99957/testdata/dir/subdir/foo/bar.txt -------------------------------------------------------------------------------- /testdata/env/env.sh: -------------------------------------------------------------------------------- 1 | FOO=green 2 | export BAR='lorem ipsum dolor sit amet' 3 | -------------------------------------------------------------------------------- /testdata/env/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func main() { 9 | for _, key := range []string{"FOO", "BAR", "WGO_RANDOM_NUMBER"} { 10 | fmt.Println(key + "=" + os.Getenv(key)) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /testdata/file_event/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/fs" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | "strings" 11 | ) 12 | 13 | var ( 14 | _, currfile, _, ok = runtime.Caller(0) 15 | ) 16 | 17 | func main() { 18 | if !ok { 19 | log.Fatal("couldn't get currfile") 20 | } 21 | currdir := filepath.Dir(currfile) + string(filepath.Separator) 22 | buf := &bytes.Buffer{} 23 | buf.WriteString("---") 24 | _ = filepath.WalkDir(currdir, func(path string, d fs.DirEntry, err error) error { 25 | if path == currdir { 26 | return nil 27 | } 28 | if d.IsDir() { 29 | return nil 30 | } 31 | buf.WriteString("\n" + filepath.ToSlash(strings.TrimPrefix(path, currdir))) 32 | if strings.HasSuffix(path, ".txt") { 33 | buf.WriteString(":") 34 | b, err := os.ReadFile(path) 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | if s := string(bytes.TrimSpace(b)); s != "" { 39 | buf.WriteString(" " + s) 40 | } 41 | } 42 | return nil 43 | }) 44 | buf.WriteString("\n") 45 | buf.WriteTo(os.Stdout) 46 | } 47 | -------------------------------------------------------------------------------- /testdata/file_event/run.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | del /q "testdata\file_event\foo.txt" >nul 2>&1 4 | rmdir /s /q "testdata\file_event\internal" >nul 2>&1 5 | 6 | echo add file 7 | echo foo | set /p=foo>"testdata\file_event\foo.txt" 8 | timeout /t 2 /nobreak >nul 9 | 10 | echo edit file 11 | echo fighters>>"testdata\file_event\foo.txt" 12 | timeout /t 2 /nobreak >nul 13 | 14 | echo create nested directory 15 | mkdir "testdata\file_event\internal\baz" >nul 2>&1 16 | echo bar>"testdata\file_event\internal\bar.txt" 17 | echo baz>"testdata\file_event\internal\baz\baz.txt" 18 | timeout /t 2 /nobreak >nul 19 | 20 | del /q "testdata\file_event\foo.txt" 21 | rmdir /s /q "testdata\file_event\internal" >nul 2>&1 22 | -------------------------------------------------------------------------------- /testdata/hello_world/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func main() { 6 | fmt.Println("hello world") 7 | } 8 | -------------------------------------------------------------------------------- /testdata/polling/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/fs" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | "strings" 11 | ) 12 | 13 | var ( 14 | _, currfile, _, ok = runtime.Caller(0) 15 | ) 16 | 17 | func main() { 18 | if !ok { 19 | log.Fatal("couldn't get currfile") 20 | } 21 | currdir := filepath.Dir(currfile) + string(filepath.Separator) 22 | buf := &bytes.Buffer{} 23 | buf.WriteString("---") 24 | _ = filepath.WalkDir(currdir, func(path string, d fs.DirEntry, err error) error { 25 | if path == currdir { 26 | return nil 27 | } 28 | if d.IsDir() { 29 | return nil 30 | } 31 | buf.WriteString("\n" + filepath.ToSlash(strings.TrimPrefix(path, currdir))) 32 | if strings.HasSuffix(path, ".txt") { 33 | buf.WriteString(":") 34 | b, err := os.ReadFile(path) 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | if s := string(bytes.TrimSpace(b)); s != "" { 39 | buf.WriteString(" " + s) 40 | } 41 | } 42 | return nil 43 | }) 44 | buf.WriteString("\n") 45 | buf.WriteTo(os.Stdout) 46 | } 47 | -------------------------------------------------------------------------------- /testdata/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net" 7 | "net/http" 8 | "os" 9 | "strconv" 10 | ) 11 | 12 | func main() { 13 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 14 | fmt.Fprintln(w, "hello world") 15 | }) 16 | http.HandleFunc("/crash", func(w http.ResponseWriter, r *http.Request) { 17 | fmt.Println("server crashed") 18 | os.Exit(1) 19 | }) 20 | var err error 21 | var ln net.Listener 22 | for i := 0; i < 10; i++ { 23 | ln, err = net.Listen("tcp", "localhost:808"+strconv.Itoa(i)) 24 | if err == nil { 25 | break 26 | } 27 | } 28 | if ln == nil { 29 | ln, err = net.Listen("tcp", "localhost:0") 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | } 34 | fmt.Println("Listening on localhost:" + strconv.Itoa(ln.Addr().(*net.TCPAddr).Port)) 35 | log.Fatal(http.Serve(ln, nil)) 36 | } 37 | -------------------------------------------------------------------------------- /testdata/signal/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | ) 10 | 11 | var trapSignal = flag.Bool("trap-signal", false, "") 12 | 13 | func main() { 14 | flag.Parse() 15 | fmt.Println("Waiting...") 16 | sigs := make(chan os.Signal, 1) 17 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 18 | if *trapSignal { 19 | // Block forever until the program is forcefully terminated or until an 20 | // interrupt signal is received. 21 | select { 22 | case <-sigs: 23 | fmt.Println("Interrupt received, graceful shutdown.") 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /testdata/stdin/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | ) 9 | 10 | func main() { 11 | scanner := bufio.NewScanner(os.Stdin) 12 | i := 0 13 | for scanner.Scan() { 14 | i++ 15 | fmt.Fprintln(os.Stderr, strconv.Itoa(i)+": "+scanner.Text()) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /util_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package main 5 | 6 | import ( 7 | "bytes" 8 | "os/exec" 9 | "strings" 10 | "syscall" 11 | "unicode/utf8" 12 | ) 13 | 14 | // NOTE: We shouldn't encounter the macOS file limit of 256 anymore now that 15 | // importing "os" automatically raises it for us in an init function. 16 | // https://github.com/golang/go/issues/46279 17 | // https://go-review.googlesource.com/c/go/+/393354/4/src/os/rlimit.go 18 | 19 | const ( 20 | specialChars = "\\'\"`${[|&;<>()*?!" 21 | extraSpecialChars = " \t\n" 22 | prefixChars = "~" 23 | ) 24 | 25 | // stop stops the command and all its child processes. 26 | func stop(cmd *exec.Cmd) { 27 | // https://stackoverflow.com/questions/22470193/why-wont-go-kill-a-child-process-correctly 28 | // https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773 29 | pgid := -cmd.Process.Pid 30 | _ = syscall.Kill(pgid, syscall.SIGTERM) 31 | } 32 | 33 | // https://stackoverflow.com/questions/22470193/why-wont-go-kill-a-child-process-correctly 34 | // https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773 35 | func setpgid(cmd *exec.Cmd) { 36 | cmd.SysProcAttr = &syscall.SysProcAttr{ 37 | Setpgid: true, 38 | } 39 | } 40 | 41 | // joinArgs joins the arguments of the command into a string which can then be 42 | // passed to `exec.Command("sh", "-c", $STRING)`. Examples: 43 | // 44 | // ["echo", "foo"] => echo foo 45 | // 46 | // ["echo", "hello goodbye"] => echo 'hello goodbye' 47 | func joinArgs(args []string) string { 48 | // https://github.com/kballard/go-shellquote/blob/master/quote.go 49 | // 50 | // Copyright (C) 2014 Kevin Ballard 51 | // 52 | // Permission is hereby granted, free of charge, to any person obtaining 53 | // a copy of this software and associated documentation files (the "Software"), 54 | // to deal in the Software without restriction, including without limitation 55 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 56 | // and/or sell copies of the Software, and to permit persons to whom the 57 | // Software is furnished to do so, subject to the following conditions: 58 | // 59 | // The above copyright notice and this permission notice shall be included 60 | // in all copies or substantial portions of the Software. 61 | // 62 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 63 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 64 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 65 | // IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 66 | // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 67 | // TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 68 | // OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 69 | var buf bytes.Buffer 70 | for i, arg := range args { 71 | if i != 0 { 72 | buf.WriteByte(' ') 73 | } 74 | quote(arg, &buf) 75 | } 76 | return buf.String() 77 | } 78 | 79 | func quote(word string, buf *bytes.Buffer) { 80 | // https://github.com/kballard/go-shellquote/blob/master/quote.go 81 | // 82 | // Copyright (C) 2014 Kevin Ballard 83 | // 84 | // Permission is hereby granted, free of charge, to any person obtaining 85 | // a copy of this software and associated documentation files (the "Software"), 86 | // to deal in the Software without restriction, including without limitation 87 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 88 | // and/or sell copies of the Software, and to permit persons to whom the 89 | // Software is furnished to do so, subject to the following conditions: 90 | // 91 | // The above copyright notice and this permission notice shall be included 92 | // in all copies or substantial portions of the Software. 93 | // 94 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 95 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 96 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 97 | // IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 98 | // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 99 | // TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 100 | // OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 101 | 102 | // We want to try to produce a "nice" output. As such, we will 103 | // backslash-escape most characters, but if we encounter a space, or if we 104 | // encounter an extra-special char (which doesn't work with 105 | // backslash-escaping) we switch over to quoting the whole word. We do this 106 | // with a space because it's typically easier for people to read multi-word 107 | // arguments when quoted with a space rather than with ugly backslashes 108 | // everywhere. 109 | origLen := buf.Len() 110 | 111 | if len(word) == 0 { 112 | // oops, no content 113 | buf.WriteString("''") 114 | return 115 | } 116 | 117 | cur, prev := word, word 118 | atStart := true 119 | for len(cur) > 0 { 120 | c, l := utf8.DecodeRuneInString(cur) 121 | cur = cur[l:] 122 | if strings.ContainsRune(specialChars, c) || (atStart && strings.ContainsRune(prefixChars, c)) { 123 | // copy the non-special chars up to this point 124 | if len(cur) < len(prev) { 125 | buf.WriteString(prev[0 : len(prev)-len(cur)-l]) 126 | } 127 | buf.WriteByte('\\') 128 | buf.WriteRune(c) 129 | prev = cur 130 | } else if strings.ContainsRune(extraSpecialChars, c) { 131 | // start over in quote mode 132 | buf.Truncate(origLen) 133 | goto quote 134 | } 135 | atStart = false 136 | } 137 | if len(prev) > 0 { 138 | buf.WriteString(prev) 139 | } 140 | return 141 | 142 | quote: 143 | // quote mode 144 | // Use single-quotes, but if we find a single-quote in the word, we need 145 | // to terminate the string, emit an escaped quote, and start the string up 146 | // again 147 | inQuote := false 148 | for len(word) > 0 { 149 | i := strings.IndexRune(word, '\'') 150 | if i == -1 { 151 | break 152 | } 153 | if i > 0 { 154 | if !inQuote { 155 | buf.WriteByte('\'') 156 | inQuote = true 157 | } 158 | buf.WriteString(word[0:i]) 159 | } 160 | word = word[i+1:] 161 | if inQuote { 162 | buf.WriteByte('\'') 163 | inQuote = false 164 | } 165 | buf.WriteString("\\'") 166 | } 167 | if len(word) > 0 { 168 | if !inQuote { 169 | buf.WriteByte('\'') 170 | } 171 | buf.WriteString(word) 172 | buf.WriteByte('\'') 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /util_unix_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package main 5 | 6 | import "testing" 7 | 8 | func Test_joinArgs(t *testing.T) { 9 | type TestTable struct { 10 | description string 11 | args []string 12 | want string 13 | } 14 | 15 | tests := []TestTable{{ 16 | description: "bare string", 17 | args: []string{"echo", "test"}, 18 | want: "echo test", 19 | }, { 20 | description: "contains spaces", 21 | args: []string{"echo", "hello goodbye"}, 22 | want: "echo 'hello goodbye'", 23 | }, { 24 | description: "simple args", 25 | args: []string{"echo", "hello", "goodbye"}, 26 | want: "echo hello goodbye", 27 | }, { 28 | description: "single quote", 29 | args: []string{"echo", "don't you know the dewey decimal system?"}, 30 | want: "echo 'don'\\''t you know the dewey decimal system?'", 31 | }, { 32 | description: "args with single quote", 33 | args: []string{"echo", "don't", "you", "know", "the", "dewey", "decimal", "system?"}, 34 | want: "echo don\\'t you know the dewey decimal system\\?", 35 | }, { 36 | description: "tilde bang", 37 | args: []string{"echo", "~user", "u~ser", " ~user", "!~user"}, 38 | want: "echo \\~user u~ser ' ~user' \\!~user", 39 | }, { 40 | description: "glob brackets", 41 | args: []string{"echo", "foo*", "M{ovies,usic}", "ab[cd]", "%3"}, 42 | want: "echo foo\\* M\\{ovies,usic} ab\\[cd] %3", 43 | }, { 44 | description: "empty string", 45 | args: []string{"echo", "one", "", "three"}, 46 | want: "echo one '' three", 47 | }, { 48 | description: "parens", 49 | args: []string{"echo", "some(parentheses)"}, 50 | want: "echo some\\(parentheses\\)", 51 | }, { 52 | description: "special chars", 53 | args: []string{"echo", "$some_ot~her_)spe!cial_*_characters"}, 54 | want: "echo \\$some_ot~her_\\)spe\\!cial_\\*_characters", 55 | }, { 56 | description: "quote space", 57 | args: []string{"echo", "' "}, 58 | want: "echo \\'' '", 59 | }} 60 | 61 | for _, tt := range tests { 62 | tt := tt 63 | t.Run(tt.description, func(t *testing.T) { 64 | t.Parallel() 65 | got := joinArgs(tt.args) 66 | if got != tt.want { 67 | t.Errorf("\ngot: %q\nwant: %q", got, tt.want) 68 | } 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /util_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package main 5 | 6 | import ( 7 | "os/exec" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | // stop stops the command and all its child processes. 13 | func stop(cmd *exec.Cmd) { 14 | // https://stackoverflow.com/a/44551450 15 | killCmd := exec.Command("taskkill.exe", "/t", "/f", "/pid", strconv.Itoa(cmd.Process.Pid)) 16 | _ = killCmd.Run() 17 | } 18 | 19 | // setpgid is a no-op on windows. 20 | func setpgid(cmd *exec.Cmd) {} 21 | 22 | // joinArgs joins the arguments of the command into a string which can then be 23 | // passed to `exec.Command("pwsh.exe", "-command", $STRING)`. Examples: 24 | // 25 | // ["echo", "foo"] => echo foo 26 | // 27 | // ["echo", "hello goodbye"] => echo 'hello goodbye' 28 | func joinArgs(args []string) string { 29 | // references: 30 | // https://www.rlmueller.net/PowerShellEscape.htm 31 | // https://stackoverflow.com/a/11231504 32 | var b strings.Builder 33 | for i, arg := range args { 34 | if i == 0 { 35 | b.WriteString(arg) 36 | continue 37 | } 38 | b.WriteString(" ") 39 | if arg == "" { 40 | b.WriteString("''") 41 | continue 42 | } 43 | if !strings.ContainsAny(arg, " '`$(){}<>|&;*") { 44 | b.WriteString(arg) 45 | continue 46 | } 47 | b.WriteString("'" + strings.ReplaceAll(arg, "'", "''") + "'") 48 | } 49 | return b.String() 50 | } 51 | -------------------------------------------------------------------------------- /util_windows_test.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package main 5 | 6 | import ( 7 | "os/exec" 8 | "strings" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func Test_stop(t *testing.T) { 14 | t.Parallel() 15 | 16 | // Ensure no notepad.exe is running. 17 | _ = exec.Command("taskkill.exe", "/f", "/im", "notepad.exe").Run() 18 | 19 | // Start notepad.exe as a child process, then kill the parent process with 20 | // Process.Kill(). 21 | cmd := exec.Command("cmd.exe", "/c", "notepad.exe") 22 | err := cmd.Start() 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | time.Sleep(500 * time.Millisecond) // Give time for it to start before killing. 27 | _ = cmd.Process.Kill() 28 | _ = cmd.Wait() 29 | 30 | // Assert that Process.Kill() did not kill the child notepad.exe process by 31 | // checking that it exists with tasklist. 32 | b, err := exec.Command("tasklist.exe", "/nh", "/fi", "imagename eq notepad.exe").CombinedOutput() 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | output := string(b) 37 | if !strings.Contains(output, "notepad.exe") { 38 | t.Fatalf("notepad.exe was successfully killed by Process.Kill(): %s", output) 39 | } 40 | 41 | // Ensure no notepad.exe is running. 42 | _ = exec.Command("taskkill.exe", "/f", "/im", "notepad.exe").Run() 43 | 44 | // Start notepad.exe as a child process again, then kill the parent process 45 | // with kill(). 46 | cmd = exec.Command("cmd.exe", "/c", "notepad.exe") 47 | err = cmd.Start() 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | time.Sleep(500 * time.Millisecond) // Give time for it to start before killing. 52 | stop(cmd) 53 | 54 | // Assert that kill() killed the child notepad.exe process. 55 | b, err = exec.Command("tasklist.exe", "/nh", "/fi", "imagename eq notepad.exe").CombinedOutput() 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | output = string(b) 60 | if strings.Contains(output, "notepad.exe") { 61 | t.Fatalf("notepad.exe was not successfully killed by cleanup(): %s", output) 62 | } 63 | } 64 | 65 | func Test_joinArgs(t *testing.T) { 66 | type TestTable struct { 67 | description string 68 | args []string 69 | want string 70 | } 71 | 72 | tests := []TestTable{{ 73 | description: "bare string", 74 | args: []string{"echo", "test"}, 75 | want: "echo test", 76 | }, { 77 | description: "contains spaces", 78 | args: []string{"echo", "hello goodbye"}, 79 | want: "echo 'hello goodbye'", 80 | }, { 81 | description: "simple args", 82 | args: []string{"echo", "hello", "goodbye"}, 83 | want: "echo hello goodbye", 84 | }, { 85 | description: "single quote", 86 | args: []string{"echo", "don't you know the dewey decimal system?"}, 87 | want: "echo 'don''t you know the dewey decimal system?'", 88 | }, { 89 | description: "args with single quote", 90 | args: []string{"echo", "don't", "you", "know", "the", "dewey", "decimal", "system?"}, 91 | want: "echo 'don''t' you know the dewey decimal system?", 92 | }, { 93 | description: "tilde bang", 94 | args: []string{"echo", "~user", "u~ser", " ~user", "!~user"}, 95 | want: "echo ~user u~ser ' ~user' !~user", 96 | }, { 97 | description: "glob brackets", 98 | args: []string{"echo", "foo*", "M{ovies,usic}", "ab[cd]", "%3"}, 99 | want: "echo 'foo*' 'M{ovies,usic}' ab[cd] %3", 100 | }, { 101 | description: "empty string", 102 | args: []string{"echo", "one", "", "three"}, 103 | want: "echo one '' three", 104 | }, { 105 | description: "parens", 106 | args: []string{"echo", "some(parentheses)"}, 107 | want: "echo 'some(parentheses)'", 108 | }, { 109 | description: "special chars", 110 | args: []string{"echo", "$some_ot~her_)spe!cial_*_characters"}, 111 | want: "echo '$some_ot~her_)spe!cial_*_characters'", 112 | }, { 113 | description: "quote space", 114 | args: []string{"echo", "' "}, 115 | want: "echo ''' '", 116 | }} 117 | 118 | for _, tt := range tests { 119 | tt := tt 120 | t.Run(tt.description, func(t *testing.T) { 121 | t.Parallel() 122 | got := joinArgs(tt.args) 123 | if got != tt.want { 124 | t.Errorf("\ngot: %q\nwant: %q", got, tt.want) 125 | } 126 | }) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /wgo_cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "io/fs" 10 | "log" 11 | "math/rand" 12 | "os" 13 | "os/exec" 14 | "path/filepath" 15 | "regexp" 16 | "runtime" 17 | "strconv" 18 | "strings" 19 | "sync" 20 | "time" 21 | "unicode/utf8" 22 | 23 | "github.com/fsnotify/fsnotify" 24 | ) 25 | 26 | // String flag names copied from `go help build`. 27 | var strFlagNames = []string{ 28 | "p", "asmflags", "buildmode", "compiler", "gccgoflags", "gcflags", 29 | "installsuffix", "ldflags", "mod", "modfile", "overlay", "pkgdir", 30 | "tags", "toolexec", "exec", 31 | } 32 | 33 | // Bool flag names copied from `go help build`. 34 | var boolFlagNames = []string{ 35 | "a", "n", "race", "msan", "asan", "v", "work", "x", "buildvcs", 36 | "linkshared", "modcacherw", "trimpath", 37 | } 38 | 39 | var defaultLogger = log.New(io.Discard, "", 0) 40 | 41 | func init() { 42 | rand.Seed(time.Now().Unix()) 43 | } 44 | 45 | // WgoCmd implements the `wgo` command. 46 | type WgoCmd struct { 47 | // The root directories to watch for changes in. Earlier roots have higher 48 | // precedence than later roots (used during file matching). 49 | // 50 | // Roots should use OS-specific file separators i.e. forward slash '/' on 51 | // Linux/macOS and backslash '\' on Windows. They will be normalized to 52 | // forward slashes later during matching. 53 | // 54 | // As a rule of thumb, this file should not import the package "path". It 55 | // should only use functions in the package "path/filepath". 56 | Roots []string 57 | 58 | // FileRegexps specifies the file patterns to include. They are matched 59 | // against the a file's path relative to the root. File patterns are 60 | // logically OR-ed together, so you can include multiple patterns at once. 61 | // All patterns must use forward slash file separators, even on Windows. 62 | // 63 | // If no FileRegexps are provided, every file is included by default unless 64 | // it is explicitly excluded by ExcludeFileRegexps. 65 | FileRegexps []*regexp.Regexp 66 | 67 | // ExcludeFileRegexps specifies the file patterns to exclude. They are 68 | // matched against a file's path relative to the root. File patterns are 69 | // logically OR-ed together, so you can exclude multiple patterns at once. 70 | // All patterns must use forward slash separators, even on Windows. 71 | // 72 | // Excluded file patterns take higher precedence than included file 73 | // patterns, so you can include a large group of files using an include 74 | // pattern and surgically ignore specific files from that group using an 75 | // exclude pattern. 76 | ExcludeFileRegexps []*regexp.Regexp 77 | 78 | // DirRegexps specifies the directory patterns to include. They are matched 79 | // against a directory's path relative to the root. Directory patterns are 80 | // logically OR-ed together, so you can include multiple patterns at once. 81 | // All patterns must use forward slash separators, even on Windows. 82 | // 83 | // If no DirRegexps are provided, every directory is included by default 84 | // unless it is explicitly excluded by ExcludeDirRegexps. 85 | DirRegexps []*regexp.Regexp 86 | 87 | // ExcludeDirRegexps specifies the directory patterns to exclude. They are 88 | // matched against a directory's path relative to the root. Directory 89 | // patterns are logically OR-ed together, so you can exclude multiple 90 | // patterns at once. All patterns must use forward slash separators, even 91 | // on Windows. 92 | ExcludeDirRegexps []*regexp.Regexp 93 | 94 | // If provided, Logger is used to log file events. 95 | Logger *log.Logger 96 | 97 | // ArgsList is the list of args slices. Each slice corresponds to a single 98 | // command to execute and is of this form [cmd arg1 arg2 arg3...]. A slice 99 | // of these commands represent the chain of commands to be executed. 100 | ArgsList [][]string 101 | 102 | // Env is sets the environment variables for the commands. Each entry is of 103 | // the form "KEY=VALUE". 104 | Env []string 105 | 106 | // Dir specifies the working directory for the commands. 107 | Dir string 108 | 109 | // EnableStdin controls whether the Stdin field is used. 110 | EnableStdin bool 111 | 112 | // Stdin is where the last command gets its stdin input from (EnableStdin 113 | // must be true). 114 | Stdin io.Reader 115 | 116 | // Stdout is where the commands write their stdout output. 117 | Stdout io.Writer 118 | 119 | // Stderr is where the commands write their stderr output. 120 | Stderr io.Writer 121 | 122 | // If Exit is true, WgoCmd exits once the last command exits. 123 | Exit bool 124 | 125 | // Debounce duration for file events. 126 | Debounce time.Duration 127 | 128 | // If Postpone is true, WgoCmd will postpone the first execution of the 129 | // command(s) until a file is modified. 130 | Postpone bool 131 | 132 | // PollDuration is the duration at which we poll for events. The zero value 133 | // means no polling. 134 | PollDuration time.Duration 135 | 136 | ctx context.Context 137 | isRun bool // Whether the command is `wgo run`. 138 | executablePath string // The output path of the `go build` executable. 139 | } 140 | 141 | // WgoCommands instantiates a slices of WgoCmds. Each "::" separator followed 142 | // by "wgo" indicates a new WgoCmd. 143 | func WgoCommands(ctx context.Context, args []string) ([]*WgoCmd, error) { 144 | var wgoCmds []*WgoCmd 145 | i, j, wgoNumber := 1, 1, 1 146 | for j < len(args) { 147 | if args[j] != "::" || j+1 >= len(args) || args[j+1] != "wgo" { 148 | j++ 149 | continue 150 | } 151 | wgoCmd, err := WgoCommand(ctx, wgoNumber, args[i:j]) 152 | if err != nil { 153 | if wgoNumber > 1 { 154 | return nil, fmt.Errorf("[wgo%d] %w", wgoNumber, err) 155 | } 156 | return nil, fmt.Errorf("[wgo] %w", err) 157 | } 158 | wgoCmds = append(wgoCmds, wgoCmd) 159 | i, j, wgoNumber = j+2, j+2, wgoNumber+1 160 | } 161 | if j > i { 162 | wgoCmd, err := WgoCommand(ctx, wgoNumber, args[i:j]) 163 | if err != nil { 164 | if wgoNumber > 1 { 165 | return nil, fmt.Errorf("[wgo%d] %w", wgoNumber, err) 166 | } 167 | return nil, fmt.Errorf("[wgo] %w", err) 168 | } 169 | wgoCmds = append(wgoCmds, wgoCmd) 170 | } 171 | return wgoCmds, nil 172 | } 173 | 174 | // WgoCommand instantiates a new WgoCmd. Each "::" separator indicates a new 175 | // chained command. 176 | func WgoCommand(ctx context.Context, wgoNumber int, args []string) (*WgoCmd, error) { 177 | cwd, err := os.Getwd() 178 | if err != nil { 179 | return nil, err 180 | } 181 | wgoCmd := WgoCmd{ 182 | Roots: []string{cwd}, 183 | Logger: defaultLogger, 184 | ctx: ctx, 185 | } 186 | var verbose bool 187 | wgoCmd.isRun = len(args) > 0 && args[0] == "run" 188 | if wgoCmd.isRun { 189 | args = args[1:] 190 | } 191 | 192 | // Parse flags. 193 | var debounce, poll string 194 | flagset := flag.NewFlagSet("", flag.ContinueOnError) 195 | flagset.StringVar(&wgoCmd.Dir, "cd", "", "Change to a different directory to run the commands.") 196 | flagset.BoolVar(&verbose, "verbose", false, "Log file events.") 197 | flagset.BoolVar(&wgoCmd.Exit, "exit", false, "Exit when the last command exits.") 198 | flagset.BoolVar(&wgoCmd.EnableStdin, "stdin", false, "Enable stdin for the last command.") 199 | flagset.StringVar(&debounce, "debounce", "300ms", "How quickly to react to file events. Lower debounce values will react quicker.") 200 | flagset.BoolVar(&wgoCmd.Postpone, "postpone", false, "Postpone the first execution of the command until a file is modified.") 201 | flagset.StringVar(&poll, "poll", "", "How often to poll for file changes. Zero or no value means no polling.") 202 | flagset.Func("root", "Specify an additional root directory to watch. Can be repeated.", func(value string) error { 203 | root, err := filepath.Abs(value) 204 | if err != nil { 205 | return err 206 | } 207 | wgoCmd.Roots = append(wgoCmd.Roots, root) 208 | return nil 209 | }) 210 | flagset.Func("file", "Include file regex. Can be repeated.", func(value string) error { 211 | r, err := compileRegexp(value) 212 | if err != nil { 213 | return err 214 | } 215 | wgoCmd.FileRegexps = append(wgoCmd.FileRegexps, r) 216 | return nil 217 | }) 218 | flagset.Func("xfile", "Exclude file regex. Can be repeated.", func(value string) error { 219 | r, err := compileRegexp(value) 220 | if err != nil { 221 | return err 222 | } 223 | wgoCmd.ExcludeFileRegexps = append(wgoCmd.ExcludeFileRegexps, r) 224 | return nil 225 | }) 226 | flagset.Func("dir", "Include directory regex. Can be repeated.", func(value string) error { 227 | r, err := compileRegexp(value) 228 | if err != nil { 229 | return err 230 | } 231 | wgoCmd.DirRegexps = append(wgoCmd.DirRegexps, r) 232 | return nil 233 | }) 234 | flagset.Func("xdir", "Exclude directory regex. Can be repeated.", func(value string) error { 235 | r, err := compileRegexp(value) 236 | if err != nil { 237 | return err 238 | } 239 | wgoCmd.ExcludeDirRegexps = append(wgoCmd.ExcludeDirRegexps, r) 240 | return nil 241 | }) 242 | flagset.Usage = func() { 243 | fmt.Fprint(flagset.Output(), `Usage: 244 | wgo [FLAGS] [ARGUMENTS...] 245 | wgo gcc -o main main.c 246 | wgo go build -o main main.go 247 | wgo -file .c gcc -o main main.c 248 | wgo -file=.go go build -o main main.go 249 | Flags: 250 | `) 251 | flagset.PrintDefaults() 252 | } 253 | // If the command is `wgo run`, also parse the go build flags. 254 | var strFlagValues []string 255 | var boolFlagValues []bool 256 | if wgoCmd.isRun { 257 | strFlagValues = make([]string, 0, len(strFlagNames)) 258 | for i := range strFlagNames { 259 | name := strFlagNames[i] 260 | flagset.Func(name, "-"+name+" build flag for Go.", func(value string) error { 261 | strFlagValues = append(strFlagValues, "-"+name, value) 262 | return nil 263 | }) 264 | } 265 | boolFlagValues = make([]bool, len(boolFlagNames)) 266 | for i := range boolFlagNames { 267 | name := boolFlagNames[i] 268 | flagset.BoolVar(&boolFlagValues[i], name, false, "-"+name+" build flag for Go.") 269 | } 270 | flagset.Usage = func() { 271 | fmt.Fprint(flagset.Output(), `Usage: 272 | wgo run [FLAGS] [GO_BUILD_FLAGS] [ARGUMENTS...] 273 | wgo run main.go 274 | wgo run -file .html main.go arg1 arg2 arg3 275 | wgo run -file .html . arg1 arg2 arg3 276 | wgo run -file=.css -file=.js -tags=fts5 ./cmd/my_project arg1 arg2 arg3 277 | Flags: 278 | `) 279 | flagset.PrintDefaults() 280 | } 281 | } 282 | err = flagset.Parse(args) 283 | if err != nil { 284 | return nil, err 285 | } 286 | if verbose { 287 | if wgoNumber > 1 { 288 | wgoCmd.Logger = log.New(os.Stderr, fmt.Sprintf("[wgo%d] ", wgoNumber), 0) 289 | } else { 290 | wgoCmd.Logger = log.New(os.Stderr, "[wgo] ", 0) 291 | } 292 | } 293 | if debounce == "" { 294 | wgoCmd.Debounce = 300 * time.Millisecond 295 | } else { 296 | wgoCmd.Debounce, err = time.ParseDuration(debounce) 297 | if err != nil { 298 | return nil, fmt.Errorf("-debounce: %w", err) 299 | } 300 | } 301 | if poll != "" { 302 | wgoCmd.PollDuration, err = time.ParseDuration(poll) 303 | if err != nil { 304 | return nil, fmt.Errorf("-poll: %w", err) 305 | } 306 | } 307 | 308 | // If the command is `wgo run`, prepend a `go build` command to the 309 | // ArgsList. 310 | flagArgs := flagset.Args() 311 | wgoCmd.ArgsList = append(wgoCmd.ArgsList, []string{}) 312 | if wgoCmd.isRun { 313 | if len(flagArgs) == 0 { 314 | return nil, fmt.Errorf("wgo run: package not provided") 315 | } 316 | // Determine the temp directory to put the binary in. 317 | // https://github.com/golang/go/issues/8451#issuecomment-341475329 318 | tmpDir := os.Getenv("GOTMPDIR") 319 | if tmpDir == "" { 320 | tmpDir = os.TempDir() 321 | } 322 | wgoCmd.executablePath = filepath.Join(tmpDir, "wgo_"+time.Now().Format("20060102150405")+"_"+strconv.Itoa(rand.Intn(5000))) 323 | if runtime.GOOS == "windows" { 324 | wgoCmd.executablePath += ".exe" 325 | } 326 | buildArgs := []string{"go", "build", "-o", wgoCmd.executablePath} 327 | buildArgs = append(buildArgs, strFlagValues...) 328 | for i, ok := range boolFlagValues { 329 | if ok { 330 | buildArgs = append(buildArgs, "-"+boolFlagNames[i]) 331 | } 332 | } 333 | buildArgs = append(buildArgs, flagArgs[0]) 334 | runArgs := []string{wgoCmd.executablePath} 335 | wgoCmd.ArgsList = [][]string{buildArgs, runArgs} 336 | flagArgs = flagArgs[1:] 337 | } 338 | 339 | for _, arg := range flagArgs { 340 | // If arg is "::", start a new command. 341 | if arg == "::" { 342 | wgoCmd.ArgsList = append(wgoCmd.ArgsList, []string{}) 343 | continue 344 | } 345 | 346 | // Unescape ":::" => "::", "::::" => ":::", etc. 347 | allColons := len(arg) > 2 348 | for _, c := range arg { 349 | if c != ':' { 350 | allColons = false 351 | break 352 | } 353 | } 354 | if allColons { 355 | arg = arg[1:] 356 | } 357 | 358 | // Append arg to the last command in the chain. 359 | n := len(wgoCmd.ArgsList) - 1 360 | wgoCmd.ArgsList[n] = append(wgoCmd.ArgsList[n], arg) 361 | } 362 | return &wgoCmd, nil 363 | } 364 | 365 | // Run runs the WgoCmd. 366 | func (wgoCmd *WgoCmd) Run() error { 367 | if wgoCmd.Stdin == nil { 368 | wgoCmd.Stdin = os.Stdin 369 | } 370 | if wgoCmd.Stdout == nil { 371 | wgoCmd.Stdout = os.Stdout 372 | } 373 | if wgoCmd.Stderr == nil { 374 | wgoCmd.Stderr = os.Stderr 375 | } 376 | if wgoCmd.Logger == nil { 377 | wgoCmd.Logger = defaultLogger 378 | } 379 | for i := range wgoCmd.Roots { 380 | var err error 381 | wgoCmd.Roots[i], err = filepath.Abs(wgoCmd.Roots[i]) 382 | if err != nil { 383 | return err 384 | } 385 | } 386 | if wgoCmd.executablePath != "" { 387 | defer os.Remove(wgoCmd.executablePath) 388 | } 389 | 390 | watcher, err := fsnotify.NewWatcher() 391 | if err != nil { 392 | return err 393 | } 394 | // events channel will receive events either from the watcher or from 395 | // polling. 396 | // 397 | // I would really prefer to use the watcher.Events channel directly instead 398 | // of creating an intermediary channel that aggregates from both sources, 399 | // but for some reason that will set off the race detector during tests so 400 | // I have to use a separate channel :(. 401 | events := make(chan fsnotify.Event) 402 | go func() { 403 | for { 404 | event := <-watcher.Events 405 | events <- event 406 | } 407 | }() 408 | defer watcher.Close() 409 | for _, root := range wgoCmd.Roots { 410 | if wgoCmd.PollDuration > 0 { 411 | wgoCmd.Logger.Println("POLL", filepath.ToSlash(root)) 412 | go wgoCmd.pollDirectory(wgoCmd.ctx, root, events) 413 | } else { 414 | wgoCmd.addDirsRecursively(watcher, root) 415 | } 416 | } 417 | // Timer is used to debounce events. Each event does not directly trigger a 418 | // reload, it only resets the timer. Only when the timer is allowed to 419 | // fully expire will the reload actually occur. 420 | timer := time.NewTimer(0) 421 | if !timer.Stop() { 422 | <-timer.C 423 | } 424 | defer timer.Stop() 425 | 426 | for restartCount := 0; ; restartCount++ { 427 | CMD_CHAIN: 428 | for i, args := range wgoCmd.ArgsList { 429 | if restartCount == 0 && wgoCmd.Postpone { 430 | for { 431 | select { 432 | case <-wgoCmd.ctx.Done(): 433 | return nil 434 | case event := <-events: 435 | if !event.Has(fsnotify.Create) && !event.Has(fsnotify.Write) { 436 | continue 437 | } 438 | fileinfo, err := os.Stat(event.Name) 439 | if err != nil { 440 | continue 441 | } 442 | if fileinfo.IsDir() { 443 | if event.Has(fsnotify.Create) && wgoCmd.PollDuration == 0 { 444 | wgoCmd.addDirsRecursively(watcher, event.Name) 445 | } 446 | } else { 447 | if wgoCmd.match(event.Op.String(), event.Name) { 448 | timer.Reset(wgoCmd.Debounce) 449 | } 450 | } 451 | case <-timer.C: 452 | break CMD_CHAIN 453 | } 454 | } 455 | } 456 | // Step 1: Prepare the command. 457 | // 458 | // We are not using exec.CommandContext() because it uses 459 | // cmd.Process.Kill() to kill the process, but we want to use our 460 | // custom stop() function to kill the process. Our stop() function 461 | // is better than cmd.Process.Kill() because it kills the child 462 | // processes as well. 463 | cmd := &exec.Cmd{ 464 | Path: args[0], 465 | Args: args, 466 | Env: wgoCmd.Env, 467 | Dir: wgoCmd.Dir, 468 | Stdout: wgoCmd.Stdout, 469 | Stderr: wgoCmd.Stderr, 470 | } 471 | setpgid(cmd) 472 | if filepath.Base(cmd.Path) == cmd.Path { 473 | cmd.Path, err = exec.LookPath(cmd.Path) 474 | if errors.Is(err, exec.ErrNotFound) { 475 | if runtime.GOOS == "windows" { 476 | path, err := exec.LookPath("pwsh.exe") 477 | if err != nil { 478 | return err 479 | } 480 | cmd.Path = path 481 | cmd.Args = []string{"pwsh.exe", "-command", joinArgs(args)} 482 | } else { 483 | path, err := exec.LookPath("sh") 484 | if err != nil { 485 | return err 486 | } 487 | cmd.Path = path 488 | cmd.Args = []string{"sh", "-c", joinArgs(args)} 489 | } 490 | } else if err != nil { 491 | return err 492 | } 493 | } 494 | // If the user enabled it, feed wgoCmd.Stdin to the command's 495 | // Stdin. Only the last command gets to read from Stdin -- if we 496 | // give Stdin to every command in the middle it will prevent the 497 | // next command from being executed if they don't consume Stdin. 498 | // 499 | // We have to use cmd.StdinPipe() here instead of assigning 500 | // cmd.Stdin directly, otherwise `wgo run ./testdata/stdin` doesn't 501 | // work interactively (the tests will pass, but somehow it won't 502 | // actually work if you run it in person. I don't know why). 503 | var wg sync.WaitGroup 504 | if wgoCmd.EnableStdin && i == len(wgoCmd.ArgsList)-1 { 505 | stdinPipe, err := cmd.StdinPipe() 506 | if err != nil { 507 | return err 508 | } 509 | wg.Add(1) 510 | go func() { 511 | defer wg.Done() 512 | defer stdinPipe.Close() 513 | _, _ = io.Copy(stdinPipe, wgoCmd.Stdin) 514 | }() 515 | } 516 | 517 | // Step 2: Run the command in the background. 518 | cmdResult := make(chan error, 1) 519 | waitDone := make(chan struct{}) 520 | err = cmd.Start() 521 | if err != nil { 522 | return err 523 | } 524 | go func() { 525 | wg.Wait() 526 | cmdResult <- cmd.Wait() 527 | close(waitDone) 528 | }() 529 | 530 | // Step 3: Wait for events in the event loop. 531 | for { 532 | select { 533 | case <-wgoCmd.ctx.Done(): 534 | stop(cmd) 535 | <-waitDone 536 | return nil 537 | case err := <-cmdResult: 538 | if i == len(wgoCmd.ArgsList)-1 { 539 | if wgoCmd.Exit { 540 | return err 541 | } 542 | break 543 | } 544 | if err != nil { 545 | break 546 | } 547 | continue CMD_CHAIN 548 | case event := <-events: 549 | if !event.Has(fsnotify.Create) && !event.Has(fsnotify.Write) { 550 | continue 551 | } 552 | fileinfo, err := os.Stat(event.Name) 553 | if err != nil { 554 | continue 555 | } 556 | if fileinfo.IsDir() { 557 | if event.Has(fsnotify.Create) && wgoCmd.PollDuration == 0 { 558 | wgoCmd.addDirsRecursively(watcher, event.Name) 559 | } 560 | } else { 561 | if wgoCmd.match(event.Op.String(), event.Name) { 562 | timer.Reset(wgoCmd.Debounce) // Start the timer. 563 | } 564 | } 565 | case <-timer.C: // Timer expired, reload commands. 566 | stop(cmd) 567 | <-waitDone 568 | break CMD_CHAIN 569 | } 570 | } 571 | } 572 | } 573 | } 574 | 575 | // compileRegexp is like regexp.Compile except it treats dots followed by 576 | // [a-zA-Z] as a dot literal. Makes expressing file extensions like .css or 577 | // .html easier. The user can always escape this behaviour by wrapping the dot 578 | // up in a grouping bracket i.e. `(.)css`. 579 | func compileRegexp(pattern string) (*regexp.Regexp, error) { 580 | n := strings.Count(pattern, ".") 581 | if n == 0 { 582 | return regexp.Compile(pattern) 583 | } 584 | if strings.HasPrefix(pattern, "./") && len(pattern) > 2 { 585 | // Any pattern starting with "./" is almost certainly a mistake - it 586 | // looks like it refers to the current directory when in actuality any 587 | // regex starting with "./" matches nothing in the current directory 588 | // because of the slash in front. Nobody every really means to match 589 | // "one character followed by a slash" so we accomodate this common use 590 | // case and trim the "./" prefix away. 591 | pattern = pattern[2:] 592 | } 593 | var b strings.Builder 594 | b.Grow(len(pattern) + n) 595 | j := 0 596 | for j < len(pattern) { 597 | prev, _ := utf8.DecodeLastRuneInString(b.String()) 598 | curr, width := utf8.DecodeRuneInString(pattern[j:]) 599 | next, _ := utf8.DecodeRuneInString(pattern[j+width:]) 600 | j += width 601 | if prev != '\\' && curr == '.' && (('a' <= next && next <= 'z') || ('A' <= next && next <= 'Z')) { 602 | b.WriteString("\\.") 603 | } else { 604 | b.WriteRune(curr) 605 | } 606 | } 607 | return regexp.Compile(b.String()) 608 | } 609 | 610 | // addDirsRecursively adds directories recursively to a watcher since it 611 | // doesn't support it natively https://github.com/fsnotify/fsnotify/issues/18. 612 | // A nice side effect is that we get to log the watched directories as we go. 613 | // 614 | // If we are polling (i.e. PollDuration > 0), do not call this method. Call 615 | // wgoCmd.pollDirectory() instead, which does its own recursive polling. 616 | func (wgoCmd *WgoCmd) addDirsRecursively(watcher *fsnotify.Watcher, dir string) { 617 | roots := make(map[string]struct{}) 618 | for _, root := range wgoCmd.Roots { 619 | roots[root] = struct{}{} 620 | } 621 | _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { 622 | if err != nil { 623 | return nil 624 | } 625 | if !d.IsDir() { 626 | return nil 627 | } 628 | normalizedDir := filepath.ToSlash(path) 629 | _, isRoot := roots[path] 630 | if isRoot { 631 | wgoCmd.Logger.Println("WATCH", normalizedDir) 632 | watcher.Add(path) 633 | return nil 634 | } 635 | for _, root := range wgoCmd.Roots { 636 | if strings.HasPrefix(path, root+string(filepath.Separator)) { 637 | normalizedDir = filepath.ToSlash(strings.TrimPrefix(path, root+string(filepath.Separator))) 638 | break 639 | } 640 | } 641 | for _, r := range wgoCmd.ExcludeDirRegexps { 642 | if r.MatchString(normalizedDir) { 643 | return filepath.SkipDir 644 | } 645 | } 646 | for _, r := range wgoCmd.DirRegexps { 647 | if r.MatchString(normalizedDir) { 648 | wgoCmd.Logger.Println("WATCH", normalizedDir) 649 | watcher.Add(path) 650 | return nil 651 | } 652 | } 653 | name := filepath.Base(path) 654 | switch name { 655 | case ".git", ".hg", ".svn", ".idea", ".vscode", ".settings", "node_modules": 656 | return filepath.SkipDir 657 | } 658 | if strings.HasPrefix(name, ".") { 659 | return filepath.SkipDir 660 | } 661 | wgoCmd.Logger.Println("WATCH", normalizedDir) 662 | watcher.Add(path) 663 | return nil 664 | }) 665 | } 666 | 667 | // match checks if a given file path should trigger a reload. The op string is 668 | // provided only for logging purposes, it is not actually used. 669 | func (wgoCmd *WgoCmd) match(op string, path string) bool { 670 | normalizedFile := filepath.ToSlash(path) 671 | normalizedDir := filepath.ToSlash(filepath.Dir(normalizedFile)) 672 | for _, root := range wgoCmd.Roots { 673 | root += string(os.PathSeparator) 674 | if strings.HasPrefix(path, root) { 675 | normalizedFile = filepath.ToSlash(strings.TrimPrefix(path, root)) 676 | normalizedDir = filepath.ToSlash(filepath.Dir(normalizedFile)) 677 | break 678 | } 679 | } 680 | for _, r := range wgoCmd.ExcludeDirRegexps { 681 | if r.MatchString(normalizedDir) { 682 | wgoCmd.Logger.Println("(skip)", op, normalizedFile) 683 | return false 684 | } 685 | } 686 | if len(wgoCmd.DirRegexps) > 0 { 687 | matched := false 688 | for _, r := range wgoCmd.DirRegexps { 689 | if r.MatchString(normalizedDir) { 690 | matched = true 691 | break 692 | } 693 | } 694 | if !matched { 695 | wgoCmd.Logger.Println("(skip)", op, normalizedFile) 696 | return false 697 | } 698 | } 699 | for _, r := range wgoCmd.ExcludeFileRegexps { 700 | if r.MatchString(normalizedFile) { 701 | wgoCmd.Logger.Println("(skip)", op, normalizedFile) 702 | return false 703 | } 704 | } 705 | for _, r := range wgoCmd.FileRegexps { 706 | if r.MatchString(normalizedFile) { 707 | wgoCmd.Logger.Println(op, normalizedFile) 708 | return true 709 | } 710 | } 711 | if wgoCmd.isRun { 712 | if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") { 713 | wgoCmd.Logger.Println(op, normalizedFile) 714 | return true 715 | } 716 | wgoCmd.Logger.Println("(skip)", op, normalizedFile) 717 | return false 718 | } 719 | if len(wgoCmd.FileRegexps) == 0 { 720 | wgoCmd.Logger.Println(op, normalizedFile) 721 | return true 722 | } 723 | wgoCmd.Logger.Println("(skip)", op, normalizedFile) 724 | return false 725 | } 726 | 727 | // pollDirectory polls a given directory path (recursively) for changes. 728 | func (wgoCmd *WgoCmd) pollDirectory(ctx context.Context, path string, events chan<- fsnotify.Event) { 729 | // wg tracks the number of active goroutines. 730 | var wg sync.WaitGroup 731 | 732 | // cancelFuncs maps names to their goroutine-cancelling functions. 733 | cancelFuncs := make(map[string]func()) 734 | 735 | // Defer cleanup. 736 | defer func() { 737 | for _, cancel := range cancelFuncs { 738 | cancel() 739 | } 740 | wg.Wait() 741 | }() 742 | 743 | dirEntries, err := os.ReadDir(path) 744 | if err != nil { 745 | wgoCmd.Logger.Println(err) 746 | return 747 | } 748 | for _, dirEntry := range dirEntries { 749 | name := dirEntry.Name() 750 | ctx, cancel := context.WithCancel(ctx) 751 | cancelFuncs[name] = cancel 752 | if dirEntry.IsDir() { 753 | match := func() bool { 754 | dir := filepath.Join(path, name) 755 | normalizedDir := filepath.ToSlash(dir) 756 | for _, root := range wgoCmd.Roots { 757 | if strings.HasPrefix(dir, root+string(filepath.Separator)) { 758 | normalizedDir = filepath.ToSlash(strings.TrimPrefix(dir, root+string(filepath.Separator))) 759 | break 760 | } 761 | } 762 | for _, r := range wgoCmd.ExcludeDirRegexps { 763 | if r.MatchString(normalizedDir) { 764 | return false 765 | } 766 | } 767 | for _, r := range wgoCmd.DirRegexps { 768 | if r.MatchString(normalizedDir) { 769 | wgoCmd.Logger.Println("POLL", normalizedDir) 770 | return true 771 | } 772 | } 773 | name := filepath.Base(normalizedDir) 774 | switch name { 775 | case ".git", ".hg", ".svn", ".idea", ".vscode", ".settings", "node_modules": 776 | return false 777 | } 778 | if strings.HasPrefix(name, ".") { 779 | return false 780 | } 781 | wgoCmd.Logger.Println("POLL", normalizedDir) 782 | return true 783 | }() 784 | if match { 785 | wg.Add(1) 786 | go func() { 787 | defer wg.Done() 788 | wgoCmd.pollDirectory(ctx, filepath.Join(path, name), events) 789 | }() 790 | } 791 | } else { 792 | wg.Add(1) 793 | go func() { 794 | defer wg.Done() 795 | wgoCmd.pollFile(ctx, filepath.Join(path, name), events) 796 | }() 797 | } 798 | } 799 | 800 | // seen tracks which names we have already seen. We are declaring it 801 | // outside the loop instead of inside the loop so that we can reuse the 802 | // map. 803 | seen := make(map[string]bool) 804 | 805 | for { 806 | for name := range seen { 807 | delete(seen, name) 808 | } 809 | time.Sleep(wgoCmd.PollDuration) 810 | err := ctx.Err() 811 | if err != nil { 812 | return 813 | } 814 | dirEntries, err := os.ReadDir(path) 815 | if err != nil { 816 | continue 817 | } 818 | for _, dirEntry := range dirEntries { 819 | name := dirEntry.Name() 820 | seen[name] = true 821 | _, ok := cancelFuncs[name] 822 | if ok { 823 | continue 824 | } 825 | ctx, cancel := context.WithCancel(ctx) 826 | cancelFuncs[name] = cancel 827 | if dirEntry.IsDir() { 828 | wg.Add(1) 829 | go func() { 830 | defer wg.Done() 831 | events <- fsnotify.Event{Name: filepath.Join(path, name), Op: fsnotify.Create} 832 | wgoCmd.pollDirectory(ctx, filepath.Join(path, name), events) 833 | }() 834 | } else { 835 | wg.Add(1) 836 | go func() { 837 | defer wg.Done() 838 | events <- fsnotify.Event{Name: filepath.Join(path, name), Op: fsnotify.Create} 839 | wgoCmd.pollFile(ctx, filepath.Join(path, name), events) 840 | }() 841 | } 842 | } 843 | // For names that no longer exist, cancel their goroutines. 844 | for name, cancel := range cancelFuncs { 845 | if !seen[name] { 846 | cancel() 847 | delete(cancelFuncs, name) 848 | } 849 | } 850 | } 851 | } 852 | 853 | // pollFile polls an individual file for changes. 854 | func (wgoCmd *WgoCmd) pollFile(ctx context.Context, path string, events chan<- fsnotify.Event) { 855 | fileInfo, err := os.Stat(path) 856 | if err != nil { 857 | return 858 | } 859 | oldModTime := fileInfo.ModTime() 860 | oldSize := fileInfo.Size() 861 | for { 862 | time.Sleep(wgoCmd.PollDuration) 863 | err := ctx.Err() 864 | if err != nil { 865 | return 866 | } 867 | fileInfo, err := os.Stat(path) 868 | if err != nil { 869 | continue 870 | } 871 | newModTime := fileInfo.ModTime() 872 | newSize := fileInfo.Size() 873 | if newModTime != oldModTime || newSize != oldSize { 874 | events <- fsnotify.Event{Name: path, Op: fsnotify.Write} 875 | } 876 | oldModTime = newModTime 877 | oldSize = newSize 878 | } 879 | } 880 | -------------------------------------------------------------------------------- /wgo_cmd_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "flag" 8 | "log" 9 | "math/rand" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "reflect" 14 | "regexp" 15 | "runtime" 16 | "sort" 17 | "strconv" 18 | "strings" 19 | "sync" 20 | "testing" 21 | "time" 22 | 23 | "github.com/fsnotify/fsnotify" 24 | "github.com/google/go-cmp/cmp" 25 | "github.com/google/go-cmp/cmp/cmpopts" 26 | ) 27 | 28 | var WGO_RANDOM_NUMBER string 29 | 30 | func init() { 31 | WGO_RANDOM_NUMBER = strconv.Itoa(rand.Intn(5000)) 32 | os.Setenv("FOO", "green") 33 | os.Setenv("BAR", "lorem ipsum dolor sit amet") 34 | os.Setenv("WGO_RANDOM_NUMBER", WGO_RANDOM_NUMBER) 35 | } 36 | 37 | func Test_compileRegexp(t *testing.T) { 38 | type TestTable struct { 39 | description string 40 | pattern string 41 | pass []string 42 | fail []string 43 | } 44 | 45 | tests := []TestTable{{ 46 | description: "normal regexp without dot", 47 | pattern: `ab\wd`, 48 | pass: []string{"abcd", "abxd", "abzd"}, 49 | fail: []string{"ab@d", "ab.d"}, 50 | }, { 51 | description: "dot followed by letter is treated as literal dot", 52 | pattern: `.html`, 53 | pass: []string{"header.html", "footer.html"}, 54 | fail: []string{"\\xhtml", "footer.xhtml", "main.go"}, 55 | }, { 56 | description: "an escaped dot is not escaped again", 57 | pattern: `\.html`, 58 | pass: []string{"header.html", "footer.html"}, 59 | fail: []string{"\\xhtml", "footer.xhtml", "main.go"}, 60 | }, { 61 | description: "dot followed by non-dot is treated as normal regexp dot", 62 | pattern: `(.)html`, 63 | pass: []string{"header.html", "footer.html", "\\xhtml", "footer.xhtml"}, 64 | fail: []string{"main.go"}, 65 | }, { 66 | description: "trim patterns starting with dot slash", 67 | pattern: `./testdata/hello_world/main.go`, 68 | pass: []string{"testdata/hello_world/main.go"}, 69 | fail: []string{}, 70 | }} 71 | 72 | for _, tt := range tests { 73 | tt := tt 74 | t.Run(tt.description, func(t *testing.T) { 75 | t.Parallel() 76 | r, err := compileRegexp(tt.pattern) 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | for _, s := range tt.pass { 81 | if !r.MatchString(s) { 82 | t.Errorf("%q failed to match %q", tt.pattern, s) 83 | } 84 | } 85 | for _, s := range tt.fail { 86 | if r.MatchString(s) { 87 | t.Errorf("%q incorrectly matches %q", tt.pattern, s) 88 | } 89 | } 90 | }) 91 | } 92 | } 93 | 94 | func TestWgoCmd_match(t *testing.T) { 95 | type TestTable struct { 96 | description string 97 | roots []string 98 | args []string 99 | path string 100 | want bool 101 | } 102 | 103 | tests := []TestTable{{ 104 | description: "-xfile", 105 | args: []string{"-xfile", "_test.go"}, 106 | path: "wgo_cmd_test.go", 107 | want: false, 108 | }, { 109 | description: "-xfile with slash", 110 | args: []string{"-xfile", "testdata/"}, 111 | path: "testdata/args/main.go", 112 | want: false, 113 | }, { 114 | description: "-file", 115 | args: []string{"-file", "main.go"}, 116 | path: "testdata/args/main.go", 117 | want: true, 118 | }, { 119 | description: "-xdir overrides -file", 120 | args: []string{"-file", "main.go", "-xdir", "testdata"}, 121 | path: "testdata/args/main.go", 122 | want: false, 123 | }, { 124 | description: "-file matches but -dir does not", 125 | args: []string{"-file", "main.go", "-dir", "src"}, 126 | path: "testdata/args/main.go", 127 | want: false, 128 | }, { 129 | description: "both -file and -dir match", 130 | args: []string{"-file", "main.go", "-dir", "testdata"}, 131 | path: "testdata/args/main.go", 132 | want: true, 133 | }, { 134 | description: "-file with slash", 135 | args: []string{"-file", "testdata/"}, 136 | path: "testdata/args/main.go", 137 | want: true, 138 | }, { 139 | description: "wgo run", 140 | args: []string{"run", "."}, 141 | path: "testdata/args/main.go", 142 | want: true, 143 | }, { 144 | description: "wgo run without flags exclude non go files", 145 | args: []string{"run", "main.go"}, 146 | path: "testdata/dir/foo/bar.txt", 147 | want: false, 148 | }, { 149 | description: "fallthrough", 150 | args: []string{"-file", ".go", "-file", "test", "-xfile", ".css", "-xfile", "assets"}, 151 | path: "index.html", 152 | want: false, 153 | }, { 154 | description: "root is truncated", 155 | roots: []string{"/Documents"}, 156 | args: []string{"-file", "Documents"}, 157 | path: "/Documents/wgo/main.go", 158 | want: false, 159 | }, { 160 | description: "root is not truncated", 161 | roots: []string{"/lorem_ipsum"}, 162 | args: []string{"-file", "Documents"}, 163 | path: "/Documents/wgo/main.go", 164 | want: true, 165 | }, { 166 | description: "nothing allows anything", 167 | args: []string{}, 168 | path: "/Documents/index.rb", 169 | want: true, 170 | }} 171 | 172 | for _, tt := range tests { 173 | tt := tt 174 | t.Run(tt.description, func(t *testing.T) { 175 | t.Parallel() 176 | wgoCmd, err := WgoCommand(context.Background(), 0, tt.args) 177 | if err != nil { 178 | t.Fatal(err) 179 | } 180 | if tt.roots != nil { 181 | wgoCmd.Roots = make([]string, len(tt.roots)) 182 | for i := range tt.roots { 183 | wgoCmd.Roots[i], err = filepath.Abs(tt.roots[i]) 184 | if err != nil { 185 | t.Fatal(err) 186 | } 187 | } 188 | } 189 | path, err := filepath.Abs(tt.path) 190 | if err != nil { 191 | t.Fatal(err) 192 | } 193 | got := wgoCmd.match("", path) 194 | if !got && tt.want { 195 | t.Errorf("%v failed to match %q", tt.args, tt.path) 196 | } else if got && !tt.want { 197 | t.Errorf("%v incorrectly matches %q", tt.args, tt.path) 198 | } 199 | }) 200 | } 201 | } 202 | 203 | func TestWgoCmd_addDirsRecursively(t *testing.T) { 204 | type TestTable struct { 205 | description string 206 | roots []string 207 | dir string 208 | args []string 209 | wantWatched []string 210 | } 211 | 212 | // NOTE: Don't hardcode absolute paths here, use only relative paths. The 213 | // test scaffolding will convert them to absolute paths for you. 214 | tests := []TestTable{{ 215 | description: "-xdir", 216 | roots: []string{"testdata/dir"}, 217 | dir: "testdata/dir", 218 | args: []string{"-xdir", "subdir"}, 219 | wantWatched: []string{ 220 | "testdata/dir", 221 | "testdata/dir/foo", 222 | }, 223 | }, { 224 | description: "-xdir with slash", 225 | roots: []string{"testdata/dir"}, 226 | dir: "testdata/dir", 227 | args: []string{"-xdir", "/"}, 228 | wantWatched: []string{ 229 | "testdata/dir", 230 | }, 231 | }, { 232 | description: "-xdir excludes non root dir", 233 | args: []string{"-xdir", "testdata/dir"}, 234 | dir: "testdata/dir", 235 | wantWatched: []string{}, 236 | }, { 237 | description: "-dir", 238 | roots: []string{"testdata/dir"}, 239 | dir: "testdata/dir", 240 | args: []string{"-dir", "foo"}, 241 | wantWatched: []string{ 242 | "testdata/dir", 243 | "testdata/dir/foo", 244 | "testdata/dir/subdir", 245 | "testdata/dir/subdir/foo", 246 | }, 247 | }, { 248 | description: "explicitly include node_modules", 249 | roots: []string{"testdata/dir"}, 250 | dir: "testdata/dir", 251 | args: []string{"-dir", "node_modules"}, 252 | wantWatched: []string{ 253 | "testdata/dir", 254 | "testdata/dir/foo", 255 | "testdata/dir/node_modules", 256 | "testdata/dir/node_modules/foo", 257 | "testdata/dir/subdir", 258 | "testdata/dir/subdir/foo", 259 | }, 260 | }} 261 | 262 | for _, tt := range tests { 263 | tt := tt 264 | t.Run(tt.description, func(t *testing.T) { 265 | t.Parallel() 266 | wgoCmd, err := WgoCommand(context.Background(), 0, tt.args) 267 | if err != nil { 268 | t.Fatal(err) 269 | } 270 | for i := range tt.roots { 271 | root, err := filepath.Abs(tt.roots[i]) 272 | if err != nil { 273 | t.Fatal(err) 274 | } 275 | wgoCmd.Roots = append(wgoCmd.Roots, root) 276 | } 277 | watcher, err := fsnotify.NewWatcher() 278 | if err != nil { 279 | t.Fatal(err) 280 | } 281 | dir, err := filepath.Abs(tt.dir) 282 | if err != nil { 283 | t.Fatal(err) 284 | } 285 | for i := range tt.wantWatched { 286 | tt.wantWatched[i], err = filepath.Abs(tt.wantWatched[i]) 287 | if err != nil { 288 | t.Fatal(err) 289 | } 290 | } 291 | wgoCmd.addDirsRecursively(watcher, dir) 292 | gotWatched := watcher.WatchList() 293 | sort.Strings(gotWatched) 294 | sort.Strings(tt.wantWatched) 295 | if diff := Diff(gotWatched, tt.wantWatched); diff != "" { 296 | t.Error(diff) 297 | } 298 | }) 299 | } 300 | } 301 | 302 | func TestWgoCommands(t *testing.T) { 303 | type TestTable struct { 304 | description string 305 | args []string 306 | wantCmds []*WgoCmd 307 | } 308 | 309 | tests := []TestTable{{ 310 | description: "chained commands", 311 | args: []string{ 312 | "wgo", "-file", ".go", "clear", 313 | "::", "echo", "building...", 314 | "::", "go", "build", "-o", "hello_world", "hello_world.go", 315 | "::", "echo", "running...", 316 | "::", "./hello_world", 317 | }, 318 | wantCmds: []*WgoCmd{{ 319 | Roots: []string{"."}, 320 | FileRegexps: []*regexp.Regexp{regexp.MustCompile(`\.go`)}, 321 | ArgsList: [][]string{ 322 | {"clear"}, 323 | {"echo", "building..."}, 324 | {"go", "build", "-o", "hello_world", "hello_world.go"}, 325 | {"echo", "running..."}, 326 | {"./hello_world"}, 327 | }, 328 | Debounce: 300 * time.Millisecond, 329 | }}, 330 | }, { 331 | description: "parallel commands", 332 | args: []string{ 333 | "wgo", "run", "-tags", "fts5", "main.go", "arg1", "arg2", 334 | "::", "wgo", "-file", ".css", "-dir", "assets", "sass", "assets/styles.scss", "assets/styles.css", 335 | "::", "wgo", "-file", ".js", "-dir", "assets", "tsc", "assets/*.ts", "--outfile", "assets/index.js", 336 | }, 337 | wantCmds: []*WgoCmd{{ 338 | Roots: []string{"."}, 339 | ArgsList: [][]string{ 340 | {"go", "build", "-o", "out", "-tags", "fts5", "main.go"}, 341 | {"out", "arg1", "arg2"}, 342 | }, 343 | Debounce: 300 * time.Millisecond, 344 | isRun: true, 345 | executablePath: "out", 346 | }, { 347 | Roots: []string{"."}, 348 | FileRegexps: []*regexp.Regexp{regexp.MustCompile(`\.css`)}, 349 | DirRegexps: []*regexp.Regexp{regexp.MustCompile(`assets`)}, 350 | ArgsList: [][]string{ 351 | {"sass", "assets/styles.scss", "assets/styles.css"}, 352 | }, 353 | Debounce: 300 * time.Millisecond, 354 | }, { 355 | Roots: []string{"."}, 356 | FileRegexps: []*regexp.Regexp{regexp.MustCompile(`\.js`)}, 357 | DirRegexps: []*regexp.Regexp{regexp.MustCompile(`assets`)}, 358 | ArgsList: [][]string{ 359 | {"tsc", "assets/*.ts", "--outfile", "assets/index.js"}, 360 | }, 361 | Debounce: 300 * time.Millisecond, 362 | }}, 363 | }, { 364 | description: "build flags", 365 | args: []string{ 366 | "wgo", "run", "-a", "-n", "-race", "-msan", "-asan", "-v=false", 367 | "-work", "-x", "-buildvcs", "-linkshared=true", "-modcacherw=1", 368 | "-trimpath=t", "-p", "5", ".", "arg1", "arg2", 369 | }, 370 | wantCmds: []*WgoCmd{{ 371 | Roots: []string{"."}, 372 | ArgsList: [][]string{ 373 | {"go", "build", "-o", "out", "-p", "5", "-a", "-n", "-race", "-msan", "-asan", "-work", "-x", "-buildvcs", "-linkshared", "-modcacherw", "-trimpath", "."}, 374 | {"out", "arg1", "arg2"}, 375 | }, 376 | Debounce: 300 * time.Millisecond, 377 | isRun: true, 378 | executablePath: "out", 379 | }}, 380 | }, { 381 | description: "wgo flags", 382 | args: []string{ 383 | "wgo", "-root", "/secrets", "-file", ".", "-verbose", "-postpone", "echo", "hello", 384 | }, 385 | wantCmds: []*WgoCmd{{ 386 | Roots: []string{".", "/secrets"}, 387 | FileRegexps: []*regexp.Regexp{regexp.MustCompile(`.`)}, 388 | ArgsList: [][]string{ 389 | {"echo", "hello"}, 390 | }, 391 | Debounce: 300 * time.Millisecond, 392 | Postpone: true, 393 | }}, 394 | }, { 395 | description: "escaped ::", 396 | args: []string{ 397 | "wgo", "-file", ".", "echo", ":::", "::::", ":::::", 398 | }, 399 | wantCmds: []*WgoCmd{{ 400 | Roots: []string{"."}, 401 | FileRegexps: []*regexp.Regexp{regexp.MustCompile(`.`)}, 402 | ArgsList: [][]string{ 403 | {"echo", "::", ":::", "::::"}, 404 | }, 405 | Debounce: 300 * time.Millisecond, 406 | }}, 407 | }, { 408 | description: "debounce flag", 409 | args: []string{ 410 | "wgo", "-debounce", "10ms", "echo", "test", 411 | }, 412 | wantCmds: []*WgoCmd{{ 413 | Roots: []string{"."}, 414 | ArgsList: [][]string{ 415 | {"echo", "test"}, 416 | }, 417 | Debounce: 10 * time.Millisecond, 418 | }}, 419 | }} 420 | 421 | for _, tt := range tests { 422 | tt := tt 423 | t.Run(tt.description, func(t *testing.T) { 424 | t.Parallel() 425 | gotCmds, err := WgoCommands(context.Background(), tt.args) 426 | if err != nil { 427 | t.Fatal(err) 428 | } 429 | for _, wgoCmd := range tt.wantCmds { 430 | wgoCmd.ctx = context.Background() 431 | for i := range wgoCmd.Roots { 432 | wgoCmd.Roots[i], err = filepath.Abs(wgoCmd.Roots[i]) 433 | if err != nil { 434 | t.Fatal(err) 435 | } 436 | } 437 | } 438 | // This is ugly, but because the binPath is randomly generated we 439 | // have to manually reach into the argslist and overwrite it with a 440 | // well-known string so that we can compare the commands properly. 441 | if tt.description == "parallel commands" || tt.description == "build flags" { 442 | gotCmds[0].executablePath = "out" 443 | gotCmds[0].ArgsList[0][3] = "out" 444 | gotCmds[0].ArgsList[1][0] = "out" 445 | } 446 | opts := []cmp.Option{ 447 | // Comparing loggers always fails, ignore it. 448 | cmpopts.IgnoreFields(WgoCmd{}, "Logger"), 449 | } 450 | if diff := Diff(gotCmds, tt.wantCmds, opts...); diff != "" { 451 | t.Error(diff) 452 | } 453 | }) 454 | } 455 | } 456 | 457 | func TestWgoCmd_Run(t *testing.T) { 458 | t.Run("args", func(t *testing.T) { 459 | t.Parallel() 460 | wgoCmd, err := WgoCommand(context.Background(), 0, []string{ 461 | "run", "-exit", "-dir", "testdata/args", "./testdata/args", "apple", "banana", "cherry", 462 | }) 463 | if err != nil { 464 | t.Fatal(err) 465 | } 466 | buf := &Buffer{} 467 | wgoCmd.Stdout = buf 468 | err = wgoCmd.Run() 469 | if err != nil { 470 | t.Fatal(err) 471 | } 472 | got := strings.TrimSpace(buf.String()) 473 | want := "[apple banana cherry]" 474 | if got != want { 475 | t.Errorf("\ngot: %q\nwant: %q", got, want) 476 | } 477 | }) 478 | 479 | t.Run("build flags off", func(t *testing.T) { 480 | t.Parallel() 481 | wgoCmd, err := WgoCommand(context.Background(), 0, []string{ 482 | "run", "-exit", "-dir", "testdata/build_flags", "./testdata/build_flags", 483 | }) 484 | if err != nil { 485 | t.Fatal(err) 486 | } 487 | buf := &Buffer{} 488 | wgoCmd.Stdout = buf 489 | err = wgoCmd.Run() 490 | if err != nil { 491 | t.Fatal(err) 492 | } 493 | got := strings.TrimSpace(buf.String()) 494 | want := "[foo]" 495 | if got != want { 496 | t.Errorf("\ngot: %q\nwant: %q", got, want) 497 | } 498 | }) 499 | 500 | t.Run("build flags on", func(t *testing.T) { 501 | t.Parallel() 502 | wgoCmd, err := WgoCommand(context.Background(), 0, []string{ 503 | "run", "-exit", "-dir", "testdata/build_flags", "-tags=bar", "./testdata/build_flags", 504 | }) 505 | if err != nil { 506 | t.Fatal(err) 507 | } 508 | buf := &Buffer{} 509 | wgoCmd.Stdout = buf 510 | err = wgoCmd.Run() 511 | if err != nil { 512 | t.Fatal(err) 513 | } 514 | got := strings.TrimSpace(buf.String()) 515 | want := "[foo bar]" 516 | if got != want { 517 | t.Errorf("\ngot: %q\nwant: %q", got, want) 518 | } 519 | }) 520 | 521 | t.Run("env", func(t *testing.T) { 522 | t.Parallel() 523 | cmd, err := WgoCommand(context.Background(), 0, []string{ 524 | "run", "-exit", "-dir", "testdata/env", "./testdata/env", 525 | }) 526 | if err != nil { 527 | t.Fatal(err) 528 | } 529 | buf := &Buffer{} 530 | cmd.Stdout = buf 531 | err = cmd.Run() 532 | if err != nil { 533 | t.Fatal(err) 534 | } 535 | got := strings.TrimSpace(buf.String()) 536 | want := "FOO=green\nBAR=lorem ipsum dolor sit amet\nWGO_RANDOM_NUMBER=" + WGO_RANDOM_NUMBER 537 | if got != want { 538 | t.Fatalf("\ngot: %q\nwant: %q", got, want) 539 | } 540 | }) 541 | 542 | t.Run("timeout off", func(t *testing.T) { 543 | t.Parallel() 544 | binPath := "./testdata/hello_world/timeout_off" 545 | if runtime.GOOS == "windows" { 546 | binPath += ".exe" 547 | } 548 | os.RemoveAll(binPath) 549 | defer os.RemoveAll(binPath) 550 | wgoCmd, err := WgoCommand(context.Background(), 0, []string{ 551 | "-exit", "-dir", "testdata/hello_world", "-file", ".go", "go", "build", "-o", binPath, "./testdata/hello_world", 552 | "::", binPath, 553 | }) 554 | if err != nil { 555 | t.Fatal(err) 556 | } 557 | buf := &Buffer{} 558 | wgoCmd.Stdout = buf 559 | err = wgoCmd.Run() 560 | if err != nil { 561 | t.Fatal(err) 562 | } 563 | got := strings.TrimSpace(buf.String()) 564 | want := "hello world" 565 | if got != want { 566 | t.Errorf("\ngot: %q\nwant: %q", got, want) 567 | } 568 | }) 569 | 570 | t.Run("timeout on", func(t *testing.T) { 571 | t.Parallel() 572 | ctx, cancel := context.WithTimeout(context.Background(), 0) 573 | defer cancel() 574 | binPath := "./testdata/hello_world/timeout_on" 575 | if runtime.GOOS == "windows" { 576 | binPath += ".exe" 577 | } 578 | os.RemoveAll(binPath) 579 | defer os.RemoveAll(binPath) 580 | wgoCmd, err := WgoCommand(ctx, 0, []string{ 581 | "-exit", "-dir", "testdata/hello_world", "-file", ".go", "go", "build", "-o", binPath, "./testdata/hello_world", 582 | "::", binPath, 583 | }) 584 | if err != nil { 585 | t.Fatal(err) 586 | } 587 | buf := &Buffer{} 588 | wgoCmd.Stdout = buf 589 | err = wgoCmd.Run() 590 | if err != nil { 591 | t.Fatal(err) 592 | } 593 | got := strings.TrimSpace(buf.String()) 594 | want := "" 595 | if got != want { 596 | t.Errorf("\ngot: %q\nwant: %q", got, want) 597 | } 598 | }) 599 | 600 | t.Run("signal off", func(t *testing.T) { 601 | t.Parallel() 602 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 603 | defer cancel() 604 | wgoCmd, err := WgoCommand(ctx, 0, []string{ 605 | "run", "-dir", "testdata/signal", "./testdata/signal", 606 | }) 607 | if err != nil { 608 | t.Fatal(err) 609 | } 610 | buf := &Buffer{} 611 | wgoCmd.Stdout = buf 612 | err = wgoCmd.Run() 613 | if err != nil { 614 | t.Fatal(err) 615 | } 616 | got := strings.TrimSpace(buf.String()) 617 | want := "Waiting..." 618 | if got != want { 619 | t.Errorf("\ngot: %q\nwant: %q", got, want) 620 | } 621 | }) 622 | 623 | t.Run("signal on", func(t *testing.T) { 624 | if runtime.GOOS == "windows" { 625 | t.Skip("Windows doesn't support sending signals to a running process, skipping.") 626 | } 627 | t.Parallel() 628 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 629 | defer cancel() 630 | wgoCmd, err := WgoCommand(ctx, 0, []string{ 631 | "run", "-dir", "testdata/signal", "./testdata/signal", "-trap-signal", 632 | }) 633 | if err != nil { 634 | t.Fatal(err) 635 | } 636 | buf := &Buffer{} 637 | wgoCmd.Stdout = buf 638 | err = wgoCmd.Run() 639 | if err != nil { 640 | t.Fatal(err) 641 | } 642 | got := strings.TrimSpace(buf.String()) 643 | want := "Waiting...\nInterrupt received, graceful shutdown." 644 | if got != want { 645 | t.Errorf("\ngot: %q\nwant: %q", got, want) 646 | } 647 | }) 648 | 649 | t.Run("postpone off", func(t *testing.T) { 650 | t.Parallel() 651 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 652 | defer cancel() 653 | wgoCmd, err := WgoCommand(ctx, 0, []string{ 654 | "-xfile", ".", "echo", "hello", 655 | }) 656 | if err != nil { 657 | t.Fatal(err) 658 | } 659 | buf := &Buffer{} 660 | wgoCmd.Stdout = buf 661 | err = wgoCmd.Run() 662 | if err != nil { 663 | t.Fatal(err) 664 | } 665 | got := strings.TrimSpace(buf.String()) 666 | want := "hello" 667 | if got != want { 668 | t.Errorf("\ngot: %q\nwant: %q", got, want) 669 | } 670 | }) 671 | 672 | t.Run("postpone on", func(t *testing.T) { 673 | t.Parallel() 674 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 675 | defer cancel() 676 | wgoCmd, err := WgoCommand(ctx, 0, []string{ 677 | "-xfile", ".", "-postpone", "echo", "hello", 678 | }) 679 | if err != nil { 680 | t.Fatal(err) 681 | } 682 | buf := &Buffer{} 683 | wgoCmd.Stdout = buf 684 | err = wgoCmd.Run() 685 | if err != nil { 686 | t.Fatal(err) 687 | } 688 | got := strings.TrimSpace(buf.String()) 689 | want := "" 690 | if got != want { 691 | t.Errorf("\ngot: %q\nwant: %q", got, want) 692 | } 693 | }) 694 | } 695 | 696 | func TestWgoCmd_FileEvent(t *testing.T) { 697 | t.Parallel() 698 | os.RemoveAll("testdata/file_event/foo.txt") 699 | os.RemoveAll("testdata/file_event/internal") 700 | defer os.RemoveAll("testdata/file_event/foo.txt") 701 | defer os.RemoveAll("testdata/file_event/internal") 702 | 703 | ctx, cancel := context.WithCancel(context.Background()) 704 | defer cancel() 705 | wgoCmd, err := WgoCommand(ctx, 0, []string{"run", "-dir", "testdata/file_event", "-file", ".txt", "./testdata/file_event"}) 706 | if err != nil { 707 | t.Fatal(err) 708 | } 709 | buf := &Buffer{} 710 | wgoCmd.Stdout = buf 711 | cmdResult := make(chan error) 712 | go func() { 713 | cmdResult <- wgoCmd.Run() 714 | }() 715 | time.Sleep(3 * time.Second) 716 | 717 | log.Println(t.Name(), "add file") 718 | err = os.WriteFile("testdata/file_event/foo.txt", []byte("foo"), 0666) 719 | if err != nil { 720 | t.Fatal(err) 721 | } 722 | time.Sleep(3 * time.Second) 723 | 724 | log.Println(t.Name(), "edit file") 725 | err = os.WriteFile("testdata/file_event/foo.txt", []byte("foo fighters"), 0666) 726 | if err != nil { 727 | t.Fatal(err) 728 | } 729 | time.Sleep(3 * time.Second) 730 | 731 | log.Println(t.Name(), "create nested directory") 732 | err = os.MkdirAll("testdata/file_event/internal/baz", 0777) 733 | if err != nil { 734 | t.Fatal(err) 735 | } 736 | err = os.WriteFile("testdata/file_event/foo.txt", []byte("foo"), 0666) 737 | if err != nil { 738 | t.Fatal(err) 739 | } 740 | err = os.WriteFile("testdata/file_event/internal/bar.txt", []byte("bar"), 0666) 741 | if err != nil { 742 | t.Fatal(err) 743 | } 744 | err = os.WriteFile("testdata/file_event/internal/baz/baz.txt", []byte("baz"), 0666) 745 | if err != nil { 746 | t.Fatal(err) 747 | } 748 | time.Sleep(3 * time.Second) 749 | 750 | cancel() 751 | err = <-cmdResult 752 | if err != nil { 753 | t.Fatal(err) 754 | } 755 | got := strings.TrimSpace(buf.String()) 756 | want := `--- 757 | main.go 758 | run.bat 759 | --- 760 | foo.txt: foo 761 | main.go 762 | run.bat 763 | --- 764 | foo.txt: foo fighters 765 | main.go 766 | run.bat 767 | --- 768 | foo.txt: foo 769 | internal/bar.txt: bar 770 | internal/baz/baz.txt: baz 771 | main.go 772 | run.bat` 773 | if diff := Diff(got, want); diff != "" { 774 | t.Error(diff) 775 | } 776 | } 777 | 778 | func TestWgoCmd_Polling(t *testing.T) { 779 | t.Parallel() 780 | os.RemoveAll("testdata/polling/foo.txt") 781 | os.RemoveAll("testdata/polling/internal") 782 | defer os.RemoveAll("testdata/polling/foo.txt") 783 | defer os.RemoveAll("testdata/polling/internal") 784 | 785 | ctx, cancel := context.WithCancel(context.Background()) 786 | defer cancel() 787 | wgoCmd, err := WgoCommand(ctx, 0, []string{"run", "-dir", "testdata/polling", "-file", ".txt", "-poll", "100ms", "./testdata/polling"}) 788 | if err != nil { 789 | t.Fatal(err) 790 | } 791 | buf := &Buffer{} 792 | wgoCmd.Stdout = buf 793 | cmdResult := make(chan error) 794 | go func() { 795 | cmdResult <- wgoCmd.Run() 796 | }() 797 | time.Sleep(3 * time.Second) 798 | 799 | log.Println(t.Name(), "add file") 800 | err = os.WriteFile("testdata/polling/foo.txt", []byte("foo"), 0666) 801 | if err != nil { 802 | t.Fatal(err) 803 | } 804 | time.Sleep(3 * time.Second) 805 | 806 | log.Println(t.Name(), "edit file") 807 | err = os.WriteFile("testdata/polling/foo.txt", []byte("foo fighters"), 0666) 808 | if err != nil { 809 | t.Fatal(err) 810 | } 811 | time.Sleep(3 * time.Second) 812 | 813 | log.Println(t.Name(), "create nested directory") 814 | err = os.MkdirAll("testdata/polling/internal/baz", 0777) 815 | if err != nil { 816 | t.Fatal(err) 817 | } 818 | err = os.WriteFile("testdata/polling/foo.txt", []byte("foo"), 0666) 819 | if err != nil { 820 | t.Fatal(err) 821 | } 822 | err = os.WriteFile("testdata/polling/internal/bar.txt", []byte("bar"), 0666) 823 | if err != nil { 824 | t.Fatal(err) 825 | } 826 | err = os.WriteFile("testdata/polling/internal/baz/baz.txt", []byte("baz"), 0666) 827 | if err != nil { 828 | t.Fatal(err) 829 | } 830 | time.Sleep(3 * time.Second) 831 | 832 | cancel() 833 | err = <-cmdResult 834 | if err != nil { 835 | t.Fatal(err) 836 | } 837 | got := strings.TrimSpace(buf.String()) 838 | want := `--- 839 | main.go 840 | --- 841 | foo.txt: foo 842 | main.go 843 | --- 844 | foo.txt: foo fighters 845 | main.go 846 | --- 847 | foo.txt: foo 848 | internal/bar.txt: bar 849 | internal/baz/baz.txt: baz 850 | main.go` 851 | if diff := Diff(got, want); diff != "" { 852 | t.Error(diff) 853 | } 854 | } 855 | 856 | func TestStdin(t *testing.T) { 857 | t.Parallel() 858 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 859 | defer cancel() 860 | wgoCmd, err := WgoCommand(ctx, 0, []string{"run", "-exit", "-dir", "testdata/stdin", "-stdin", "./testdata/stdin"}) 861 | if err != nil { 862 | t.Fatal(err) 863 | } 864 | wgoCmd.Stdin = strings.NewReader("foo\nbar\nbaz") 865 | buf := &Buffer{} 866 | wgoCmd.Stderr = buf 867 | err = wgoCmd.Run() 868 | if err != nil { 869 | t.Fatal(err) 870 | } 871 | got := strings.TrimSpace(buf.String()) 872 | want := "1: foo\n2: bar\n3: baz" 873 | if got != want { 874 | t.Errorf("\ngot: %q\nwant: %q", got, want) 875 | } 876 | } 877 | 878 | func TestShellWrapping(t *testing.T) { 879 | t.Parallel() 880 | // builtins are commands that don't exist in PATH, they are manually 881 | // handled by the shell. We can use builtin commands to induce an 882 | // exec.LookPath() error, which will cause WgoCmd to retry by wrapping the 883 | // command in a shell. 884 | builtin := ":" 885 | if runtime.GOOS == "windows" { 886 | builtin = "Get-Location" 887 | } 888 | 889 | // Assert that vanilla exec.Command can't find the builtin. 890 | err := exec.Command(builtin).Run() 891 | if !errors.Is(err, exec.ErrNotFound) { 892 | t.Fatalf("expected exec.ErrNotFound, got %#v", err) 893 | } 894 | 895 | // Assert that WgoCommand handles the builtin (via shell wrapping). 896 | wgoCmd, err := WgoCommand(context.Background(), 0, []string{"-exit", builtin}) 897 | if err != nil { 898 | t.Fatal(err) 899 | } 900 | err = wgoCmd.Run() 901 | if err != nil { 902 | t.Error(err) 903 | } 904 | } 905 | 906 | func TestHelp(t *testing.T) { 907 | _, err := WgoCommand(context.Background(), 0, []string{"-h"}) 908 | if !errors.Is(err, flag.ErrHelp) { 909 | t.Errorf("expected flag.ErrHelp, got %#v", err) 910 | } 911 | _, err = WgoCommand(context.Background(), 0, []string{"run", "-h"}) 912 | if !errors.Is(err, flag.ErrHelp) { 913 | t.Errorf("expected flag.ErrHelp, got %#v", err) 914 | } 915 | } 916 | 917 | func Diff(got, want interface{}, opts ...cmp.Option) string { 918 | opts = append(opts, 919 | cmp.Exporter(func(typ reflect.Type) bool { return true }), 920 | cmpopts.EquateEmpty(), 921 | ) 922 | diff := cmp.Diff(got, want, opts...) 923 | if diff != "" { 924 | return "\n-got +want\n" + diff 925 | } 926 | return "" 927 | } 928 | 929 | // Buffer is a custom buffer type that is guarded by a sync.RWMutex. 930 | // 931 | // Some of the tests (signal on, signal off, timeout on, timeout off) initially 932 | // wrote to a *bytes.Buffer as their Stdout and the *bytes.Buffer was read from 933 | // to assert test results. But these tests occasionally failed with data races 934 | // which caused CI/CD tests to fail and I can't find the cause so I'll just use 935 | // a blunt hammer and use a goroutine-safe buffer for those tests. 936 | type Buffer struct { 937 | rw sync.RWMutex 938 | buf bytes.Buffer 939 | } 940 | 941 | func (b *Buffer) Read(p []byte) (n int, err error) { 942 | b.rw.RLock() 943 | defer b.rw.RUnlock() 944 | return b.buf.Read(p) 945 | } 946 | 947 | func (b *Buffer) Write(p []byte) (n int, err error) { 948 | b.rw.Lock() 949 | defer b.rw.Unlock() 950 | return b.buf.Write(p) 951 | } 952 | 953 | func (b *Buffer) String() string { 954 | b.rw.Lock() 955 | defer b.rw.Unlock() 956 | return b.buf.String() 957 | } 958 | --------------------------------------------------------------------------------