diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aafb563..b036d47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,3 +22,6 @@ jobs: - name: Build run: bun run build + + - name: Tests + run: bun run test diff --git a/package.json b/package.json index 7c8a990..5311604 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "scripts": { "build": "tsup", "dev": "tsx src/index.ts", + "test": "tsx tests/fs.test.ts", "prepublishOnly": "npm run build", "check:lint": "biome check . --diagnostic-level=error", "check:unsafe": "biome check . --write --unsafe --diagnostic-level=error", diff --git a/src/utils/fs.ts b/src/utils/fs.ts index e1ccbe8..96d1c8a 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -7,6 +7,7 @@ import { readFileSync, readlinkSync, renameSync, + rmdirSync, statSync, symlinkSync, unlinkSync, @@ -85,6 +86,17 @@ export function copyFile(src: string, dest: string): void { copyFileSync(src, dest); } +// Directories to skip when copying (version control, package managers, caches) +const SKIP_DIRECTORIES = new Set([ + ".git", + ".svn", + ".hg", + "node_modules", + ".cache", + "__pycache__", + ".DS_Store", +]); + export function copyDir(src: string, dest: string): void { ensureDir(dest); const entries = readdirSync(src, { withFileTypes: true }); @@ -93,10 +105,56 @@ export function copyDir(src: string, dest: string): void { const srcPath = join(src, entry.name); const destPath = join(dest, entry.name); + // Skip entries in the skip list + if (SKIP_DIRECTORIES.has(entry.name)) { + continue; + } + + // Handle symlinks - preserve them rather than following + if (entry.isSymbolicLink()) { + try { + const linkTarget = readlinkSync(srcPath); + // Check if target exists to avoid broken symlinks + if (existsSync(srcPath)) { + // Recreate the symlink at destination + ensureParentDir(destPath); + try { + // Remove existing file/symlink if present + unlinkSync(destPath); + } catch { + // Ignore if doesn't exist + } + symlinkSync(linkTarget, destPath); + } + // Skip broken symlinks silently + } catch { + // Skip symlinks that can't be read + } + continue; + } + if (entry.isDirectory()) { copyDir(srcPath, destPath); } else { - copyFileSync(srcPath, destPath); + // Skip special file types + if ( + entry.isSocket() || + entry.isFIFO() || + entry.isCharacterDevice() || + entry.isBlockDevice() + ) { + continue; + } + try { + copyFileSync(srcPath, destPath); + } catch (error) { + // Skip files that can't be copied (permission issues, etc.) + // but don't fail the entire operation + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code !== "ENOENT" && nodeError.code !== "EACCES") { + throw error; + } + } } } } @@ -115,7 +173,6 @@ export function removeDir(path: string): void { } // Remove the directory itself - const { rmdirSync } = require("node:fs"); rmdirSync(path); } diff --git a/tests/fs.test.ts b/tests/fs.test.ts new file mode 100644 index 0000000..7a4452b --- /dev/null +++ b/tests/fs.test.ts @@ -0,0 +1,295 @@ +#!/usr/bin/env tsx + +/** + * Unit tests for fs.ts + * + * These tests validate the filesystem helpers used by sync operations. + * + * Run with: npx tsx tests/fs.test.ts + */ + +import assert from "node:assert/strict"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + backup, + copyDir, + copyFile, + createSymlink, + ensureDir, + ensureParentDir, + exists, + getFileDiff, + getSymlinkTarget, + isDirectory, + isSymlink, + readFile, + removeDir, +} from "../src/utils/fs"; + +type TestCase = { + name: string; + run: () => void | Promise; +}; + +const tests: TestCase[] = []; + +function test(name: string, run: TestCase["run"]) { + tests.push({ name, run }); +} + +function makeTempDir(label: string): string { + return mkdtempSync(join(tmpdir(), `syncode-${label}-`)); +} + +function cleanupTempDir(dir: string): void { + rmSync(dir, { recursive: true, force: true }); +} + +test("exists/isDirectory basics", () => { + const dir = makeTempDir("fs-basic"); + try { + const missing = join(dir, "missing"); + assert.equal(exists(missing), false); + assert.equal(isDirectory(missing), false); + + const subdir = join(dir, "subdir"); + ensureDir(subdir); + assert.equal(exists(subdir), true); + assert.equal(isDirectory(subdir), true); + + const file = join(dir, "file.txt"); + writeFileSync(file, "data"); + assert.equal(exists(file), true); + assert.equal(isDirectory(file), false); + assert.equal(isSymlink(file), false); + } finally { + cleanupTempDir(dir); + } +}); + +test("ensureParentDir creates parent path", () => { + const dir = makeTempDir("fs-parent"); + try { + const filePath = join(dir, "a", "b", "c.txt"); + ensureParentDir(filePath); + assert.equal(isDirectory(join(dir, "a", "b")), true); + } finally { + cleanupTempDir(dir); + } +}); + +test("readFile/getFileDiff", () => { + const dir = makeTempDir("fs-read"); + try { + const fileA = join(dir, "a.txt"); + const fileB = join(dir, "b.txt"); + writeFileSync(fileA, "same"); + writeFileSync(fileB, "same"); + + assert.equal(readFile(fileA), "same"); + assert.equal(readFile(join(dir, "missing.txt")), null); + assert.equal(getFileDiff(fileA, fileB), false); + + writeFileSync(fileB, "diff"); + assert.equal(getFileDiff(fileA, fileB), true); + } finally { + cleanupTempDir(dir); + } +}); + +test("copyFile copies content", () => { + const dir = makeTempDir("fs-copyfile"); + try { + const src = join(dir, "src.txt"); + const dest = join(dir, "out", "dest.txt"); + writeFileSync(src, "hello"); + + copyFile(src, dest); + assert.equal(readFileSync(dest, "utf-8"), "hello"); + } finally { + cleanupTempDir(dir); + } +}); + +test("copyDir copies nested entries", () => { + const dir = makeTempDir("fs-copydir"); + try { + const src = join(dir, "src"); + const dest = join(dir, "dest"); + ensureDir(join(src, "nested")); + writeFileSync(join(src, "root.txt"), "root"); + writeFileSync(join(src, "nested", "child.txt"), "child"); + + copyDir(src, dest); + assert.equal(readFileSync(join(dest, "root.txt"), "utf-8"), "root"); + assert.equal( + readFileSync(join(dest, "nested", "child.txt"), "utf-8"), + "child", + ); + } finally { + cleanupTempDir(dir); + } +}); + +test("copyDir skips unix sockets", async () => { + if (process.platform === "win32") { + console.log("↷ skipped socket test on win32"); + return; + } + + const dir = makeTempDir("fs-socket"); + const server = createServer(); + const socketPath = join(dir, "src", "agent.sock"); + + try { + const src = join(dir, "src"); + const dest = join(dir, "dest"); + ensureDir(src); + writeFileSync(join(src, "file.txt"), "ok"); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => resolve()); + }); + + copyDir(src, dest); + assert.equal(readFileSync(join(dest, "file.txt"), "utf-8"), "ok"); + assert.equal(existsSync(join(dest, "agent.sock")), false); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + if (existsSync(socketPath)) { + rmSync(socketPath, { force: true }); + } + cleanupTempDir(dir); + } +}); + +test("backup moves file to .backup", () => { + const dir = makeTempDir("fs-backup"); + try { + const file = join(dir, "config.json"); + writeFileSync(file, "data"); + + const backupPath = backup(file); + assert.equal(backupPath, `${file}.backup`); + assert.equal(exists(file), false); + assert.equal(readFileSync(`${file}.backup`, "utf-8"), "data"); + } finally { + cleanupTempDir(dir); + } +}); + +test("createSymlink and getSymlinkTarget", () => { + if (process.platform === "win32") { + console.log("↷ skipped symlink test on win32"); + return; + } + + const dir = makeTempDir("fs-symlink"); + try { + const target = join(dir, "target.txt"); + const link = join(dir, "link.txt"); + writeFileSync(target, "content"); + + createSymlink(target, link); + assert.equal(isSymlink(link), true); + assert.equal(getSymlinkTarget(link), target); + } finally { + cleanupTempDir(dir); + } +}); + +test("removeDir deletes nested directory", () => { + const dir = makeTempDir("fs-removedir"); + try { + const nested = join(dir, "nested", "deep"); + ensureDir(nested); + writeFileSync(join(nested, "file.txt"), "data"); + + removeDir(join(dir, "nested")); + assert.equal(exists(join(dir, "nested")), false); + } finally { + cleanupTempDir(dir); + } +}); + +test("copyDir skips .git directories", () => { + const dir = makeTempDir("fs-skipgit"); + try { + const src = join(dir, "src"); + const dest = join(dir, "dest"); + ensureDir(join(src, ".git", "objects")); + ensureDir(join(src, "skill")); + writeFileSync(join(src, ".git", "config"), "git config"); + writeFileSync(join(src, ".git", "objects", "pack.idx"), "pack data"); + writeFileSync(join(src, "skill", "test.md"), "skill content"); + writeFileSync(join(src, "config.json"), "app config"); + + copyDir(src, dest); + + // .git should NOT be copied + assert.equal(exists(join(dest, ".git")), false); + // Regular files should be copied + assert.equal( + readFileSync(join(dest, "config.json"), "utf-8"), + "app config", + ); + assert.equal( + readFileSync(join(dest, "skill", "test.md"), "utf-8"), + "skill content", + ); + } finally { + cleanupTempDir(dir); + } +}); + +test("copyDir skips node_modules", () => { + const dir = makeTempDir("fs-skipnodemod"); + try { + const src = join(dir, "src"); + const dest = join(dir, "dest"); + ensureDir(join(src, "node_modules", "some-package")); + writeFileSync( + join(src, "node_modules", "some-package", "index.js"), + "module", + ); + writeFileSync(join(src, "index.ts"), "source"); + + copyDir(src, dest); + + // node_modules should NOT be copied + assert.equal(exists(join(dest, "node_modules")), false); + // Regular files should be copied + assert.equal(readFileSync(join(dest, "index.ts"), "utf-8"), "source"); + } finally { + cleanupTempDir(dir); + } +}); + +async function run() { + for (const { name, run } of tests) { + try { + await run(); + console.log(`✓ ${name}`); + } catch (error) { + console.error(`✗ ${name}`); + console.error(error); + process.exitCode = 1; + } + } + + if (process.exitCode) { + process.exit(1); + } +} + +await run();