项目目标
本项目构建一个把源目录镜像到目标的工具,先计算完整变更集,再统一执行。先规划后执行使操作可审阅、可预演,也让执行器回归为对纯计划的一次简单循环。
核心特性
- 按相对路径与修改时间比对两棵目录树
- 复制新增并覆盖变更文件
- 删除目标中源不存在的文件
- 预演模式只报告不改动
架构与分层
扫描器构建两份「路径到元数据」映射;规划器把差异化为复制、更新与删除的有序动作;执行器应用计划;报告器打印计划。规划器是纯函数,可对伪造元数据测试而无需磁盘。
实施阶段
六个阶段为:目录扫描、同步规划、执行器、报告器、CLI 表面、测试与加固。预演与真实执行共用同一纯规划结果。
测试与验收
用 vitest 运行。规划测试用内存映射覆盖新增、变更与多余文件并断言精确动作表;集成测试构建两棵临时目录树,执行同步后断言目标逐文件镜像源,含一次删除。
延伸方向
改用内容哈希可抵抗文件重新生成;过滤列表排除特定模式;双向模式在同一计划结构上加冲突检测。这些都是规划器的改动,而非执行器。
本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。
代码示例
export function planSync(source, target) {
const actions = [];
for (const [rel, meta] of source) {
const existing = target.get(rel);
if (!existing) actions.push({ type: 'copy', rel, size: meta.size });
else if (meta.mtimeMs > existing.mtimeMs || meta.size !== existing.size) {
actions.push({ type: 'update', rel, size: meta.size });
}
}
for (const rel of target.keys()) {
if (!source.has(rel)) actions.push({ type: 'delete', rel });
}
return actions.sort((a, b) => a.rel.localeCompare(b.rel));
}
mkdir dir-sync && cd dir-sync
npm init -y
npm install -D vitest@^2.1.0
{
"name": "dir-sync",
"type": "module",
"scripts": {
"sync": "node src/cli.js",
"test": "vitest run"
}
}
import { readdir, lstat } from 'node:fs/promises';
import { join, relative } from 'node:path';
export async function scan(root) {
const map = new Map();
async function walk(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) await walk(full);
else {
const info = await lstat(full);
map.set(relative(root, full).replace(/\\/g, '/'), {
size: info.size,
mtimeMs: info.mtimeMs,
});
}
}
}
await walk(root);
return map;
}
export function planSync(source, target) {
const actions = [];
for (const [rel, meta] of source) {
const existing = target.get(rel);
if (!existing) actions.push({ type: 'copy', rel });
else if (meta.mtimeMs > existing.mtimeMs || meta.size !== existing.size) {
actions.push({ type: 'update', rel });
}
}
for (const rel of target.keys()) if (!source.has(rel)) actions.push({ type: 'delete', rel });
return actions.sort((a, b) => a.rel.localeCompare(b.rel));
}
import { mkdir, copyFile, rm } from 'node:fs/promises';
import { dirname, join } from 'node:path';
export async function execute(actions, { source, target }) {
const done = [];
for (const action of actions) {
const from = join(source, action.rel);
const to = join(target, action.rel);
if (action.type === 'delete') await rm(to, { force: true });
else {
await mkdir(dirname(to), { recursive: true });
await copyFile(from, to);
}
done.push(action);
}
return done;
}
const VERB = { copy: '+', update: '~', delete: '-' };
export function formatPlan(actions) {
if (actions.length === 0) return 'already in sync';
return actions.map((a) => VERB[a.type] + ' ' + a.rel).join('\n');
}
export function summarize(actions) {
const counts = { copy: 0, update: 0, delete: 0 };
for (const a of actions) counts[a.type]++;
return counts;
}
import { scan } from './scan.js';
import { planSync } from './plan.js';
import { execute } from './execute.js';
import { formatPlan, summarize } from './report.js';
const args = parse(process.argv.slice(2));
if (!args.source || !args.target) {
console.error('usage: sync --source <dir> --target <dir> [--dry-run]');
process.exit(1);
}
if (args.target.startsWith(args.source)) {
console.error('refusing: target is inside source');
process.exit(1);
}
const plan = planSync(await scan(args.source), await scan(args.target));
console.log(formatPlan(plan));
if (!args.dryRun) {
await execute(plan, { source: args.source, target: args.target });
console.log(JSON.stringify(summarize(plan)));
}
import { planSync } from '../src/plan.js';
test('new, changed, and extra files produce one action each', () => {
const source = new Map([
['keep.txt', { size: 10, mtimeMs: 200 }],
['new.txt', { size: 5, mtimeMs: 100 }],
]);
const target = new Map([
['keep.txt', { size: 9, mtimeMs: 100 }],
['extra.txt', { size: 3, mtimeMs: 100 }],
]);
expect(planSync(source, target)).toEqual([
{ type: 'update', rel: 'keep.txt' },
{ type: 'copy', rel: 'new.txt' },
{ type: 'delete', rel: 'extra.txt' },
]);
});