├── .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 | [](https://github.com/bokwoon95/wgo/actions)
2 | [](https://goreportcard.com/report/github.com/bokwoon95/wgo)
3 | [](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 |
--------------------------------------------------------------------------------