aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/cli/internal/filewatcher/filewatcher.go
blob: 4f79495f2231f3250e9e601da508a373a8336578 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
// Package filewatcher is used to handle watching for file changes inside the monorepo
package filewatcher

import (
	"path/filepath"
	"strings"
	"sync"

	"github.com/hashicorp/go-hclog"
	"github.com/pkg/errors"
	"github.com/vercel/turbo/cli/internal/turbopath"
)

// _ignores is the set of paths we exempt from file-watching
var _ignores = []string{".git", "node_modules"}

// FileWatchClient defines the callbacks used by the file watching loop.
// All methods are called from the same goroutine so they:
// 1) do not need synchronization
// 2) should minimize the work they are doing when called, if possible
type FileWatchClient interface {
	OnFileWatchEvent(ev Event)
	OnFileWatchError(err error)
	OnFileWatchClosed()
}

// FileEvent is an enum covering the kinds of things that can happen
// to files that we might be interested in
type FileEvent int

const (
	// FileAdded - this is a new file
	FileAdded FileEvent = iota + 1
	// FileDeleted - this file has been removed
	FileDeleted
	// FileModified - this file has been changed in some way
	FileModified
	// FileRenamed - a file's name has changed
	FileRenamed
	// FileOther - some other backend-specific event has happened
	FileOther
)

var (
	// ErrFilewatchingClosed is returned when filewatching has been closed
	ErrFilewatchingClosed = errors.New("Close() has already been called for filewatching")
	// ErrFailedToStart is returned when filewatching fails to start up
	ErrFailedToStart = errors.New("filewatching failed to start")
)

// Event is the backend-independent information about a file change
type Event struct {
	Path      turbopath.AbsoluteSystemPath
	EventType FileEvent
}

// Backend is the interface that describes what an underlying filesystem watching backend
// must provide.
type Backend interface {
	AddRoot(root turbopath.AbsoluteSystemPath, excludePatterns ...string) error
	Events() <-chan Event
	Errors() <-chan error
	Close() error
	Start() error
}

// FileWatcher handles watching all of the files in the monorepo.
// We currently ignore .git and top-level node_modules. We can revisit
// if necessary.
type FileWatcher struct {
	backend Backend

	logger         hclog.Logger
	repoRoot       turbopath.AbsoluteSystemPath
	excludePattern string

	clientsMu sync.RWMutex
	clients   []FileWatchClient
	closed    bool
}

// New returns a new FileWatcher instance
func New(logger hclog.Logger, repoRoot turbopath.AbsoluteSystemPath, backend Backend) *FileWatcher {
	excludes := make([]string, len(_ignores))
	for i, ignore := range _ignores {
		excludes[i] = filepath.ToSlash(repoRoot.UntypedJoin(ignore).ToString() + "/**")
	}
	excludePattern := "{" + strings.Join(excludes, ",") + "}"
	return &FileWatcher{
		backend:        backend,
		logger:         logger,
		repoRoot:       repoRoot,
		excludePattern: excludePattern,
	}
}

// Close shuts down filewatching
func (fw *FileWatcher) Close() error {
	return fw.backend.Close()
}

// Start recursively adds all directories from the repo root, redacts the excluded ones,
// then fires off a goroutine to respond to filesystem events
func (fw *FileWatcher) Start() error {
	if err := fw.backend.AddRoot(fw.repoRoot, fw.excludePattern); err != nil {
		return err
	}
	if err := fw.backend.Start(); err != nil {
		return err
	}
	go fw.watch()
	return nil
}

// AddRoot registers the root a filesystem hierarchy to be watched for changes. Events are *not*
// fired for existing files when AddRoot is called, only for subsequent changes.
// NOTE: if it appears helpful, we could change this behavior so that we provide a stream of initial
// events.
func (fw *FileWatcher) AddRoot(root turbopath.AbsoluteSystemPath, excludePatterns ...string) error {
	return fw.backend.AddRoot(root, excludePatterns...)
}

// watch is the main file-watching loop. Watching is not recursive,
// so when new directories are added, they are manually recursively watched.
func (fw *FileWatcher) watch() {
outer:
	for {
		select {
		case ev, ok := <-fw.backend.Events():
			if !ok {
				fw.logger.Info("Events channel closed. Exiting watch loop")
				break outer
			}
			fw.clientsMu.RLock()
			for _, client := range fw.clients {
				client.OnFileWatchEvent(ev)
			}
			fw.clientsMu.RUnlock()
		case err, ok := <-fw.backend.Errors():
			if !ok {
				fw.logger.Info("Errors channel closed. Exiting watch loop")
				break outer
			}
			fw.clientsMu.RLock()
			for _, client := range fw.clients {
				client.OnFileWatchError(err)
			}
			fw.clientsMu.RUnlock()
		}
	}
	fw.clientsMu.Lock()
	fw.closed = true
	for _, client := range fw.clients {
		client.OnFileWatchClosed()
	}
	fw.clientsMu.Unlock()
}

// AddClient registers a client for filesystem events
func (fw *FileWatcher) AddClient(client FileWatchClient) {
	fw.clientsMu.Lock()
	defer fw.clientsMu.Unlock()
	fw.clients = append(fw.clients, client)
	if fw.closed {
		client.OnFileWatchClosed()
	}
}