Skip to content
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

Merged
Merged
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
1 change: 1 addition & 0 deletions packages/app/src/cli/models/app/app.test-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ function defaultFunctionConfiguration(): FunctionConfigType {
type: 'product_discounts',
build: {
command: 'echo "hello world"',
watch: ['src/**/*.rs'],
},
api_version: '2022-07',
configuration_ui: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ async function ReloadAppHandler({event, app}: HandlerInput): Promise<AppEvent> {
const deletedEvents = diff.deleted.map((ext) => ({type: EventType.Deleted, extension: ext}))
const updatedEvents = diff.updated.map((ext) => ({type: EventType.Updated, extension: ext}))
const extensionEvents = [...createdEvents, ...deletedEvents, ...updatedEvents]
return {app: newApp, extensionEvents, startTime: event.startTime, path: event.path}
return {app: newApp, extensionEvents, startTime: event.startTime, path: event.path, appWasReloaded: true}
}

/*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '../../../models/app/app.test-data.js'
import {ExtensionInstance} from '../../../models/extensions/extension-instance.js'
import {loadApp, reloadApp} from '../../../models/app/loader.js'
import {AppInterface} from '../../../models/app/app.js'
import {AppLinkedInterface} from '../../../models/app/app.js'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import {AbortSignal, AbortController} from '@shopify/cli-kit/node/abort'
import {flushPromises} from '@shopify/cli-kit/node/promises'
Expand Down Expand Up @@ -260,7 +260,6 @@ describe('app-event-watcher', () => {

const mockManager = new MockESBuildContextManager()
const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent])

const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher)
const emitSpy = vi.spyOn(watcher, 'emit')
await watcher.start({stdout, stderr, signal: abortController.signal})
Expand Down Expand Up @@ -290,6 +289,7 @@ describe('app-event-watcher', () => {
extensionEvents: expect.arrayContaining(extensionEvents),
startTime: expect.anything(),
path: expect.anything(),
appWasReloaded: needsAppReload,
})

const initialEvents = app.realExtensions.map((eve) => ({
Expand Down Expand Up @@ -426,7 +426,7 @@ class MockFileWatcher extends FileWatcher {
private readonly events: WatcherEvent[]
private listener?: (events: WatcherEvent[]) => void

constructor(app: AppInterface, options: OutputContextOptions, events: WatcherEvent[]) {
constructor(app: AppLinkedInterface, options: OutputContextOptions, events: WatcherEvent[]) {
super(app, options)
this.events = events
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface AppEvent {
extensionEvents: ExtensionEvent[]
path: string
startTime: [number, number]
appWasReloaded?: boolean
}

type ExtensionBuildResult = {status: 'ok'; handle: string} | {status: 'error'; error: string; handle: string}
Expand Down Expand Up @@ -150,6 +151,7 @@ export class AppEventWatcher extends EventEmitter {
if (!appEvent || appEvent.extensionEvents.length === 0) return

this.app = appEvent.app
if (appEvent.appWasReloaded) this.fileWatcher?.updateApp(this.app)
Copy link
Contributor

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?

Copy link
Contributor Author

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 :)

await this.esbuildManager.updateContexts(appEvent)

// Find affected created/updated extensions and build them
Expand Down
37 changes: 24 additions & 13 deletions packages/app/src/cli/services/dev/app-events/file-watcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {FileWatcher, OutputContextOptions, WatcherEvent} from './file-watcher.js'
import {
testApp,
testAppAccessConfigExtension,
testAppConfigExtensions,
testAppLinked,
testFunctionExtension,
testUIExtension,
} from '../../../models/app/app.test-data.js'
import {flushPromises} from '@shopify/cli-kit/node/promises'
Expand All @@ -12,10 +12,12 @@ import chokidar from 'chokidar'
import {AbortSignal} from '@shopify/cli-kit/node/abort'
import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
import {sleep} from '@shopify/cli-kit/node/system'

const extension1 = await testUIExtension({type: 'ui_extension', handle: 'h1', directory: '/extensions/ui_extension_1'})
const extension1B = await testUIExtension({type: 'ui_extension', handle: 'h2', directory: '/extensions/ui_extension_1'})
const extension2 = await testUIExtension({type: 'ui_extension', directory: '/extensions/ui_extension_2'})
const functionExtension = await testFunctionExtension({dir: '/extensions/my-function'})
const posExtension = await testAppConfigExtensions()
const appAccessExtension = await testAppAccessConfigExtension()

Expand All @@ -30,7 +32,7 @@ interface TestCaseSingleEvent {
name: string
fileSystemEvent: string
path: string
expectedEvent: WatcherEvent
expectedEvent?: WatcherEvent
}

/**
Expand Down Expand Up @@ -127,6 +129,12 @@ const singleEventTestCases: TestCaseSingleEvent[] = [
startTime: expect.any(Array),
},
},
{
name: 'change in function extension is ignored if not in watch list',
fileSystemEvent: 'change',
path: '/extensions/my-function/src/cargo.lock',
expectedEvent: undefined,
},
]

const multiEventTestCases: TestCaseMultiEvent[] = [
Expand Down Expand Up @@ -167,8 +175,8 @@ const multiEventTestCases: TestCaseMultiEvent[] = [
]

const outputOptions: OutputContextOptions = {stdout: process.stdout, stderr: process.stderr, signal: new AbortSignal()}
const defaultApp = testApp({
allExtensions: [extension1, extension1B, extension2, posExtension, appAccessExtension],
const defaultApp = testAppLinked({
allExtensions: [extension1, extension1B, extension2, posExtension, appAccessExtension, functionExtension],
directory: '/',
configuration: {scopes: '', extension_directories: ['/extensions'], path: '/shopify.app.toml'},
})
Expand Down Expand Up @@ -212,9 +220,7 @@ describe('file-watcher events', () => {
'**/dist/**',
'**/*.swp',
'**/generated/**',
joinPath(dir, '/extensions/ext1/a_folder'),
joinPath(dir, '/extensions/ext1/a_file.txt'),
joinPath(dir, '/extensions/ext1/**/nested/**'),
'**/.gitignore',
],
ignoreInitial: true,
persistent: true,
Expand Down Expand Up @@ -244,12 +250,17 @@ describe('file-watcher events', () => {
await flushPromises()

// use waitFor to so that we can test the debouncers and timeouts
await vi.waitFor(
() => {
expect(onChange).toHaveBeenCalledWith([expectedEvent])
},
{timeout: 2000, interval: 100},
)
if (expectedEvent) {
await vi.waitFor(
() => {
expect(onChange).toHaveBeenCalledWith([expectedEvent])
},
{timeout: 2000, interval: 100},
)
} else {
await sleep(0.01)
expect(onChange).not.toHaveBeenCalled()
}
},
)

Expand Down
126 changes: 70 additions & 56 deletions packages/app/src/cli/services/dev/app-events/file-watcher.ts
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
Expand Down Expand Up @@ -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) {
Expand All @@ -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'])]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about including extension_directories in the app model and adding the default value there?

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd extract this list to a constant

],
persistent: true,
ignoreInitial: true,
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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})
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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})
Expand All @@ -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)
}
}
Loading
Loading