Skip to content

Commit

Permalink
fix: Listen for file changes in watch mode in longest common directory
Browse files Browse the repository at this point in the history
Prior to #9009, which moved from Chokidar to `@parcel/watcher`, the behavior
was to watch for all relevant files. However, since switching to
`@parcel/watcher`, the new behavior has been to watch all files below
`process.cwd()`, and then filter the change events for only the relevant files.
This approach works fine, except that it's possible for a valid config to
reference paths outside of the current working directory (e.g. `documents:
"../some-other/*.graphql`), and these paths are included in build mode, but
were not being included in watch mode because they're outside of
`process.cwd()`.

This commit adds logic, after parsing all relevant file paths, to find the
"longest common directory prefix" of all those file paths, i.e. the "highest"
(closest to `/`) directory that contains all the relevant file paths. Then,
when subscribing to the Parcel watcher, this directory is used instead of
`process.cwd()`. For example, the longest common directory of the paths
`/foo/bar/*.graphql` and `/foo/fizz/*.graphql` would be `/foo`.

Note that the filtering behavior is left unchanged, and this only affects the
root path given to the Parcel watcher. When an event is received, the filtering
can still filter out irrelevant paths, including those filtered by
`config.watchPattern` if it's defined.
  • Loading branch information
milesrichardson committed Apr 3, 2023
1 parent 6e3fd38 commit 4e171d2
Show file tree
Hide file tree
Showing 2 changed files with 72 additions and 7 deletions.
5 changes: 5 additions & 0 deletions .changeset/fast-candles-sort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-codegen/cli': patch
---

Fix watch mode to listen to longest common directory prefix of relevant files, rather than only files below the current working directory (fixes #9266).
74 changes: 67 additions & 7 deletions packages/graphql-codegen-cli/src/utils/watcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { join } from 'path';
import { access } from 'node:fs/promises';
import { join, isAbsolute, resolve, sep } from 'path';
import { normalizeInstanceOrArray, normalizeOutputParam, Types } from '@graphql-codegen/plugin-helpers';
import { isValidPath } from '@graphql-tools/utils';
import type { subscribe } from '@parcel/watcher';
Expand All @@ -17,8 +18,8 @@ function log(msg: string) {
getLogger().info(` ${msg}`);
}

function emitWatching() {
log(`${logSymbols.info} Watching for changes...`);
function emitWatching(watchDir: string) {
log(`${logSymbols.info} Watching for changes in ${watchDir}...`);
}

export const createWatcher = (
Expand Down Expand Up @@ -62,6 +63,8 @@ export const createWatcher = (
let watcherSubscription: Awaited<ReturnType<typeof subscribe>>;

const runWatcher = async () => {
const watchDirectory = await findHighestCommonDirectory(files);

const parcelWatcher = await import('@parcel/watcher');
debugLog(`[Watcher] Parcel watcher loaded...`);

Expand All @@ -71,10 +74,10 @@ export const createWatcher = (
if (!isShutdown) {
executeCodegen(initalContext)
.then(onNext, () => Promise.resolve())
.then(() => emitWatching());
.then(() => emitWatching(watchDirectory));
}
}, 100);
emitWatching();
emitWatching(watchDirectory);

const ignored: string[] = [];
for (const entry of Object.keys(config.generates).map(filename => ({
Expand All @@ -92,7 +95,7 @@ export const createWatcher = (
}

watcherSubscription = await parcelWatcher.subscribe(
process.cwd(),
watchDirectory,
async (_, events) => {
// it doesn't matter what has changed, need to run whole process anyway
await Promise.all(
Expand All @@ -105,7 +108,7 @@ export const createWatcher = (

lifecycleHooks(config.hooks).onWatchTriggered(eventName, path);
debugLog(`[Watcher] triggered due to a file ${eventName} event: ${path}`);
const fullPath = join(process.cwd(), path);
const fullPath = join(watchDirectory, path);
// In ESM require is not defined
try {
delete require.cache[fullPath];
Expand Down Expand Up @@ -156,3 +159,60 @@ export const createWatcher = (
});
});
};

/**
* Given a list of file paths (each of which may be absolute, or relative to
* `process.cwd()`), find absolute path of the "highest" common directory,
* i.e. the directory that contains all the files in the list.
*
* @param files List of relative and/or absolute file paths (or micromatch patterns)
*/
const findHighestCommonDirectory = async (files: string[]): Promise<string> => {
// Map files to a list of basePaths, where "base" is the result of mm.scan(pathOrPattern)
// e.g. mm.scan("/**/foo/bar").base -> "/" ; mm.scan("/foo/bar/**/fizz/*.graphql") -> /foo/bar
const dirPaths = files
.map(filePath => (isAbsolute(filePath) ? filePath : resolve(filePath)))
.map(patterned => mm.scan(patterned).base);

// Return longest common prefix if it's accessible, otherwise process.cwd()
return (async (maybeValidPath: string) => {
debugLog(`[Watcher] Longest common prefix of all files: ${maybeValidPath}...`);
try {
await access(maybeValidPath);
return maybeValidPath;
} catch {
log(`[Watcher] Longest common prefix (${maybeValidPath}) is not accessible`);
log(`[Watcher] Watching current working directory (${process.cwd()}) instead`);
return process.cwd();
}
})(longestCommonPrefix(dirPaths.map(path => path.split(sep))).join(sep));
};

/**
* Find the longest common prefix of an array of paths, where each item in
* the array an array of path segments which comprise an absolute path when
* joined together by a path separator
*
* Adapted from:
* https://duncan-mcardle.medium.com/leetcode-problem-14-longest-common-prefix-javascript-3bc6a2f777c4
*
* @param splitPaths An array of arrays, where each item is a path split by its separator
* @returns An array of path segments representing the longest common prefix of splitPaths
*/
const longestCommonPrefix = (splitPaths: string[][]): string[] => {
// Return early on empty input
if (!splitPaths.length) {
return [];
}

// Loop through the segments of the first path
for (let i = 0; i <= splitPaths[0].length; i++) {
// Check if this path segment is present in the same position of every path
if (!splitPaths.every(string => string[i] === splitPaths[0][i])) {
// If not, return the path segments up to and including the previous segment
return splitPaths[0].slice(0, i);
}
}

return splitPaths[0];
};

0 comments on commit 4e171d2

Please sign in to comment.