git.sr.ht/~pingoo/stdx@v0.0.0-20240218134121-094174641f6e/tools/watchgod/main.go (about)

     1  /*
     2  watchgod is a very simple compile daemon for Go.
     3  watchgod watches your .go files in a directory and invokes `go build`
     4  if a file changes.
     5  Examples
     6  In its simplest form, the defaults will do. With the current working directory set
     7  to the source directory you can simply…
     8  
     9  	$ watchgod
    10  
    11  … and it will recompile your code whenever you save a source file.
    12  If you want it to also run your program each time it builds you might add…
    13  
    14  	$ watchgod -command="./MyProgram -my-options"
    15  
    16  … and it will also keep a copy of your program running. Killing the old one and
    17  starting a new one each time you build. For advanced usage you can also supply
    18  the changed file to the command by doing…
    19  
    20  	$ watchgod -command="./MyProgram -my-options %[1]s"
    21  
    22  …but note that this will not be set on the first start.
    23  You may find that you need to exclude some directories and files from
    24  monitoring, such as a .git repository or emacs temporary files…
    25  
    26  	$ watchgod -exclude-dir=.git -exclude=".#*"
    27  
    28  If you want to monitor files other than .go and .c files you might…
    29  
    30  	$ watchgod -include=Makefile -include="*.less" -include="*.tmpl"
    31  
    32  Options
    33  There are command line options.
    34  
    35  	FILE SELECTION
    36  	-directory=XXX    – Which directory to monitor for changes
    37  	-recursive=XXX    – Look into subdirectories
    38  	-exclude-dir=XXX  – Exclude directories matching glob pattern XXX
    39  	-exlude=XXX       – Exclude files whose basename matches glob pattern XXX
    40  	-include=XXX      – Include files whose basename matches glob pattern XXX
    41  	-pattern=XXX      – Include files whose path matches regexp XXX
    42  	MISC
    43  	-log-prefix       - Enable/disable stdout/stderr labelling for the child process
    44  	-graceful-kill    - On supported platforms, send the child process a SIGTERM to
    45  	                    allow it to exit gracefully if possible.
    46  	-graceful-timeout - Duration (in seconds) to wait for graceful kill to complete
    47  	-verbose          - Print information about watched directories.
    48  	ACTIONS
    49  	-build=CCC        – Execute CCC to rebuild when a file changes
    50  	-command=CCC      – Run command CCC after a successful build, stops previous command first
    51  */
    52  package main
    53  
    54  import (
    55  	"bufio"
    56  	"flag"
    57  	"fmt"
    58  	"io"
    59  	"log"
    60  	"os"
    61  	"os/exec"
    62  	"os/signal"
    63  	"path/filepath"
    64  	"regexp"
    65  	"strings"
    66  	"syscall"
    67  	"time"
    68  
    69  	"github.com/fsnotify/fsnotify"
    70  )
    71  
    72  // Milliseconds to wait for the next job to begin after a file change
    73  const WorkDelay = 900
    74  
    75  // Default pattern to match files which trigger a build
    76  const FilePattern = `(.+\.go|.+\.c)$`
    77  
    78  type globList []string
    79  
    80  func (g *globList) String() string {
    81  	return fmt.Sprint(*g)
    82  }
    83  func (g *globList) Set(value string) error {
    84  	*g = append(*g, filepath.Clean(value))
    85  	return nil
    86  }
    87  func (g *globList) Matches(value string) bool {
    88  	for _, v := range *g {
    89  		if match, err := filepath.Match(v, value); err != nil {
    90  			log.Fatalf("Bad pattern \"%s\": %s", v, err.Error())
    91  		} else if match {
    92  			return true
    93  		}
    94  	}
    95  	return false
    96  }
    97  
    98  var (
    99  	flag_directory       = flag.String("directory", ".", "Directory to watch for changes")
   100  	flag_pattern         = flag.String("pattern", FilePattern, "Pattern of watched files")
   101  	flag_command         = flag.String("command", "", "Command to run and restart after build")
   102  	flag_command_stop    = flag.Bool("command-stop", false, "Stop command before building")
   103  	flag_build           = flag.String("build", "", "Command to rebuild after changes")
   104  	flag_build_dir       = flag.String("build-dir", "", "Directory to run build command in.  Defaults to directory")
   105  	flag_run_dir         = flag.String("run-dir", "", "Directory to run command in.  Defaults to directory")
   106  	flag_logprefix       = flag.Bool("log-prefix", true, "Print log timestamps and subprocess stderr/stdout output")
   107  	flag_gracefulkill    = flag.Bool("graceful-kill", false, "Gracefully attempt to kill the child process by sending a SIGTERM first")
   108  	flag_gracefultimeout = flag.Uint("graceful-timeout", 3, "Duration (in seconds) to wait for graceful kill to complete")
   109  	flag_verbose         = flag.Bool("verbose", false, "Be verbose about which directories are watched.")
   110  
   111  	// initialized in main() due to custom type.
   112  	flag_include_dirs  globList
   113  	flag_excludedDirs  globList
   114  	flag_excludedFiles globList
   115  	flag_includedFiles globList
   116  )
   117  
   118  func okColor(format string, args ...interface{}) string {
   119  	return fmt.Sprintf(format, args...)
   120  }
   121  
   122  func failColor(format string, args ...interface{}) string {
   123  	return fmt.Sprintf(format, args...)
   124  }
   125  
   126  // Run `go build` and print the output if something's gone wrong.
   127  func build() bool {
   128  	log.Println(okColor("Running build command!"))
   129  
   130  	args := strings.Split(*flag_build, " ")
   131  	if len(args) == 0 || *flag_build == "" {
   132  		// If the user has specified and empty then we are done.
   133  		return true
   134  	}
   135  
   136  	cmd := exec.Command(args[0], args[1:]...)
   137  
   138  	if *flag_build_dir != "" {
   139  		cmd.Dir = *flag_build_dir
   140  	} else {
   141  		cmd.Dir = *flag_directory
   142  	}
   143  
   144  	output, err := cmd.CombinedOutput()
   145  
   146  	if err == nil {
   147  		log.Println(okColor("Build ok."))
   148  	} else {
   149  		log.Println(failColor("Error while building:\n"), failColor(string(output)))
   150  	}
   151  
   152  	return err == nil
   153  }
   154  
   155  func matchesPattern(pattern *regexp.Regexp, file string) bool {
   156  	return pattern.MatchString(file)
   157  }
   158  
   159  // Accept build jobs and start building when there are no jobs rushing in.
   160  // The inrush protection is WorkDelay milliseconds long, in this period
   161  // every incoming job will reset the timer.
   162  func builder(jobs <-chan string, buildStarted chan<- string, buildDone chan<- bool) {
   163  	createThreshold := func() <-chan time.Time {
   164  		return time.After(time.Duration(WorkDelay * time.Millisecond))
   165  	}
   166  
   167  	threshold := createThreshold()
   168  	eventPath := ""
   169  
   170  	for {
   171  		select {
   172  		case eventPath = <-jobs:
   173  			threshold = createThreshold()
   174  		case <-threshold:
   175  			buildStarted <- eventPath
   176  			buildDone <- build()
   177  		}
   178  	}
   179  }
   180  
   181  func logger(pipeChan <-chan io.ReadCloser) {
   182  	dumper := func(pipe io.ReadCloser, prefix string) {
   183  		reader := bufio.NewReader(pipe)
   184  
   185  	readloop:
   186  		for {
   187  			line, err := reader.ReadString('\n')
   188  
   189  			if err != nil {
   190  				break readloop
   191  			}
   192  
   193  			if *flag_logprefix {
   194  				log.Print(prefix, " ", line)
   195  			} else {
   196  				log.Print(line)
   197  			}
   198  		}
   199  	}
   200  
   201  	for {
   202  		pipe := <-pipeChan
   203  		go dumper(pipe, "stdout:")
   204  
   205  		pipe = <-pipeChan
   206  		go dumper(pipe, "stderr:")
   207  	}
   208  }
   209  
   210  // Start the supplied command and return stdout and stderr pipes for logging.
   211  func startCommand(command string) (cmd *exec.Cmd, stdout io.ReadCloser, stderr io.ReadCloser, err error) {
   212  	args := strings.Split(command, " ")
   213  	cmd = exec.Command(args[0], args[1:]...)
   214  
   215  	if *flag_run_dir != "" {
   216  		cmd.Dir = *flag_run_dir
   217  	}
   218  
   219  	if stdout, err = cmd.StdoutPipe(); err != nil {
   220  		err = fmt.Errorf("can't get stdout pipe for command: %s", err)
   221  		return
   222  	}
   223  
   224  	if stderr, err = cmd.StderrPipe(); err != nil {
   225  		err = fmt.Errorf("can't get stderr pipe for command: %s", err)
   226  		return
   227  	}
   228  
   229  	if err = cmd.Start(); err != nil {
   230  		err = fmt.Errorf("can't start command: %s", err)
   231  		return
   232  	}
   233  
   234  	return
   235  }
   236  
   237  // Run the command in the given string and restart it after
   238  // a message was received on the buildDone channel.
   239  func runner(commandTemplate string, buildStarted <-chan string, buildSuccess <-chan bool) {
   240  	var currentProcess *os.Process
   241  	pipeChan := make(chan io.ReadCloser)
   242  
   243  	go logger(pipeChan)
   244  
   245  	go func() {
   246  		sigChan := make(chan os.Signal, 1)
   247  		signal.Notify(sigChan, fatalSignals...)
   248  		<-sigChan
   249  		log.Println(okColor("Received signal, terminating cleanly."))
   250  		if currentProcess != nil {
   251  			killProcess(currentProcess)
   252  		}
   253  		os.Exit(0)
   254  	}()
   255  
   256  	for {
   257  		eventPath := <-buildStarted
   258  
   259  		// append %0.s to use format specifier even if not supplied by user
   260  		// to suppress warning in returned string.
   261  		command := fmt.Sprintf("%0.s"+commandTemplate, eventPath)
   262  
   263  		if !*flag_command_stop {
   264  			if !<-buildSuccess {
   265  				continue
   266  			}
   267  		}
   268  
   269  		if currentProcess != nil {
   270  			killProcess(currentProcess)
   271  		}
   272  		if *flag_command_stop {
   273  			log.Println(okColor("Command stopped. Waiting for build to complete."))
   274  			if !<-buildSuccess {
   275  				continue
   276  			}
   277  		}
   278  
   279  		log.Println(okColor("Restarting the given command."))
   280  		cmd, stdoutPipe, stderrPipe, err := startCommand(command)
   281  
   282  		if err != nil {
   283  			log.Fatal(failColor("Could not start command: %s", err))
   284  		}
   285  
   286  		pipeChan <- stdoutPipe
   287  		pipeChan <- stderrPipe
   288  
   289  		currentProcess = cmd.Process
   290  	}
   291  }
   292  
   293  func killProcess(process *os.Process) {
   294  	if *flag_gracefulkill {
   295  		killProcessGracefully(process)
   296  	} else {
   297  		killProcessHard(process)
   298  	}
   299  }
   300  
   301  func killProcessHard(process *os.Process) {
   302  	log.Println(okColor("Hard stopping the current process.."))
   303  
   304  	if err := process.Kill(); err != nil {
   305  		log.Println(failColor("Warning: could not kill child process.  It may have already exited."))
   306  	}
   307  
   308  	if _, err := process.Wait(); err != nil {
   309  		log.Fatal(failColor("Could not wait for child process. Aborting due to danger of infinite forks."))
   310  	}
   311  }
   312  
   313  func killProcessGracefully(process *os.Process) {
   314  	done := make(chan error, 1)
   315  	go func() {
   316  		log.Println(okColor("Gracefully stopping the current process.."))
   317  		if err := terminateGracefully(process); err != nil {
   318  			done <- err
   319  			return
   320  		}
   321  		_, err := process.Wait()
   322  		done <- err
   323  	}()
   324  
   325  	select {
   326  	case <-time.After(time.Duration(*flag_gracefultimeout) * time.Second):
   327  		log.Println(failColor("Could not gracefully stop the current process, proceeding to hard stop."))
   328  		killProcessHard(process)
   329  		<-done
   330  	case err := <-done:
   331  		if err != nil {
   332  			log.Fatal(failColor("Could not kill child process. Aborting due to danger of infinite forks."))
   333  		}
   334  	}
   335  }
   336  
   337  func flusher(buildStarted <-chan string, buildSuccess <-chan bool) {
   338  	for {
   339  		<-buildStarted
   340  		<-buildSuccess
   341  	}
   342  }
   343  
   344  func main() {
   345  	flag.Var(&flag_excludedDirs, "exclude-dir", " Don't watch directories matching this name")
   346  	flag.Var(&flag_excludedFiles, "exclude", " Don't watch files matching this name")
   347  	flag.Var(&flag_includedFiles, "include", " Watch files matching this name")
   348  	flag.Var(&flag_include_dirs, "include-dir", "More directories to watch")
   349  
   350  	flag.Parse()
   351  
   352  	if !*flag_logprefix {
   353  		log.SetFlags(0)
   354  	}
   355  
   356  	if *flag_directory == "" {
   357  		fmt.Fprintf(os.Stderr, "-directory=... is required.\n")
   358  		os.Exit(1)
   359  	}
   360  
   361  	if *flag_gracefulkill && !gracefulTerminationPossible() {
   362  		log.Fatal("Graceful termination is not supported on your platform.")
   363  	}
   364  
   365  	watcher, err := fsnotify.NewWatcher()
   366  
   367  	if err != nil {
   368  		log.Fatal(err)
   369  	}
   370  
   371  	defer watcher.Close()
   372  
   373  	if flag_include_dirs == nil {
   374  		flag_include_dirs = make(globList, 0)
   375  	}
   376  	dirs := append(flag_include_dirs, *flag_directory)
   377  
   378  	for _, dir := range dirs {
   379  		err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
   380  			if err == nil && info.IsDir() {
   381  				if flag_excludedDirs.Matches(path) {
   382  					return filepath.SkipDir
   383  				} else {
   384  					if *flag_verbose {
   385  						log.Printf("Watching directory '%s' for changes.\n", path)
   386  					}
   387  					return watcher.Add(path)
   388  				}
   389  			}
   390  			return err
   391  		})
   392  
   393  		if err != nil {
   394  			log.Fatal("filepath.Walk():", err)
   395  		}
   396  
   397  		if err := watcher.Add(dir); err != nil {
   398  			log.Fatal("watcher.Add():", err)
   399  		}
   400  	}
   401  
   402  	pattern := regexp.MustCompile(*flag_pattern)
   403  	jobs := make(chan string)
   404  	buildSuccess := make(chan bool)
   405  	buildStarted := make(chan string)
   406  
   407  	go builder(jobs, buildStarted, buildSuccess)
   408  
   409  	if *flag_command != "" {
   410  		go runner(*flag_command, buildStarted, buildSuccess)
   411  	} else {
   412  		go flusher(buildStarted, buildSuccess)
   413  	}
   414  
   415  	for {
   416  		select {
   417  		case ev := <-watcher.Events:
   418  			if ev.Op&fsnotify.Remove == fsnotify.Remove || ev.Op&fsnotify.Write == fsnotify.Write || ev.Op&fsnotify.Create == fsnotify.Create {
   419  				base := filepath.Base(ev.Name)
   420  
   421  				// Assume it is a directory and track it.
   422  				if !flag_excludedDirs.Matches(ev.Name) {
   423  					watcher.Add(ev.Name)
   424  				}
   425  
   426  				if flag_includedFiles.Matches(base) || matchesPattern(pattern, ev.Name) {
   427  					if !flag_excludedFiles.Matches(base) {
   428  						jobs <- ev.Name
   429  					}
   430  				}
   431  			}
   432  
   433  		case err := <-watcher.Errors:
   434  			if v, ok := err.(*os.SyscallError); ok {
   435  				if v.Err == syscall.EINTR {
   436  					continue
   437  				}
   438  				log.Fatal("watcher.Error: SyscallError:", v)
   439  			}
   440  			log.Fatal("watcher.Error:", err)
   441  		}
   442  	}
   443  }