-
Notifications
You must be signed in to change notification settings - Fork 130
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add proper support for gitignore in AppWatcher #4917
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,13 +1,14 @@ | ||
/* eslint-disable no-case-declarations */ | ||
import {AppInterface} from '../../../models/app/app.js' | ||
import {AppLinkedInterface} from '../../../models/app/app.js' | ||
import {configurationFileNames} from '../../../constants.js' | ||
import {dirname, isSubpath, joinPath, normalizePath} from '@shopify/cli-kit/node/path' | ||
import {dirname, isSubpath, joinPath, normalizePath, relativePath} from '@shopify/cli-kit/node/path' | ||
import {FSWatcher} from 'chokidar' | ||
import {outputDebug} from '@shopify/cli-kit/node/output' | ||
import {AbortSignal} from '@shopify/cli-kit/node/abort' | ||
import {startHRTime, StartTime} from '@shopify/cli-kit/node/hrtime' | ||
import {fileExistsSync, matchGlob, readFileSync} from '@shopify/cli-kit/node/fs' | ||
import {debounce} from '@shopify/cli-kit/common/function' | ||
import ignore from 'ignore' | ||
import {Writable} from 'stream' | ||
|
||
const DEFAULT_DEBOUNCE_TIME_IN_MS = 200 | ||
|
@@ -48,42 +49,29 @@ export interface OutputContextOptions { | |
|
||
export class FileWatcher { | ||
private currentEvents: WatcherEvent[] = [] | ||
private extensionPaths: string[] | ||
private readonly watchPaths: string[] | ||
private readonly customGitIgnoredPatterns: string[] | ||
private readonly app: AppInterface | ||
private extensionPaths: string[] = [] | ||
private app: AppLinkedInterface | ||
private readonly options: OutputContextOptions | ||
private onChangeCallback?: (events: WatcherEvent[]) => void | ||
private watcher?: FSWatcher | ||
private readonly debouncedEmit: () => void | ||
private readonly ignored: {[key: string]: ignore.Ignore | undefined} = {} | ||
|
||
constructor(app: AppInterface, options: OutputContextOptions, debounceTime: number = DEFAULT_DEBOUNCE_TIME_IN_MS) { | ||
constructor( | ||
app: AppLinkedInterface, | ||
options: OutputContextOptions, | ||
debounceTime: number = DEFAULT_DEBOUNCE_TIME_IN_MS, | ||
) { | ||
this.app = app | ||
this.options = options | ||
|
||
// Current active extension paths (not defined in the main app configuration file) | ||
// If a change happens outside of these paths, it will be ignored unless is for a new extension being created | ||
// When a new extension is created, the path is added to this list | ||
// When an extension is deleted, the path is removed from this list | ||
// For every change, the corresponding extensionPath will be also reported in the event | ||
this.extensionPaths = app.realExtensions | ||
.map((ext) => normalizePath(ext.directory)) | ||
.filter((dir) => dir !== app.directory) | ||
|
||
const extensionDirectories = [...(app.configuration.extension_directories ?? ['extensions'])].map((directory) => { | ||
return joinPath(app.directory, directory) | ||
}) | ||
|
||
this.watchPaths = [app.configuration.path, ...extensionDirectories] | ||
|
||
// Read .gitignore files from extension directories and add the patterns to the ignored list | ||
this.customGitIgnoredPatterns = this.getCustomGitIgnorePatterns() | ||
|
||
/** | ||
* Debounced function to emit the accumulated events. | ||
* This function will be called at most once every 500ms to avoid emitting too many events in a short period. | ||
* This function will be called at most once every DEFAULT_DEBOUNCE_TIME_IN_MS | ||
* to avoid emitting too many events in a short period. | ||
*/ | ||
this.debouncedEmit = debounce(this.emitEvents.bind(this), debounceTime, {leading: true, trailing: true}) | ||
this.updateApp(app) | ||
} | ||
|
||
onChange(listener: (events: WatcherEvent[]) => void) { | ||
|
@@ -93,15 +81,20 @@ export class FileWatcher { | |
async start(): Promise<void> { | ||
const {default: chokidar} = await import('chokidar') | ||
|
||
this.watcher = chokidar.watch(this.watchPaths, { | ||
const extensionDirectories = [...(this.app.configuration.extension_directories ?? ['extensions'])] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about including Also, I see we are not using the value from the configuration in some places, like here. That approach would simplify the correct usage everywhere. |
||
const fullExtensionDirectories = extensionDirectories.map((directory) => joinPath(this.app.directory, directory)) | ||
|
||
const watchPaths = [this.app.configuration.path, ...fullExtensionDirectories] | ||
|
||
this.watcher = chokidar.watch(watchPaths, { | ||
ignored: [ | ||
'**/node_modules/**', | ||
'**/.git/**', | ||
'**/*.test.*', | ||
'**/dist/**', | ||
'**/*.swp', | ||
'**/generated/**', | ||
...this.customGitIgnoredPatterns, | ||
'**/.gitignore', | ||
Comment on lines
91
to
+97
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd extract this list to a constant |
||
], | ||
persistent: true, | ||
ignoreInitial: true, | ||
|
@@ -111,6 +104,16 @@ export class FileWatcher { | |
this.options.signal.addEventListener('abort', this.close) | ||
} | ||
|
||
updateApp(app: AppLinkedInterface) { | ||
this.app = app | ||
this.extensionPaths = this.app.realExtensions | ||
.map((ext) => normalizePath(ext.directory)) | ||
.filter((dir) => dir !== this.app.directory) | ||
this.extensionPaths.forEach((path) => { | ||
this.ignored[path] ??= this.createIgnoreInstance(path) | ||
}) | ||
} | ||
|
||
/** | ||
* Emits the accumulated events and resets the current events list. | ||
* It also logs the number of events emitted and their paths for debugging purposes. | ||
|
@@ -132,11 +135,22 @@ export class FileWatcher { | |
private pushEvent(event: WatcherEvent) { | ||
const extension = this.app.realExtensions.find((ext) => ext.directory === event.extensionPath) | ||
const watchPaths = extension?.devSessionWatchPaths | ||
const ignoreInstance = this.ignored[event.extensionPath] | ||
|
||
// If the affected extension defines custom watch paths, ignore the event if it's not in the list | ||
// ELSE, if the extension has a custom gitignore file, ignore the event if it matches the patterns | ||
// Explicit watch paths have priority over custom gitignore files | ||
if (watchPaths) { | ||
const isAValidWatchedPath = watchPaths.some((pattern) => matchGlob(event.path, pattern)) | ||
if (!isAValidWatchedPath) return | ||
} else if (ignoreInstance) { | ||
const relative = relativePath(event.extensionPath, event.path) | ||
if (ignoreInstance.ignores(relative)) return | ||
} | ||
|
||
// If the event is for a new extension folder, create a new ignore instance | ||
if (event.type === 'extension_folder_created') { | ||
this.ignored[event.path] = this.createIgnoreInstance(event.path) | ||
} | ||
|
||
// If the event is already in the list, don't push it again | ||
|
@@ -150,15 +164,25 @@ export class FileWatcher { | |
const isConfigAppPath = path === this.app.configuration.path | ||
const extensionPath = | ||
this.extensionPaths.find((dir) => isSubpath(dir, path)) ?? (isConfigAppPath ? this.app.directory : 'unknown') | ||
const isToml = path.endsWith('.toml') | ||
const isExtensionToml = path.endsWith('.extension.toml') | ||
const isUnknownExtension = extensionPath === 'unknown' | ||
|
||
outputDebug(`🌀: ${event} ${path.replace(this.app.directory, '')}\n`) | ||
|
||
if (extensionPath === 'unknown' && !isToml) return | ||
if (isUnknownExtension && !isExtensionToml && !isConfigAppPath) { | ||
// Ignore an event if it's not part of an existing extension | ||
// Except if it is a toml file (either app config or extension config) | ||
return | ||
} | ||
|
||
switch (event) { | ||
case 'change': | ||
if (isToml) { | ||
if (isUnknownExtension) { | ||
// If the extension path is unknown, it means the extension was just created. | ||
// We need to wait for the lock file to disappear before triggering the event. | ||
return | ||
} | ||
if (isExtensionToml || isConfigAppPath) { | ||
this.pushEvent({type: 'extensions_config_updated', path, extensionPath, startTime}) | ||
} else { | ||
this.pushEvent({type: 'file_updated', path, extensionPath, startTime}) | ||
|
@@ -168,7 +192,7 @@ export class FileWatcher { | |
// If it's a normal non-toml file, just report a file_created event. | ||
// If a toml file was added, a new extension(s) is being created. | ||
// We need to wait for the lock file to disappear before triggering the event. | ||
if (!isToml) { | ||
if (!isExtensionToml) { | ||
this.pushEvent({type: 'file_created', path, extensionPath, startTime}) | ||
break | ||
} | ||
|
@@ -185,9 +209,7 @@ export class FileWatcher { | |
} | ||
if (totalWaitedTime >= EXTENSION_CREATION_TIMEOUT_IN_MS) { | ||
clearInterval(intervalId) | ||
this.options.stderr.write( | ||
`Extension creation detection timeout at path: ${path}\nYou might need to restart dev`, | ||
) | ||
this.options.stderr.write(`Error loading new extension at path: ${path}.\n Please restart the process.`) | ||
} | ||
}, EXTENSION_CREATION_CHECK_INTERVAL_IN_MS) | ||
break | ||
|
@@ -197,7 +219,7 @@ export class FileWatcher { | |
|
||
if (isConfigAppPath) { | ||
this.pushEvent({type: 'app_config_deleted', path, extensionPath, startTime}) | ||
} else if (isToml) { | ||
} else if (isExtensionToml) { | ||
// When a toml is deleted, we can consider every extension in that folder was deleted. | ||
this.extensionPaths = this.extensionPaths.filter((extPath) => extPath !== extensionPath) | ||
this.pushEvent({type: 'extension_folder_deleted', path: extensionPath, extensionPath, startTime}) | ||
|
@@ -216,31 +238,23 @@ export class FileWatcher { | |
} | ||
} | ||
|
||
/** | ||
* Returns the custom gitignore patterns for the given extension directories. | ||
* | ||
* @returns The custom gitignore patterns | ||
*/ | ||
private getCustomGitIgnorePatterns(): string[] { | ||
return this.extensionPaths | ||
.map((dir) => { | ||
const gitIgnorePath = joinPath(dir, '.gitignore') | ||
if (!fileExistsSync(gitIgnorePath)) return [] | ||
const gitIgnoreContent = readFileSync(gitIgnorePath).toString() | ||
return gitIgnoreContent | ||
.split('\n') | ||
.map((pattern) => pattern.trim()) | ||
.filter((pattern) => pattern !== '' && !pattern.startsWith('#')) | ||
.map((pattern) => joinPath(dir, pattern)) | ||
}) | ||
.flat() | ||
} | ||
|
||
private readonly close = () => { | ||
outputDebug(`Closing file watcher`, this.options.stdout) | ||
this.watcher | ||
?.close() | ||
.then(() => outputDebug(`File watching closed`, this.options.stdout)) | ||
.catch((error: Error) => outputDebug(`File watching failed to close: ${error.message}`, this.options.stderr)) | ||
} | ||
|
||
// Creates an "Ignore" instance for the given path if a .gitignore file exists, otherwise undefined | ||
private createIgnoreInstance(path: string): ignore.Ignore | undefined { | ||
const gitIgnorePath = joinPath(path, '.gitignore') | ||
if (!fileExistsSync(gitIgnorePath)) return undefined | ||
const gitIgnoreContent = readFileSync(gitIgnorePath) | ||
.toString() | ||
.split('\n') | ||
.map((pattern) => pattern.trim()) | ||
.filter((pattern) => pattern !== '' && !pattern.startsWith('#')) | ||
return ignore.default().add(gitIgnoreContent) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just curious, how come we don't have to
await
for the fileWatcher to update the app here?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
updateApp
is not an async method, so we don't need to await :)