diff --git a/package.json b/package.json index 58801bd55..faa66008a 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "@types/split2": "^4.2.3", "append-transform": "^2.0.0", "cachedir": "^2.4.0", - "chokidar": "^3.6.0", + "chokidar": "^4.0.3", "clean-pkg-json": "^1.2.0", "cleye": "^1.3.2", "cross-spawn": "^7.0.3", @@ -92,6 +92,7 @@ "fs-fixture": "^2.4.0", "fs-require": "^1.6.0", "get-node": "^15.0.1", + "glob": "^11.0.1", "kolorist": "^1.8.0", "lintroll": "^1.8.1", "magic-string": "^0.30.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45dfa6134..7fd234c5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: ^2.4.0 version: 2.4.0 chokidar: - specifier: ^3.6.0 - version: 3.6.0 + specifier: ^4.0.3 + version: 4.0.3 clean-pkg-json: specifier: ^1.2.0 version: 1.2.0 @@ -62,6 +62,9 @@ importers: get-node: specifier: ^15.0.1 version: 15.0.1 + glob: + specifier: ^11.0.1 + version: 11.0.1 kolorist: specifier: ^1.8.0 version: 1.8.0 @@ -1584,6 +1587,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -2194,8 +2201,14 @@ packages: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true + glob@11.0.1: + resolution: {integrity: sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==} + engines: {node: 20 || >=22} + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported global-cache-dir@6.0.0: resolution: {integrity: sha512-UOwXU6ulg3VQsSyKf0QAVcW4EFq3hFehFHV/ne76iQ9FAw4ZpXHXsmw8AwUueGI13y4apVML/Pb+njilLn/RCw==} @@ -2522,6 +2535,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.1.0: + resolution: {integrity: sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==} + engines: {node: 20 || >=22} + jest-diff@29.7.0: resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2658,6 +2675,10 @@ packages: resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==} engines: {node: 14 || >=16.14} + lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} + lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -2726,6 +2747,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.0.1: + resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3005,6 +3030,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -3182,6 +3211,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + refa@0.12.1: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -5130,6 +5163,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chownr@3.0.0: {} ci-info@3.8.0: {} @@ -6021,6 +6058,15 @@ snapshots: package-json-from-dist: 1.0.0 path-scurry: 1.11.1 + glob@11.0.1: + dependencies: + foreground-child: 3.1.1 + jackspeak: 4.1.0 + minimatch: 10.0.1 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 2.0.0 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -6342,6 +6388,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@4.1.0: + dependencies: + '@isaacs/cliui': 8.0.2 + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -6511,6 +6561,8 @@ snapshots: lru-cache@10.2.2: {} + lru-cache@11.1.0: {} + lru-cache@6.0.0: dependencies: yallist: 4.0.0 @@ -6595,6 +6647,10 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.0.1: + dependencies: + brace-expansion: 2.0.1 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -6875,6 +6931,11 @@ snapshots: lru-cache: 10.2.2 minipass: 7.1.2 + path-scurry@2.0.0: + dependencies: + lru-cache: 11.1.0 + minipass: 7.1.2 + path-type@4.0.0: {} path-type@5.0.0: {} @@ -7046,6 +7107,8 @@ snapshots: dependencies: picomatch: 2.3.1 + readdirp@4.1.2: {} + refa@0.12.1: dependencies: '@eslint-community/regexpp': 4.11.0 diff --git a/src/watch/index.ts b/src/watch/index.ts index 826bce1c3..db6a09e00 100644 --- a/src/watch/index.ts +++ b/src/watch/index.ts @@ -4,6 +4,7 @@ import { constants as osConstants } from 'node:os'; import path from 'node:path'; import { command } from 'cleye'; import { watch } from 'chokidar'; +import { Glob, hasMagic, Ignore } from 'glob'; import { lightMagenta, lightGreen, yellow } from 'kolorist'; import { run } from '../run.js'; import { @@ -199,6 +200,32 @@ export const watchCommand = command({ process.on('SIGINT', relaySignal); process.on('SIGTERM', relaySignal); + // Parse all include paths as globs, then build a Set for fast lookups during the ignored function + const watchGlob = new Glob([...argv._, ...options.include], { + absolute: true, + windowsPathsNoEscape: true, + }); + const watchPaths = new Set(watchGlob); + + // Build an Ignore object for checking whether a path should be ignored or not. Note that we can't + // pass patterns in the constructor because the changes to mmopts won't be made yet. + const ignore = new Ignore([], watchGlob); + ignore.mmopts.windowsPathsNoEscape = true; + + // Now add the built-in and user-specified ignore patterns + [ + // Hidden directories (like .git) and files (e.g. logs or temp files) + '**/.*/**', + + // 3rd party packages + '**/{node_modules,bower_components,vendor}/**', + + // Convert normal, non-glob paths into globs ending in '/**', so descendants are also included + ...options.exclude.map(o => (hasMagic(o, { windowsPathsNoEscape: true }) ? o : `${o}/**`)), + ].forEach(p => ignore.add(p)); + + const cwd = process.cwd(); + /** * Ideally, we can get a list of files loaded from the run above * and only watch those files, but it's not possible to detect @@ -208,25 +235,28 @@ export const watchCommand = command({ * As an alternative, we watch cwd and all run-time dependencies */ const watcher = watch( - [ - ...argv._, - ...options.include, - ], + Array.from(watchPaths), { - cwd: process.cwd(), + cwd, ignoreInitial: true, - ignored: [ - // Hidden directories like .git - '**/.*/**', + ignored: (fullPath) => { + // Resolve the path to ensure it has the correct directory separators + const resolvedPath = path.resolve(fullPath); - // Hidden files (e.g. logs or temp files) - '**/.*', + // Never ignore files that were explicitly watched + if (watchPaths.has(resolvedPath)) { + return false; + } - // 3rd party packages - '**/{node_modules,bower_components,vendor}/**', + // ignore.ignored() expects a 'path-scurry' Path, but it only uses the fullpath() and + // relative() functions, so just make an object with those properties. + const p = { + fullpath: () => resolvedPath, + relative: () => path.relative(cwd, resolvedPath), + } as Parameters[0]; - ...options.exclude, - ], + return ignore.ignored(p); + }, ignorePermissionErrors: true, }, ).on('all', reRun); diff --git a/tests/specs/watch.ts b/tests/specs/watch.ts index 8960a9f45..a46697f93 100644 --- a/tests/specs/watch.ts +++ b/tests/specs/watch.ts @@ -355,5 +355,102 @@ export default testSuite(async ({ describe }, { tsx }: NodeApis) => { await tsxProcess; }, 10_000); }); + + describe('prefer include over exclude', ({ test }) => { + test('file path & glob', async ({ onTestFail }) => { + const entryFile = 'index.js'; + const fileA = 'file-a.js'; + const fileB = 'directory/file-b.js'; + const fileC = 'directory/file-c.js'; + const depA = 'node_modules/a/index.js'; + + await using fixture = await createFixture({ + [fileA]: 'export default "logA"', + [fileB]: 'export default "logB"', + [fileC]: 'export default "logC"', + [depA]: 'export default "logD"', + [entryFile]: ` + import valueA from './${fileA}' + import valueB from './${fileB}' + import valueC from './${fileC}' + import valueD from './${depA}' + console.log(valueA, valueB, valueC, valueD) + `.trim(), + }); + + const tsxProcess = tsx( + [ + 'watch', + '--clear-screen=false', + `--ignore=${fileA}`, + '--exclude=directory/*', + `--include=${fileC}`, + entryFile, + ], + fixture.path, + ); + + onTestFail(async () => { + // If timed out, force kill process + if (tsxProcess.exitCode === null) { + console.log('Force killing hanging process\n\n'); + tsxProcess.kill(); + console.log({ + tsxProcess: await tsxProcess, + }); + } + }); + + const negativeSignal = 'fail'; + + await expect( + processInteract( + tsxProcess.stdout!, + [ + async (data) => { + if (data !== 'logA logB logC logD\n') { + return; + } + + // These changes should not trigger a re-run + await Promise.all([ + fixture.writeFile(fileA, `export default "${negativeSignal}"`), + fixture.writeFile(fileB, `export default "${negativeSignal}"`), + fixture.writeFile(depA, `export default "${negativeSignal}"`), + ]); + return true; + }, + (data) => { + if (data.includes(negativeSignal)) { + throw new Error('Unexpected re-run'); + } + }, + ], + 4000, + ), + ).rejects.toThrow('Timeout'); // Watch should not trigger + + // This change should trigger a re-run, even though fileC's directory is excluded + fixture.writeFile(fileC, 'export default "updateC"'); + + await processInteract( + tsxProcess.stdout!, + [ + (data) => { + // Now that the app has restarted, we should see the negativeSignal updates too + if (data === `${negativeSignal} ${negativeSignal} updateC ${negativeSignal}\n`) { + return true; + } + }, + ], + 4000, + ); + + tsxProcess.kill(); + + const tsxProcessResolved = await tsxProcess; + expect(tsxProcessResolved.stderr).toBe(''); + }, 10_000); + }); }); });