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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,9 @@ Instruction
| `--skill-output <path>` | 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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
262 changes: 262 additions & 0 deletions src/cli/actions/watchAction.ts
Original file line number Diff line number Diff line change
@@ -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<WatchDeps> => {
// 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<PackResult> => {
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<WatchDeps>,
): Promise<void> => {
// 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,
});
Comment on lines +120 to +124
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/cli/actions/watchAction.ts | head -200

Repository: yamadashy/repomix

Length of output: 8259


🏁 Script executed:

cat -n src/cli/actions/watchAction.ts | sed -n '200,250p'

Repository: yamadashy/repomix

Length of output: 1283


🏁 Script executed:

# Check if there are any existing error handlers on the watcher elsewhere
grep -n "watcher\.on" src/cli/actions/watchAction.ts

Repository: yamadashy/repomix

Length of output: 192


🌐 Web query:

chokidar FSWatcher error event handling EventEmitter

💡 Result:

In chokidar, the FSWatcher class extends Node.js EventEmitter and emits an 'error' event when significant filesystem errors occur during watching. This includes errors like EMFILE (too many open files), UNKNOWN, EPERM, EACCES (unless ignorePermissionErrors is true), and others excluding common ignorable ones like ENOENT and ENOTDIR. To handle these errors and prevent unhandled error crashes, always attach an 'error' event listener: const watcher = chokidar.watch('path/to/watch', { ignorePermissionErrors: true // Optional: suppress permission errors }); watcher.on('error', (error) => { console.error('FS Watcher error:', error); // Handle gracefully, e.g., log, retry, or close watcher // watcher.close; }); The internal _handleError method decides whether to emit the error: - Ignores ENOENT, ENOTDIR - Ignores EPERM/EACCES if ignorePermissionErrors: true - Emits 'error' event for other significant errors - Common in large directories (e.g., node_modules) causing EMFILE Without a listener, errors can crash the process with "Emitted 'error' event on FSWatcher instance". Best practice: always listen for 'error' events, especially in production or watch mode. Example from official docs: watcher.on('error', (error) => log(Watcher error: ${error}));

Citations:


Add error event handler to watcher.

FSWatcher extends Node.js EventEmitter and emits error events when filesystem monitoring fails (e.g., EMFILE, EACCES, EPERM). Without a listener, these errors crash the process with "Emitted 'error' event on FSWatcher instance". Hook error events into graceful shutdown to prevent watch mode from crashing on filesystem errors.

Suggested fix
  watcher.on('change', scheduleRebuild);
  watcher.on('add', scheduleRebuild);
  watcher.on('unlink', scheduleRebuild);
+  watcher.on('error', (error) => {
+    logger.error('Watcher failed:', error);
+    void cleanup();
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/actions/watchAction.ts` around lines 120 - 124, The FSWatcher created
by resolvedDeps.watch (assigned to watcher) lacks an 'error' event handler; add
watcher.on('error', handler) that catches watcher errors (e.g., EMFILE, EACCES)
and routes them into your existing graceful shutdown logic (call the same
shutdown/cleanup function you use for SIGINT/SIGTERM or invoke the shutdown
sequence used elsewhere) so the process doesn't crash on unhandled 'error'
events; ensure the handler logs the error via the same logger used nearby and
performs the same cleanup/exit path as other shutdown triggers.


// Handle watcher errors (EMFILE, EACCES, EPERM, etc.) to prevent uncaught exceptions
watcher.on('error', (error) => {
logger.error('File watcher error:', error);
});
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Rebuild guard — prevents concurrent packs and queues a follow-up if changes arrive mid-pack
let isRebuilding = false;
let pendingRebuild = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let shuttingDown = false;
let activeRebuildPromise: Promise<void> | 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);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

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);
}
Comment on lines +248 to +251
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

When runWatchAction is used as a library (which is a supported use case for Repomix), attaching global listeners to process.on('SIGINT') and process.on('SIGTERM') that call process.exit(0) is problematic. It will terminate the entire host process when the user intended to only stop the watch action.

Additionally, if this function is called multiple times in a long-running process (e.g., during tests or in a wrapper), it will leak event listeners on the process object. Consider only attaching these listeners if no AbortSignal is provided and ensure they are cleaned up properly.


// 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<void>((resolve) => {
if (cleanupDone) {
resolve();
return;
}
cleanupResolve = resolve;
});
};
Loading