diff --git a/src/filesystem/__tests__/compare-directories.test.ts b/src/filesystem/__tests__/compare-directories.test.ts new file mode 100644 index 0000000000..dcb5c2d3fe --- /dev/null +++ b/src/filesystem/__tests__/compare-directories.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { compareDirectories, setAllowedDirectories } from "../lib.js"; + +describe("compareDirectories", () => { + const testDir1 = path.join(os.tmpdir(), "test-compare-1-" + Date.now()); + const testDir2 = path.join(os.tmpdir(), "test-compare-2-" + Date.now()); + + beforeEach(async () => { + await fs.mkdir(testDir1, { recursive: true }); + await fs.mkdir(testDir2, { recursive: true }); + // Set allowed directories for validation + setAllowedDirectories([testDir1, testDir2]); + }); + + afterEach(async () => { + await fs.rm(testDir1, { recursive: true, force: true }); + await fs.rm(testDir2, { recursive: true, force: true }); + }); + + it("identifies files only in first directory", async () => { + await fs.writeFile(path.join(testDir1, "only1.txt"), "content1"); + await fs.writeFile(path.join(testDir1, "common.txt"), "common"); + await fs.writeFile(path.join(testDir2, "common.txt"), "common"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.onlyInDir1).toContain("only1.txt"); + expect(result.onlyInDir1).not.toContain("common.txt"); + expect(result.identical).toContain("common.txt"); + }); + + it("identifies files only in second directory", async () => { + await fs.writeFile(path.join(testDir1, "common.txt"), "common"); + await fs.writeFile(path.join(testDir2, "only2.txt"), "content2"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.onlyInDir2).toContain("only2.txt"); + expect(result.onlyInDir2).not.toContain("common.txt"); + }); + + it("detects files with different content by size", async () => { + await fs.writeFile(path.join(testDir1, "diff.txt"), "content1"); + await fs.writeFile(path.join(testDir2, "diff.txt"), "different content"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.differentContent).toHaveLength(1); + expect(result.differentContent[0].path).toBe("diff.txt"); + expect(result.differentContent[0].dir1Size).not.toBe(result.differentContent[0].dir2Size); + }); + + it("identifies identical files by size and mtime", async () => { + await fs.writeFile(path.join(testDir1, "same.txt"), "identical content"); + await fs.writeFile(path.join(testDir2, "same.txt"), "identical content"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.identical).toContain("same.txt"); + expect(result.differentContent).toHaveLength(0); + }); + + it("handles empty directories", async () => { + const result = await compareDirectories(testDir1, testDir2); + + expect(result.onlyInDir1).toHaveLength(0); + expect(result.onlyInDir2).toHaveLength(0); + expect(result.differentContent).toHaveLength(0); + expect(result.identical).toHaveLength(0); + }); + + it("compares nested directory structures", async () => { + await fs.mkdir(path.join(testDir1, "subdir"), { recursive: true }); + await fs.mkdir(path.join(testDir2, "subdir"), { recursive: true }); + await fs.writeFile(path.join(testDir1, "subdir", "nested.txt"), "nested"); + await fs.writeFile(path.join(testDir2, "subdir", "nested.txt"), "nested"); + + const result = await compareDirectories(testDir1, testDir2); + + expect(result.identical).toContain("subdir/nested.txt"); + }); + + describe("compareContent=true", () => { + it("detects different content with same size", async () => { + // Same size but different content + await fs.writeFile(path.join(testDir1, "same-size.txt"), "aaa"); + await fs.writeFile(path.join(testDir2, "same-size.txt"), "bbb"); + + const result = await compareDirectories(testDir1, testDir2, true); + + expect(result.differentContent).toHaveLength(1); + expect(result.differentContent[0].path).toBe("same-size.txt"); + expect(result.identical).toHaveLength(0); + }); + + it("identifies same content despite different mtime", async () => { + // Write same content to both files + await fs.writeFile(path.join(testDir1, "same-content.txt"), "same data"); + await fs.writeFile(path.join(testDir2, "same-content.txt"), "same data"); + + // Wait and touch one file to change its mtime (re-write same content) + await new Promise(resolve => setTimeout(resolve, 100)); + await fs.writeFile(path.join(testDir1, "same-content.txt"), "same data"); + + const result = await compareDirectories(testDir1, testDir2, true); + + // With compareContent=true, content is what matters, not mtime + expect(result.identical).toContain("same-content.txt"); + expect(result.differentContent).toHaveLength(0); + }); + + it("detects different content when sizes match", async () => { + // Create files with same size but different content + await fs.writeFile(path.join(testDir1, "binary.bin"), Buffer.from([0x01, 0x02, 0x03])); + await fs.writeFile(path.join(testDir2, "binary.bin"), Buffer.from([0x04, 0x05, 0x06])); + + const result = await compareDirectories(testDir1, testDir2, true); + + expect(result.differentContent).toHaveLength(1); + expect(result.differentContent[0].path).toBe("binary.bin"); + expect(result.identical).toHaveLength(0); + }); + + it("identifies identical binary content", async () => { + const binaryData = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff]); + await fs.writeFile(path.join(testDir1, "identical.bin"), binaryData); + await fs.writeFile(path.join(testDir2, "identical.bin"), binaryData); + + const result = await compareDirectories(testDir1, testDir2, true); + + expect(result.identical).toContain("identical.bin"); + expect(result.differentContent).toHaveLength(0); + }); + }); + + describe("compareContent=false (default)", () => { + it("marks files with different mtime as different", async () => { + await fs.writeFile(path.join(testDir1, "diff-mtime.txt"), "same"); + await fs.writeFile(path.join(testDir2, "diff-mtime.txt"), "same"); + + // Wait and touch the file in dir1 to change its mtime (re-write same content) + await new Promise(resolve => setTimeout(resolve, 100)); + await fs.writeFile(path.join(testDir1, "diff-mtime.txt"), "same"); + + const result = await compareDirectories(testDir1, testDir2, false); + + // With compareContent=false, different mtime means different files + expect(result.differentContent).toHaveLength(1); + expect(result.differentContent[0].path).toBe("diff-mtime.txt"); + expect(result.identical).toHaveLength(0); + }); + }); +}); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 7b67e63e58..65f32f4fd4 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -26,6 +26,7 @@ import { tailFile, headFile, setAllowedDirectories, + compareDirectories, } from './lib.js'; // Command line argument parsing @@ -702,6 +703,62 @@ server.registerTool( } ); +server.registerTool( + "compare_directories", + { + title: "Compare Directories", + description: + "Compare two directories and report differences. Returns files only in first dir, " + + "only in second dir, files with different content (size/mtime), and identical files. " + + "Useful for syncing and finding changes between directory versions.", + inputSchema: { + dir1: z.string(), + dir2: z.string(), + compareContent: z.boolean().optional() + }, + outputSchema: { content: z.string() }, + annotations: { readOnlyHint: true } + }, + async (args: { dir1: string; dir2: string; compareContent?: boolean }) => { + const validDir1 = await validatePath(args.dir1); + const validDir2 = await validatePath(args.dir2); + const result = await compareDirectories(validDir1, validDir2, args.compareContent); + + const lines: string[] = []; + lines.push(`Comparison: ${args.dir1} vs ${args.dir2}`); + lines.push(""); + + if (result.onlyInDir1.length > 0) { + lines.push(`Only in ${args.dir1} (${result.onlyInDir1.length}):`); + result.onlyInDir1.forEach(f => lines.push(` - ${f}`)); + lines.push(""); + } + + if (result.onlyInDir2.length > 0) { + lines.push(`Only in ${args.dir2} (${result.onlyInDir2.length}):`); + result.onlyInDir2.forEach(f => lines.push(` - ${f}`)); + lines.push(""); + } + + if (result.differentContent.length > 0) { + lines.push(`Different content (${result.differentContent.length}):`); + result.differentContent.forEach(f => { + lines.push(` - ${f.path}`); + lines.push(` Size: ${f.dir1Size} vs ${f.dir2Size}`); + }); + lines.push(""); + } + + lines.push(`Identical files: ${result.identical.length}`); + + const text = lines.join("\n"); + return { + content: [{ type: "text" as const, text }], + structuredContent: { result } + }; + } +); + // Updates allowed directories based on MCP client roots async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) { const validatedRootDirs = await getValidRootDirectories(requestedRoots); diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 17e4654cd5..85cdaa8d02 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -413,3 +413,134 @@ export async function searchFilesWithValidation( await search(rootPath); return results; } + +// Helper function to compare file contents byte-by-byte +async function compareFileContents(file1: string, file2: string): Promise { + try { + const [content1, content2] = await Promise.all([ + fs.readFile(file1), + fs.readFile(file2) + ]); + + if (content1.length !== content2.length) { + return false; + } + + return content1.equals(content2); + } catch { + return false; + } +} + +export interface DirectoryComparisonResult { + onlyInDir1: string[]; + onlyInDir2: string[]; + differentContent: Array<{ + path: string; + dir1Size: number; + dir2Size: number; + dir1Mtime: number; + dir2Mtime: number; + }>; + identical: string[]; +} + +export async function compareDirectories( + dir1: string, + dir2: string, + compareContent: boolean = false +): Promise { + // Temporarily set allowed directories for validation during comparison + const previousAllowed = getAllowedDirectories(); + setAllowedDirectories([dir1, dir2]); + + try { + const dir1Files = await searchFilesWithValidation(dir1, "**/*", [dir1]); + const dir2Files = await searchFilesWithValidation(dir2, "**/*", [dir2]); + + const dir1Set = new Set(dir1Files.map((f: string) => path.relative(dir1, f))); + const dir2Set = new Set(dir2Files.map((f: string) => path.relative(dir2, f))); + + const onlyInDir1: string[] = []; + const onlyInDir2: string[] = []; + const differentContent: DirectoryComparisonResult["differentContent"] = []; + const identical: string[] = []; + + // Files only in dir1 + for (const file of dir1Files) { + const relPath = path.relative(dir1, file); + if (!dir2Set.has(relPath)) { + onlyInDir1.push(relPath); + } + } + + // Files only in dir2 + for (const file of dir2Files) { + const relPath = path.relative(dir2, file); + if (!dir1Set.has(relPath)) { + onlyInDir2.push(relPath); + } + } + + // Compare common files + for (const relPath of dir1Set) { + if (dir2Set.has(relPath)) { + const file1 = path.join(dir1, relPath); + const file2 = path.join(dir2, relPath); + + const [stat1, stat2] = await Promise.all([ + fs.stat(file1), + fs.stat(file2) + ]); + + // If sizes differ, files are definitely different + if (stat1.size !== stat2.size) { + differentContent.push({ + path: relPath, + dir1Size: stat1.size, + dir2Size: stat2.size, + dir1Mtime: stat1.mtimeMs, + dir2Mtime: stat2.mtimeMs + }); + } else if (compareContent) { + // Sizes are equal, compare actual content + const contentsEqual = await compareFileContents(file1, file2); + if (contentsEqual) { + identical.push(relPath); + } else { + differentContent.push({ + path: relPath, + dir1Size: stat1.size, + dir2Size: stat2.size, + dir1Mtime: stat1.mtimeMs, + dir2Mtime: stat2.mtimeMs + }); + } + } else { + // compareContent is false, use mtime as indicator + if (stat1.mtimeMs !== stat2.mtimeMs) { + differentContent.push({ + path: relPath, + dir1Size: stat1.size, + dir2Size: stat2.size, + dir1Mtime: stat1.mtimeMs, + dir2Mtime: stat2.mtimeMs + }); + } else { + identical.push(relPath); + } + } + } + } + + return { + onlyInDir1, + onlyInDir2, + differentContent, + identical + }; + } finally { + // Restore previous allowed directories + setAllowedDirectories(previousAllowed); + } +}