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.
89 lines
3.0 KiB
TypeScript
89 lines
3.0 KiB
TypeScript
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');
|
|
});
|
|
});
|