From 3567751591cc8b1e3d179813fdb47148d28dcf6c Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 10 Apr 2026 07:11:37 +0200 Subject: [PATCH 01/11] Add compare_directories tool for directory comparison --- src/filesystem/index.ts | 56 +++++++++++++++++++++++++++++ src/filesystem/lib.ts | 78 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 7b67e63e58..efc9747d25 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -702,6 +702,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..158e68061e 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -413,3 +413,81 @@ export async function searchFilesWithValidation( await search(rootPath); return results; } + +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 { + const dir1Files = await searchFiles(dir1, "**/*"); + const dir2Files = await searchFiles(dir2, "**/*"); + + const dir1Set = new Set(dir1Files.map(f => path.relative(dir1, f))); + const dir2Set = new Set(dir2Files.map(f => 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 (stat1.size !== stat2.size || 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 + }; +} From cc7ea31b6059b2c3d3536ddd72f451e5b83b978e Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 10 Apr 2026 07:42:22 +0200 Subject: [PATCH 02/11] Add tests for compare_directories function --- src/filesystem/__tests__/lib.test.ts | 77 ++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index f7e585af22..8a8c039bc8 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -723,3 +723,80 @@ describe('Lib Functions', () => { }); }); }); + +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 }); + }); + + 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(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", 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", 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(path.join("subdir", "nested.txt")); + }); +}); From f332c7c560b0fb03749d14c1f5041f5a63dd83b4 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 10 Apr 2026 15:06:51 +0200 Subject: [PATCH 03/11] Fix fs mock issue for compare_directories tests --- src/filesystem/__tests__/lib.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index 8a8c039bc8..5d6019f4de 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -19,7 +19,9 @@ import { // File editing functions applyFileEdits, tailFile, - headFile + headFile, + // Directory comparison + compareDirectories } from '../lib.js'; // Mock fs module @@ -725,6 +727,9 @@ describe('Lib Functions', () => { }); describe("compareDirectories", () => { + // Unmock fs for integration tests + vi.unmock('fs/promises'); + const testDir1 = path.join(os.tmpdir(), "test-compare-1-" + Date.now()); const testDir2 = path.join(os.tmpdir(), "test-compare-2-" + Date.now()); From 7f5db95e28d7e005e6f5f5a23ace2c9c824dbcd9 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 10 Apr 2026 15:41:59 +0200 Subject: [PATCH 04/11] Fix TypeScript errors: add compareDirectories import, fix searchFiles usage, add types --- src/filesystem/index.ts | 1 + src/filesystem/lib.ts | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index efc9747d25..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 diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 158e68061e..6c4aa65723 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -432,11 +432,11 @@ export async function compareDirectories( dir2: string, compareContent: boolean = false ): Promise { - const dir1Files = await searchFiles(dir1, "**/*"); - const dir2Files = await searchFiles(dir2, "**/*"); + const dir1Files = await searchFilesWithValidation(dir1, "**/*"); + const dir2Files = await searchFilesWithValidation(dir2, "**/*"); - const dir1Set = new Set(dir1Files.map(f => path.relative(dir1, f))); - const dir2Set = new Set(dir2Files.map(f => path.relative(dir2, f))); + 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[] = []; From c9aa26ddcb2246d76f1b517f49172572aaf51f0d Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 10 Apr 2026 16:56:19 +0200 Subject: [PATCH 05/11] Fix TypeScript errors: add missing allowedDirectories parameter to searchFilesWithValidation calls --- src/filesystem/lib.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 6c4aa65723..3113af87b5 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -432,8 +432,8 @@ export async function compareDirectories( dir2: string, compareContent: boolean = false ): Promise { - const dir1Files = await searchFilesWithValidation(dir1, "**/*"); - const dir2Files = await searchFilesWithValidation(dir2, "**/*"); + 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))); From f6f1eb1b5808a701f03dbd5a347253b300f6ebb4 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 10 Apr 2026 17:36:44 +0200 Subject: [PATCH 06/11] Fix test isolation: move compareDirectories tests to separate file --- .../__tests__/compare-directories.test.ts | 84 +++++++++++++++++++ src/filesystem/__tests__/lib.test.ts | 84 +------------------ 2 files changed, 85 insertions(+), 83 deletions(-) create mode 100644 src/filesystem/__tests__/compare-directories.test.ts diff --git a/src/filesystem/__tests__/compare-directories.test.ts b/src/filesystem/__tests__/compare-directories.test.ts new file mode 100644 index 0000000000..edb4955955 --- /dev/null +++ b/src/filesystem/__tests__/compare-directories.test.ts @@ -0,0 +1,84 @@ +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(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", 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", 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(path.join("subdir", "nested.txt")); + }); +}); diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index 5d6019f4de..f7e585af22 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -19,9 +19,7 @@ import { // File editing functions applyFileEdits, tailFile, - headFile, - // Directory comparison - compareDirectories + headFile } from '../lib.js'; // Mock fs module @@ -725,83 +723,3 @@ describe('Lib Functions', () => { }); }); }); - -describe("compareDirectories", () => { - // Unmock fs for integration tests - vi.unmock('fs/promises'); - - 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 }); - }); - - 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(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", 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", 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(path.join("subdir", "nested.txt")); - }); -}); From c8944de825ba75fa8af0951cb14ede1212b605d1 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 10 Apr 2026 19:28:18 +0200 Subject: [PATCH 07/11] Fix test logic: common.txt in both dirs, use forward slashes for nested paths --- src/filesystem/__tests__/compare-directories.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/filesystem/__tests__/compare-directories.test.ts b/src/filesystem/__tests__/compare-directories.test.ts index edb4955955..67dbb97c2a 100644 --- a/src/filesystem/__tests__/compare-directories.test.ts +++ b/src/filesystem/__tests__/compare-directories.test.ts @@ -22,6 +22,7 @@ describe("compareDirectories", () => { 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); @@ -79,6 +80,6 @@ describe("compareDirectories", () => { const result = await compareDirectories(testDir1, testDir2); - expect(result.identical).toContain(path.join("subdir", "nested.txt")); + expect(result.identical).toContain("subdir/nested.txt"); }); }); From 5ea9b667a13d924d7dd91ac2d5a1a0e9c135293d Mon Sep 17 00:00:00 2001 From: Maksym Vasylchenko Date: Mon, 13 Apr 2026 19:01:10 +0200 Subject: [PATCH 08/11] feat: implement content comparison in compareDirectories - Add compareFileContents helper for byte-by-byte comparison - compareContent=true now reads and compares actual file contents - Files with same content (but different mtime) go to identical - Fix: compareContent parameter is now actually used - When sizes differ, skip content comparison for efficiency --- src/filesystem/lib.ts | 48 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 3113af87b5..19e239f692 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -414,6 +414,24 @@ export async function searchFilesWithValidation( 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[]; @@ -470,7 +488,8 @@ export async function compareDirectories( fs.stat(file2) ]); - if (stat1.size !== stat2.size || stat1.mtimeMs !== stat2.mtimeMs) { + // If sizes differ, files are definitely different + if (stat1.size !== stat2.size) { differentContent.push({ path: relPath, dir1Size: stat1.size, @@ -478,8 +497,33 @@ export async function compareDirectories( 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 { - identical.push(relPath); + // 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); + } } } } From 96172ba93309d41d25abe0597e47e45d8cca6cd2 Mon Sep 17 00:00:00 2001 From: Maksym Vasylchenko Date: Mon, 13 Apr 2026 19:02:25 +0200 Subject: [PATCH 09/11] test: add tests for compareContent parameter - Test same-size different-content detection with compareContent=true - Test same-content different-mtime detection (should be identical) - Test binary file content comparison - Test compareContent=false behavior (mtime-based) --- .../__tests__/compare-directories.test.ts | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/src/filesystem/__tests__/compare-directories.test.ts b/src/filesystem/__tests__/compare-directories.test.ts index 67dbb97c2a..d9c417c28b 100644 --- a/src/filesystem/__tests__/compare-directories.test.ts +++ b/src/filesystem/__tests__/compare-directories.test.ts @@ -42,7 +42,7 @@ describe("compareDirectories", () => { expect(result.onlyInDir2).not.toContain("common.txt"); }); - it("detects files with different content", async () => { + 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"); @@ -53,7 +53,7 @@ describe("compareDirectories", () => { expect(result.differentContent[0].dir1Size).not.toBe(result.differentContent[0].dir2Size); }); - it("identifies identical files", async () => { + 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"); @@ -82,4 +82,75 @@ describe("compareDirectories", () => { 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 modify one file to change mtime + await new Promise(resolve => setTimeout(resolve, 100)); + await fs.writeFile(path.join(testDir1, "marker.txt"), "marker"); + + 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 modify one file to change mtime + await new Promise(resolve => setTimeout(resolve, 100)); + await fs.writeFile(path.join(testDir1, "marker.txt"), "marker"); + + 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); + }); + }); }); From d5db849de1451f0978bb741bbee99cffa446ea4c Mon Sep 17 00:00:00 2001 From: Maksym Vasylchenko Date: Mon, 13 Apr 2026 19:45:04 +0200 Subject: [PATCH 10/11] fix: set allowed directories in compareDirectories for validation - save and restore global allowedDirectories state - fixes tests that expect validatePath to work with test directories --- src/filesystem/lib.ts | 147 ++++++++++++++++++++++-------------------- 1 file changed, 78 insertions(+), 69 deletions(-) diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 19e239f692..85cdaa8d02 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -450,70 +450,51 @@ export async function compareDirectories( dir2: string, compareContent: boolean = false ): Promise { - const dir1Files = await searchFilesWithValidation(dir1, "**/*", [dir1]); - const dir2Files = await searchFilesWithValidation(dir2, "**/*", [dir2]); + // Temporarily set allowed directories for validation during comparison + const previousAllowed = getAllowedDirectories(); + setAllowedDirectories([dir1, 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); + 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); + + // 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) { + + // 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, @@ -521,17 +502,45 @@ export async function compareDirectories( 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 { - identical.push(relPath); + // 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); } - - return { - onlyInDir1, - onlyInDir2, - differentContent, - identical - }; } From add0a1cd98b2f4108b934e02435cc3958f37190b Mon Sep 17 00:00:00 2001 From: Maksym Vasylchenko Date: Mon, 13 Apr 2026 19:47:45 +0200 Subject: [PATCH 11/11] test: fix mtime test - write to target file instead of marker The test was writing to marker.txt expecting diff-mtime.txt mtime to change. Now it directly re-writes diff-mtime.txt to update its mtime. --- src/filesystem/__tests__/compare-directories.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/filesystem/__tests__/compare-directories.test.ts b/src/filesystem/__tests__/compare-directories.test.ts index d9c417c28b..dcb5c2d3fe 100644 --- a/src/filesystem/__tests__/compare-directories.test.ts +++ b/src/filesystem/__tests__/compare-directories.test.ts @@ -101,9 +101,9 @@ describe("compareDirectories", () => { await fs.writeFile(path.join(testDir1, "same-content.txt"), "same data"); await fs.writeFile(path.join(testDir2, "same-content.txt"), "same data"); - // Wait and modify one file to change mtime + // 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, "marker.txt"), "marker"); + await fs.writeFile(path.join(testDir1, "same-content.txt"), "same data"); const result = await compareDirectories(testDir1, testDir2, true); @@ -141,9 +141,9 @@ describe("compareDirectories", () => { await fs.writeFile(path.join(testDir1, "diff-mtime.txt"), "same"); await fs.writeFile(path.join(testDir2, "diff-mtime.txt"), "same"); - // Wait and modify one file to change mtime + // 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, "marker.txt"), "marker"); + await fs.writeFile(path.join(testDir1, "diff-mtime.txt"), "same"); const result = await compareDirectories(testDir1, testDir2, false);