├── .gitignore ├── go.mod ├── Dockerfile ├── gok.sh ├── README.md ├── image-tag ├── .circleci └── config.yml ├── go.sum └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /Watch 3 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/weaveworks/Watch 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/fsnotify/fsnotify v1.4.7 7 | golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 // indirect 8 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 // indirect 9 | golang.org/x/tools v0.1.0 // indirect 10 | ) 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.17-alpine AS builder 2 | 3 | RUN mkdir /build 4 | RUN mkdir /app 5 | WORKDIR /app 6 | 7 | COPY . /app 8 | 9 | RUN go build -v -o /build /app 10 | 11 | FROM alpine:3.15 12 | RUN apk add --no-cache curl 13 | RUN mkdir /build 14 | COPY --from=builder /build . 15 | 16 | ENTRYPOINT ["/Watch"] 17 | -------------------------------------------------------------------------------- /gok.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Verifies that go code passes go fmt, go vet, golint, and go test. 4 | # 5 | 6 | o=$(tempfile) 7 | 8 | fail() { 9 | echo Failed 10 | cat $o 11 | exit 1 12 | } 13 | 14 | echo Formatting 15 | gofmt -l $(find . -name '*.go') 2>&1 > $o 16 | test $(wc -l $o | awk '{ print $1 }') = "0" || fail 17 | 18 | echo Vetting 19 | go vet ./... 2>&1 > $o || fail 20 | 21 | echo Testing 22 | go test ./... 2>&1 > $o || fail 23 | 24 | echo Linting 25 | golint . 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Watch 2 | ===== 3 | 4 | Usage: ``Watch [-v] [-t] [-p ] [-x ] `` 5 | 6 | Watches for changes in a directory tree, and runs a command when something 7 | changed. Note that only changes that happen while Watch is running are 8 | detected. Also, a failed execution of the command is not retried. 9 | 10 | -t deprecated, always true. 11 | 12 | -v enables verbose debugging output 13 | 14 | -p specifies the path to watch (if it is a directory then it watches recursively) 15 | 16 | -x specifies a regexp used to exclude files and directories from the watcher. 17 | -------------------------------------------------------------------------------- /image-tag: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | OUTPUT=--quiet 8 | if [ "${1:-}" = '--show-diff' ]; then 9 | OUTPUT= 10 | fi 11 | 12 | # If a tagged version, just print that tag 13 | HEAD_TAGS=$(git tag --points-at HEAD) 14 | if [ -n "${HEAD_TAGS}" ] ; then 15 | echo ${HEAD_TAGS} 16 | exit 0 17 | fi 18 | 19 | 20 | WORKING_SUFFIX=$(if ! git diff --exit-code ${OUTPUT} HEAD >&2; \ 21 | then echo "-wip"; \ 22 | else echo ""; \ 23 | fi) 24 | BRANCH_PREFIX=$(git rev-parse --abbrev-ref HEAD) 25 | 26 | # replace spaces with dash 27 | BRANCH_PREFIX=${BRANCH_PREFIX// /-} 28 | # next, replace slashes with dash 29 | BRANCH_PREFIX=${BRANCH_PREFIX//[\/\\]/-} 30 | # now, clean out anything that's not alphanumeric or an dash 31 | BRANCH_PREFIX=${BRANCH_PREFIX//[^a-zA-Z0-9-]/} 32 | # finally, lowercase with TR 33 | BRANCH_PREFIX=`echo -n $BRANCH_PREFIX | tr A-Z a-z` 34 | 35 | echo "$BRANCH_PREFIX-$(git rev-parse --short HEAD)$WORKING_SUFFIX" 36 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | workflows: 4 | build_deploy: 5 | jobs: 6 | - tests 7 | - build 8 | 9 | jobs: 10 | tests: 11 | working_directory: /go/src/github.com/weaveworks/Watch 12 | docker: 13 | - image: circleci/golang:latest 14 | steps: 15 | - checkout 16 | - run: go get -u golang.org/x/lint/golint 17 | - run: ./gok.sh 18 | 19 | build: 20 | docker: 21 | - image: cimg/base:stable 22 | steps: 23 | - setup_remote_docker: 24 | version: 20.10.7 25 | - checkout 26 | - run: | 27 | docker context create buildcontext 28 | docker buildx create buildcontext --use 29 | - run: | 30 | docker buildx build --platform linux/arm/v7,linux/arm64/v8,linux/amd64 --tag docker.io/weaveworks/watch:$(./image-tag) . 31 | - deploy: 32 | name: push image 33 | command: | 34 | if [ -z "${CIRCLE_TAG}" -a "${CIRCLE_BRANCH}" == "master" ]; then 35 | docker login -u "$DOCKER_REGISTRY_USER" -p "$DOCKER_REGISTRY_PASSWORD" docker.io 36 | docker buildx build --platform linux/arm/v7,linux/arm64/v8,linux/amd64 --push --tag docker.io/weaveworks/watch:$(./image-tag) . 37 | docker buildx build --platform linux/arm/v7,linux/arm64/v8,linux/amd64 --push --tag docker.io/weaveworks/watch . 38 | fi 39 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 2 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 3 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 4 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 5 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 6 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 7 | golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI= 8 | golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 9 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 10 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 11 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 12 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 13 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 14 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 15 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 16 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 17 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 18 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 19 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k= 20 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 21 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 22 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 23 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 24 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 25 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 26 | golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY= 27 | golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= 28 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 29 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 30 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 31 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | "os/exec" 11 | "path" 12 | "reflect" 13 | "regexp" 14 | "strconv" 15 | "strings" 16 | "syscall" 17 | "time" 18 | 19 | "github.com/fsnotify/fsnotify" 20 | ) 21 | 22 | var ( 23 | debug = flag.Bool("v", false, "Enable verbose debugging output") 24 | _ = flag.Bool("t", true, "Run in a terminal (deprecated, always true)") 25 | exclude = flag.String("x", "", "Exclude files and directories matching this regular expression") 26 | watchPath = flag.String("p", ".", "The path to watch") 27 | ) 28 | 29 | var excludeRe *regexp.Regexp 30 | 31 | const rebuildDelay = 200 * time.Millisecond 32 | 33 | // The name of the syscall.SysProcAttr.Setpgid field. 34 | const setpgidName = "Setpgid" 35 | 36 | var ( 37 | hasSetPGID bool 38 | killChan = make(chan time.Time, 1) 39 | ) 40 | 41 | func main() { 42 | flag.Usage = func() { 43 | fmt.Fprintf(os.Stderr, "Usage of %s: [flags] command [command args…]\n", os.Args[0]) 44 | flag.PrintDefaults() 45 | } 46 | flag.Parse() 47 | 48 | t := reflect.TypeOf(syscall.SysProcAttr{}) 49 | f, ok := t.FieldByName(setpgidName) 50 | if ok && f.Type.Kind() == reflect.Bool { 51 | debugPrint("syscall.SysProcAttr.Setpgid exists and is a bool") 52 | hasSetPGID = true 53 | } else if ok { 54 | debugPrint("syscall.SysProcAttr.Setpgid exists but is a %s, not a bool", f.Type.Kind()) 55 | } else { 56 | debugPrint("syscall.SysProcAttr.Setpgid does not exist") 57 | } 58 | 59 | if flag.NArg() == 0 { 60 | flag.Usage() 61 | os.Exit(1) 62 | } 63 | 64 | if *exclude != "" { 65 | var err error 66 | excludeRe, err = regexp.Compile(*exclude) 67 | if err != nil { 68 | log.Fatalln("Bad regexp: ", *exclude) 69 | } 70 | } 71 | 72 | timer := time.NewTimer(0) 73 | <-timer.C // Avoid to run command just after startup. 74 | changes := startWatching(*watchPath) 75 | lastRun := time.Now() 76 | lastChange := lastRun 77 | 78 | for { 79 | select { 80 | case lastChange = <-changes: 81 | if lastRun.Before(lastChange) { 82 | timer.Reset(rebuildDelay) 83 | } 84 | 85 | case <-timer.C: 86 | lastRun = run() 87 | } 88 | } 89 | } 90 | 91 | func run() time.Time { 92 | cmd := exec.Command(flag.Arg(0), flag.Args()[1:]...) 93 | cmd.Stdout = os.Stdout 94 | cmd.Stderr = os.Stdout 95 | 96 | if hasSetPGID { 97 | var attr syscall.SysProcAttr 98 | reflect.ValueOf(&attr).Elem().FieldByName(setpgidName).SetBool(true) 99 | cmd.SysProcAttr = &attr 100 | } 101 | fmt.Print(strings.Join(flag.Args(), " ") + "\n") 102 | start := time.Now() 103 | if err := cmd.Start(); err != nil { 104 | fmt.Print("fatal: " + err.Error() + "\n") 105 | return time.Now() 106 | } 107 | if s := wait(start, cmd); s != 0 { 108 | fmt.Print("exit status " + strconv.Itoa(s) + "\n") 109 | } 110 | fmt.Println(time.Now()) 111 | 112 | return time.Now() 113 | } 114 | 115 | func wait(start time.Time, cmd *exec.Cmd) int { 116 | var n int 117 | ticker := time.NewTicker(5 * time.Millisecond) 118 | defer ticker.Stop() 119 | for { 120 | select { 121 | case t := <-killChan: 122 | if t.Before(start) { 123 | continue 124 | } 125 | p := cmd.Process.Pid 126 | if hasSetPGID { 127 | p = -p 128 | } 129 | if n == 0 { 130 | debugPrint("Sending SIGTERM") 131 | syscall.Kill(p, syscall.SIGTERM) 132 | } else { 133 | debugPrint("Sending SIGKILL") 134 | syscall.Kill(p, syscall.SIGKILL) 135 | } 136 | n++ 137 | 138 | case <-ticker.C: 139 | var status syscall.WaitStatus 140 | p := cmd.Process.Pid 141 | switch q, err := syscall.Wait4(p, &status, syscall.WNOHANG, nil); { 142 | case err != nil: 143 | panic(err) 144 | case q > 0: 145 | cmd.Wait() // Clean up any goroutines created by cmd.Start. 146 | return status.ExitStatus() 147 | } 148 | } 149 | } 150 | } 151 | 152 | func startWatching(p string) <-chan time.Time { 153 | w, err := fsnotify.NewWatcher() 154 | if err != nil { 155 | panic(err) 156 | } 157 | 158 | switch isdir, err := isDir(p); { 159 | case err != nil: 160 | log.Fatalf("Failed to watch %s: %s", p, err) 161 | case isdir: 162 | watchDir(w, p) 163 | default: 164 | watch(w, p) 165 | } 166 | 167 | changes := make(chan time.Time) 168 | 169 | go sendChanges(w, changes) 170 | 171 | return changes 172 | } 173 | 174 | func sendChanges(w *fsnotify.Watcher, changes chan<- time.Time) { 175 | for { 176 | select { 177 | case err := <-w.Errors: 178 | log.Fatalf("Watcher error: %s\n", err) 179 | 180 | case ev := <-w.Events: 181 | if excludeRe != nil && excludeRe.MatchString(ev.Name) { 182 | debugPrint("ignoring event for excluded %s", ev.Name) 183 | continue 184 | } 185 | time, err := modTime(ev.Name) 186 | if err != nil { 187 | log.Printf("Failed to get event time: %s", err) 188 | continue 189 | } 190 | 191 | debugPrint("%s at %s", ev, time) 192 | 193 | if ev.Op&fsnotify.Create != 0 { 194 | switch isdir, err := isDir(ev.Name); { 195 | case err != nil: 196 | log.Printf("Couldn't check if %s is a directory: %s", ev.Name, err) 197 | continue 198 | 199 | case isdir: 200 | watchDir(w, ev.Name) 201 | } 202 | } 203 | 204 | changes <- time 205 | } 206 | } 207 | } 208 | 209 | func modTime(p string) (time.Time, error) { 210 | switch s, err := os.Stat(p); { 211 | case os.IsNotExist(err): 212 | q := path.Dir(p) 213 | if q == p { 214 | err := errors.New("Failed to find directory for " + p) 215 | return time.Time{}, err 216 | } 217 | return modTime(q) 218 | 219 | case err != nil: 220 | return time.Time{}, err 221 | 222 | default: 223 | return s.ModTime(), nil 224 | } 225 | } 226 | 227 | func watchDir(w *fsnotify.Watcher, p string) { 228 | ents, err := ioutil.ReadDir(p) 229 | switch { 230 | case os.IsNotExist(err): 231 | return 232 | 233 | case err != nil: 234 | log.Printf("Failed to watch %s: %s", p, err) 235 | } 236 | 237 | for _, e := range ents { 238 | sub := path.Join(p, e.Name()) 239 | if excludeRe != nil && excludeRe.MatchString(sub) { 240 | debugPrint("excluding %s", sub) 241 | continue 242 | } 243 | switch isdir, err := isDir(sub); { 244 | case err != nil: 245 | log.Printf("Failed to watch %s: %s", sub, err) 246 | 247 | case isdir: 248 | watchDir(w, sub) 249 | } 250 | } 251 | 252 | watch(w, p) 253 | } 254 | 255 | func watch(w *fsnotify.Watcher, p string) { 256 | debugPrint("Watching %s", p) 257 | 258 | switch err := w.Add(p); { 259 | case os.IsNotExist(err): 260 | debugPrint("%s no longer exists", p) 261 | 262 | case err != nil: 263 | log.Printf("Failed to watch %s: %s", p, err) 264 | } 265 | } 266 | 267 | func isDir(p string) (bool, error) { 268 | switch s, err := os.Stat(p); { 269 | case os.IsNotExist(err): 270 | return false, nil 271 | case err != nil: 272 | return false, err 273 | default: 274 | return s.IsDir(), nil 275 | } 276 | } 277 | 278 | func debugPrint(f string, vals ...interface{}) { 279 | if *debug { 280 | log.Printf("DEBUG: "+f, vals...) 281 | } 282 | } 283 | --------------------------------------------------------------------------------