Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 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
235 changes: 235 additions & 0 deletions src/cli/actions/watchAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
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 thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.


// 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;

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;
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;
// Check if shutdown has been initiated before draining pendingRebuild
if (shuttingDown) {
pendingRebuild = false;
} else if (pendingRebuild) {
pendingRebuild = false;
scheduleRebuild();
}
}
}, 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);
}
process.removeListener('SIGINT', onSigint);
process.removeListener('SIGTERM', onSigterm);
// Close watcher before resolving so callers don't see
// runWatchAction() resolve until shutdown is truly finished
await watcher.close();
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;
});
};
32 changes: 32 additions & 0 deletions src/cli/cliRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ const semanticSuggestionMap: Record<string, string[]> = {
console: ['--stdout'],
terminal: ['--stdout'],
pipe: ['--stdin'],
monitor: ['--watch'],
live: ['--watch'],
auto: ['--watch'],
};

export const run = async () => {
Expand Down Expand Up @@ -183,6 +186,9 @@ export const run = async () => {
)
.option('--skill-output <path>', '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
Expand Down Expand Up @@ -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.');
}
}
Comment on lines +242 to +261

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

Reject --watch with positional remote URLs too.

This only guards options.remote. repomix -w https://github.com/... still falls through to the explicit-URL auto-detection later in runCli, so watch mode is silently ignored instead of producing the documented conflict error.

Suggested fix
   // Validate --watch conflicts early, before log level changes can suppress error messages
   if (options.watch) {
-    if (options.remote) {
+    const positionalRemote = directories.length === 1 && isExplicitRemoteUrl(directories[0]);
+    if (options.remote || positionalRemote) {
       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.');
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/cliRun.ts` around lines 242 - 253, The watch-mode validation misses
positional remote URLs, so extend the existing check in runCli (the block that
tests options.watch and options.remote/stdout/stdin) to also scan parsed
positional arguments (e.g., options._ or the parsed positional array) for remote
URLs and reject them; implement a small predicate (isRemoteUrl) that matches
common remote forms (https?:// or git@) and if any positional matches, throw the
same RepomixError('--watch cannot be used with --remote. Watch mode only works
with local directories.').

Comment on lines +243 to +261

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.

🟡 Watch mode silently ignores --skill-generate instead of rejecting the incompatible combination

The --watch conflict validation in src/cli/cliRun.ts:243-261 checks for --remote, --stdout, --stdin, and --split-output, but does not check for --skill-generate. If a user runs repomix --watch --skill-generate myskill, the config's skillGenerate is set via mergeConfigs, but watchAction.ts:106 calls pack(targetPaths, config, handleProgress) without passing PackOptions.skillDir. The pack function at src/core/packager.ts:171 checks config.skillGenerate !== undefined && options.skillDir — since options.skillDir is undefined, it silently falls through to normal output generation. The user gets regular packed output instead of skill output with no error or warning.

Suggested change
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.');
}
}
// 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 (options.skillGenerate !== undefined) {
throw new RepomixError('--watch cannot be used with --skill-generate. Watch mode does not support skill generation.');
}
if (directories.length === 1 && isExplicitRemoteUrl(directories[0])) {
throw new RepomixError('--watch cannot be used with remote URLs. Watch mode only works with local directories.');
}
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +243 to +261

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.

🔴 Missing conflict check: --watch --skill-generate silently ignores skill generation

When a user passes --watch --skill-generate, the watch action runs but --skill-generate is silently ignored. The buildCliConfig at src/cli/actions/defaultAction.ts:341 correctly adds skillGenerate to the config, and mergeConfigs includes it in the merged config. However, runPack in watchAction.ts:41 calls pack(targetPaths, config, handleProgress) without passing any PackOptions (specifically skillDir). In pack() at src/core/packager.ts:171, the skill generation path requires config.skillGenerate !== undefined && options.skillDir, which is false because skillDir is never set. The result is regular output (repomix-output.xml) instead of skill directory output, with no error or warning to the user. The validation block at src/cli/cliRun.ts:243-261 checks for --remote, --stdout, --stdin, and --split-output conflicts but misses --skill-generate.

Suggested change
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.');
}
}
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 (options.skillGenerate !== undefined) {
throw new RepomixError(
'--watch cannot be used with --skill-generate. Watch mode does not support skill generation.',
);
}
if (directories.length === 1 && isExplicitRemoteUrl(directories[0])) {
throw new RepomixError('--watch cannot be used with remote URLs. Watch mode only works with local directories.');
}
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


// Set log level based on verbose and quiet flags
if (options.quiet) {
logger.setLogLevel(repomixLogLevels.SILENT);
Expand Down Expand Up @@ -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);
};
3 changes: 3 additions & 0 deletions src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading