diff --git a/src/agents/utils/setup.ts b/src/agents/utils/setup.ts index 3fd1ac52..d08fb0cd 100644 --- a/src/agents/utils/setup.ts +++ b/src/agents/utils/setup.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs'; import { join } from 'node:path'; import { runCommand as execCommand } from '../../utils/repo.js'; @@ -32,6 +32,38 @@ export interface ContextFile { content: string; } +/** + * Checks whether two context files are duplicates — either because one is a + * symlink pointing at the other, or because their trimmed content is identical. + * + * The symlink check is a fast path: `lstat` does NOT follow symlinks, so it + * reliably detects the symlink target. The content comparison is the fallback + * for copy-paste duplicates or cases where `lstat`/`readlink` fail. + */ +function areDuplicateContextFiles(cwd: string, a: ContextFile, b: ContextFile): boolean { + // Fast path: check if one file is a symlink pointing to the other + try { + const aPath = join(cwd, a.path); + const bPath = join(cwd, b.path); + const aStat = lstatSync(aPath); + const bStat = lstatSync(bPath); + + if (aStat.isSymbolicLink()) { + const target = readlinkSync(aPath); + if (target === b.path || join(cwd, target) === bPath) return true; + } + if (bStat.isSymbolicLink()) { + const target = readlinkSync(bPath); + if (target === a.path || join(cwd, target) === aPath) return true; + } + } catch { + // Fall through to content comparison on permission errors or race conditions + } + + // Fallback: compare trimmed content strings + return a.content === b.content; +} + export async function readContextFiles(cwd: string): Promise { const files = ['CLAUDE.md', 'AGENTS.md']; const results: ContextFile[] = []; @@ -47,6 +79,12 @@ export async function readContextFiles(cwd: string): Promise { } } + // Deduplicate: when both files exist and have identical content (or one is a + // symlink of the other), keep only the CLAUDE.md entry (canonical reference). + if (results.length === 2 && areDuplicateContextFiles(cwd, results[0], results[1])) { + return [results[0]]; + } + return results; } diff --git a/tests/unit/agents/utils/setup.test.ts b/tests/unit/agents/utils/setup.test.ts index fb1d3501..8fb98dc1 100644 --- a/tests/unit/agents/utils/setup.test.ts +++ b/tests/unit/agents/utils/setup.test.ts @@ -2,14 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('node:fs', () => ({ existsSync: vi.fn(), + lstatSync: vi.fn(), readFileSync: vi.fn(), + readlinkSync: vi.fn(), })); vi.mock('../../../../src/utils/repo.js', () => ({ runCommand: vi.fn(), })); -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs'; import { getLogLevel, installDependencies, @@ -20,7 +22,9 @@ import { import { runCommand } from '../../../../src/utils/repo.js'; const mockExistsSync = vi.mocked(existsSync); +const mockLstatSync = vi.mocked(lstatSync); const mockReadFileSync = vi.mocked(readFileSync); +const mockReadlinkSync = vi.mocked(readlinkSync); const mockRunCommand = vi.mocked(runCommand); beforeEach(() => { @@ -134,6 +138,95 @@ describe('readContextFiles', () => { expect(result[0].content).toBe('# Claude docs'); }); + + // --- Deduplication tests --- + + it('deduplicates when both files have identical content, keeping CLAUDE.md', async () => { + const sharedContent = '# Shared context'; + mockRunCommand + .mockResolvedValueOnce({ stdout: sharedContent, stderr: '' }) + .mockResolvedValueOnce({ stdout: sharedContent, stderr: '' }); + // lstatSync: neither file is a symlink + mockLstatSync.mockReturnValue({ isSymbolicLink: () => false } as ReturnType); + + const result = await readContextFiles('/repo'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('CLAUDE.md'); + expect(result[0].content).toBe(sharedContent); + }); + + it('keeps both files when content differs', async () => { + mockRunCommand + .mockResolvedValueOnce({ stdout: '# Claude content', stderr: '' }) + .mockResolvedValueOnce({ stdout: '# Agents content', stderr: '' }); + mockLstatSync.mockReturnValue({ isSymbolicLink: () => false } as ReturnType); + + const result = await readContextFiles('/repo'); + + expect(result).toHaveLength(2); + expect(result[0].path).toBe('CLAUDE.md'); + expect(result[1].path).toBe('AGENTS.md'); + }); + + it('deduplicates when AGENTS.md is a symlink to CLAUDE.md', async () => { + mockRunCommand + .mockResolvedValueOnce({ stdout: '# Claude docs', stderr: '' }) + .mockResolvedValueOnce({ stdout: '# Claude docs', stderr: '' }); + mockLstatSync + .mockReturnValueOnce({ isSymbolicLink: () => false } as ReturnType) // CLAUDE.md + .mockReturnValueOnce({ isSymbolicLink: () => true } as ReturnType); // AGENTS.md + mockReadlinkSync.mockReturnValue('CLAUDE.md' as never); + + const result = await readContextFiles('/repo'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('CLAUDE.md'); + }); + + it('deduplicates when CLAUDE.md is a symlink to AGENTS.md', async () => { + mockRunCommand + .mockResolvedValueOnce({ stdout: '# Agents docs', stderr: '' }) + .mockResolvedValueOnce({ stdout: '# Agents docs', stderr: '' }); + mockLstatSync + .mockReturnValueOnce({ isSymbolicLink: () => true } as ReturnType) // CLAUDE.md + .mockReturnValueOnce({ isSymbolicLink: () => false } as ReturnType); // AGENTS.md + mockReadlinkSync.mockReturnValue('AGENTS.md' as never); + + const result = await readContextFiles('/repo'); + + expect(result).toHaveLength(1); + // CLAUDE.md (index 0) is kept as the canonical entry even though it is the symlink + expect(result[0].path).toBe('CLAUDE.md'); + }); + + it('falls back to content comparison when lstat throws', async () => { + const sharedContent = '# Same content'; + mockRunCommand + .mockResolvedValueOnce({ stdout: sharedContent, stderr: '' }) + .mockResolvedValueOnce({ stdout: sharedContent, stderr: '' }); + mockLstatSync.mockImplementation(() => { + throw new Error('EPERM: permission denied'); + }); + + const result = await readContextFiles('/repo'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('CLAUDE.md'); + }); + + it('does not deduplicate when only one file exists', async () => { + mockRunCommand + .mockResolvedValueOnce({ stdout: '# Claude docs', stderr: '' }) + .mockRejectedValueOnce(new Error('ENOENT')); + + const result = await readContextFiles('/repo'); + + // lstatSync should NOT be called when only 1 result + expect(mockLstatSync).not.toHaveBeenCalled(); + expect(result).toHaveLength(1); + expect(result[0].path).toBe('CLAUDE.md'); + }); }); // ============================================================================