Skip to content

Commit 6d0a5c6

Browse files
committed
Initial import
0 parents  commit 6d0a5c6

File tree

9 files changed

+654
-0
lines changed

9 files changed

+654
-0
lines changed

.editorconfig

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Root
2+
root = true
3+
4+
# Default
5+
[*]
6+
charset = utf-8
7+
end_of_line = lf
8+
insert_final_newline = true
9+
indent_style = tab
10+
indent_size = 4
11+
12+
# Markdown
13+
[*.md]
14+
indent_style = space
15+
indent_size = 4

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/nwatch

MIT-LICENSE

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
Copyright (c) 2022 Latchezar Tzvetkoff
2+
3+
Permission is hereby granted, free of charge, to any person obtaining
4+
a copy of this software and associated documentation files (the
5+
"Software"), to deal in the Software without restriction, including
6+
without limitation the rights to use, copy, modify, merge, publish,
7+
distribute, sublicense, and/or sell copies of the Software, and to
8+
permit persons to whom the Software is furnished to do so, subject to
9+
the following conditions:
10+
11+
The above copyright notice and this permission notice shall be
12+
included in all copies or substantial portions of the Software.
13+
14+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# The Night's Watch
2+
3+
A simple watch/build/run daemon.
4+
5+
Originally made to watch and rebuild Go projects.
6+
7+
8+
## Installation
9+
10+
``` bash
11+
go install github.com/tzvetkoff-go/nwatch
12+
```
13+
14+
## Common usage
15+
16+
| Short option | Long option | Description | Example |
17+
| ------------ | -------------------- | -------------------------------------------- | ------------------------ |
18+
| `-h` | `--help` | Print help and exit | |
19+
| `-v` | `--version` | Print version and exit | |
20+
| `-V` | `--verbose` | Verbose output | |
21+
| `-d DIR` | `--directory=DIR` | Directories to watch | `-d '.'` |
22+
| `-e EXC` | `--exclude-dir=EXC` | Directories to exclude | `-e '.git'` |
23+
| `-p PAT` | `--pattern=PAT` | File patterns to match | `-p '*.go'` |
24+
| `-i IGN` | `--ignore=IGN` | File patterns to ignore | `-i '*-go-tmp-umask'` |
25+
| `-b BLD` | `--build=BLD` | Build command to execute | `-b 'go build'` |
26+
| `-s SRV` | `--server=SRV` | Server command to run after successful build | `-s './webserver start'` |
27+
| `-w ERR` | `--error-server=ERR` | Web server address in case of an error | `-w 0.0.0.0:1337` |
28+
29+
30+
## Pattern matching
31+
32+
Pattern matching is executed against the whole path of the file using [fnmatch](https://github.com/tzvetkoff-go/fnmatch).
33+
34+
If you pass `--directory=.` (the default) the path that will be tested against the pattern will be `dir/path`.
35+
36+
If you pass `--directory=$PWD` the path will be `/home/user/project-root/dir/path`.
37+
38+
This allows you to do things like `--ignore=.git*`, although for this you'd rather use `--exclude-dir=.git`.

go.mod

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module github.com/tzvetkoff-go/nwatch
2+
3+
go 1.17
4+
5+
require (
6+
github.com/fsnotify/fsnotify v1.5.1
7+
github.com/tzvetkoff-go/fnmatch v0.0.0-20220210160758-879480b5e662
8+
github.com/tzvetkoff-go/optparse v0.0.0-20220208065226-bfc7550e31df
9+
)
10+
11+
require golang.org/x/sys v0.0.0-20220207234003-57398862261d // indirect

go.sum

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
2+
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
3+
github.com/tzvetkoff-go/fnmatch v0.0.0-20220210160758-879480b5e662 h1:8V/L04n475+FwC8Nt+FgJxgUOMFr2leI+ZVUevkTRQU=
4+
github.com/tzvetkoff-go/fnmatch v0.0.0-20220210160758-879480b5e662/go.mod h1:iqVyINO86ItLjWSIUosYiSnW/KK+TL3ZUtouysxLass=
5+
github.com/tzvetkoff-go/optparse v0.0.0-20220208065226-bfc7550e31df h1:I9jIY5chK3vaUS4RWUyD/aZMt8yULWAWTnGJxhJHnIM=
6+
github.com/tzvetkoff-go/optparse v0.0.0-20220208065226-bfc7550e31df/go.mod h1:Dx9SiqlayuubxXMp8FXQkbRvq1IiG8uL71jx5yuvxEE=
7+
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
8+
golang.org/x/sys v0.0.0-20220207234003-57398862261d h1:Bm7BNOQt2Qv7ZqysjeLjgCBanX+88Z/OtdvsrEv1Djc=
9+
golang.org/x/sys v0.0.0-20220207234003-57398862261d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

nwatch.go

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
///usr/bin/env true; exec /usr/bin/env go run "$0" "$@"
2+
package main
3+
4+
import (
5+
"context"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"os/exec"
11+
"os/signal"
12+
"path"
13+
"strings"
14+
"sync"
15+
"syscall"
16+
"time"
17+
18+
"github.com/tzvetkoff-go/optparse"
19+
20+
"github.com/tzvetkoff-go/fnmatch"
21+
"github.com/tzvetkoff-go/nwatch/pkg/watcher"
22+
)
23+
24+
// usage ...
25+
func usage(f io.Writer, name string) {
26+
fmt.Fprintln(f, "Usage:")
27+
fmt.Fprintf(f, " %s [options]\n", name)
28+
fmt.Fprintln(f)
29+
30+
fmt.Fprintln(f, "Options:")
31+
fmt.Fprintln(f, " -h, --help Print help and exit")
32+
fmt.Fprintln(f, " -v, --version Print version and exit")
33+
fmt.Fprintln(f, " -V, --verbose Verbose output")
34+
fmt.Fprintln(f, " -d DIR, --directory=DIR Directories to watch")
35+
fmt.Fprintln(f, " -e EXC, --exclude-dir=EXC Directories to exclude")
36+
fmt.Fprintln(f, " -p PAT, --pattern=PAT File patterns to match")
37+
fmt.Fprintln(f, " -i IGN, --ignore=IGN File patterns to ignore")
38+
fmt.Fprintln(f, " -b BLD, --build=BLD Build command to execute")
39+
fmt.Fprintln(f, " -s SRV, --server=SRV Server command to run after successful build")
40+
fmt.Fprintln(f, " -w ERR, --error-server=ERR Web server address in case of an error")
41+
42+
if f == os.Stderr {
43+
os.Exit(1)
44+
}
45+
46+
os.Exit(0)
47+
}
48+
49+
// mu ...
50+
var mu sync.Mutex
51+
52+
// lastBuildOutput ...
53+
var lastBuildOutput []byte
54+
55+
// runBuild ...
56+
func runBuild(command string) error {
57+
mu.Lock()
58+
defer mu.Unlock()
59+
60+
verbose("runBuild: %s", command)
61+
62+
cmd := exec.Command("/bin/sh", "-c", command)
63+
out, err := cmd.CombinedOutput()
64+
if err != nil {
65+
lastBuildOutput = out
66+
fmt.Fprintln(os.Stderr, "-------- build error: --------")
67+
fmt.Fprintln(os.Stderr, strings.TrimSpace(string(out)))
68+
fmt.Fprintln(os.Stderr, "------------------------------")
69+
}
70+
71+
return err
72+
}
73+
74+
// serverCmd ...
75+
var serverCmd *exec.Cmd
76+
77+
// runServer ...
78+
func runServer(command string) {
79+
mu.Lock()
80+
defer mu.Unlock()
81+
82+
verbose("runServer: %s", command)
83+
84+
if serverCmd != nil && serverCmd.Process != nil {
85+
_ = serverCmd.Process.Kill()
86+
serverCmd.Process.Wait()
87+
serverCmd = nil
88+
}
89+
90+
if errorServerChan != nil {
91+
errorServerChan <- true
92+
if errorServerChan != nil {
93+
close(errorServerChan)
94+
}
95+
errorServerChan = nil
96+
}
97+
98+
serverCmd = exec.Command("/bin/sh", "-c", command)
99+
serverCmd.Stdout = os.Stdout
100+
serverCmd.Stderr = os.Stderr
101+
err := serverCmd.Start()
102+
if err != nil {
103+
serverCmd = nil
104+
fmt.Fprintln(os.Stderr, "-------- server error: --------")
105+
fmt.Fprintln(os.Stderr, strings.TrimSpace(err.Error()))
106+
fmt.Fprintln(os.Stderr, "-------------------------------")
107+
}
108+
}
109+
110+
// errorServerChan ...
111+
var errorServerChan chan bool
112+
113+
// runErrorServer ...
114+
func runErrorServer(address string) {
115+
mu.Lock()
116+
defer mu.Unlock()
117+
118+
verbose("runErrorServer: %s", address)
119+
120+
if serverCmd != nil && serverCmd.Process != nil {
121+
_ = serverCmd.Process.Kill()
122+
serverCmd.Process.Wait()
123+
serverCmd = nil
124+
}
125+
126+
if errorServerChan != nil {
127+
errorServerChan <- true
128+
if errorServerChan != nil {
129+
close(errorServerChan)
130+
}
131+
errorServerChan = nil
132+
}
133+
134+
s := &http.Server{
135+
Addr: address,
136+
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
137+
w.Header().Add("Content-Type", "text/plain")
138+
w.Write(lastBuildOutput)
139+
}),
140+
}
141+
errorServerChan = make(chan bool, 1)
142+
go func() {
143+
<-errorServerChan
144+
if errorServerChan != nil {
145+
close(errorServerChan)
146+
}
147+
errorServerChan = nil
148+
s.Shutdown(context.TODO())
149+
}()
150+
go func() {
151+
e := s.ListenAndServe()
152+
fmt.Println(e)
153+
}()
154+
}
155+
156+
// matchAny ...
157+
func matchAny(s string, patterns []string) bool {
158+
for _, pattern := range patterns {
159+
if fnmatch.Match(pattern, s) {
160+
return true
161+
}
162+
}
163+
164+
return false
165+
}
166+
167+
// pVerbose ...
168+
var pVerbose = optparse.Bool("verbose", 'V', false)
169+
170+
// verbose ...
171+
func verbose(format string, args ...interface{}) {
172+
if *pVerbose {
173+
fmt.Fprintf(os.Stderr, ">> "+format+"\n", args...)
174+
}
175+
}
176+
177+
// main ...
178+
func main() {
179+
// Options
180+
pHelp := optparse.Bool("help", 'h', false)
181+
pVersion := optparse.Bool("version", 'v', false)
182+
pDirectories := optparse.StringList("directory", 'd')
183+
pExcludes := optparse.StringList("exclude", 'e')
184+
pPatterns := optparse.StringList("pattern", 'p')
185+
pIgnores := optparse.StringList("ignore", 'i')
186+
pBuild := optparse.String("build", 'b', "")
187+
pServer := optparse.String("server", 's', "")
188+
pErrorServer := optparse.String("error-server", 'w', "")
189+
190+
// Parse
191+
args, err := optparse.Parse()
192+
if err != nil {
193+
fmt.Fprintf(os.Stderr, "%s: %s\n\n", os.Args[0], err.Error())
194+
usage(os.Stderr, os.Args[0])
195+
}
196+
197+
// We don't accept positional arguments
198+
if len(args) != 0 {
199+
fmt.Fprintf(os.Stderr, "%s: wrong number of arguments (given %d, expected %d)\n\n", os.Args[0], len(args), 0)
200+
usage(os.Stderr, os.Args[0])
201+
}
202+
203+
// Help
204+
if *pHelp {
205+
usage(os.Stdout, os.Args[0])
206+
}
207+
208+
// Version
209+
if *pVersion {
210+
fmt.Println("0.1.0")
211+
os.Exit(0)
212+
}
213+
214+
// Default to current directory
215+
if len(*pDirectories) == 0 {
216+
*pDirectories = []string{"."}
217+
}
218+
219+
// Default to *
220+
if len(*pPatterns) == 0 {
221+
*pPatterns = []string{"*"}
222+
}
223+
224+
// Build command is required
225+
if *pBuild == "" {
226+
fmt.Fprintf(os.Stderr, "%s: --build is required\n\n", os.Args[0])
227+
usage(os.Stderr, os.Args[0])
228+
}
229+
230+
// Create watcher
231+
watcher, err := watcher.NewWatcher(*pExcludes)
232+
if err != nil {
233+
panic(err)
234+
}
235+
236+
// Watch in the background
237+
go func() {
238+
ticker := time.Tick(100 * time.Millisecond)
239+
hasChanges := false
240+
241+
for {
242+
select {
243+
case evt := <-watcher.Events:
244+
verbose("evt: %s", evt)
245+
246+
basename := path.Base(evt)
247+
248+
if !matchAny(basename, *pPatterns) {
249+
verbose("no-match: %q does not match any of %q", basename, *pPatterns)
250+
continue
251+
}
252+
if matchAny(basename, *pIgnores) {
253+
verbose("match-ignore: %q matches at least one of %q", basename, *pIgnores)
254+
continue
255+
}
256+
257+
hasChanges = true
258+
case <-ticker:
259+
if hasChanges {
260+
hasChanges = false
261+
262+
if runBuild(*pBuild) == nil && *pServer != "" {
263+
runServer(*pServer)
264+
} else if *pErrorServer != "" {
265+
runErrorServer(*pErrorServer)
266+
}
267+
}
268+
}
269+
}
270+
}()
271+
272+
// Exit strategy
273+
ch := make(chan os.Signal)
274+
go func() {
275+
<-ch
276+
fmt.Println("\nAborting...")
277+
watcher.Close()
278+
}()
279+
signal.Notify(ch, syscall.SIGINT)
280+
signal.Notify(ch, syscall.SIGTERM)
281+
282+
// Run initial build if we have a server command passed
283+
if *pServer != "" && runBuild(*pBuild) == nil {
284+
runServer(*pServer)
285+
} else if *pErrorServer != "" {
286+
runErrorServer(*pErrorServer)
287+
}
288+
289+
// Add directories to watch
290+
for _, directory := range *pDirectories {
291+
watcher.Add(directory)
292+
}
293+
294+
// Finally, watch
295+
watcher.Run()
296+
}

0 commit comments

Comments
 (0)