
Utkarsh Kr. SinghIn my previous blog, I talked about my project Snag and discussed questions like: What am I going...
In my previous blog, I talked about my project Snag and discussed questions like:
But one important question naturally comes up:
“Hey Utkarsh, how are you going to detect file creation, modification, or deletion inside a directory?”
The answer lies in how the operating system handles filesystem events.
For example, Linux provides a subsystem called inotify which allows programs to monitor filesystem events such as:
To interact with this subsystem in Go, we can use the package fsnotify.
fsnotify provides a simple and cross-platform way to watch filesystem events in Go.
In this article, we will build a recursive file watcher in Go using fsnotify.
To follow along with this tutorial, you should have:
Install the dependency:
go get github.com/fsnotify/fsnotify
One limitation of fsnotify is that it does not watch directories recursively.
This means if you only watch the root directory:
project/
├── main.go
├── config/
│ └── config.yaml
└── handlers/
└── user.go
Only changes in project/ will trigger events.
Changes inside:
config/handlers/will not be detected unless watchers are explicitly added to those directories.
Because of this, we must:
That is exactly what we will implement.
The first step is to gather all directories inside the project.
Since fsnotify does not support recursive watching by default, we must manually collect all directories and register them.
We will create a function called getAllDirs.
// getAllDirs recursively walks the filesystem starting
// from the given root directory and returns all directories.
// This is needed because fsnotify does not watch directories
// recursively by default.
func getAllDirs(root string) ([]string, error) {
var dirs []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
fmt.Printf("Error accessing path %s: %v\n", root, err)
return nil
}
// Collect directories only
if d.IsDir() {
dirs = append(dirs, path)
}
return nil
})
if err != nil {
return nil, err
}
return dirs, nil
}
This function walks the entire directory tree and returns a slice containing all directories.
Next, we create a function called startWatching which:
// lastEvent keeps track of the last time we processed
// an event for a specific file. This helps prevent
// duplicate events that can occur in rapid succession.
var lastEvent = make(map[string]time.Time)
// startWatching starts a filesystem watcher on the
// provided directories and listens for file events.
func startWatching(dirs []string) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal(err)
}
defer watcher.Close()
// Goroutine that listens for filesystem events
go func() {
for {
select {
// File system events
case event, ok := <-watcher.Events:
if !ok {
return
}
// Ignore permission change events
if event.Has(fsnotify.Chmod) {
continue
}
// Ignore temporary files (like editor backups)
if strings.HasSuffix(event.Name, "~") {
continue
}
// Debounce events that fire too quickly
now := time.Now()
if t, exists := lastEvent[event.Name]; exists {
if now.Sub(t) < 500*time.Microsecond {
continue
}
}
lastEvent[event.Name] = now
// Handle file/directory creation
if event.Has(fsnotify.Create) {
isDir, err := isDirectory(event.Name)
if err != nil {
log.Fatal(err)
}
if isDir {
log.Println("Add watcher dynamically to new directory:", event.Name)
} else {
log.Println("Run configured command for file:", event.Name)
}
}
// Handle file modifications
if event.Has(fsnotify.Write) {
log.Println("Modified file:", event.Name)
}
// Error handling
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Println("Watcher error:", err)
}
}
}()
// Register watchers for all provided directories
for _, dir := range dirs {
err = watcher.Add(dir)
if err != nil {
log.Fatal(err)
}
}
// Block forever to keep the program running
<-make(chan struct{})
}
When a Create event occurs, we need to determine whether the created entity is a file or directory.
// isDirectory checks whether the provided path
// refers to a directory.
func isDirectory(path string) (bool, error) {
info, err := os.Stat(path)
if err != nil {
return false, err
}
return info.IsDir(), nil
}
This function simply checks filesystem metadata to determine if the path is a directory.
Finally, everything comes together in the main function.
// Entry point of the program.
// It gathers all directories starting from the root
// and starts the filesystem watcher.
func main() {
root := "."
// Collect all directories recursively
dirs, err := getAllDirs(root)
if err != nil {
log.Fatalf("Failed to get directories: %v\n", err)
}
// Start watching filesystem changes
startWatching(dirs)
}
The overall workflow looks like this:
Project Directory
↓
Scan All Directories
↓
Register Watchers
↓
Listen for Events
↓
Filter / Debounce Events
↓
Trigger Action
This pipeline allows us to efficiently monitor filesystem changes across the entire project.
With this approach, we have built a recursive file watcher system.
The program can:
This structure can serve as the foundation for tools like:
You can extend this watcher to trigger commands, run tests, reload servers, or automate workflows whenever files change in your project.