diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ede563e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.tychus.yml +tmp/ +vendor/ diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..6952426 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,69 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/devlocker/devproxy" + packages = ["devproxy"] + revision = "7f4d7778f9331c172de31b34f2d91fed79290ad1" + version = "0.1.0" + +[[projects]] + name = "github.com/fatih/color" + packages = ["."] + revision = "570b54cabe6b8eb0bc2dfce68d964677d63b5260" + version = "v1.5.0" + +[[projects]] + name = "github.com/fsnotify/fsnotify" + packages = ["."] + revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" + version = "v1.4.7" + +[[projects]] + name = "github.com/inconshreveable/mousetrap" + packages = ["."] + revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" + version = "v1.0" + +[[projects]] + name = "github.com/mattn/go-colorable" + packages = ["."] + revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072" + version = "v0.0.9" + +[[projects]] + name = "github.com/mattn/go-isatty" + packages = ["."] + revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" + version = "v0.0.3" + +[[projects]] + name = "github.com/spf13/cobra" + packages = ["."] + revision = "7b2c5ac9fc04fc5efafb60700713d4fa609b777b" + version = "v0.0.1" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = ["unix"] + revision = "ff2a66f350cefa5c93a634eadb5d25bb60c85a9c" + +[[projects]] + branch = "v2" + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "d670f9405373e636a5a2765eea47fac0c9bc91a4" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "8feb42a2160ed5ee29737b155c2205491ca39a50c79546c63452bfc6f1130ae1" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..b6bea6f --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,23 @@ +[[constraint]] + name = "github.com/devlocker/devproxy" + version = "0.1.0" + +[[constraint]] + name = "github.com/fatih/color" + version = "1.5.0" + +[[constraint]] + name = "github.com/fsnotify/fsnotify" + version = "1.4.7" + +[[constraint]] + name = "github.com/spf13/cobra" + version = "0.0.1" + +[[constraint]] + branch = "v2" + name = "gopkg.in/yaml.v2" + +[prune] + go-tests = true + unused-packages = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab7c0f8 --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ +tychus +======== + +`tychus` is a command line utility to live-reload your application. `tychus` +will watch your filesystem for changes and automatically recompile and restart +code on change. + +`tychus` is language agnostic - it can be configured to work with just about +anything: Go, Rust, Ruby, Python, etc. Should you desire you can use `tychus` +as a proxy to your application. The proxy is pretty smart - it will pause +requests while your app rebuilds and won't let you run into that super annoying +case where you refresh your webpage after your app boots, but before it can +serve a request. + + +## Installation + +### Homebrew on macOS +Coming soon... + +### With Go +Assuming you have a working Go environment and `GOPATH/bin` is in your `PATH` + +``` +go get github.com/devlocker/tychus +``` + +## Getting Started +You will need to create a `.tychus.yml` file configuration file. Easiest way is +to generate one with: + +``` +$ tychus init +``` + +## Usage + +``` +tychus run +``` + +Want to pass additional arguments? + +``` +tychus run bundle exec ruby myapp.rb +``` + +Need to pass flags? The following are equivalent: + +``` +# Yep, that will just run "ls -al" on any file change with the build step +# disabled. You can really run anything you want. +tychus run "ls -al" +tychus run ls --with=-al +tychus run ls -w -al +``` + +## Configuration + +```yaml +# Settings for the file watcher +watch: + # List of extentions to watch. A change to a file with one of these extensions + # will trigger a fresh of your application. + extensions: + - .go + # List of folders to not watch. + ignore: + - node_modules + - tmp + - log + - vendor + +# Build settings. +build: + # Disable this if you don't have a compile step (Ruby, Python, etc.). + enabled: false + # Command to run to rebuild your binary. Tychus will automatically tack on a + # -o bin_name so it can be omitted. + build_command: go build -i + # Name of binary that gets built. + bin_name: tychus-bin + # Where to put your built binary. + target_path: tmp + +# Proxy settings. +proxy: + # If not enabled, proxy will not start. + enabled: true + # Port your application runs on. NOTE: a PORT environment will take overwrite + # whatever you put here. + app_port: 3000 + # Port to run the proxy on. + proxy_port: 4000 + # In seconds, how long the proxy will attempt to proxy a request until it + # gives up and returns a 502. + timeout: 10 +``` + +### Sample Configuration for a Sinatra Application + +```yaml +watch: + extensions: + - .rb + - .erb + ignore: + - node_modules + - tmp + - log + - vendor +build: + enabled: false + build_command: "" + bin_name: "" + target_path: tmp/ +proxy: + enabled: true + app_port: 4567 + proxy_port: 4000 + timeout: 10 +``` + +``` +tychus run bundle exec ruby myapp.rb + +# Or in a Procfile (for use with foreman) +# Foreman will set a PORT env variable, which will automatically get picked up +# by tychus. No need to modify your config file. +web: tychus run bundle exec ruby myapp.rb +``` + +### Sample Configuration for a Rust Application + +```yaml +watch: + extensions: + - .rs + ignore: + - node_modules + - tmp + - log + - vendor +build: + enabled: true + build_command: rustc hello.rs + bin_name: tychus-bin + target_path: ./ +proxy: + enabled: true + app_port: 3000 + proxy_port: 4000 +``` + +Sample program + +```rs +// hello.rs +fn main() { + println!("Hello World!"); +} +``` + +Each save rebuilds and runs the hello.rs program. +``` +$ tychus run + +[tychus] Starting: build [enabled], proxy [enabled] +[tychus] Build: Successful +Hello World! +[tychus] Build: Successful +Hello World! +[tychus] Build: Successful +Hello World! +``` diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..5ec6075 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,150 @@ +package cmd + +import ( + "bufio" + "fmt" + "io/ioutil" + "log" + "os" + "path/filepath" + "strings" + + "github.com/devlocker/tychus/tychus" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(initCmd) +} + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Initialize Tychus with a configuration file", + Run: func(cmd *cobra.Command, args []string) { + // Get the current working directory + wd, err := os.Getwd() + if err != nil { + fmt.Println("Cant get working dir") + return + } + + // Generate a configuration based on project. + c, err := detectLangauge(wd) + if err != nil { + fmt.Println(err) + return + } + + // Conig already exist? Ask to overwrite if so. + if _, err = os.Stat(configFile); !os.IsNotExist(err) { + fmt.Println("Config file already exists") + reader := bufio.NewReader(os.Stdin) + + for { + fmt.Print("Replace configuration file? [y/n]: ") + + response, err := reader.ReadString('\n') + if err != nil { + log.Fatal(err) + } + + response = strings.ToLower(strings.TrimSpace(response)) + if response == "y" || response == "yes" { + break + } else if response == "n" || response == "no" { + return + } + } + } + + // Write config file to disk. + fmt.Printf("Creating %v\n", configFile) + c.Write(configFile) + }, +} + +// Naive project language detection. Scans the top level files to determine +// project type. Return a configuration with sensible defaults. +func detectLangauge(dir string) (*tychus.Configuration, error) { + files, err := ioutil.ReadDir("./") + if err != nil { + return nil, err + } + + c := &tychus.Configuration{ + Build: tychus.BuildConfig{ + BuildCommand: "go build -i", + BinName: "tychus-bin", + Enabled: true, + TargetPath: "tmp/", + }, + Watch: tychus.WatchConfig{ + Extensions: []string{".go"}, + Ignore: []string{"node_modules", "tmp", "log", "vendor"}, + }, + Proxy: tychus.ProxyConfig{ + Enabled: true, + AppPort: 3000, + ProxyPort: 4000, + Timeout: 10, + }, + } + + // Go Project? + for _, f := range files { + ext := filepath.Ext(f.Name()) + if ext == ".go" { + // Already configured for Go + return c, nil + } + } + + // Ruby Project? + for _, f := range files { + ext := filepath.Ext(f.Name()) + if f.Name() == "Gemfile" || ext == ".rb" { + c.Build.Enabled = false + c.Build.BuildCommand = "" + c.Build.BinName = "" + c.Watch.Extensions = []string{".rb"} + return c, nil + } + } + + // Python Project? + for _, f := range files { + ext := filepath.Ext(f.Name()) + if ext == ".py" { + c.Build.Enabled = false + c.Build.BuildCommand = "" + c.Build.BinName = "" + c.Watch.Extensions = []string{".py"} + return c, nil + } + } + + // Rust Project? + for _, f := range files { + ext := filepath.Ext(f.Name()) + if f.Name() == "Cargo.toml" || ext == ".rs" { + c.Build.BuildCommand = "rustc main.rs" + c.Watch.Extensions = []string{".rs"} + return c, nil + } + } + + // JS Project? + for _, f := range files { + ext := filepath.Ext(f.Name()) + if f.Name() == "package.json" || ext == ".js" { + c.Build.Enabled = false + c.Build.BuildCommand = "" + c.Build.BinName = "" + c.Watch.Extensions = []string{".js"} + return c, nil + } + } + + // Something else, return default + return c, nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..004de9a --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var configFile string +var port string +var debug bool + +var rootCmd = &cobra.Command{ + Use: "tychus", + Short: "Restart and your application as you make changes", + Run: func(cmd *cobra.Command, args []string) { + start(args) + }, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", ".tychus.yml", "path to tychus config") + rootCmd.Flags().StringVarP(&port, "port", "p", "", "Port to run on") + rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "print debug output") +} diff --git a/cmd/run.go b/cmd/run.go new file mode 100644 index 0000000..6dfb6c6 --- /dev/null +++ b/cmd/run.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "os" + "strconv" + "strings" + + "github.com/devlocker/tychus/tychus" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +var with string + +func init() { + rootCmd.AddCommand(runCmd) + runCmd.Flags().StringVarP(&with, "with", "w", "", "extra options to run with") +} + +var runCmd = &cobra.Command{ + Use: "run", + Short: "Reloads your application as you make changes to source files.", + Run: func(cmd *cobra.Command, args []string) { + if len(with) > 1 { + args = append(args, strings.Fields(with)...) + } + + start(args) + }, +} + +func start(args []string) { + c := &tychus.Configuration{} + c.Logger = tychus.NewLogger(debug) + + err := c.Load(configFile) + if err != nil { + c.Logger.Fatal(err.Error()) + } + + c.Logger.Printf( + "Starting: build [%v], proxy [%v]", + isEnabledStr(c.Build.Enabled), + isEnabledStr(c.Proxy.Enabled), + ) + + // If PORT is set, use that instead of AppPort. For things like foreman + // where ports are automatically assigned. + port := os.Getenv("PORT") + if len(port) > 0 { + appPort, err := strconv.Atoi(port) + if err == nil { + c.Proxy.AppPort = appPort + } + } + + err = tychus.Start(args, c) + if err != nil { + c.Logger.Fatal(err.Error()) + } +} + +func isEnabledStr(b bool) string { + if b { + return color.GreenString("enabled") + } + + return color.YellowString("disabled") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0d6afd4 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/devlocker/tychus/cmd" + +func main() { + cmd.Execute() +} diff --git a/tychus/builder.go b/tychus/builder.go new file mode 100644 index 0000000..e05130d --- /dev/null +++ b/tychus/builder.go @@ -0,0 +1,80 @@ +package tychus + +import ( + "bytes" + "io" + "os/exec" + "path" + "strings" +) + +type builder struct { + rebuild chan bool + events chan event + buildCmd []string + cmd *exec.Cmd +} + +func newBuilder(c *Configuration) *builder { + var buildCmd []string + + if c.Build.Enabled { + buildCmd = append( + strings.Fields(c.Build.BuildCommand), + "-o", + path.Join(c.Build.TargetPath, c.Build.BinName), + ) + } + + return &builder{ + rebuild: make(chan bool), + events: make(chan event), + buildCmd: buildCmd, + } +} + +// Wait for rebuild messages. Once received, rebuilds the binary. Should +// another file system event occur while a build is running, the build will be +// aborted. This is to prevent the case where the user saves, triggering a +// rebuild then saves in while the build is occuring, causing another process +// to start or a binary is reloaded with now stale code. +func (b *builder) start(c *Configuration) { + for { + <-b.rebuild + + go func() { + if b.cmd != nil && b.cmd.Process != nil { + b.cmd.Process.Kill() + } + + c.Logger.Debugf("Building: %v", strings.Join(b.buildCmd, " ")) + + b.cmd = exec.Command(b.buildCmd[0], b.buildCmd[1:]...) + + var stderr bytes.Buffer + b.cmd.Stdout = nil + b.cmd.Stderr = io.MultiWriter(&stderr) + + err := b.cmd.Start() + if err != nil { + b.events <- event{info: stderr.String(), op: errored} + return + } + + if err = b.cmd.Wait(); err != nil { + // If ProcessState exists, means process was not aborted by + // another go routine - just failed to compile or some other + // error that the user should be presented with. + if b.cmd.ProcessState != nil { + b.events <- event{info: stderr.String(), op: errored} + } + + return + } + + b.cmd = nil + + b.events <- event{info: "Rebuilt", op: rebuilt} + }() + } +} diff --git a/tychus/configuration.go b/tychus/configuration.go new file mode 100644 index 0000000..c5df11e --- /dev/null +++ b/tychus/configuration.go @@ -0,0 +1,52 @@ +package tychus + +import ( + "fmt" + "io/ioutil" + + yaml "gopkg.in/yaml.v2" +) + +type Configuration struct { + Watch WatchConfig `yaml:"watch"` + Build BuildConfig `yaml:"build'` + Proxy ProxyConfig `yaml:"proxy"` + Logger Logger `yaml:"-"` +} + +type BuildConfig struct { + Enabled bool `yaml:"enabled"` + BuildCommand string `yaml:"build_command"` + BinName string `yaml:"bin_name"` + TargetPath string `yaml:"target_path"` +} + +type WatchConfig struct { + Extensions []string `yaml:"extensions"` + Ignore []string `yaml:"ignore"` +} + +type ProxyConfig struct { + Enabled bool `yaml:"enabled"` + AppPort int `yaml:"app_port"` + ProxyPort int `yaml:"proxy_port"` + Timeout int `yaml:"timeout"` +} + +func (c *Configuration) Write(path string) error { + data, err := yaml.Marshal(c) + if err != nil { + return err + } + + return ioutil.WriteFile(path, data, 0666) +} + +func (c *Configuration) Load(path string) error { + data, err := ioutil.ReadFile(path) + if err != nil { + return fmt.Errorf("%v\nHave you run 'tychus init'? Run 'tychus help' for more information.", err) + } + + return yaml.Unmarshal(data, c) +} diff --git a/tychus/event.go b/tychus/event.go new file mode 100644 index 0000000..4a626a1 --- /dev/null +++ b/tychus/event.go @@ -0,0 +1,22 @@ +package tychus + +import "fmt" + +type op uint32 + +// Possible operations +const ( + restarted op = 1 << iota + rebuilt + changed + errored +) + +type event struct { + op op + info string +} + +func (e event) String() string { + return fmt.Sprintf("Event: %v", e.info) +} diff --git a/tychus/logger.go b/tychus/logger.go new file mode 100644 index 0000000..976462b --- /dev/null +++ b/tychus/logger.go @@ -0,0 +1,51 @@ +package tychus + +import ( + "log" + "os" + + "github.com/fatih/color" +) + +type Logger interface { + Debug(interface{}) + Debugf(string, ...interface{}) + Error(string) + Fatal(...interface{}) + Printf(string, ...interface{}) + Success(string) +} + +func NewLogger(debug bool) Logger { + l := &logger{ + Logger: log.New(os.Stdout, "[tychus] ", 0), + debug: debug, + } + + return l +} + +type logger struct { + *log.Logger + debug bool +} + +func (l *logger) Debug(msg interface{}) { + if l.debug { + l.Printf("DEBUG: %v", msg) + } +} + +func (l *logger) Debugf(format string, args ...interface{}) { + if l.debug { + l.Printf("DEBUG: "+format, args...) + } +} + +func (l *logger) Error(msg string) { + l.Print(color.RedString(msg)) +} + +func (l *logger) Success(msg string) { + l.Print(color.GreenString(msg)) +} diff --git a/tychus/orchestrator.go b/tychus/orchestrator.go new file mode 100644 index 0000000..34d3245 --- /dev/null +++ b/tychus/orchestrator.go @@ -0,0 +1,122 @@ +// Package tychus is a command line application that will watch your files and +// on change, trigger a rerun of a command. It's designed to work best with web +// applications, but certainly not lmited to. +// +// Unlike other application reloaders written in Go, Tychus is language +// agnostic. It can be used with Go, Rust, Python, Ruby, scripts, etc. +// +// Tychus has 3 parts to it's configuration. +// +// 1. Watch: configures what extensions to watch in what directories. A change +// to a watched file will trigger an application reload. +// +// 2. Build: if enabled, adds a build step (compile) after a file system change +// is detected. +// +// 3. Proxy: if enabled, will serve an application through a proxy. This can +// help mitigate annoyances like reloading your web page before the app server +// finishes booting (and getting an error page). +package tychus + +import ( + "errors" + "path/filepath" + "strings" + + "github.com/devlocker/devproxy/devproxy" +) + +func Start(args []string, c *Configuration) error { + args, err := formatArgs(args, c) + if err != nil { + return err + } + + w := newWatcher() + b := newBuilder(c) + r := newRunner(args) + p := devproxy.New(&devproxy.Configuration{ + AppPort: c.Proxy.AppPort, + ProxyPort: c.Proxy.ProxyPort, + Timeout: c.Proxy.Timeout, + Logger: c.Logger, + }) + + go w.start(c) + go b.start(c) + go r.start(c) + + if c.Proxy.Enabled { + go p.Start() + } + + if c.Build.Enabled { + b.rebuild <- true + } else { + r.restart <- true + } + + for { + select { + // Watcher events + case event := <-w.events: + c.Logger.Debug(event) + switch event.op { + case changed: + if c.Build.Enabled { + p.Command <- devproxy.Command{Cmd: devproxy.Pause} + b.rebuild <- true + } else { + r.restart <- true + } + } + + // Builder events + case event := <-b.events: + c.Logger.Debug(event) + switch event.op { + case rebuilt: + c.Logger.Success("Build: Successful") + r.restart <- true + case errored: + c.Logger.Error("Build: Failed\n" + event.info) + p.Command <- devproxy.Command{Cmd: devproxy.Error, Data: event.info} + } + + // Runner events + case event := <-r.events: + c.Logger.Debug(event) + switch event.op { + case restarted: + p.Command <- devproxy.Command{Cmd: devproxy.Serve} + case errored: + p.Command <- devproxy.Command{Cmd: devproxy.Error, Data: event.info} + } + } + } +} + +// Format arguments to take into account any build targets, bin names. And make +// sure to expand any quotes strings. +func formatArgs(args []string, c *Configuration) ([]string, error) { + if c.Build.Enabled { + args = append([]string{filepath.Join( + c.Build.TargetPath, + c.Build.BinName, + )}, args...) + } + + // Can occur when running with build disabled. Since no binary to run, need + // some command to run, e.g. "ruby myapp.rb" + if len(args) < 1 { + return nil, errors.New("Not enough arguments") + } + + // Expand quoted strings - split by whitespace: + // []string{"ls -al"} => []string{"ls", "-al"}. + for i, a := range args { + args = append(args[:i], append(strings.Fields(a), args[i+1:]...)...) + } + + return args, nil +} diff --git a/tychus/runner.go b/tychus/runner.go new file mode 100644 index 0000000..a70a7f6 --- /dev/null +++ b/tychus/runner.go @@ -0,0 +1,94 @@ +package tychus + +import ( + "bytes" + "errors" + "io" + "os" + "os/exec" + "time" +) + +type runner struct { + args []string + cmd *exec.Cmd + events chan event + restart chan bool +} + +func newRunner(args []string) *runner { + return &runner{ + args: args, + events: make(chan event), + restart: make(chan bool), + } +} + +func (r *runner) start(c *Configuration) { + for { + <-r.restart + + if err := r.kill(); err != nil { + r.events <- event{op: errored, info: err.Error()} + } + + go func() { + if err := r.rerun(); err != nil { + r.events <- event{op: errored, info: err.Error()} + } + }() + } +} + +// Kill running process. Give it a chance to exit cleanly, otherwise kill it +// after a certain timeout. +func (r *runner) kill() error { + if r.cmd != nil && r.cmd.Process != nil { + done := make(chan error, 1) + go func() { + r.cmd.Wait() + close(done) + }() + + r.cmd.Process.Signal(os.Interrupt) + + select { + case <-time.After(3 * time.Second): + if err := r.cmd.Process.Kill(); err != nil { + return err + } + case <-done: + } + + r.cmd = nil + } + + return nil +} + +// Rerun the command. +func (r *runner) rerun() error { + if r.cmd != nil && r.cmd.ProcessState != nil && r.cmd.ProcessState.Exited() { + return nil + } + + var stderr bytes.Buffer + mw := io.MultiWriter(&stderr, os.Stderr) + + r.cmd = exec.Command(r.args[0], r.args[1:]...) + r.cmd.Stdout = os.Stdout + r.cmd.Stderr = mw + + err := r.cmd.Start() + if err != nil { + return errors.New(stderr.String()) + } + + r.events <- event{info: "Restarted", op: restarted} + + if err := r.cmd.Wait(); err != nil { + r.events <- event{info: stderr.String(), op: errored} + } + + return nil +} diff --git a/tychus/watcher.go b/tychus/watcher.go new file mode 100644 index 0000000..7ee4a64 --- /dev/null +++ b/tychus/watcher.go @@ -0,0 +1,125 @@ +package tychus + +import ( + "errors" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fsnotify/fsnotify" +) + +type watcher struct { + *fsnotify.Watcher + events chan event +} + +func newWatcher() *watcher { + w, err := fsnotify.NewWatcher() + if err != nil { + log.Fatal("Could not start watcher") + } + + return &watcher{ + Watcher: w, + events: make(chan event), + } +} + +func (w *watcher) start(c *Configuration) { + go w.watchForChanges(c) + + // With the way most editors work, they will generate multiple events on + // save. To avoid excessive restarts, batch all messages together within + // some interval and notify the orchestrator once per interval. + tick := time.Tick(200 * time.Millisecond) + events := make([]fsnotify.Event, 0) + + for { + select { + case event := <-w.Events: + if event.Op == fsnotify.Chmod { + continue + } + + if w.isWatchedFile(event.Name, c) { + events = append(events, event) + } + + case <-tick: + if len(events) == 0 { + continue + } + + c.Logger.Debugf("FS Changes: %v", events) + w.events <- event{info: "File System Change", op: changed} + + events = make([]fsnotify.Event, 0) + } + } +} + +// Setup file watchers for all valid extensions and directories. +func (w *watcher) watchForChanges(c *Configuration) error { + for { + err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error { + if info == nil { + return errors.New("nil directory") + } + + if info.IsDir() { + if w.shouldSkipDir(path, c) { + return filepath.SkipDir + } + + c.Logger.Debugf("Watching: %v", path) + w.Add(path) + } + + return nil + }) + + if err != nil { + break + } + + // Walk once a second. + time.Sleep(1 * time.Second) + } + + return errors.New("Watcher died") +} + +// Checks to see if this directory should be watched. Don't want to watch +// hidden directories (like .git) or ignored directories. +func (w *watcher) shouldSkipDir(path string, c *Configuration) bool { + if len(path) > 1 && strings.HasPrefix(filepath.Base(path), ".") { + return true + } + + for _, f := range c.Watch.Ignore { + f = strings.TrimSpace(f) + f = strings.TrimRight(f, "/") + + if f == path { + return true + } + } + + return false +} + +// Checks to see if path matches a configured extension. +func (w *watcher) isWatchedFile(path string, c *Configuration) bool { + ext := filepath.Ext(path) + + for _, e := range c.Watch.Extensions { + if strings.TrimSpace(e) == ext { + return true + } + } + + return false +}