From a6e59bf829242d32fff124028449339c80bdd295 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 2 Apr 2026 20:35:37 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20syncDirectory=20=E2=80=94=20guard=20same?= =?UTF-8?q?-path=20copy=20and=20skip=20nested=20.git=20dirs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs causing 'EACCES: permission denied, copyfile' when source and target are the same path (e.g. wizard with sourceDir == mosaicHome): 1. No same-path guard — syncDirectory tried to copy every file onto itself; git pack files are read-only (0444) so copyFileSync fails. 2. excludeGit only matched top-level .git — nested .git dirs like sources/agent-skills/.git were copied, hitting the same permission issue. Fixes: - Early return when resolve(source) === resolve(target) - Match .git dirs at any depth via dirName and relPath checks - Skip files inside .git/ paths Added file-ops.test.ts with 4 tests covering all cases. --- .../__tests__/platform/file-ops.test.ts | 88 +++++++++++++++++++ packages/mosaic/package.json | 2 +- packages/mosaic/src/platform/file-ops.ts | 13 ++- 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 packages/mosaic/__tests__/platform/file-ops.test.ts diff --git a/packages/mosaic/__tests__/platform/file-ops.test.ts b/packages/mosaic/__tests__/platform/file-ops.test.ts new file mode 100644 index 0000000..26c8f0c --- /dev/null +++ b/packages/mosaic/__tests__/platform/file-ops.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + chmodSync, + rmSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { syncDirectory } from '../../src/platform/file-ops.js'; + +describe('syncDirectory', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-file-ops-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('is a no-op when source and target are the same path', () => { + const dir = join(tmpDir, 'same'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'file.txt'), 'hello'); + // Should not throw even with read-only files + const gitDir = join(dir, '.git', 'objects', 'pack'); + mkdirSync(gitDir, { recursive: true }); + const packFile = join(gitDir, 'pack-abc.idx'); + writeFileSync(packFile, 'data'); + chmodSync(packFile, 0o444); + + expect(() => syncDirectory(dir, dir)).not.toThrow(); + }); + + it('skips nested .git directories when excludeGit is true', () => { + const src = join(tmpDir, 'src'); + const dest = join(tmpDir, 'dest'); + + // Create source with a nested .git + mkdirSync(join(src, 'sources', 'skills', '.git', 'objects'), { recursive: true }); + writeFileSync(join(src, 'sources', 'skills', '.git', 'objects', 'pack.idx'), 'git-data'); + writeFileSync(join(src, 'sources', 'skills', 'SKILL.md'), 'skill content'); + writeFileSync(join(src, 'README.md'), 'readme'); + + syncDirectory(src, dest, { excludeGit: true }); + + // .git contents should NOT be copied + expect(existsSync(join(dest, 'sources', 'skills', '.git'))).toBe(false); + // Normal files should be copied + expect(readFileSync(join(dest, 'sources', 'skills', 'SKILL.md'), 'utf-8')).toBe( + 'skill content', + ); + expect(readFileSync(join(dest, 'README.md'), 'utf-8')).toBe('readme'); + }); + + it('copies nested .git directories when excludeGit is false', () => { + const src = join(tmpDir, 'src'); + const dest = join(tmpDir, 'dest'); + + mkdirSync(join(src, 'sub', '.git'), { recursive: true }); + writeFileSync(join(src, 'sub', '.git', 'HEAD'), 'ref: refs/heads/main'); + + syncDirectory(src, dest, { excludeGit: false }); + + expect(readFileSync(join(dest, 'sub', '.git', 'HEAD'), 'utf-8')).toBe('ref: refs/heads/main'); + }); + + it('respects preserve option', () => { + const src = join(tmpDir, 'src'); + const dest = join(tmpDir, 'dest'); + + mkdirSync(src, { recursive: true }); + mkdirSync(dest, { recursive: true }); + writeFileSync(join(src, 'SOUL.md'), 'new soul'); + writeFileSync(join(dest, 'SOUL.md'), 'old soul'); + writeFileSync(join(src, 'README.md'), 'new readme'); + + syncDirectory(src, dest, { preserve: ['SOUL.md'] }); + + expect(readFileSync(join(dest, 'SOUL.md'), 'utf-8')).toBe('old soul'); + expect(readFileSync(join(dest, 'README.md'), 'utf-8')).toBe('new readme'); + }); +}); diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 16cd384..8be25ec 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -1,6 +1,6 @@ { "name": "@mosaic/mosaic", - "version": "0.0.3", + "version": "0.0.3-1", "description": "Mosaic agent framework — installation wizard and meta package", "type": "module", "main": "dist/index.js", diff --git a/packages/mosaic/src/platform/file-ops.ts b/packages/mosaic/src/platform/file-ops.ts index 5b67f56..cce7887 100644 --- a/packages/mosaic/src/platform/file-ops.ts +++ b/packages/mosaic/src/platform/file-ops.ts @@ -9,7 +9,7 @@ import { unlinkSync, statSync, } from 'node:fs'; -import { dirname, join, relative } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; const MAX_BACKUPS = 3; @@ -68,6 +68,9 @@ export function syncDirectory( target: string, options: { preserve?: string[]; excludeGit?: boolean } = {}, ): void { + // Guard: source and target are the same directory — nothing to sync + if (resolve(source) === resolve(target)) return; + const preserveSet = new Set(options.preserve ?? []); // Collect files from source @@ -77,9 +80,10 @@ export function syncDirectory( const stat = statSync(src); if (stat.isDirectory()) { const relPath = relative(relBase, src); + const dirName = relPath.split('/').pop() ?? ''; - // Skip .git - if (options.excludeGit && relPath === '.git') return; + // Skip any .git directory (top-level or nested, e.g. sources/agent-skills/.git) + if (options.excludeGit && (dirName === '.git' || relPath.includes('/.git'))) return; // Skip preserved paths at top level if (preserveSet.has(relPath) && existsSync(dest)) return; @@ -91,6 +95,9 @@ export function syncDirectory( } else { const relPath = relative(relBase, src); + // Skip files inside .git directories + if (options.excludeGit && relPath.includes('/.git/')) return; + // Skip preserved files at top level if (preserveSet.has(relPath) && existsSync(dest)) return;