Skip to content
Open
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
156 changes: 156 additions & 0 deletions src/filesystem/__tests__/compare-directories.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
57 changes: 57 additions & 0 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
tailFile,
headFile,
setAllowedDirectories,
compareDirectories,
} from './lib.js';

// Command line argument parsing
Expand Down Expand Up @@ -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);
Expand Down
131 changes: 131 additions & 0 deletions src/filesystem/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<DirectoryComparisonResult> {
// 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);
}
}
Loading