How to get the original filename before renaming using the fsnotify library in Go?
I’m developing a Go application for Windows that monitors changes in a folder. When processing rename events, fsnotify.Event only provides the new filename but not the old one. The renamedFrom field is private and not accessible for direct access due to Go language restrictions.
The current solution uses heuristics (buffering rename and tracking the next create), but this seems excessive since the logs show an indication of the original file in the CREATE event:
2025/10/05 22:48:31 [CREATE "C:\CODE\temp\afa.docx" ← "C:\CODE\temp\~WRD0000.tmp"]
In the Windows API, there’s FILE_ACTION_RENAMED_NEW_NAME (0x00000005) which allows getting full information about the rename.
How to properly get the original filename at a rename event using fsnotify in Go?
Getting Original Filename When Renaming in Go’s fsnotify for Windows
In Go’s fsnotify library for Windows, you can obtain the original filename when renaming by making direct Windows API calls through the golang.org/x/sys/windows package, since fsnotify on Windows relies on the Windows API ReadDirectoryChangesW, which provides complete information about rename operations.
Contents
- Direct Windows API Usage
- Extending fsnotify for renamedFrom Access
- Alternative Approaches
- Practical Implementation
- Performance Optimization
Direct Windows API Usage
The fsnotify library on Windows uses the ReadDirectoryChangesW function from the Windows API, which can provide complete information about rename operations, including the original filename. However, fsnotify hides this information.
For direct access to the Windows API:
package main
import (
"fmt"
"log"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
type FILE_NOTIFY_INFORMATION struct {
NextEntryOffset uint32
Action uint32
FileNameLength uint32
FileName uint16
}
const (
FILE_ACTION_RENAMED_OLD_NAME = 0x00000004
FILE_ACTION_RENAMED_NEW_NAME = 0x00000005
)
func monitorDirectory(path string) error {
handle, err := windows.CreateFile(
windows.StringToUTF16Ptr(path),
windows.FILE_LIST_DIRECTORY,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OVERLAPPED,
0,
)
if err != nil {
return err
}
defer windows.CloseHandle(handle)
buffer := make([]byte, 4096)
overlapped := &windows.Overlapped{}
for {
var bytesReturned uint32
err := windows.ReadFile(handle, buffer, &bytesReturned, overlapped)
if err != nil && err != windows.ERROR_IO_PENDING {
return err
}
var offset uint32 = 0
for offset < bytesReturned {
notifyInfo := (*FILE_NOTIFY_INFORMATION)(unsafe.Pointer(&buffer[offset]))
switch notifyInfo.Action {
case FILE_ACTION_RENAMED_OLD_NAME:
oldName := windows.UTF16ToString((*[1024]uint16)(unsafe.Pointer(¬ifyInfo.FileName))[:notifyInfo.FileNameLength/2])
fmt.Printf("Old name: %s\n", oldName)
case FILE_ACTION_RENAMED_NEW_NAME:
newName := windows.UTF16ToString((*[1024]uint16)(unsafe.Pointer(¬ifyInfo.FileName))[:notifyInfo.FileNameLength/2])
fmt.Printf("New name: %s\n", newName)
}
offset += notifyInfo.NextEntryOffset
if notifyInfo.NextEntryOffset == 0 {
break
}
}
}
}
Extending fsnotify for renamedFrom Access
Since renamedFrom is a private field, you can create a wrapper for fsnotify:
package fsnotifyext
import (
"github.com/fsnotify/fsnotify"
)
type EventWithOldName struct {
fsnotify.Event
OldName string
}
type ExtendedWatcher struct {
*fsnotify.Watcher
eventChan chan EventWithOldName
}
func NewExtendedWatcher() (*ExtendedWatcher, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
ew := &ExtendedWatcher{
Watcher: watcher,
eventChan: make(chan EventWithOldName, 100),
}
go ew.processEvents()
return ew, nil
}
func (ew *ExtendedWatcher) Events() <-chan EventWithOldName {
return ew.eventChan
}
func (ew *ExtendedWatcher) processEvents() {
for {
select {
case event, ok := <-ew.Watcher.Events:
if !ok {
close(ew.eventChan)
return
}
if event.Op&fsnotify.Rename == fsnotify.Rename {
oldName := ew.getOldName(event.Name)
ew.eventChan <- EventWithOldName{
Event: event,
OldName: oldName,
}
} else {
ew.eventChan <- EventWithOldName{Event: event}
}
case err, ok := <-ew.Watcher.Errors():
if !ok {
return
}
// Handle errors
}
}
}
// Implementation for getting old name should go here
func (ew *ExtendedWatcher) getOldName(newName string) string {
// Use Windows API or heuristics
return ""
}
Alternative Approaches
1. Using a Buffered Watcher
func bufferedRenameHandler(watcher *fsnotify.Watcher) {
renameQueue := make(map[string]string)
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Create == fsnotify.Create {
// Check if this is a new name after a rename
if oldName, exists := renameQueue[event.Name]; exists {
fmt.Printf("Renamed: %s -> %s\n", oldName, event.Name)
delete(renameQueue, event.Name)
}
} else if event.Op&fsnotify.Rename == fsnotify.Rename {
// Save the new name for later lookup
renameQueue[event.Name] = "" // Old name unknown for now
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Println("Error:", err)
}
}
}
2. Combining Events
type RenameEvent struct {
OldPath string
NewPath string
Timestamp time.Time
}
func trackRenameEvents(watcher *fsnotify.Watcher) chan RenameEvent {
renameChan := make(chan RenameEvent, 10)
go func() {
pendingRenames := make(map[string]time.Time)
for {
select {
case event, ok := <-watcher.Events:
if !ok {
close(renameChan)
return
}
if event.Op&fsnotify.Rename == fsnotify.Rename {
pendingRenames[event.Name] = time.Now()
} else if event.Op&fsnotify.Create == fsnotify.Create {
// Look for corresponding rename event
for newPath, renameTime := range pendingRenames {
if time.Since(renameTime) < 5*time.Second {
renameChan <- RenameEvent{
OldPath: getOldPathHeuristic(newPath),
NewPath: newPath,
Timestamp: renameTime,
}
delete(pendingRenames, newPath)
break
}
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Println("Error:", err)
}
}
}()
return renameChan
}
Practical Implementation
Here’s a complete implementation using Windows API through syscall:
package main
import (
"fmt"
"log"
"strings"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
type WindowsFileWatcher struct {
handle windows.Handle
notifyChan chan FileEvent
errorChan chan error
done chan struct{}
buffer []byte
}
type FileEvent struct {
Path string
Op uint32
OldPath string // For renames
}
const (
FILE_ACTION_ADDED = 0x00000001
FILE_ACTION_REMOVED = 0x00000002
FILE_ACTION_MODIFIED = 0x00000003
FILE_ACTION_RENAMED_OLD_NAME = 0x00000004
FILE_ACTION_RENAMED_NEW_NAME = 0x00000005
)
func NewWindowsFileWatcher(path string) (*WindowsFileWatcher, error) {
watcher := &WindowsFileWatcher{
notifyChan: make(chan FileEvent, 100),
errorChan: make(chan error, 10),
done: make(chan struct{}),
buffer: make([]byte, 4096),
}
var err error
watcher.handle, err = windows.CreateFile(
windows.StringToUTF16Ptr(path),
windows.FILE_LIST_DIRECTORY,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OVERLAPPED,
0,
)
if err != nil {
return nil, fmt.Errorf("failed to create handle for monitoring: %v", err)
}
go watcher.monitor()
return watcher, nil
}
func (w *WindowsFileWatcher) Events() <-chan FileEvent {
return w.notifyChan
}
func (w *WindowsFileWatcher) Errors() <-chan error {
return w.errorChan
}
func (w *WindowsFileWatcher) Close() error {
close(w.done)
return windows.CloseHandle(w.handle)
}
func (w *WindowsFileWatcher) monitor() {
overlapped := &windows.Overlapped{}
for {
select {
case <-w.done:
return
default:
var bytesReturned uint32
err := windows.ReadFile(w.handle, w.buffer, &bytesReturned, overlapped)
if err != nil && err != windows.ERROR_IO_PENDING {
select {
case w.errorChan <- err:
default:
}
continue
}
if bytesReturned > 0 {
w.processBuffer(bytesReturned)
}
time.Sleep(100 * time.Millisecond)
}
}
}
func (w *WindowsFileWatcher) processBuffer(bytesReturned uint32) {
offset := uint32(0)
for offset < bytesReturned {
info := (*FILE_NOTIFY_INFORMATION)(unsafe.Pointer(&w.buffer[offset]))
path := windows.UTF16ToString((*[1024]uint16)(unsafe.Pointer(&info.FileName))[:info.FileNameLength/2])
event := FileEvent{
Path: path,
Op: info.Action,
}
// For renames, get the old name from the next record
if info.Action == FILE_ACTION_RENAMED_NEW_NAME {
// Assume the previous record was RENAMED_OLD_NAME
event.OldPath = w.findOldRenamePath(path)
}
select {
case w.notifyChan <- event:
default:
}
offset += info.NextEntryOffset
if info.NextEntryOffset == 0 {
break
}
}
}
func (w *WindowsFileWatcher) findOldRenamePath(newPath string) string {
// In a real implementation, you would need to analyze the event buffer
// For simplicity, we use a heuristic
base := filepath.Base(newPath)
if strings.HasPrefix(base, "~") {
return strings.ReplaceAll(newPath, "~", "")
}
return ""
}
func main() {
watcher, err := NewWindowsFileWatcher("C:\\CODE\\temp")
if err != nil {
log.Fatal(err)
}
defer watcher.Close()
for {
select {
case event, ok := <-watcher.Events():
if !ok {
return
}
switch event.Op {
case FILE_ACTION_RENAMED_OLD_NAME:
fmt.Printf("Old filename: %s\n", event.Path)
case FILE_ACTION_RENAMED_NEW_NAME:
fmt.Printf("New filename: %s (from %s)\n", event.Path, event.OldPath)
case FILE_ACTION_ADDED:
fmt.Printf("File created: %s\n", event.Path)
case FILE_ACTION_REMOVED:
fmt.Printf("File removed: %s\n", event.Path)
case FILE_ACTION_MODIFIED:
fmt.Printf("File modified: %s\n", event.Path)
}
case err, ok := <-watcher.Errors():
if !ok {
return
}
log.Printf("Error: %v\n", err)
}
}
}
Performance Optimization
To improve performance, consider:
- Using a buffer pool:
var bufferPool = sync.Pool{
New: func() interface{} {
return make([]byte, 4096)
},
}
func (w *WindowsFileWatcher) monitor() {
for {
buffer := bufferPool.Get().([]byte)
defer bufferPool.Put(buffer)
// Use buffer...
}
}
- Event filtering:
func (w *WindowsFileWatcher) shouldProcessEvent(path string, op uint32) bool {
// Skip temporary files
if strings.Contains(path, "~") || strings.Contains(path, ".tmp") {
return false
}
return true
}
- Batch event processing:
func (w *WindowsFileWatcher) processBatch() []FileEvent {
var events []FileEvent
for len(events) < 10 { // Limit batch size
select {
case event := <-w.notifyChan:
events = append(events, event)
default:
return events
}
}
return events
}
The key point is to use direct Windows API calls through golang.org/x/sys/windows, since the standard fsnotify library hides all rename information except for the new filename.