Skip to content
Merged
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
40 changes: 39 additions & 1 deletion src/agents/utils/setup.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<ContextFile[]> {
const files = ['CLAUDE.md', 'AGENTS.md'];
const results: ContextFile[] = [];
Expand All @@ -47,6 +79,12 @@ export async function readContextFiles(cwd: string): Promise<ContextFile[]> {
}
}

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

Expand Down
95 changes: 94 additions & 1 deletion tests/unit/agents/utils/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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<typeof lstatSync>);

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<typeof lstatSync>);

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<typeof lstatSync>) // CLAUDE.md
.mockReturnValueOnce({ isSymbolicLink: () => true } as ReturnType<typeof lstatSync>); // 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<typeof lstatSync>) // CLAUDE.md
.mockReturnValueOnce({ isSymbolicLink: () => false } as ReturnType<typeof lstatSync>); // 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');
});
});

// ============================================================================
Expand Down
Loading