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

@@ -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;