diff --git a/README.md b/README.md index 94b5c524f..81df28600 100644 --- a/README.md +++ b/README.md @@ -676,6 +676,9 @@ Instruction | `--skill-output ` | Specify skill output directory path directly (skips location prompt) | | `-f, --force` | Skip all confirmation prompts (e.g., skill directory overwrite) | +#### Watch Mode +- `-w, --watch`: Watch for file changes and automatically re-pack. Debounces rapid changes (300ms) and logs a timestamp on each rebuild. Stop with `Ctrl+C`. + #### Examples ```bash @@ -708,6 +711,10 @@ repomix --remote https://github.com/user/repo/commit/836abcd7335137228ad77feb286 # Remote repository with shorthand repomix --remote user/repo + +# Watch mode — automatically re-pack on file changes +repomix --watch +repomix -w --include "src/**/*.ts" ``` ### Updating Repomix diff --git a/package-lock.json b/package-lock.json index cb03743c4..23783c30c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@repomix/tree-sitter-wasms": "^0.1.16", "@secretlint/core": "^11.5.0", "@secretlint/secretlint-rule-preset-recommend": "^11.4.1", + "chokidar": "^5.0.0", "commander": "^14.0.3", "fast-xml-builder": "^1.1.4", "git-url-parse": "^16.1.0", @@ -2529,6 +2530,21 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -4278,6 +4294,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", diff --git a/package.json b/package.json index e07cadc13..4a78c2ea1 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@repomix/tree-sitter-wasms": "^0.1.16", "@secretlint/core": "^11.5.0", "@secretlint/secretlint-rule-preset-recommend": "^11.4.1", + "chokidar": "^5.0.0", "commander": "^14.0.3", "fast-xml-builder": "^1.1.4", "git-url-parse": "^16.1.0", diff --git a/src/cli/actions/watchAction.ts b/src/cli/actions/watchAction.ts new file mode 100644 index 000000000..ea55aad29 --- /dev/null +++ b/src/cli/actions/watchAction.ts @@ -0,0 +1,262 @@ +import path from 'node:path'; +import process from 'node:process'; +import type { ChokidarOptions, FSWatcher } from 'chokidar'; +import pc from 'picocolors'; +import { loadFileConfig, mergeConfigs } from '../../config/configLoad.js'; +import type { RepomixConfigCli, RepomixConfigFile, RepomixConfigMerged } from '../../config/configSchema.js'; +import { defaultIgnoreList } from '../../config/defaultIgnore.js'; +import { type PackResult, pack } from '../../core/packager.js'; +import { logger } from '../../shared/logger.js'; +import type { RepomixProgressCallback } from '../../shared/types.js'; +import { reportResults } from '../cliReport.js'; +import { Spinner } from '../cliSpinner.js'; +import type { CliOptions } from '../types.js'; +import { buildCliConfig } from './defaultAction.js'; +import { runMigrationAction } from './migrationAction.js'; + +export interface WatchDeps { + watch: (paths: string | string[], options?: ChokidarOptions) => FSWatcher; + signal?: AbortSignal; +} + +const resolveDefaultDeps = async (): Promise => { + // Lazy-load chokidar so it is only imported when --watch is actually used + const chokidar = await import('chokidar'); + return { watch: chokidar.watch }; +}; + +const runPack = async ( + targetPaths: string[], + config: RepomixConfigMerged, + cliOptions: CliOptions, +): Promise => { + const spinner = new Spinner('Packing...', cliOptions); + spinner.start(); + + try { + const handleProgress: RepomixProgressCallback = (message) => { + spinner.update(message); + }; + + const packResult = await pack(targetPaths, config, handleProgress); + spinner.succeed('Packing completed successfully!'); + return packResult; + } catch (error) { + spinner.fail('Error during packing'); + throw error; + } +}; + +/** + * Builds ignore patterns for chokidar based on the packer's ignore configuration. + * This ensures watch mode ignores the same files/directories as the packer. + */ +const buildWatchIgnorePatterns = (cwd: string, config: RepomixConfigMerged): (string | RegExp)[] => { + const patterns: (string | RegExp)[] = []; + + // Add default ignore patterns if enabled + if (config.ignore.useDefaultPatterns) { + for (const pattern of defaultIgnoreList) { + patterns.push(pattern); + } + } + + // Add custom ignore patterns + if (config.ignore.customPatterns) { + for (const pattern of config.ignore.customPatterns) { + patterns.push(pattern); + } + } + + // Add the output file path + if (config.output.filePath) { + patterns.push(path.resolve(cwd, config.output.filePath)); + } + + return patterns; +}; + +export const runWatchAction = async ( + directories: string[], + cwd: string, + cliOptions: CliOptions, + deps?: Partial, +): Promise => { + // Early-return guard: if the signal is already aborted, do no work + // Must check before any await to prevent race conditions + if (deps?.signal?.aborted) { + return; + } + + // Only load chokidar if no watch function is provided (enables faster tests) + const resolvedDeps: WatchDeps = deps?.watch ? (deps as WatchDeps) : { ...(await resolveDefaultDeps()), ...deps }; + + logger.trace('Watch mode: loaded CLI options:', cliOptions); + + // Build config — same pattern as defaultAction + await runMigrationAction(cwd); + + const fileConfig: RepomixConfigFile = await loadFileConfig(cwd, cliOptions.config ?? null, { + skipLocalConfig: cliOptions.skipLocalConfig, + }); + logger.trace('Watch mode: loaded file config:', fileConfig); + + const cliConfig: RepomixConfigCli = buildCliConfig(cliOptions); + logger.trace('Watch mode: CLI config:', cliConfig); + + const config: RepomixConfigMerged = mergeConfigs(cwd, fileConfig, cliConfig); + logger.trace('Watch mode: merged config:', config); + + const targetPaths = directories.map((directory) => path.resolve(cwd, directory)); + + // Run initial pack + const packResult = await runPack(targetPaths, config, cliOptions); + reportResults(cwd, packResult, config, cliOptions); + logger.log(pc.dim(`\nWatching ${packResult.safeFilePaths.length} files for changes... (Ctrl+C to stop)\n`)); + + // Watch target directories instead of individual files so new files are detected + // Apply the same ignore patterns the packer uses to avoid unnecessary rebuilds + const watchIgnorePatterns = buildWatchIgnorePatterns(cwd, config); + const watcher = resolvedDeps.watch(targetPaths, { + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: 100 }, + ignored: watchIgnorePatterns, + }); + + // Handle watcher errors (EMFILE, EACCES, EPERM, etc.) to prevent uncaught exceptions + watcher.on('error', (error) => { + logger.error('File watcher error:', error); + }); + + // Rebuild guard — prevents concurrent packs and queues a follow-up if changes arrive mid-pack + let isRebuilding = false; + let pendingRebuild = false; + let debounceTimer: ReturnType | null = null; + let shuttingDown = false; + let activeRebuildPromise: Promise | null = null; + + const scheduleRebuild = () => { + // Guard: don't schedule new work if shutdown has been initiated + if (shuttingDown) { + return; + } + + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(async () => { + debounceTimer = null; + + // Re-check shutdown in case it was initiated while timer was pending + if (shuttingDown) { + return; + } + + if (isRebuilding) { + pendingRebuild = true; + return; + } + + isRebuilding = true; + const rebuildWork = async () => { + try { + const result = await runPack(targetPaths, config, cliOptions); + reportResults(cwd, result, config, cliOptions); + const now = new Date(); + const timestamp = now.toLocaleTimeString('en-GB', { hour12: false }); + logger.success(`Rebuilt at ${timestamp}`); + logger.log(pc.dim('Watching for changes...')); + } catch (error) { + logger.error('Watch rebuild failed:', error); + } finally { + isRebuilding = false; + activeRebuildPromise = null; + // Check if shutdown has been initiated before draining pendingRebuild + if (shuttingDown) { + pendingRebuild = false; + } else if (pendingRebuild) { + pendingRebuild = false; + scheduleRebuild(); + } + } + }; + activeRebuildPromise = rebuildWork(); + await activeRebuildPromise; + }, 300); + }; + + watcher.on('change', scheduleRebuild); + watcher.on('add', scheduleRebuild); + watcher.on('unlink', scheduleRebuild); + + // Graceful shutdown — shared cleanup promise that both signal and SIGINT/SIGTERM paths await + let cleanupResolve: (() => void) | null = null; + let cleanupStarted = false; + let cleanupDone = false; + + const cleanup = async () => { + // Prevent multiple cleanup calls + if (cleanupStarted) { + return; + } + cleanupStarted = true; + shuttingDown = true; + + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + pendingRebuild = false; + process.removeListener('SIGINT', onSigint); + process.removeListener('SIGTERM', onSigterm); + + // Use separate try/catch blocks so activeRebuildPromise is always awaited + // even if watcher.close() fails + try { + await watcher.close(); + } catch (error) { + logger.error('Error closing watcher:', error); + } + + try { + if (activeRebuildPromise) { + await activeRebuildPromise; + } + } catch (error) { + logger.error('Error waiting for rebuild to complete:', error); + } + + // Always settle the keep-alive promise + cleanupDone = true; + cleanupResolve?.(); + }; + + const onSigint = () => { + cleanup(); + }; + const onSigterm = () => { + cleanup(); + }; + + if (resolvedDeps.signal) { + // Register abort listener with { once: true } to avoid duplicate calls + resolvedDeps.signal.addEventListener('abort', () => cleanup(), { once: true }); + + // Handle race condition: signal may already be aborted before listener was registered + if (resolvedDeps.signal.aborted) { + cleanup(); + } + } else { + process.on('SIGINT', onSigint); + process.on('SIGTERM', onSigterm); + } + + // Keep alive — wait until cleanup is fully complete (including watcher.close()) + // Check cleanupDone first in case cleanup finished before we got here + await new Promise((resolve) => { + if (cleanupDone) { + resolve(); + return; + } + cleanupResolve = resolve; + }); +}; diff --git a/src/cli/cliRun.ts b/src/cli/cliRun.ts index 754b4ab80..de1781f00 100644 --- a/src/cli/cliRun.ts +++ b/src/cli/cliRun.ts @@ -40,6 +40,9 @@ const semanticSuggestionMap: Record = { console: ['--stdout'], terminal: ['--stdout'], pipe: ['--stdin'], + monitor: ['--watch'], + live: ['--watch'], + auto: ['--watch'], }; export const run = async () => { @@ -183,6 +186,9 @@ export const run = async () => { ) .option('--skill-output ', 'Specify skill output directory path directly (skips location prompt)') .option('-f, --force', 'Skip all confirmation prompts (currently: skill directory overwrite)') + // Watch Mode + .optionsGroup('Watch Mode') + .option('-w, --watch', 'Watch for file changes and automatically re-pack') .action(commanderActionEndpoint); // Custom error handling function @@ -233,6 +239,27 @@ export const runCli = async (directories: string[], cwd: string, options: CliOpt options.stdout = true; } + // Validate --watch conflicts early, before log level changes can suppress error messages + if (options.watch) { + if (options.remote) { + throw new RepomixError('--watch cannot be used with --remote. Watch mode only works with local directories.'); + } + if (options.stdout) { + throw new RepomixError('--watch cannot be used with --stdout. Watch mode writes to a file.'); + } + if (options.stdin) { + throw new RepomixError('--watch cannot be used with --stdin. Watch mode discovers files automatically.'); + } + if (options.splitOutput) { + throw new RepomixError( + '--watch cannot be used with --split-output. Watch mode does not yet support split output files.', + ); + } + if (directories.length === 1 && isExplicitRemoteUrl(directories[0])) { + throw new RepomixError('--watch cannot be used with remote URLs. Watch mode only works with local directories.'); + } + } + // Set log level based on verbose and quiet flags if (options.quiet) { logger.setLogLevel(repomixLogLevels.SILENT); @@ -286,6 +313,11 @@ export const runCli = async (directories: string[], cwd: string, options: CliOpt return await runRemoteAction(directories[0], options); } + if (options.watch) { + const { runWatchAction } = await import('./actions/watchAction.js'); + return await runWatchAction(directories, cwd, options); + } + const { runDefaultAction } = await import('./actions/defaultAction.js'); return await runDefaultAction(directories, cwd, options); }; diff --git a/src/cli/types.ts b/src/cli/types.ts index b945ca1bf..01b33ec82 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -67,6 +67,9 @@ export interface CliOptions extends OptionValues { skillOutput?: string; // Output path for skill (skips location prompt) force?: boolean; // Skip all confirmation prompts + // Watch Mode + watch?: boolean; + // Other Options topFilesLen?: number; verbose?: boolean; diff --git a/tests/cli/actions/watchAction.test.ts b/tests/cli/actions/watchAction.test.ts new file mode 100644 index 000000000..3aefc84b4 --- /dev/null +++ b/tests/cli/actions/watchAction.test.ts @@ -0,0 +1,442 @@ +import { EventEmitter } from 'node:events'; +import path from 'node:path'; +import process from 'node:process'; +import { afterEach, beforeEach, describe, expect, it, type MockedFunction, vi } from 'vitest'; +import type { WatchDeps } from '../../../src/cli/actions/watchAction.js'; +import type { CliOptions } from '../../../src/cli/types.js'; +import * as configLoader from '../../../src/config/configLoad.js'; +import { defaultIgnoreList } from '../../../src/config/defaultIgnore.js'; +import * as packager from '../../../src/core/packager.js'; +import * as loggerModule from '../../../src/shared/logger.js'; +import { createMockConfig } from '../../testing/testUtils.js'; + +vi.mock('../../../src/core/packager'); +vi.mock('../../../src/config/configLoad'); +vi.mock('../../../src/shared/logger'); + +const mockSpinner = { + start: vi.fn() as MockedFunction<() => void>, + update: vi.fn() as MockedFunction<(message: string) => void>, + succeed: vi.fn() as MockedFunction<(message: string) => void>, + fail: vi.fn() as MockedFunction<(message: string) => void>, +}; + +vi.mock('../../../src/cli/cliSpinner', () => { + const MockSpinner = class { + start = mockSpinner.start; + update = mockSpinner.update; + succeed = mockSpinner.succeed; + fail = mockSpinner.fail; + }; + return { Spinner: MockSpinner }; +}); +vi.mock('../../../src/cli/cliReport'); +vi.mock('../../../src/cli/actions/migrationAction', () => ({ + runMigrationAction: vi.fn().mockResolvedValue({}), +})); + +function createMockPackResult(overrides: Partial = {}): packager.PackResult { + return { + totalFiles: 5, + totalCharacters: 500, + totalTokens: 100, + fileCharCounts: {}, + fileTokenCounts: {}, + suspiciousFilesResults: [], + suspiciousGitDiffResults: [], + suspiciousGitLogResults: [], + processedFiles: [], + safeFilePaths: ['src/index.ts', 'src/utils.ts'], + gitDiffTokenCount: 0, + gitLogTokenCount: 0, + skippedFiles: [], + ...overrides, + }; +} + +function createMockWatcher() { + const emitter = new EventEmitter(); + const watcher = Object.assign(emitter, { + close: vi.fn().mockResolvedValue(undefined), + add: vi.fn(), + unwatch: vi.fn(), + getWatched: vi.fn().mockReturnValue({}), + closed: false, + }); + return watcher; +} + +function createMockWatch(watcher: ReturnType): WatchDeps['watch'] { + return vi.fn().mockReturnValue(watcher) as unknown as WatchDeps['watch']; +} + +describe('watch option conflicts', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should throw when --watch is used with --remote', async () => { + const { runCli } = await import('../../../src/cli/cliRun.js'); + const options: CliOptions = { watch: true, remote: 'user/repo' }; + await expect(runCli(['.'], process.cwd(), options)).rejects.toThrow('--watch cannot be used with --remote'); + }); + + it('should throw when --watch is used with --stdout', async () => { + const { runCli } = await import('../../../src/cli/cliRun.js'); + const options: CliOptions = { watch: true, stdout: true }; + await expect(runCli(['.'], process.cwd(), options)).rejects.toThrow('--watch cannot be used with --stdout'); + }); + + it('should throw when --watch is used with --stdin', async () => { + const { runCli } = await import('../../../src/cli/cliRun.js'); + const options: CliOptions = { watch: true, stdin: true }; + await expect(runCli(['.'], process.cwd(), options)).rejects.toThrow('--watch cannot be used with --stdin'); + }); + + it('should throw when --watch is used with --split-output', async () => { + const { runCli } = await import('../../../src/cli/cliRun.js'); + const options: CliOptions = { watch: true, splitOutput: 1000 }; + await expect(runCli(['.'], process.cwd(), options)).rejects.toThrow('--watch cannot be used with --split-output'); + }); + + it('should throw when --watch is used with a positional remote URL', async () => { + const { runCli } = await import('../../../src/cli/cliRun.js'); + const options: CliOptions = { watch: true }; + await expect(runCli(['https://github.com/user/repo'], process.cwd(), options)).rejects.toThrow( + '--watch cannot be used with remote URLs', + ); + }); +}); + +describe('watchAction', () => { + let mockWatcher: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + vi.resetAllMocks(); + vi.clearAllMocks(); + + mockWatcher = createMockWatcher(); + + vi.mocked(configLoader.loadFileConfig).mockResolvedValue({}); + vi.mocked(configLoader.mergeConfigs).mockReturnValue( + createMockConfig({ + cwd: process.cwd(), + output: { + filePath: 'repomix-output.xml', + style: 'plain', + parsableStyle: false, + fileSummary: true, + directoryStructure: true, + topFilesLength: 5, + showLineNumbers: false, + removeComments: false, + removeEmptyLines: false, + compress: false, + copyToClipboard: false, + stdout: false, + git: { + sortByChanges: true, + sortByChangesMaxCommits: 100, + includeDiffs: false, + }, + files: true, + }, + ignore: { + useGitignore: true, + useDefaultPatterns: true, + customPatterns: [], + }, + include: [], + security: { + enableSecurityCheck: true, + }, + tokenCount: { + encoding: 'o200k_base', + }, + }), + ); + + vi.mocked(packager.pack).mockResolvedValue(createMockPackResult()); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); + }); + + it('should run initial pack on start', async () => { + const controller = new AbortController(); + const options: CliOptions = {}; + + // Import separately to ensure runWatchAction is called before abort + const { runWatchAction } = await import('../../../src/cli/actions/watchAction.js'); + const watchPromise = runWatchAction(['.'], process.cwd(), options, { + watch: createMockWatch(mockWatcher), + signal: controller.signal, + }); + + // Let initial pack complete + await vi.advanceTimersByTimeAsync(0); + + controller.abort(); + await watchPromise; + + expect(packager.pack).toHaveBeenCalledTimes(1); + expect(mockSpinner.start).toHaveBeenCalled(); + expect(mockSpinner.succeed).toHaveBeenCalled(); + }); + + it('should watch target directories instead of individual files', async () => { + const controller = new AbortController(); + const options: CliOptions = {}; + const mockWatch = createMockWatch(mockWatcher); + const cwd = process.cwd(); + + const watchPromise = (async () => { + const { runWatchAction } = await import('../../../src/cli/actions/watchAction.js'); + return runWatchAction(['.'], cwd, options, { + watch: mockWatch, + signal: controller.signal, + }); + })(); + + await vi.advanceTimersByTimeAsync(0); + + controller.abort(); + await watchPromise; + + const expectedTargetPaths = [path.resolve(cwd, '.')]; + const expectedIgnorePatterns = [...defaultIgnoreList, path.resolve(cwd, 'repomix-output.xml')]; + + expect(mockWatch).toHaveBeenCalledWith(expectedTargetPaths, { + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: 100 }, + ignored: expectedIgnorePatterns, + }); + }); + + it('should ignore the output file path in the watcher', async () => { + const controller = new AbortController(); + const options: CliOptions = {}; + const mockWatch = createMockWatch(mockWatcher); + const cwd = process.cwd(); + + const watchPromise = (async () => { + const { runWatchAction } = await import('../../../src/cli/actions/watchAction.js'); + return runWatchAction(['.'], cwd, options, { + watch: mockWatch, + signal: controller.signal, + }); + })(); + + await vi.advanceTimersByTimeAsync(0); + + controller.abort(); + await watchPromise; + + const watchCall = (mockWatch as ReturnType).mock.calls[0]; + const watchOptions = watchCall[1]; + // The ignored option should be an array that includes the output file path + expect(Array.isArray(watchOptions.ignored)).toBe(true); + expect(watchOptions.ignored).toContain(path.resolve(cwd, 'repomix-output.xml')); + }); + + it('should re-pack on file change after debounce', async () => { + const controller = new AbortController(); + const options: CliOptions = {}; + const mockWatch = createMockWatch(mockWatcher); + + const watchPromise = (async () => { + const { runWatchAction } = await import('../../../src/cli/actions/watchAction.js'); + return runWatchAction(['.'], process.cwd(), options, { + watch: mockWatch, + signal: controller.signal, + }); + })(); + + // Let initial pack complete + await vi.advanceTimersByTimeAsync(0); + + expect(packager.pack).toHaveBeenCalledTimes(1); + + // Simulate a file change event + mockWatcher.emit('change', 'src/index.ts'); + + // Advance past the 300ms debounce + await vi.advanceTimersByTimeAsync(350); + + expect(packager.pack).toHaveBeenCalledTimes(2); + + controller.abort(); + await watchPromise; + }); + + it('should debounce multiple rapid changes into one rebuild', async () => { + const controller = new AbortController(); + const options: CliOptions = {}; + const mockWatch = createMockWatch(mockWatcher); + + const watchPromise = (async () => { + const { runWatchAction } = await import('../../../src/cli/actions/watchAction.js'); + return runWatchAction(['.'], process.cwd(), options, { + watch: mockWatch, + signal: controller.signal, + }); + })(); + + // Let initial pack complete + await vi.advanceTimersByTimeAsync(0); + expect(packager.pack).toHaveBeenCalledTimes(1); + + // Fire multiple rapid changes within the debounce window + mockWatcher.emit('change', 'src/index.ts'); + await vi.advanceTimersByTimeAsync(100); + mockWatcher.emit('change', 'src/utils.ts'); + await vi.advanceTimersByTimeAsync(100); + mockWatcher.emit('add', 'src/new.ts'); + + // Advance past the 300ms debounce from the last event + await vi.advanceTimersByTimeAsync(350); + + // Should only have rebuilt once (plus the initial pack) + expect(packager.pack).toHaveBeenCalledTimes(2); + + controller.abort(); + await watchPromise; + }); + + it('should not start a concurrent rebuild while one is in progress', async () => { + const controller = new AbortController(); + const options: CliOptions = {}; + const mockWatch = createMockWatch(mockWatcher); + + // Make pack take some time so we can trigger a change mid-rebuild + let resolveSecondPack: (() => void) | undefined; + let packCallCount = 0; + + vi.mocked(packager.pack).mockImplementation(async () => { + packCallCount++; + if (packCallCount === 2) { + // Second pack (first rebuild) — hold it open so we can trigger a change mid-rebuild + await new Promise((resolve) => { + resolveSecondPack = resolve; + }); + } + return createMockPackResult(); + }); + + const watchPromise = (async () => { + const { runWatchAction } = await import('../../../src/cli/actions/watchAction.js'); + return runWatchAction(['.'], process.cwd(), options, { + watch: mockWatch, + signal: controller.signal, + }); + })(); + + // Let initial pack complete + await vi.advanceTimersByTimeAsync(0); + expect(packager.pack).toHaveBeenCalledTimes(1); + + // Trigger first rebuild + mockWatcher.emit('change', 'src/index.ts'); + await vi.advanceTimersByTimeAsync(350); + + // Second pack is now in progress (held open by resolveSecondPack) + expect(packager.pack).toHaveBeenCalledTimes(2); + + // Trigger another change while rebuild is in progress + mockWatcher.emit('change', 'src/utils.ts'); + await vi.advanceTimersByTimeAsync(350); + + // Should still be 2 because the rebuild guard prevents a concurrent pack + expect(packager.pack).toHaveBeenCalledTimes(2); + + // Resolve the in-progress pack — the pending rebuild should now fire + resolveSecondPack?.(); + await vi.advanceTimersByTimeAsync(350); + + // Now the queued rebuild should have run + expect(packager.pack).toHaveBeenCalledTimes(3); + + controller.abort(); + await watchPromise; + }); + + it('should log "Rebuilt at" timestamp after rebuild', async () => { + const controller = new AbortController(); + const options: CliOptions = {}; + const mockWatch = createMockWatch(mockWatcher); + + const watchPromise = (async () => { + const { runWatchAction } = await import('../../../src/cli/actions/watchAction.js'); + return runWatchAction(['.'], process.cwd(), options, { + watch: mockWatch, + signal: controller.signal, + }); + })(); + + // Let initial pack complete + await vi.advanceTimersByTimeAsync(0); + + // Simulate a file change + mockWatcher.emit('change', 'src/index.ts'); + + // Advance past debounce + await vi.advanceTimersByTimeAsync(350); + + expect(loggerModule.logger.success).toHaveBeenCalledWith(expect.stringContaining('Rebuilt at')); + + controller.abort(); + await watchPromise; + }); + + it('should log "Watching for changes..." after initial pack', async () => { + const controller = new AbortController(); + const options: CliOptions = {}; + const mockWatch = createMockWatch(mockWatcher); + + const watchPromise = (async () => { + const { runWatchAction } = await import('../../../src/cli/actions/watchAction.js'); + return runWatchAction(['.'], process.cwd(), options, { + watch: mockWatch, + signal: controller.signal, + }); + })(); + + // Let initial pack complete + await vi.advanceTimersByTimeAsync(0); + + expect(loggerModule.logger.log).toHaveBeenCalledWith(expect.stringContaining('Watching')); + + controller.abort(); + await watchPromise; + }); + + it('should close watcher on abort signal', async () => { + const controller = new AbortController(); + const options: CliOptions = {}; + const mockWatch = createMockWatch(mockWatcher); + + const watchPromise = (async () => { + const { runWatchAction } = await import('../../../src/cli/actions/watchAction.js'); + return runWatchAction(['.'], process.cwd(), options, { + watch: mockWatch, + signal: controller.signal, + }); + })(); + + // Let initial pack complete + await vi.advanceTimersByTimeAsync(0); + + controller.abort(); + await watchPromise; + + expect(mockWatcher.close).toHaveBeenCalled(); + }); +});