I wrote reload
, my own file watcher!
The last year and a half I’ve been making my way through CSPrimer. It consists of hundreds of computer science problems, so working through these means I’m writing hundreds of small programs. Like most programming, it’s a very iterative process where I tweak my program and rerun it, typically switching from my editor to run a command.
gcc main.c -o main && ./main
It would be nice if this just reran automatically whenever I needed it to, so I didn’t have to switch between my editor and the terminal!
Of course, there exist tools like this. They’re sometimes called file watchers or hot reloaders. I tried a few existing tools such as entr and wach but they weren’t just right.
So I figured I’d write my own.
After a few hours the result was reload
. It’s one main.go
file with less than 200 lines. It does exactly what I need, nothing more.
Requirements
I wanted to be able to just put reload
in front of the command I’d usually run.
To do that, what reload
needed to do was
- figure out which file(s) to watch
- figure out which command to run
- run the command
- listen to file changes
- reload the command on file changes
And it also needed to
- run command pipelines like
gcc main.c -o main && ./main
sequentially - cancel the command if it’s still running when there’s a file change
- deduplicate file events – so that a change to
main.c
and themain
binary file don’t trigger two subsequent reloads
There needs to be some concurrent code here, but Go excels at this with channels and goroutines.
In the rest of the post, let’s look at how reload
- Figures out which files to watch
- Detects file changes
- Runs the command
Figuring out which files to watch
Some of these tools have you explicitly list the files that should trigger a reload. For my use case, my command usually has the filename already. Why not just grab that?
So reload
looks at each part of the command, and checks if it’s the filename of an existing file.
If it is, we add it to the list of files to watch.
If we didn’t find any file, we fall back to watching the working directory.
Kinda neat!
// Find files in command that we will watch
toWatch := make([]string, 0)
for _, part := range input {
// Check if there's a file to watch
info, err := os.Stat(part)
if os.IsNotExist(err) {
continue
}
check(err)
if !slices.Contains(toWatch, info.Name()) {
toWatch = append(toWatch, info.Name())
}
}
// Fall back to watching the working directory
if len(toWatch) == 0 {
wd, err := os.Getwd()
check(err)
toWatch = append(toWatch, wd)
}
Detecting file changes
The most unfamiliar thing to me here was watching for file changes.
I used the fsnotify package to handle file notifications. It supports all the usual platforms like macOS, Windows, and Linux. On macOS it uses the kqueue notification interface, whereas on Linux it uses inotify.
Here’s the code for that.
watcher, err := fsnotify.NewWatcher()
check(err)
defer watcher.Close()
for _, file := range toWatch {
err = watcher.Add(file)
check(err)
}
fileChanges := make(chan string)
wg.Add(1)
go func() {
defer wg.Done()
lastChange := time.Now()
dedupWindow := 100 * time.Millisecond
for event := range watcher.Events {
if event.Has(fsnotify.Write) {
// Treat multiple events at same time as one
if time.Since(lastChange) < dedupWindow {
continue
}
lastChange = time.Now()
fileChanges <- event.Name
}
}
}()
And we listen on the fileChanges
channel in the code to run the command, which we will see now.
Running commands
To create a command that we can run, and cancel, we use exec.CommandContext
from Go’s standard library.
We can specify its standard in, standard out, and standard error.
In our case, we want standard out and standard error to be the operating system’s stdout and stderr, since we want the command to behave as if we ran it directly in the terminal.
sh -c '<command>'
allows us to pass the full provided command. All use of pipes and operators like &&
and ||
will just work, as it’s handed over to the shell.
// In `main()`
// ...
// First run the command
runCommand(ctx, command, fileChanges)
// Then rerun it on file changes
for name := range fileChanges {
fmt.Fprintf(os.Stderr, "--- Changed: %s\n", name)
fmt.Fprintf(os.Stderr, "--- Running: %s\n", command)
runCommand(ctx, command, fileChanges)
}
// ...
func runCommand(ctx context.Context, command string, fileChanges chan string) {
// Create child context so we can cancel this command
// without cancelling the entire program
commandCtx, commandCancel := context.WithCancel(ctx)
defer commandCancel()
// Cancel and rerun the command if the file changes
go func() {
name, ok := <-fileChanges
// The channel was closed, shut down
if !ok {
return
}
commandCancel()
fileChanges <- name
}()
// Run the command using `sh -c <command>` to allow for
// shell syntax such as pipes and boolean operators
cmd := exec.CommandContext(commandCtx, "sh", []string{"-c", command}...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run() // It's fine if the command fails!
}
I hope you’ll try it out!