|
9 | 9 | "os/exec" |
10 | 10 | "path/filepath" |
11 | 11 | "strings" |
| 12 | + "sync" |
12 | 13 | "sync/atomic" |
13 | 14 | "time" |
14 | 15 |
|
@@ -45,7 +46,6 @@ const ( |
45 | 46 | baseInterval = 5 * time.Second |
46 | 47 | maxInterval = 60 * time.Second |
47 | 48 | fullSnapshotInterval = 5 // polls between forced full snapshots |
48 | | - projectsCacheTTL = 60 * time.Second |
49 | 49 | ) |
50 | 50 |
|
51 | 51 | type fileSnapshot struct { |
@@ -94,40 +94,81 @@ func (ps *projectState) close() { |
94 | 94 | // IndexFunc is the callback signature for triggering a re-index. |
95 | 95 | type IndexFunc func(ctx context.Context, projectName, rootPath string) error |
96 | 96 |
|
| 97 | +// watchEntry tracks a project in the explicit watch list. |
| 98 | +type watchEntry struct { |
| 99 | + rootPath string |
| 100 | + touchedAt time.Time |
| 101 | +} |
| 102 | + |
97 | 103 | // Watcher polls indexed projects for file changes and triggers re-indexing. |
98 | 104 | // Change detection uses a 3-tier strategy per project: |
99 | 105 | // |
100 | 106 | // 1. Git — git status + HEAD tracking (for git repos) |
101 | 107 | // 2. FSNotify — event-driven via OS file notifications (for non-git dirs) |
102 | 108 | // 3. Dir-mtime — directory mtime polling (fallback if fsnotify setup fails) |
103 | 109 | type Watcher struct { |
104 | | - router *store.StoreRouter |
105 | | - indexFn IndexFunc |
106 | | - projects map[string]*projectState |
107 | | - ctx context.Context |
108 | | - cachedProjects []*store.ProjectInfo |
109 | | - projectsCacheTime time.Time |
| 110 | + router *store.StoreRouter |
| 111 | + indexFn IndexFunc |
| 112 | + projects map[string]*projectState |
| 113 | + ctx context.Context |
| 114 | + |
| 115 | + // Explicit watch list — only watched projects get polled. |
| 116 | + mu sync.Mutex |
| 117 | + watchList map[string]watchEntry |
110 | 118 |
|
111 | 119 | // testStrategy overrides auto-detection when non-zero (for tests). |
112 | 120 | testStrategy watchStrategy |
113 | 121 | } |
114 | 122 |
|
115 | 123 | // New creates a Watcher. indexFn is called when file changes are detected. |
116 | 124 | func New(r *store.StoreRouter, indexFn IndexFunc) *Watcher { |
117 | | - w := &Watcher{ |
118 | | - router: r, |
119 | | - indexFn: indexFn, |
120 | | - projects: make(map[string]*projectState), |
121 | | - ctx: context.Background(), |
122 | | - } |
123 | | - // Wire invalidation: when a project is deleted, clear the cache immediately. |
124 | | - r.OnDelete(func(_ string) { w.InvalidateProjectsCache() }) |
125 | | - return w |
| 125 | + return &Watcher{ |
| 126 | + router: r, |
| 127 | + indexFn: indexFn, |
| 128 | + projects: make(map[string]*projectState), |
| 129 | + watchList: make(map[string]watchEntry), |
| 130 | + ctx: context.Background(), |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +// Watch adds a project to the watch list. Called after successful index. |
| 135 | +func (w *Watcher) Watch(name, rootPath string) { |
| 136 | + w.mu.Lock() |
| 137 | + defer w.mu.Unlock() |
| 138 | + w.watchList[name] = watchEntry{rootPath: rootPath, touchedAt: time.Now()} |
| 139 | + slog.Debug("watcher.watch", "project", name, "path", rootPath) |
126 | 140 | } |
127 | 141 |
|
128 | | -// InvalidateProjectsCache forces the next pollAll to re-query ListProjects. |
129 | | -func (w *Watcher) InvalidateProjectsCache() { |
130 | | - w.projectsCacheTime = time.Time{} |
| 142 | +// Unwatch removes a project from the watch list. Called on delete. |
| 143 | +func (w *Watcher) Unwatch(name string) { |
| 144 | + w.mu.Lock() |
| 145 | + defer w.mu.Unlock() |
| 146 | + delete(w.watchList, name) |
| 147 | + slog.Debug("watcher.unwatch", "project", name) |
| 148 | +} |
| 149 | + |
| 150 | +// TouchProject refreshes a project's timestamp in the watch list. |
| 151 | +// If the project isn't watched yet, adds it (looks up rootPath from DB). |
| 152 | +func (w *Watcher) TouchProject(name string) { |
| 153 | + w.mu.Lock() |
| 154 | + defer w.mu.Unlock() |
| 155 | + if e, ok := w.watchList[name]; ok { |
| 156 | + e.touchedAt = time.Now() |
| 157 | + w.watchList[name] = e |
| 158 | + return |
| 159 | + } |
| 160 | + // Not yet watched — look up rootPath from DB. |
| 161 | + st, release, err := w.router.AcquireStore(name) |
| 162 | + if err != nil { |
| 163 | + return |
| 164 | + } |
| 165 | + proj, projErr := st.GetProject(name) |
| 166 | + release() |
| 167 | + if projErr != nil || proj == nil || proj.RootPath == "" { |
| 168 | + return |
| 169 | + } |
| 170 | + w.watchList[name] = watchEntry{rootPath: proj.RootPath, touchedAt: time.Now()} |
| 171 | + slog.Debug("watcher.touch_add", "project", name, "path", proj.RootPath) |
131 | 172 | } |
132 | 173 |
|
133 | 174 | // Run blocks until ctx is cancelled. Ticks at baseInterval, polling each |
@@ -155,55 +196,51 @@ func (w *Watcher) closeAll() { |
155 | 196 | } |
156 | 197 | } |
157 | 198 |
|
158 | | -// pollAll lists all indexed projects and polls each that is due. |
159 | | -// Prunes watcher state for projects that no longer exist. |
| 199 | +// pollAll iterates the explicit watch list and polls each project that is due. |
| 200 | +// Prunes watcher state for unwatched projects. |
160 | 201 | func (w *Watcher) pollAll() { |
161 | | - // Cache ListProjects to avoid repeated ReadDir+SQLite queries. |
162 | | - if time.Since(w.projectsCacheTime) > projectsCacheTTL { |
163 | | - infos, err := w.router.ListProjects() |
164 | | - if err != nil { |
165 | | - slog.Warn("watcher.list_projects", "err", err) |
166 | | - return |
167 | | - } |
168 | | - w.cachedProjects = infos |
169 | | - w.projectsCacheTime = time.Now() |
| 202 | + w.mu.Lock() |
| 203 | + // Copy watch list under lock to avoid holding lock during poll. |
| 204 | + entries := make(map[string]watchEntry, len(w.watchList)) |
| 205 | + for k, v := range w.watchList { |
| 206 | + entries[k] = v |
170 | 207 | } |
171 | | - projectInfos := w.cachedProjects |
| 208 | + w.mu.Unlock() |
172 | 209 |
|
173 | | - // Prune stale entries. |
174 | | - activeNames := make(map[string]struct{}, len(projectInfos)) |
175 | | - for _, info := range projectInfos { |
176 | | - activeNames[info.Name] = struct{}{} |
177 | | - } |
| 210 | + // Prune projectState for unwatched projects. |
178 | 211 | for name, state := range w.projects { |
179 | | - if _, ok := activeNames[name]; !ok { |
| 212 | + if _, ok := entries[name]; !ok { |
180 | 213 | slog.Info("watcher.prune", "project", name) |
181 | 214 | state.close() |
182 | 215 | delete(w.projects, name) |
183 | 216 | } |
184 | 217 | } |
185 | 218 |
|
186 | 219 | now := time.Now() |
187 | | - for _, info := range projectInfos { |
188 | | - state, exists := w.projects[info.Name] |
| 220 | + for name, entry := range entries { |
| 221 | + state, exists := w.projects[name] |
189 | 222 | if exists && now.Before(state.nextPoll) { |
190 | | - continue // not due yet |
| 223 | + continue |
191 | 224 | } |
192 | 225 |
|
193 | | - // AcquireStore increments refs so the evictor can't close mid-query. |
194 | | - st, release, stErr := w.router.AcquireStore(info.Name) |
| 226 | + st, release, stErr := w.router.AcquireStore(name) |
195 | 227 | if stErr != nil { |
196 | 228 | continue |
197 | 229 | } |
198 | | - proj, projErr := st.GetProject(info.Name) |
| 230 | + proj, projErr := st.GetProject(name) |
199 | 231 | release() |
200 | 232 | if projErr != nil || proj == nil { |
201 | 233 | continue |
202 | 234 | } |
203 | 235 |
|
| 236 | + // Use rootPath from watch list (most current). |
| 237 | + if entry.rootPath != "" { |
| 238 | + proj.RootPath = entry.rootPath |
| 239 | + } |
| 240 | + |
204 | 241 | if !exists { |
205 | 242 | state = &projectState{} |
206 | | - w.projects[info.Name] = state |
| 243 | + w.projects[name] = state |
207 | 244 | } |
208 | 245 |
|
209 | 246 | w.pollProject(proj, state) |
|
0 commit comments