Merge pull request 'fix: syncDirectory same-path guard, nested .git exclusion, and sync stash handling' (#356) from fix/idempotent-init into main
Reviewed-on: mosaic/mosaic-stack#356
This commit was merged in pull request #356.
This commit is contained in:
88
packages/mosaic/__tests__/platform/file-ops.test.ts
Normal file
88
packages/mosaic/__tests__/platform/file-ops.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,26 @@ mkdir -p "$MOSAIC_HOME" "$MOSAIC_SKILLS_DIR" "$MOSAIC_LOCAL_SKILLS_DIR"
|
||||
if [[ $fetch -eq 1 ]]; then
|
||||
if [[ -d "$SKILLS_REPO_DIR/.git" ]]; then
|
||||
echo "[mosaic-skills] Updating skills source: $SKILLS_REPO_DIR"
|
||||
git -C "$SKILLS_REPO_DIR" pull --rebase
|
||||
|
||||
# Stash any local changes (dirty index or worktree) before pulling
|
||||
local_changes=0
|
||||
if ! git -C "$SKILLS_REPO_DIR" diff --quiet 2>/dev/null || \
|
||||
! git -C "$SKILLS_REPO_DIR" diff --cached --quiet 2>/dev/null; then
|
||||
local_changes=1
|
||||
echo "[mosaic-skills] Stashing local changes..."
|
||||
git -C "$SKILLS_REPO_DIR" stash push -q -m "mosaic-sync-skills auto-stash"
|
||||
fi
|
||||
|
||||
if ! git -C "$SKILLS_REPO_DIR" pull --rebase; then
|
||||
echo "[mosaic-skills] WARN: pull failed — continuing with existing checkout" >&2
|
||||
fi
|
||||
|
||||
# Restore stashed changes
|
||||
if [[ $local_changes -eq 1 ]]; then
|
||||
echo "[mosaic-skills] Restoring local changes..."
|
||||
git -C "$SKILLS_REPO_DIR" stash pop -q 2>/dev/null || \
|
||||
echo "[mosaic-skills] WARN: stash pop had conflicts — check $SKILLS_REPO_DIR" >&2
|
||||
fi
|
||||
else
|
||||
echo "[mosaic-skills] Cloning skills source to: $SKILLS_REPO_DIR"
|
||||
mkdir -p "$(dirname "$SKILLS_REPO_DIR")"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user