diff --git a/__tests__/watcher.test.ts b/__tests__/watcher.test.ts index b372fc3d6..2251c8e2b 100644 --- a/__tests__/watcher.test.ts +++ b/__tests__/watcher.test.ts @@ -429,5 +429,90 @@ describe('FileWatcher', () => { cg.unwatch(); }); + + it('should auto-sync changes inside a symlinked directory (symlink to project dir, #770)', async () => { + // Set up a separate "external" project directory + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-external-')); + const externalSrc = path.join(externalDir, 'src'); + fs.mkdirSync(externalSrc); + fs.writeFileSync(path.join(externalSrc, 'external.ts'), 'export const external = 1;'); + + // Create a symlink inside testDir pointing to the external project + const symlinkPath = path.join(testDir, 'linked-project'); + fs.symlinkSync(externalDir, symlinkPath); + + cg = CodeGraph.initSync(testDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + + const initialStats = cg.getStats(); + const initialNodes = initialStats.nodeCount; + + cg.watch({ debounceMs: 300 }); + // Let the watcher install (and discover the symlinked dir) before writing. + await new Promise((r) => setTimeout(r, 200)); + + // Write to a file inside the symlinked directory — the watcher must pick it up. + const symlinkedFile = path.join(symlinkPath, 'src', 'new-in-symlink.ts'); + fs.writeFileSync(symlinkedFile, 'export function newInSymlink() { return 99; }'); + + // Wait for auto-sync to pick it up (OS event delivery + debounce). + await waitFor( + () => { + const stats = cg.getStats(); + return stats.nodeCount > initialNodes; + }, + 8000 + ); + + // The new function inside the symlink should be in the graph. + const results = cg.searchNodes('newInSymlink'); + expect(results.length).toBeGreaterThan(0); + + cg.unwatch(); + + // Clean up external dir + fs.rmSync(externalDir, { recursive: true, force: true }); + }); + + it('should not crash on symlink cycles (A→B, B→A or A→B→A)', async () => { + // Create two dirs with mutual symlinks: dirA/pointsToB → dirB, dirB/pointsToA → dirA + const dirA = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cycle-a-')); + const dirB = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cycle-b-')); + const srcA = path.join(dirA, 'src'); + const srcB = path.join(dirB, 'src'); + fs.mkdirSync(srcA); + fs.mkdirSync(srcB); + fs.symlinkSync(srcB, path.join(dirA, 'pointsToB')); + fs.symlinkSync(srcA, path.join(dirB, 'pointsToA')); + fs.writeFileSync(path.join(srcA, 'a.ts'), 'export const a = 1;'); + fs.writeFileSync(path.join(srcB, 'b.ts'), 'export const b = 2;'); + + cg = CodeGraph.initSync(dirA, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + + const initialNodes = cg.getStats().nodeCount; + + cg.watch({ debounceMs: 200 }); + await new Promise((r) => setTimeout(r, 300)); + + // Write to both dirs + fs.writeFileSync(path.join(srcA, 'newA.ts'), 'export const newA = 3;'); + fs.writeFileSync(path.join(srcB, 'newB.ts'), 'export const newB = 4;'); + + await new Promise((r) => setTimeout(r, 1000)); + + cg.unwatch(); + + // Should not have crashed, and new files should be indexed + const finalNodes = cg.getStats().nodeCount; + expect(finalNodes).toBeGreaterThan(initialNodes); + const searchResults = cg.searchNodes('newA'); + expect(searchResults.length).toBeGreaterThan(0); + + fs.rmSync(dirA, { recursive: true, force: true }); + fs.rmSync(dirB, { recursive: true, force: true }); + }); }); }); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 271309b9b..8280bf0fd 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -606,7 +606,7 @@ export class ExtractionOrchestrator { getAllFiles: () => files, getProjectRoot: () => rootDir, fileExists: (relativePath: string) => { - const full = validatePathWithinRoot(rootDir, relativePath); + const full = validatePathWithinRoot(rootDir, relativePath, true); if (!full) return false; try { return fs.existsSync(full); @@ -615,7 +615,7 @@ export class ExtractionOrchestrator { } }, readFile: (relativePath: string) => { - const full = validatePathWithinRoot(rootDir, relativePath); + const full = validatePathWithinRoot(rootDir, relativePath, true); if (!full) return null; try { return fs.readFileSync(full, 'utf-8'); @@ -903,7 +903,7 @@ export class ExtractionOrchestrator { const fileContents = await Promise.all( batch.map(async (fp) => { try { - const fullPath = validatePathWithinRoot(this.rootDir, fp); + const fullPath = validatePathWithinRoot(this.rootDir, fp, true); // allowSymlinkedDirs: watched symlinks are pre-validated if (!fullPath) { logWarn('Path traversal blocked in batch reader', { filePath: fp }); return { filePath: fp, content: null as string | null, stats: null as fs.Stats | null, error: new Error('Path traversal blocked') }; @@ -1057,7 +1057,7 @@ export class ExtractionOrchestrator { let content: string; try { - const fullPath = validatePathWithinRoot(this.rootDir, filePath); + const fullPath = validatePathWithinRoot(this.rootDir, filePath, true); if (!fullPath) continue; content = await fsp.readFile(fullPath, 'utf-8'); } catch { @@ -1102,7 +1102,7 @@ export class ExtractionOrchestrator { let fullContent: string; try { - const fullPath = validatePathWithinRoot(this.rootDir, filePath); + const fullPath = validatePathWithinRoot(this.rootDir, filePath, true); if (!fullPath) continue; fullContent = await fsp.readFile(fullPath, 'utf-8'); } catch { @@ -1209,7 +1209,7 @@ export class ExtractionOrchestrator { * Index a single file */ async indexFile(relativePath: string): Promise { - const fullPath = validatePathWithinRoot(this.rootDir, relativePath); + const fullPath = validatePathWithinRoot(this.rootDir, relativePath, true); if (!fullPath) { return { @@ -1257,7 +1257,7 @@ export class ExtractionOrchestrator { stats: fs.Stats ): Promise { // Prevent path traversal - const fullPath = validatePathWithinRoot(this.rootDir, relativePath); + const fullPath = validatePathWithinRoot(this.rootDir, relativePath, true); if (!fullPath) { logWarn('Path traversal blocked in indexFileWithContent', { relativePath }); return { diff --git a/src/sync/watcher.ts b/src/sync/watcher.ts index 9bc654708..e7fcbf998 100644 --- a/src/sync/watcher.ts +++ b/src/sync/watcher.ts @@ -167,6 +167,12 @@ export class FileWatcher { private dirCapWarned = false; /** Test-only inert mode: started, but with no OS watcher installed. */ private inert = false; + /** + * Resolved realpath of directories already watched (or skipped). Prevents + * infinite recursion via symlink cycles — mirrors the same guard used in + * the indexer's `walk()` (extraction/index.ts). + */ + private visitedRealDirs = new Set(); private debounceTimer: ReturnType | null = null; /** * Files seen by the watcher since the last successful sync — populated on @@ -351,7 +357,33 @@ export class FileWatcher { const child = path.join(dir, entry.name); if (entry.isDirectory()) { if (this.shouldIgnoreDir(child)) continue; + // Track regular dirs too so a symlink pointing to an already-visited + // real path is also skipped (A→B via isDirectory, then symlink→B). + try { + this.visitedRealDirs.add(fs.realpathSync(child)); + } catch { /* ignore — will be caught by watch failure below */ } this.watchTree(child, markExisting); + } else if (entry.isSymbolicLink()) { + // Follow directory symlinks so symlinked project dirs are watched. + // Mirrors the indexer's walk() in extraction/index.ts. + try { + const realTarget = fs.realpathSync(child); + const stat = fs.statSync(realTarget); + if (stat.isDirectory()) { + if (this.visitedRealDirs.has(realTarget)) { + logDebug('Skipping already-visited directory (symlink cycle)', { dir: child, realTarget }); + continue; + } + this.visitedRealDirs.add(realTarget); + if (!this.shouldIgnoreDir(child)) { + this.watchTree(child, markExisting); + } + } else if (markExisting && stat.isFile()) { + this.handleChange(normalizePath(path.relative(this.projectRoot, child))); + } + } catch { + logDebug('Skipping broken symlink in watcher', { path: child }); + } } else if (markExisting && entry.isFile()) { this.handleChange(normalizePath(path.relative(this.projectRoot, child))); } @@ -477,6 +509,7 @@ export class FileWatcher { } this.dirWatchers.clear(); this.dirCapWarned = false; + this.visitedRealDirs.clear(); this.inert = false; this.pendingFiles.clear(); diff --git a/src/utils.ts b/src/utils.ts index 3c4d78e2d..f4c36d8f0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -93,10 +93,19 @@ function isWithinDir(child: string, parent: string): boolean { * * @param projectRoot - The project root directory * @param filePath - The (relative or absolute) file path to validate + * @param allowSymlinkedDirs - When true, skips the realpath check so that files + * inside symlinked directories (whose real target may be outside the root) are + * allowed. Use this for paths that come from the file watcher — the watcher + * already validated that symlinks are within the monitored set, so re-checking + * would incorrectly reject valid auto-sync events (#770). * @returns The resolved absolute path (realpath when it exists), or null if it * escapes the root */ -export function validatePathWithinRoot(projectRoot: string, filePath: string): string | null { +export function validatePathWithinRoot( + projectRoot: string, + filePath: string, + allowSymlinkedDirs = false +): string | null { const resolved = path.resolve(projectRoot, filePath); const normalizedRoot = path.resolve(projectRoot); @@ -107,6 +116,10 @@ export function validatePathWithinRoot(projectRoot: string, filePath: string): s // 2. Symlink-aware containment — resolve symlinks on both sides and re-check, // so an in-repo symlink whose real target escapes the root is rejected. + // Skip this check when allowSymlinkedDirs is true (watcher-validated paths). + if (allowSymlinkedDirs) { + return resolved; + } try { const realRoot = fs.realpathSync(normalizedRoot); const realResolved = fs.realpathSync(resolved);