fix: syncDirectory — guard same-path copy and skip nested .git dirs

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.
This commit is contained in:
Jarvis
2026-04-02 20:35:37 -05:00
parent e46f0641f6
commit a6e59bf829
3 changed files with 99 additions and 4 deletions

View File

@@ -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');
});
});

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/mosaic", "name": "@mosaic/mosaic",
"version": "0.0.3", "version": "0.0.3-1",
"description": "Mosaic agent framework — installation wizard and meta package", "description": "Mosaic agent framework — installation wizard and meta package",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -9,7 +9,7 @@ import {
unlinkSync, unlinkSync,
statSync, statSync,
} from 'node:fs'; } from 'node:fs';
import { dirname, join, relative } from 'node:path'; import { dirname, join, relative, resolve } from 'node:path';
const MAX_BACKUPS = 3; const MAX_BACKUPS = 3;
@@ -68,6 +68,9 @@ export function syncDirectory(
target: string, target: string,
options: { preserve?: string[]; excludeGit?: boolean } = {}, options: { preserve?: string[]; excludeGit?: boolean } = {},
): void { ): void {
// Guard: source and target are the same directory — nothing to sync
if (resolve(source) === resolve(target)) return;
const preserveSet = new Set(options.preserve ?? []); const preserveSet = new Set(options.preserve ?? []);
// Collect files from source // Collect files from source
@@ -77,9 +80,10 @@ export function syncDirectory(
const stat = statSync(src); const stat = statSync(src);
if (stat.isDirectory()) { if (stat.isDirectory()) {
const relPath = relative(relBase, src); const relPath = relative(relBase, src);
const dirName = relPath.split('/').pop() ?? '';
// Skip .git // Skip any .git directory (top-level or nested, e.g. sources/agent-skills/.git)
if (options.excludeGit && relPath === '.git') return; if (options.excludeGit && (dirName === '.git' || relPath.includes('/.git'))) return;
// Skip preserved paths at top level // Skip preserved paths at top level
if (preserveSet.has(relPath) && existsSync(dest)) return; if (preserveSet.has(relPath) && existsSync(dest)) return;
@@ -91,6 +95,9 @@ export function syncDirectory(
} else { } else {
const relPath = relative(relBase, src); const relPath = relative(relBase, src);
// Skip files inside .git directories
if (options.excludeGit && relPath.includes('/.git/')) return;
// Skip preserved files at top level // Skip preserved files at top level
if (preserveSet.has(relPath) && existsSync(dest)) return; if (preserveSet.has(relPath) && existsSync(dest)) return;