Skip to content
Open
Show file tree
Hide file tree
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
67 changes: 65 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 44 additions & 14 deletions src/watch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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<Ignore['ignored']>[0];

...options.exclude,
],
return ignore.ignored(p);
},
ignorePermissionErrors: true,
},
).on('all', reRun);
Expand Down
97 changes: 97 additions & 0 deletions tests/specs/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});