├── .gitignore ├── .wu.json ├── LICENSE ├── README.md ├── command ├── command.go └── empty.go ├── configs.go ├── main.go └── runner ├── runner.go └── utils.go /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/go 3 | 4 | ### Go ### 5 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 6 | *.o 7 | *.a 8 | *.so 9 | 10 | # Folders 11 | _obj 12 | _test 13 | 14 | # Architecture specific extensions/prefixes 15 | *.[568vq] 16 | [568vq].out 17 | 18 | *.cgo1.go 19 | *.cgo2.c 20 | _cgo_defun.c 21 | _cgo_gotypes.go 22 | _cgo_export.* 23 | 24 | _testmain.go 25 | 26 | *.exe 27 | *.test 28 | *.prof 29 | wu 30 | -------------------------------------------------------------------------------- /.wu.json: -------------------------------------------------------------------------------- 1 | { 2 | "Directory": ".", 3 | "Patterns": [ 4 | "*.go" 5 | ], 6 | "Command": [ 7 | "go", 8 | "build" 9 | ] 10 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | wu - A watch utility written in Go 2 | Copyright © 2016 Chase Zhang 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 1. Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 3. Neither the name of the organization nor the 13 | names of its contributors may be used to endorse or promote products 14 | derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY Chase Zhang ''AS IS'' AND ANY 17 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL Chase Zhang BE LIABLE FOR ANY 20 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wu (呜~) 2 | 3 | A minimal **W**atch **U**tility who can run and restart specified command in 4 | response to file changes automatically. 5 | 6 | This utility is intended to provide a tiny tool to automate the Edit-Build-Run 7 | loop of development. Although it is quite similar to watch tasks of Grunt or Gulp, 8 | `wu` is designed to be just a single command with simplest interfaces to work with. 9 | 10 | # Install 11 | 12 | To install `wu` from source code, you have to install Golang's tool chain first. 13 | Then run: 14 | 15 | ``` 16 | go get github.com/shanzi/wu 17 | go install github.com/shanzi/wu 18 | ``` 19 | 20 | Precompiled version can be found [here](https://github.com/shanzi/wu/releases). 21 | 22 | # Usage 23 | 24 | Run `wu -h` for help message: 25 | 26 | ``` 27 | Usage: wu [options] [command] 28 | -config string 29 | Config file (default ".wu.json") 30 | -dir string 31 | Directory to watch 32 | -pattern string 33 | Patterns to filter filenames 34 | -save 35 | Save options to conf 36 | ``` 37 | 38 | # Examples 39 | 40 | You just run you command with `wu`, `wu` will run your command at the start, 41 | try to terminate previous when new changes take place and then start running a new one. 42 | You can stop the process by sending a `SIGINT` signal (typically by `CTRL-C`): 43 | 44 | ``` 45 | wu sleep 10 46 | ``` 47 | 48 | Output: 49 | ``` 50 | Start watching... 51 | - Running command: sleep 10 52 | - Terminated. 53 | File changed: /path/to/changed/file.txt 54 | - Running command: sleep 10 55 | - Done. 56 | File changed: /path/to/changed/file.txt 57 | - Running command: sleep 10 58 | - Done. 59 | ^C 60 | Shutting down... 61 | ``` 62 | 63 | Usually you can only run one command with `wu`, but it doesn't prevent you from 64 | running complex command with `sh`, `bash` or other shell command: 65 | 66 | ``` 67 | wu sh -c 'echo "START" && sleep 5 && echo "END"' 68 | ``` 69 | 70 | Output: 71 | ``` 72 | Start watching... 73 | - Running command: sh -c echo "START" && sleep 5 && echo "END" 74 | START 75 | END 76 | - Done. 77 | ``` 78 | 79 | You can specified a pattern to filter the files to watch. Multiple patterns 80 | should be seperated by spaces or commas: 81 | 82 | ``` 83 | wu -pattern="*.js, *.html" 84 | ``` 85 | 86 | Output (If no command specified, `wu` just log changed files): 87 | 88 | ``` 89 | Start watching... 90 | File changed: /path/to/changed/file.js 91 | File changed: /path/to/changed/file.html 92 | ``` 93 | 94 | One practical use case is to use `wu` with some light weight web frameworks, 95 | For example, you can start a server written in go by: 96 | 97 | ``` 98 | wu -pattern="*.go" go run main.go 99 | ``` 100 | 101 | `wu` will try to read config file under current directory at the start (default: `.wu.json`), 102 | you can user `-config` flag to specify the config file by hand. Use `-save` flag to save 103 | current options. 104 | 105 | ``` 106 | wu -pattern="*.go" -save go build 107 | # A `.wu.json` has been created 108 | 109 | wu # Running `wu` without any options, it will read from `.wu.json` 110 | ``` 111 | 112 | # LICENSE 113 | 114 | See [LICENSE](./LICENSE) 115 | -------------------------------------------------------------------------------- /command/command.go: -------------------------------------------------------------------------------- 1 | // Package command provides a wrap over os/exec for easier command handling 2 | package command 3 | 4 | import ( 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "sync" 11 | "syscall" 12 | "time" 13 | ) 14 | 15 | type Command interface { 16 | String() string 17 | Start(delay time.Duration) 18 | Terminate(wait time.Duration) 19 | } 20 | 21 | type command struct { 22 | name string 23 | args []string 24 | cmd *exec.Cmd 25 | mutex *sync.Mutex 26 | exited chan struct{} 27 | } 28 | 29 | func New(cmdstring []string) Command { 30 | if len(cmdstring) == 0 { 31 | return Empty() 32 | } 33 | 34 | name := cmdstring[0] 35 | args := cmdstring[1:] 36 | 37 | return &command{ 38 | name, 39 | args, 40 | nil, 41 | &sync.Mutex{}, 42 | nil, 43 | } 44 | } 45 | 46 | func (c *command) String() string { 47 | return fmt.Sprintf("%s %s", c.name, strings.Join(c.args, " ")) 48 | } 49 | 50 | func (c *command) Start(delay time.Duration) { 51 | time.Sleep(delay) // delay for a while to avoid start too frequently 52 | 53 | c.mutex.Lock() 54 | defer c.mutex.Unlock() 55 | 56 | if c.cmd != nil && !c.cmd.ProcessState.Exited() { 57 | log.Fatalln("Failed to start command: previous command hasn't exit.") 58 | } 59 | 60 | cmd := exec.Command(c.name, c.args...) 61 | 62 | cmd.Stdin = os.Stdin 63 | cmd.Stdout = os.Stdout 64 | cmd.Stderr = os.Stdout // Redirect stderr of sub process to stdout of parent 65 | 66 | // Make process group id available for the command to run 67 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 68 | 69 | log.Println("- Running command:", c.String()) 70 | 71 | err := cmd.Start() 72 | exited := make(chan struct{}) 73 | 74 | if err != nil { 75 | log.Println("Failed:", err) 76 | } else { 77 | c.cmd = cmd 78 | c.exited = exited 79 | 80 | go func() { 81 | defer func() { 82 | exited <- struct{}{} 83 | close(exited) 84 | }() 85 | 86 | cmd.Wait() 87 | if cmd.ProcessState.Success() { 88 | log.Println("- Done.") 89 | } else { 90 | log.Println("- Terminated.") 91 | } 92 | }() 93 | } 94 | } 95 | 96 | func (c *command) Terminate(wait time.Duration) { 97 | c.mutex.Lock() 98 | defer c.mutex.Unlock() 99 | // set c.cmd to nil after finished 100 | defer func() { 101 | c.cmd = nil 102 | }() 103 | 104 | if c.cmd == nil { 105 | // No command is runing, just return 106 | return 107 | } 108 | 109 | if c.cmd.ProcessState != nil && c.cmd.ProcessState.Exited() { 110 | // Command has exited, just return 111 | return 112 | } 113 | 114 | log.Println("- Stopping") 115 | // Try to stop the process by sending a SIGINT signal 116 | if err := c.kill(syscall.SIGINT); err != nil { 117 | log.Println("Failed to terminate process with interrupt:", err) 118 | } 119 | 120 | for { 121 | select { 122 | case <-c.exited: 123 | return 124 | case <-time.After(wait): 125 | log.Println("- Killing process") 126 | c.kill(syscall.SIGTERM) 127 | } 128 | } 129 | } 130 | 131 | func (c *command) kill(sig syscall.Signal) error { 132 | cmd := c.cmd 133 | pgid, err := syscall.Getpgid(cmd.Process.Pid) 134 | if err == nil { 135 | return syscall.Kill(-pgid, sig) 136 | } 137 | return err 138 | } 139 | -------------------------------------------------------------------------------- /command/empty.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import "time" 4 | 5 | // An empty command is a command that do nothing 6 | type empty string 7 | 8 | func Empty() Command { 9 | return empty("Empty command") 10 | } 11 | 12 | func (c empty) String() string { 13 | return string(c) 14 | } 15 | 16 | func (c empty) Start(delay time.Duration) { 17 | // Start an empty command just do nothing but delay for given duration 18 | <-time.After(delay) 19 | } 20 | 21 | func (c empty) Terminate(wait time.Duration) { 22 | // Terminate empty command just return immediately without any error 23 | } 24 | -------------------------------------------------------------------------------- /configs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "regexp" 10 | "strings" 11 | ) 12 | 13 | type Configs struct { 14 | Directory string 15 | Patterns []string 16 | Command []string 17 | } 18 | 19 | var configfile = flag.String("config", ".wu.json", "Config file") 20 | var directory = flag.String("dir", "", "Directory to watch") 21 | var pattern = flag.String("pattern", "", "Patterns to filter filenames") 22 | var saveconf = flag.Bool("save", false, "Save options to conf") 23 | 24 | func init() { 25 | flag.Usage = func() { 26 | fmt.Fprintf(os.Stderr, "Usage: %s [options] [command]\n", os.Args[0]) 27 | flag.PrintDefaults() 28 | } 29 | } 30 | 31 | func getConfigs() Configs { 32 | flag.Parse() 33 | 34 | conf := readConfigFile() 35 | 36 | if dir := parseDirectory(); dir != "" { 37 | conf.Directory = dir 38 | } 39 | 40 | if patterns := parsePatterns(); patterns != nil { 41 | conf.Patterns = patterns 42 | } 43 | 44 | if command := parseCommand(); command != nil { 45 | conf.Command = command 46 | } 47 | 48 | if *saveconf { 49 | saveConfigFile(conf) 50 | } 51 | 52 | return conf 53 | } 54 | 55 | func readConfigFile() Configs { 56 | file, err := os.Open(*configfile) 57 | defer file.Close() 58 | 59 | if err == nil { 60 | log.Println("Reading options from", *configfile) 61 | var conf Configs 62 | if err := json.NewDecoder(file).Decode(&conf); err != nil { 63 | log.Fatalln("Failed to parse config file:", err) 64 | } 65 | return conf 66 | } 67 | return Configs{".", []string{"*"}, []string{}} 68 | } 69 | 70 | func saveConfigFile(conf Configs) { 71 | log.Println("Saving options to", *configfile) 72 | file, err := os.Create(*configfile) 73 | defer file.Close() 74 | 75 | if err != nil { 76 | log.Fatalln("Failed to open config file:", err) 77 | } 78 | if bytes, err := json.MarshalIndent(conf, "", " "); err == nil { 79 | if _, err := file.Write(bytes); err != nil { 80 | log.Fatalln("Failed to write config file:", err) 81 | } 82 | } else { 83 | log.Fatalln("Failed to encode options:", err) 84 | } 85 | } 86 | 87 | func parseDirectory() string { 88 | dir := *directory 89 | if info, err := os.Stat(dir); err == nil { 90 | if !info.IsDir() { 91 | log.Fatal(dir, "is not a directory") 92 | } 93 | } 94 | return dir 95 | } 96 | 97 | func parsePatterns() []string { 98 | pat := strings.Trim(*pattern, " ") 99 | if pat == "" { 100 | return nil 101 | } 102 | 103 | patternSep, _ := regexp.Compile("[,\\s]+") 104 | 105 | patternMap := make(map[string]bool) 106 | ret := []string{} 107 | 108 | for _, part := range patternSep.Split(pat, -1) { 109 | patternMap[part] = true 110 | } 111 | for part := range patternMap { 112 | ret = append(ret, part) 113 | } 114 | 115 | return ret 116 | } 117 | 118 | func parseCommand() []string { 119 | if flag.NArg() == 0 { 120 | return nil 121 | } 122 | return flag.Args() 123 | } 124 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package main provides entry for the command line tool 2 | package main 3 | 4 | import ( 5 | "github.com/shanzi/wu/command" 6 | "github.com/shanzi/wu/runner" 7 | "log" 8 | "os" 9 | "os/signal" 10 | "path/filepath" 11 | ) 12 | 13 | func init() { 14 | log.SetFlags(0) // Turn off date and time on standard logger 15 | } 16 | 17 | func main() { 18 | conf := getConfigs() 19 | 20 | abspath, _ := filepath.Abs(conf.Directory) 21 | patterns := conf.Patterns 22 | cmd := command.New(conf.Command) 23 | 24 | r := runner.New(abspath, patterns, cmd) 25 | 26 | go func() { 27 | // Handle interrupt signal 28 | ch := make(chan os.Signal) 29 | signal.Notify(ch, os.Interrupt) 30 | 31 | <-ch 32 | r.Exit() 33 | }() 34 | 35 | r.Start() 36 | } 37 | -------------------------------------------------------------------------------- /runner/runner.go: -------------------------------------------------------------------------------- 1 | // Package manager provides manager for running watch and exec loop 2 | package runner 3 | 4 | import ( 5 | "github.com/shanzi/wu/command" 6 | "log" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | type Runner interface { 12 | Path() string 13 | Patterns() []string 14 | Command() command.Command 15 | Start() 16 | Exit() 17 | } 18 | 19 | type runner struct { 20 | path string 21 | patterns []string 22 | command command.Command 23 | 24 | abort chan struct{} 25 | } 26 | 27 | func New(path string, patterns []string, command command.Command) Runner { 28 | return &runner{ 29 | path: path, 30 | patterns: patterns, 31 | command: command, 32 | } 33 | } 34 | 35 | func (r *runner) Path() string { 36 | return r.path 37 | } 38 | 39 | func (r *runner) Patterns() []string { 40 | return r.patterns 41 | } 42 | 43 | func (r *runner) Command() command.Command { 44 | return r.command 45 | } 46 | 47 | func (r *runner) Start() { 48 | r.abort = make(chan struct{}) 49 | changed, err := watch(r.path, r.abort) 50 | if err != nil { 51 | log.Fatal("Failed to initialize watcher:", err) 52 | } 53 | matched := match(changed, r.patterns) 54 | log.Println("Start watching...") 55 | 56 | // Run the command once at initially 57 | r.command.Start(200 * time.Millisecond) 58 | for fp := range matched { 59 | files := gather(fp, matched, 500*time.Millisecond) 60 | 61 | // Terminate previous running command 62 | r.command.Terminate(2 * time.Second) 63 | 64 | log.Println("File changed:", strings.Join(files, ", ")) 65 | 66 | // Run new command 67 | r.command.Start(200 * time.Millisecond) 68 | } 69 | } 70 | 71 | func (r *runner) Exit() { 72 | log.Println() 73 | log.Println("Shutting down...") 74 | 75 | r.abort <- struct{}{} 76 | close(r.abort) 77 | r.command.Terminate(2 * time.Second) 78 | } 79 | -------------------------------------------------------------------------------- /runner/utils.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "github.com/fsnotify/fsnotify" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "sort" 9 | "time" 10 | ) 11 | 12 | func watch(path string, abort <-chan struct{}) (<-chan string, error) { 13 | watcher, err := fsnotify.NewWatcher() 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | for p := range list(path) { 19 | err = watcher.Add(p) 20 | if err != nil { 21 | log.Printf("Failed to watch: %s, error: %s", p, err) 22 | } 23 | } 24 | 25 | out := make(chan string) 26 | go func() { 27 | defer close(out) 28 | defer watcher.Close() 29 | for { 30 | select { 31 | case <-abort: 32 | // Abort watching 33 | err := watcher.Close() 34 | if err != nil { 35 | log.Fatalln("Failed to stop watch") 36 | } 37 | return 38 | case fp := <-watcher.Events: 39 | if fp.Op == fsnotify.Create { 40 | info, err := os.Stat(fp.Name) 41 | if err == nil && info.IsDir() { 42 | // Add newly created sub directories to watch list 43 | watcher.Add(fp.Name) 44 | } 45 | } 46 | out <- fp.Name 47 | case err := <-watcher.Errors: 48 | log.Println("Watch Error:", err) 49 | } 50 | } 51 | }() 52 | 53 | return out, nil 54 | } 55 | 56 | func match(in <-chan string, patterns []string) <-chan string { 57 | out := make(chan string) 58 | 59 | go func() { 60 | defer close(out) 61 | for fp := range in { 62 | info, err := os.Stat(fp) 63 | if os.IsNotExist(err) || !info.IsDir() { 64 | _, fn := filepath.Split(fp) 65 | for _, p := range patterns { 66 | if ok, _ := filepath.Match(p, fn); ok { 67 | out <- fp 68 | } 69 | } 70 | } 71 | } 72 | }() 73 | 74 | return out 75 | } 76 | 77 | func list(root string) <-chan string { 78 | out := make(chan string) 79 | 80 | info, err := os.Stat(root) 81 | if err != nil { 82 | log.Fatalf("Failed to visit %s, error: %s\n", root, err) 83 | } 84 | if !info.IsDir() { 85 | go func() { 86 | defer close(out) 87 | out <- root 88 | }() 89 | 90 | return out 91 | } 92 | 93 | go func() { 94 | defer close(out) 95 | filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 96 | if info.IsDir() { 97 | if err != nil { 98 | log.Printf("Failed to visit directory: %s, error: %s", path, err) 99 | return err 100 | } 101 | out <- path 102 | } 103 | return nil 104 | }) 105 | }() 106 | 107 | return out 108 | } 109 | 110 | // gather delays further operations for a while and gather 111 | // all changes happened in this period 112 | func gather(first string, changes <-chan string, delay time.Duration) []string { 113 | files := make(map[string]bool) 114 | files[first] = true 115 | after := time.After(delay) 116 | loop: 117 | for { 118 | select { 119 | case fp := <-changes: 120 | files[fp] = true 121 | case <-after: 122 | // After the delay, return collected filenames 123 | break loop 124 | } 125 | } 126 | 127 | ret := []string{} 128 | for k := range files { 129 | ret = append(ret, k) 130 | } 131 | 132 | sort.Strings(ret) 133 | return ret 134 | } 135 | --------------------------------------------------------------------------------