Repository: nodejs/node
Pull request: watch: check parent and child path properly

Fixes: https://github.com/nodejs/node/issues/57422

Changed files:
filename: lib/internal/watch_mode/files_watcher.js
patch: @@ -7,6 +7,7 @@ const {
   SafeMap,
   SafeSet,
   SafeWeakMap,
+  StringPrototypeEndsWith,
   StringPrototypeStartsWith,
 } = primordials;
 
@@ -18,12 +19,19 @@ const EventEmitter = require('events');
 const { addAbortListener } = require('internal/events/abort_listener');
 const { watch } = require('fs');
 const { fileURLToPath } = require('internal/url');
-const { resolve, dirname } = require('path');
+const { resolve, dirname, sep } = require('path');
 const { setTimeout, clearTimeout } = require('timers');
 
 const supportsRecursiveWatching = process.platform === 'win32' ||
   process.platform === 'darwin';
 
+const isParentPath = (parentCandidate, childCandidate) => {
+  const parent = resolve(parentCandidate);
+  const child = resolve(childCandidate);
+  const normalizedParent = StringPrototypeEndsWith(parent, sep) ? parent : parent + sep;
+  return StringPrototypeStartsWith(child, normalizedParent);
+};
+
 class FilesWatcher extends EventEmitter {
   #watchers = new SafeMap();
   #filteredFiles = new SafeSet();
@@ -58,7 +66,7 @@ class FilesWatcher extends EventEmitter {
     }
 
     for (const { 0: watchedPath, 1: watcher } of this.#watchers.entries()) {
-      if (watcher.recursive && StringPrototypeStartsWith(path, watchedPath)) {
+      if (watcher.recursive && isParentPath(watchedPath, path)) {
         return true;
       }
     }
@@ -68,7 +76,7 @@ class FilesWatcher extends EventEmitter {
 
   #removeWatchedChildren(path) {
     for (const { 0: watchedPath, 1: watcher } of this.#watchers.entries()) {
-      if (path !== watchedPath && StringPrototypeStartsWith(watchedPath, path)) {
+      if (path !== watchedPath && isParentPath(path, watchedPath)) {
         this.#unwatch(watcher);
         this.#watchers.delete(watchedPath);
       }

filename: test/parallel/test-watch-mode-files_watcher.mjs
patch: @@ -6,7 +6,8 @@ import path from 'node:path';
 import assert from 'node:assert';
 import process from 'node:process';
 import { describe, it, beforeEach, afterEach } from 'node:test';
-import { writeFileSync, mkdirSync } from 'node:fs';
+import { writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
+import { createInterface } from 'node:readline';
 import { setTimeout } from 'node:timers/promises';
 import { once } from 'node:events';
 import { spawn } from 'node:child_process';
@@ -51,6 +52,33 @@ describe('watch mode file watcher', () => {
     assert.strictEqual(changesCount, 1);
   });
 
+  it('should watch changed files with same prefix path string', async () => {
+    mkdirSync(tmpdir.resolve('subdir'));
+    mkdirSync(tmpdir.resolve('sub'));
+    const file1 = tmpdir.resolve('subdir', 'file1.mjs');
+    const file2 = tmpdir.resolve('sub', 'file2.mjs');
+    writeFileSync(file2, 'export const hello = () => { return "hello world"; };');
+    writeFileSync(file1, 'import { hello } from "../sub/file2.mjs"; console.log(hello());');
+
+    const child = spawn(process.execPath,
+                        ['--watch', file1],
+                        { stdio: ['ignore', 'pipe', 'ignore'] });
+    let completeCount = 0;
+    for await (const line of createInterface(child.stdout)) {
+      if (!line.startsWith('Completed running')) {
+        continue;
+      }
+      completeCount++;
+      if (completeCount === 1) {
+        appendFileSync(file1, '\n // append 1');
+      }
+      // The file is reloaded due to file watching
+      if (completeCount === 2) {
+        child.kill();
+      }
+    }
+  });
+
   it('should debounce changes', async () => {
     const file = tmpdir.resolve('file2');
     writeFileSync(file, 'written');