← 中文资源

简体中文版本

构建单向目录同步工具

把源目录镜像到目标:复制新增与变更文件、删除多余文件,先预演再执行并报告每个动作。

阅读英文原文

项目目标

本项目构建一个把源目录镜像到目标的工具,先计算完整变更集,再统一执行。先规划后执行使操作可审阅、可预演,也让执行器回归为对纯计划的一次简单循环。

核心特性

  • 按相对路径与修改时间比对两棵目录树
  • 复制新增并覆盖变更文件
  • 删除目标中源不存在的文件
  • 预演模式只报告不改动

架构与分层

扫描器构建两份「路径到元数据」映射;规划器把差异化为复制、更新与删除的有序动作;执行器应用计划;报告器打印计划。规划器是纯函数,可对伪造元数据测试而无需磁盘。

实施阶段

六个阶段为:目录扫描、同步规划、执行器、报告器、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' },
  ]);
});