diff --git a/package.json b/package.json index bb11eddcf..441a70b12 100644 --- a/package.json +++ b/package.json @@ -79,11 +79,15 @@ "devDependencies": { "@ampproject/remapping": "^2.3.0", "@types/cross-spawn": "^6.0.6", + "@types/glob-parent": "^5.1.3", + "@types/is-glob": "^4.0.4", "@types/node": "^20.14.9", + "@types/normalize-path": "^3.0.2", + "@types/picomatch": "^3.0.1", "@types/split2": "^4.2.3", "append-transform": "^2.0.0", "cachedir": "^2.4.0", - "chokidar": "^3.6.0", + "chokidar": "^4.0.1", "clean-pkg-json": "^1.2.0", "cleye": "^1.3.2", "cross-spawn": "^7.0.3", @@ -92,13 +96,17 @@ "fs-fixture": "^2.4.0", "fs-require": "^1.6.0", "get-node": "^15.0.1", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", "kolorist": "^1.8.0", "lintroll": "^1.8.1", "magic-string": "^0.30.10", "manten": "^1.3.0", "memfs": "^4.9.3", "node-pty": "^1.0.0", + "normalize-path": "^3.0.0", "outdent": "^0.8.0", + "picomatch": "^4.0.2", "pkgroll": "^2.4.1", "proxyquire": "^2.1.3", "split2": "^4.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e62720dce..996771833 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,9 +25,21 @@ importers: '@types/cross-spawn': specifier: ^6.0.6 version: 6.0.6 + '@types/glob-parent': + specifier: ^5.1.3 + version: 5.1.3 + '@types/is-glob': + specifier: ^4.0.4 + version: 4.0.4 '@types/node': specifier: ^20.14.9 version: 20.14.9 + '@types/normalize-path': + specifier: ^3.0.2 + version: 3.0.2 + '@types/picomatch': + specifier: ^3.0.1 + version: 3.0.1 '@types/split2': specifier: ^4.2.3 version: 4.2.3 @@ -38,8 +50,8 @@ importers: specifier: ^2.4.0 version: 2.4.0 chokidar: - specifier: ^3.6.0 - version: 3.6.0 + specifier: ^4.0.1 + version: 4.0.1 clean-pkg-json: specifier: ^1.2.0 version: 1.2.0 @@ -64,6 +76,12 @@ importers: get-node: specifier: ^15.0.1 version: 15.0.1 + glob-parent: + specifier: ^6.0.2 + version: 6.0.2 + is-glob: + specifier: ^4.0.3 + version: 4.0.3 kolorist: specifier: ^1.8.0 version: 1.8.0 @@ -82,9 +100,15 @@ importers: node-pty: specifier: ^1.0.0 version: 1.0.0 + normalize-path: + specifier: ^3.0.0 + version: 3.0.0 outdent: specifier: ^0.8.0 version: 0.8.0 + picomatch: + specifier: ^4.0.2 + version: 4.0.2 pkgroll: specifier: ^2.4.1 version: 2.4.1(typescript@5.5.2) @@ -956,9 +980,15 @@ packages: '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + '@types/glob-parent@5.1.3': + resolution: {integrity: sha512-p+NciRH8TRvrgISOCQ55CP+lktMmDpOXsp4spULIIz0L4aJ6G9zFX+N0UZ2xulmJRgaQLRxXIp4xHdL6YOQjDg==} + '@types/http-cache-semantics@4.0.4': resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + '@types/is-glob@4.0.4': + resolution: {integrity: sha512-3mFBtIPQ0TQetKRDe94g8YrxJZxdMillMGegyv6zRBXvq4peRRhf2wLZ/Dl53emtTsC29dQQBwYvovS20yXpiQ==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -992,6 +1022,12 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/normalize-path@3.0.2': + resolution: {integrity: sha512-DO++toKYPaFn0Z8hQ7Tx+3iT9t77IJo/nDiqTXilgEP+kPNIYdpS9kh3fXuc53ugqwp9pxC1PVjCpV1tQDyqMA==} + + '@types/picomatch@3.0.1': + resolution: {integrity: sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==} + '@types/prop-types@15.7.12': resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} @@ -1335,8 +1371,8 @@ packages: brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} - braces@3.0.2: - resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} browserslist@4.23.0: @@ -1408,6 +1444,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.1: + resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==} + engines: {node: '>= 14.16.0'} + ci-info@3.8.0: resolution: {integrity: sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==} engines: {node: '>=8'} @@ -1878,8 +1918,8 @@ packages: resolution: {integrity: sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==} engines: {node: '>=0.10.0'} - fill-range@7.0.1: - resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} filter-obj@5.1.0: @@ -1994,6 +2034,7 @@ packages: 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==} @@ -2108,6 +2149,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2477,8 +2519,12 @@ packages: micromark@2.11.4: resolution: {integrity: sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA==} - micromatch@4.0.5: - resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + micromatch@4.0.7: + resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} + engines: {node: '>=8.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} mimic-fn@4.0.0: @@ -2894,6 +2940,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.0.1: + resolution: {integrity: sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==} + engines: {node: '>= 14.16.0'} + refa@0.12.1: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -2949,6 +2999,7 @@ packages: rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rollup@4.17.1: @@ -4140,8 +4191,12 @@ snapshots: '@types/estree@1.0.5': {} + '@types/glob-parent@5.1.3': {} + '@types/http-cache-semantics@4.0.4': {} + '@types/is-glob@4.0.4': {} + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -4175,6 +4230,10 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/normalize-path@3.0.2': {} + + '@types/picomatch@3.0.1': {} + '@types/prop-types@15.7.12': optional: true @@ -4602,9 +4661,9 @@ snapshots: dependencies: balanced-match: 1.0.2 - braces@3.0.2: + braces@3.0.3: dependencies: - fill-range: 7.0.1 + fill-range: 7.1.1 browserslist@4.23.0: dependencies: @@ -4674,7 +4733,7 @@ snapshots: chokidar@3.6.0: dependencies: anymatch: 3.1.3 - braces: 3.0.2 + braces: 3.0.3 glob-parent: 5.1.2 is-binary-path: 2.1.0 is-glob: 4.0.3 @@ -4683,6 +4742,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.1: + dependencies: + readdirp: 4.0.1 + ci-info@3.8.0: {} ci-info@4.0.0: {} @@ -5360,7 +5423,7 @@ snapshots: '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 - micromatch: 4.0.5 + micromatch: 4.0.7 fast-json-stable-stringify@2.1.0: {} @@ -5396,7 +5459,7 @@ snapshots: is-object: 1.0.2 merge-descriptors: 1.0.3 - fill-range@7.0.1: + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -5847,7 +5910,7 @@ snapshots: '@types/stack-utils': 2.0.3 chalk: 4.1.2 graceful-fs: 4.2.11 - micromatch: 4.0.5 + micromatch: 4.0.8 pretty-format: 29.7.0 slash: 3.0.0 stack-utils: 2.0.6 @@ -6046,9 +6109,14 @@ snapshots: transitivePeerDependencies: - supports-color - micromatch@4.0.5: + micromatch@4.0.7: dependencies: - braces: 3.0.2 + braces: 3.0.3 + picomatch: 2.3.1 + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 picomatch: 2.3.1 mimic-fn@4.0.0: {} @@ -6443,6 +6511,8 @@ snapshots: dependencies: picomatch: 2.3.1 + readdirp@4.0.1: {} + refa@0.12.1: dependencies: '@eslint-community/regexpp': 4.11.0 @@ -6775,7 +6845,7 @@ snapshots: is-glob: 4.0.3 jiti: 1.21.0 lilconfig: 2.1.0 - micromatch: 4.0.5 + micromatch: 4.0.7 normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.0.0 diff --git a/src/watch/index.ts b/src/watch/index.ts index 826bce1c3..8629ade9e 100644 --- a/src/watch/index.ts +++ b/src/watch/index.ts @@ -4,7 +4,10 @@ import { constants as osConstants } from 'node:os'; import path from 'node:path'; import { command } from 'cleye'; import { watch } from 'chokidar'; +import picomatch from 'picomatch'; import { lightMagenta, lightGreen, yellow } from 'kolorist'; +import globParent from 'glob-parent'; +import isGlob from 'is-glob'; import { run } from '../run.js'; import { removeArgvFlags, @@ -15,6 +18,7 @@ import { clearScreen, debounce, log, + resolveGlobPattern, } from './utils.js'; const flags = { @@ -81,6 +85,23 @@ export const watchCommand = command({ const server = await createIpcServer(); + const isDefaultIgnore = picomatch([ + // Hidden directories like .git + '**/.*/**', + + // Hidden files (e.g. logs or temp files) + '**/.*', + + // 3rd party packages + '**/{node_modules,bower_components,vendor}/**', + ]); + + const resolvedIncludes = options.include.map(resolveGlobPattern); + const isOptionsInclude = picomatch(resolvedIncludes); + + const resolvedExcludes = options.exclude.map(resolveGlobPattern); + const isOptionsExclude = picomatch(resolvedExcludes); + server.on('data', (data) => { // Collect run-time dependencies to watch if ( @@ -97,7 +118,12 @@ export const watchCommand = command({ : data.path ); - if (path.isAbsolute(dependencyPath)) { + if ( + path.isAbsolute(dependencyPath) + && !isOptionsInclude(dependencyPath) + && !isOptionsExclude(dependencyPath) + && !isDefaultIgnore(dependencyPath) + ) { watcher.add(dependencyPath); } } @@ -208,29 +234,39 @@ export const watchCommand = command({ * As an alternative, we watch cwd and all run-time dependencies */ const watcher = watch( - [ - ...argv._, - ...options.include, - ], + argv._, { cwd: process.cwd(), ignoreInitial: true, - ignored: [ - // Hidden directories like .git - '**/.*/**', - - // Hidden files (e.g. logs or temp files) - '**/.*', - - // 3rd party packages - '**/{node_modules,bower_components,vendor}/**', - - ...options.exclude, - ], ignorePermissionErrors: true, + // ignore all files that are by default ignored or explicitly excluded + ignored: file => isDefaultIgnore(file) || isOptionsExclude(file), }, ).on('all', reRun); + if (resolvedIncludes.length > 0) { + const globParents = resolvedIncludes.map(pattern => ( + isGlob(pattern) + ? globParent(pattern) + : pattern + )); + + watch(globParents, { + cwd: process.cwd(), + ignoreInitial: true, + ignorePermissionErrors: true, + // ignore all files not in includes or explicitly excluded + // we need to make sure not to ignore directories otherwise chokidar won't check for it + ignored: file => ( + !globParents.includes(file) + && ( + !isOptionsInclude(file) + || isOptionsExclude(file) + ) + ), + }).on('all', reRun); + } + // On "Return" key process.stdin.on('data', () => reRun('Return key')); }); diff --git a/src/watch/utils.ts b/src/watch/utils.ts index 852f74aa8..cc542518a 100644 --- a/src/watch/utils.ts +++ b/src/watch/utils.ts @@ -1,4 +1,6 @@ +import path from 'node:path'; import { gray, lightCyan } from 'kolorist'; +import normalizePath from 'normalize-path'; const currentTime = () => (new Date()).toLocaleTimeString(); @@ -29,3 +31,10 @@ export const debounce = <T extends (this: unknown, ...args: any[]) => void>( ); } as T; }; + +export const resolveGlobPattern = (pattern: string): string => { + if (path.isAbsolute(pattern)) { + return normalizePath(pattern); + } + return normalizePath(path.join(process.cwd(), pattern)); +}; diff --git a/tests/specs/watch.ts b/tests/specs/watch.ts index 8960a9f45..36abf2e5e 100644 --- a/tests/specs/watch.ts +++ b/tests/specs/watch.ts @@ -224,7 +224,7 @@ export default testSuite(async ({ describe }, { tsx }: NodeApis) => { describe('include', ({ test }) => { test('file path & glob', async () => { const entryFile = 'index.js'; - const fileA = 'file-a'; + const fileA = '.file-a'; // Watches hidden files const fileB = 'directory/file-b'; await using fixture = await createFixture({ [entryFile]: ` @@ -354,6 +354,80 @@ export default testSuite(async ({ describe }, { tsx }: NodeApis) => { await tsxProcess; }, 10_000); + + test('with parent directory', async ({ onTestFail }) => { + const entryFile = 'process-directory/index.js'; + const fileA = 'file-a.js'; + const fileB = 'directory/file-b.js'; + const depA = 'node_modules/a/index.js'; + + await using fixtureGlob = await createFixture({ + [fileA]: 'export default "logA"', + [fileB]: 'export default "logB"', + [depA]: 'export default "logC"', + [entryFile]: ` + import valueA from '../${fileA}' + import valueB from '../${fileB}' + import valueC from '../${depA}' + console.log(valueA, valueB, valueC) + `.trim(), + }); + + const tsxProcess = tsx( + [ + 'watch', + '--clear-screen=false', + `--ignore=../${fileA}`, + `--exclude=../${fileB}`, + 'index.js', + ], + fixtureGlob.getPath('process-directory'), + ); + + 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\n') { + return; + } + + // These changes should not trigger a re-run + await Promise.all([ + fixtureGlob.writeFile(fileA, `export default "${negativeSignal}"`), + fixtureGlob.writeFile(fileB, `export default "${negativeSignal}"`), + fixtureGlob.writeFile(depA, `export default "${negativeSignal}"`), + ]); + return true; + }, + (data) => { + if (data.includes(negativeSignal)) { + throw new Error('Unexpected re-run'); + } + }, + ], + 2000, + ), + ).rejects.toThrow('Timeout'); // Watch should not trigger + + tsxProcess.kill(); + + await tsxProcess; + }, 10_000); }); }); });