UNPKG

git-contextor

Version:

A code context tool with vector search and real-time monitoring, with optional Git integration.

232 lines (204 loc) 6.14 kB
const chokidar = require('chokidar'); const path = require('path'); const { execSync } = require('child_process'); const ignore = require('ignore'); const logger = require('../cli/utils/logger'); /** * Watches the repository for file changes and triggers indexing operations. */ class FileWatcher { /** * @param {string} repoPath - The absolute path to the repository. * @param {Indexer} indexer - An instance of the Indexer class. * @param {object} config - The application configuration object. */ constructor(repoPath, indexer, config) { this.repoPath = repoPath; this.indexer = indexer; this.config = config; this.watcher = null; this.processingQueue = []; this.isProcessing = false; this.ignoreFilter = ignore().add(config.indexing.excludePatterns); this.activityLog = []; this.maxLogSize = 50; this.isGitRepo = false; // Ensure monitoring config exists with defaults if (!config.monitoring) { config.monitoring = { debounceMs: 500, maxQueueSize: 10 }; } } /** * Starts the file watcher. */ start() { logger.info('Starting file watcher...'); this.watcher = chokidar.watch(this.repoPath, { ignored: [ '**/node_modules/**', '**/.git/**', '**/.gitcontextor/**' ], persistent: true, ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 } }); this.watcher .on('add', (filePath) => this.handleFileChange('add', filePath)) .on('change', (filePath) => this.handleFileChange('change', filePath)) .on('unlink', (filePath) => this.handleFileChange('delete', filePath)) .on('error', (error) => logger.error('File watcher error:', error)); logger.info('File watcher started'); } /** * Stops the file watcher. */ stop() { if (this.watcher) { this.watcher.close(); this.watcher = null; logger.info('File watcher stopped'); } } /** * Gets the status of the file watcher. * @returns {object} Status object. */ getStatus() { return { isWatching: this.watcher !== null, queueSize: this.processingQueue.length, recentActivity: this.activityLog.slice(-5) // Return last 5 activities }; } /** * Handles a file change event from chokidar. * @param {string} event - The type of event ('add', 'change', 'unlink'). * @param {string} filePath - The path to the file that changed. */ handleFileChange(event, filePath) { const relativePath = path.relative(this.repoPath, filePath); // Check if file should be ignored if (this.ignoreFilter.ignores(relativePath)) { return; } // Check if file is tracked by git if (!this.isGitTracked(filePath)) { return; } // Check file extension if (!this.isSupportedFile(filePath)) { return; } const logEntry = { event, path: relativePath, timestamp: new Date().toISOString() }; this.activityLog.unshift(logEntry); if (this.activityLog.length > this.maxLogSize) { this.activityLog.pop(); } logger.debug(`File ${event}: ${relativePath}`); this.queueFileProcessing(event, filePath); } /** * Checks if a file is tracked by Git. * @param {string} filePath - The path to the file. * @returns {boolean} True if the file is tracked by Git. */ isGitTracked(filePath) { if (!this.isGitRepo) { // In a non-git folder, if a file is not ignored by our patterns, we treat it as "tracked". return true; } try { const relativePath = path.relative(this.repoPath, filePath); execSync(`git ls-files --error-unmatch "${relativePath}"`, { cwd: this.repoPath, stdio: 'ignore' }); return true; } catch { return false; } } /** * Checks if a file has a supported extension. * @param {string} filePath - The path to the file. * @returns {boolean} True if the file extension is in the include list. */ isSupportedFile(filePath) { const ext = path.extname(filePath).toLowerCase(); return this.config.indexing.includeExtensions.includes(ext); } /** * Adds a file change event to a queue for debounced processing. * @param {string} event - The file change event type. * @param {string} filePath - The path to the file. */ queueFileProcessing(event, filePath) { const existing = this.processingQueue.find(item => item.filePath === filePath); if (existing) { existing.event = event; existing.timestamp = Date.now(); return; } this.processingQueue.push({ action: event, // Use 'action' instead of 'event' for consistency with tests filePath }); setTimeout(() => this.processQueue(), this.config.monitoring.debounceMs); } /** * Processes the file change queue. * @private */ async processQueue() { if (this.isProcessing || this.processingQueue.length === 0) { return; } this.isProcessing = true; const items = this.processingQueue.splice(0, this.config.monitoring.maxQueueSize); for (const item of items) { try { await this.processFileChange(item); } catch (error) { logger.error(`Error processing ${item.filePath}:`, error); } } this.isProcessing = false; if (this.processingQueue.length > 0) { setTimeout(() => this.processQueue(), 100); } } /** * Dispatches the file change to the indexer. * @param {object} item - The item from the processing queue. * @param {string} item.action - The action type. * @param {string} item.filePath - The file path. * @private */ async processFileChange({ action, filePath }) { switch (action) { case 'add': case 'change': await this.indexer.indexFile(filePath); break; case 'delete': case 'unlink': await this.indexer.removeFile(filePath); break; } } getActivityLog() { return this.activityLog; } } module.exports = FileWatcher;