Skip to content

Commit b5be816

Browse files
committed
feat(watch): prefer includes over excludes
Fixes #210 This change makes any explicitly included files in watch mode never get excluded, even if they otherwise would be. Particularly, this is useful for watching files that are excluded by the default excludes, like ".env" files, which were previously impossible to watch. In making these changes, chokidar has also been upgraded to v4, which no longer supports glob patterns. Glob functionality is now achieved with the 'glob' library. The `windowsPathsNoEscape` glob option is enabled in order to keep the behavior the same as it was with chokidar v3, where backslash cannot be used as a glob escape character, and only as a directory separator.
1 parent 6af2aa9 commit b5be816

File tree

4 files changed

+208
-17
lines changed

4 files changed

+208
-17
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
"@types/split2": "^4.2.3",
8484
"append-transform": "^2.0.0",
8585
"cachedir": "^2.4.0",
86-
"chokidar": "^3.6.0",
86+
"chokidar": "^4.0.3",
8787
"clean-pkg-json": "^1.2.0",
8888
"cleye": "^1.3.2",
8989
"cross-spawn": "^7.0.3",
@@ -92,6 +92,7 @@
9292
"fs-fixture": "^2.4.0",
9393
"fs-require": "^1.6.0",
9494
"get-node": "^15.0.1",
95+
"glob": "^11.0.1",
9596
"kolorist": "^1.8.0",
9697
"lintroll": "^1.8.1",
9798
"magic-string": "^0.30.10",

pnpm-lock.yaml

Lines changed: 65 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/watch/index.ts

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { constants as osConstants } from 'node:os';
44
import path from 'node:path';
55
import { command } from 'cleye';
66
import { watch } from 'chokidar';
7+
import { Glob, hasMagic, Ignore } from 'glob';
78
import { lightMagenta, lightGreen, yellow } from 'kolorist';
89
import { run } from '../run.js';
910
import {
@@ -199,6 +200,32 @@ export const watchCommand = command({
199200
process.on('SIGINT', relaySignal);
200201
process.on('SIGTERM', relaySignal);
201202

203+
// Parse all include paths as globs, then build a Set for fast lookups during the ignored function
204+
const watchGlob = new Glob([...argv._, ...options.include], {
205+
absolute: true,
206+
windowsPathsNoEscape: true,
207+
});
208+
const watchPaths = new Set(watchGlob);
209+
210+
// Build an Ignore object for checking whether a path should be ignored or not. Note that we can't
211+
// pass patterns in the constructor because the changes to mmopts won't be made yet.
212+
const ignore = new Ignore([], watchGlob);
213+
ignore.mmopts.windowsPathsNoEscape = true;
214+
215+
// Now add the built-in and user-specified ignore patterns
216+
[
217+
// Hidden directories (like .git) and files (e.g. logs or temp files)
218+
'**/.*/**',
219+
220+
// 3rd party packages
221+
'**/{node_modules,bower_components,vendor}/**',
222+
223+
// Convert normal, non-glob paths into globs ending in '/**', so descendants are also included
224+
...options.exclude.map(o => (hasMagic(o, { windowsPathsNoEscape: true }) ? o : `${o}/**`)),
225+
].forEach(p => ignore.add(p));
226+
227+
const cwd = process.cwd();
228+
202229
/**
203230
* Ideally, we can get a list of files loaded from the run above
204231
* and only watch those files, but it's not possible to detect
@@ -208,25 +235,28 @@ export const watchCommand = command({
208235
* As an alternative, we watch cwd and all run-time dependencies
209236
*/
210237
const watcher = watch(
211-
[
212-
...argv._,
213-
...options.include,
214-
],
238+
Array.from(watchPaths),
215239
{
216-
cwd: process.cwd(),
240+
cwd,
217241
ignoreInitial: true,
218-
ignored: [
219-
// Hidden directories like .git
220-
'**/.*/**',
242+
ignored: (fullPath) => {
243+
// Resolve the path to ensure it has the correct directory separators
244+
const resolvedPath = path.resolve(fullPath);
221245

222-
// Hidden files (e.g. logs or temp files)
223-
'**/.*',
246+
// Never ignore files that were explicitly watched
247+
if (watchPaths.has(resolvedPath)) {
248+
return false;
249+
}
224250

225-
// 3rd party packages
226-
'**/{node_modules,bower_components,vendor}/**',
251+
// ignore.ignored() expects a 'path-scurry' Path, but it only uses the fullpath() and
252+
// relative() functions, so just make an object with those properties.
253+
const p = {
254+
fullpath: () => resolvedPath,
255+
relative: () => path.relative(cwd, resolvedPath),
256+
} as Parameters<Ignore['ignored']>[0];
227257

228-
...options.exclude,
229-
],
258+
return ignore.ignored(p);
259+
},
230260
ignorePermissionErrors: true,
231261
},
232262
).on('all', reRun);

tests/specs/watch.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,5 +355,102 @@ export default testSuite(async ({ describe }, { tsx }: NodeApis) => {
355355
await tsxProcess;
356356
}, 10_000);
357357
});
358+
359+
describe('prefer include over exclude', ({ test }) => {
360+
test('file path & glob', async ({ onTestFail }) => {
361+
const entryFile = 'index.js';
362+
const fileA = 'file-a.js';
363+
const fileB = 'directory/file-b.js';
364+
const fileC = 'directory/file-c.js';
365+
const depA = 'node_modules/a/index.js';
366+
367+
await using fixture = await createFixture({
368+
[fileA]: 'export default "logA"',
369+
[fileB]: 'export default "logB"',
370+
[fileC]: 'export default "logC"',
371+
[depA]: 'export default "logD"',
372+
[entryFile]: `
373+
import valueA from './${fileA}'
374+
import valueB from './${fileB}'
375+
import valueC from './${fileC}'
376+
import valueD from './${depA}'
377+
console.log(valueA, valueB, valueC, valueD)
378+
`.trim(),
379+
});
380+
381+
const tsxProcess = tsx(
382+
[
383+
'watch',
384+
'--clear-screen=false',
385+
`--ignore=${fileA}`,
386+
'--exclude=directory/*',
387+
`--include=${fileC}`,
388+
entryFile,
389+
],
390+
fixture.path,
391+
);
392+
393+
onTestFail(async () => {
394+
// If timed out, force kill process
395+
if (tsxProcess.exitCode === null) {
396+
console.log('Force killing hanging process\n\n');
397+
tsxProcess.kill();
398+
console.log({
399+
tsxProcess: await tsxProcess,
400+
});
401+
}
402+
});
403+
404+
const negativeSignal = 'fail';
405+
406+
await expect(
407+
processInteract(
408+
tsxProcess.stdout!,
409+
[
410+
async (data) => {
411+
if (data !== 'logA logB logC logD\n') {
412+
return;
413+
}
414+
415+
// These changes should not trigger a re-run
416+
await Promise.all([
417+
fixture.writeFile(fileA, `export default "${negativeSignal}"`),
418+
fixture.writeFile(fileB, `export default "${negativeSignal}"`),
419+
fixture.writeFile(depA, `export default "${negativeSignal}"`),
420+
]);
421+
return true;
422+
},
423+
(data) => {
424+
if (data.includes(negativeSignal)) {
425+
throw new Error('Unexpected re-run');
426+
}
427+
},
428+
],
429+
4000,
430+
),
431+
).rejects.toThrow('Timeout'); // Watch should not trigger
432+
433+
// This change should trigger a re-run, even though fileC's directory is excluded
434+
fixture.writeFile(fileC, 'export default "updateC"');
435+
436+
await processInteract(
437+
tsxProcess.stdout!,
438+
[
439+
(data) => {
440+
// Now that the app has restarted, we should see the negativeSignal updates too
441+
if (data === `${negativeSignal} ${negativeSignal} updateC ${negativeSignal}\n`) {
442+
return true;
443+
}
444+
},
445+
],
446+
4000,
447+
);
448+
449+
tsxProcess.kill();
450+
451+
const tsxProcessResolved = await tsxProcess;
452+
expect(tsxProcessResolved.stderr).toBe('');
453+
}, 10_000);
454+
});
358455
});
359456
});

0 commit comments

Comments
 (0)