Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 140 additions & 27 deletions src/commands/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,44 +139,157 @@ export async function startWatch(options: WatchOptions): Promise<void> {

console.log("Watching for file changes in", watchRoot);
fileSystem.loadMgrepignore(watchRoot);
fs.watch(watchRoot, { recursive: true }, (eventType, rawFilename) => {
const filename = rawFilename?.toString();
if (!filename) {

// Use per-directory non-recursive watchers to avoid watching ignored
// directories like .git/, node_modules/, target/, etc. fs.watch with
// recursive: true allocates inotify watches on ALL subdirectories before
// the callback filter runs, which exhausts the kernel watcher limit on
// large repos.
const watchers = new Map<string, fs.FSWatcher>();

function isMissingPathError(error: unknown): boolean {
return (
error instanceof Error &&
"code" in error &&
(error.code === "ENOENT" || error.code === "ENOTDIR")
);
}

function getPathStats(filePath: string): fs.Stats | null | undefined {
try {
return fs.statSync(filePath);
} catch (error) {
if (isMissingPathError(error)) {
return null;
}

console.warn(`Warning: failed to inspect path ${filePath}:`, error);
return undefined;
}
}

function closeWatcher(dirPath: string): void {
const watcher = watchers.get(dirPath);
if (!watcher) {
return;
}
const filePath = path.join(watchRoot, filename);

watcher.close();
watchers.delete(dirPath);
}

function closeWatcherSubtree(dirPath: string): void {
const prefix = `${dirPath}${path.sep}`;

for (const watchedPath of Array.from(watchers.keys())) {
if (watchedPath === dirPath || watchedPath.startsWith(prefix)) {
closeWatcher(watchedPath);
}
}
}

function handleDeletion(filePath: string): void {
closeWatcherSubtree(filePath);

void deleteFile(store, options.store, filePath)
.then(() => {
console.log(`delete: ${filePath}`);
})
.catch((error) => {
console.error("Failed to delete file:", filePath, error);
});
}

function handleFileEvent(
eventType: fs.WatchEventType,
dirPath: string,
name: string,
): void {
const filePath = path.join(dirPath, name);

if (fileSystem.isIgnored(filePath, watchRoot)) {
return;
}

const stats = getPathStats(filePath);
if (stats === undefined) {
return;
}

if (stats === null) {
handleDeletion(filePath);
return;
}

if (stats.isDirectory()) {
watchDirectory(filePath);
return;
}

if (!stats.isFile()) {
return;
}

void uploadFile(
store,
options.store,
filePath,
path.basename(filePath),
config,
)
.then((didUpload) => {
if (didUpload) {
console.log(`${eventType}: ${filePath}`);
}
})
.catch((error) => {
console.error("Failed to upload changed file:", filePath, error);
});
}

function watchDirectory(dirPath: string): void {
if (watchers.has(dirPath)) {
return;
}

if (dirPath !== watchRoot && fileSystem.isIgnored(dirPath, watchRoot)) {
return;
}

const stats = getPathStats(dirPath);
if (!stats?.isDirectory()) {
return;
}

try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) {
return;
}
const watcher = fs.watch(dirPath, (eventType, rawFilename) => {
const name = rawFilename?.toString();
if (!name) {
return;
}

uploadFile(store, options.store, filePath, filename, config)
.then((didUpload) => {
if (didUpload) {
console.log(`${eventType}: ${filePath}`);
}
})
.catch((err) => {
console.error("Failed to upload changed file:", filePath, err);
});
} catch {
if (filePath.startsWith(watchRoot) && !fs.existsSync(filePath)) {
deleteFile(store, options.store, filePath)
.then(() => {
console.log(`delete: ${filePath}`);
})
.catch((err) => {
console.error("Failed to delete file:", filePath, err);
});
handleFileEvent(eventType, dirPath, name);
});
watchers.set(dirPath, watcher);
} catch (error) {
console.warn(`Warning: failed to watch ${dirPath}:`, error);
return;
}

try {
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}

watchDirectory(path.join(dirPath, entry.name));
}
} catch (error) {
console.warn(`Warning: failed to read directory ${dirPath}:`, error);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New directories' existing files are never uploaded

Medium Severity

When handleFileEvent discovers a new directory, it calls watchDirectory which sets up watchers and recurses into subdirectories — but only for directory entries. The readdirSync loop filters with !entry.isDirectory() and skips all files. This means when a directory tree appears with files already inside (e.g., git checkout, git stash pop, mv, or archive extraction), those existing files are silently never uploaded. Only files created after the watcher is installed are detected. At initial startup this is fine because initialSync handles existing files, but for directories discovered mid-watch it's a gap.

Additional Locations (1)
Fix in Cursor Fix in Web

}
});
}

watchDirectory(watchRoot);
} catch (error) {
if (refreshInterval) {
clearInterval(refreshInterval);
Expand Down
Loading