Build a One-Way Directory Sync Tool
A command-line tool that mirrors a source directory into a target by copying new and changed files, deleting extras in the target, and reporting every action, with a dry-run mode.
Project goal
Build a CLI that compares a source and target directory tree and makes the target match the source by copying, updating, and deleting files, with a dry-run preview and a summary of actions.
Final features
- Compare two directory trees by relative path and modification time.
- Copy new files and overwrite changed files in the target.
- Delete files present in the target but absent from the source.
- Support a dry-run mode that reports actions without changing anything.
- Never follow symbolic links out of the source tree.
Architecture
A scanner builds two path-to-metadata maps; a planner diffs the maps into a list of copy, update, and delete actions; an executor applies the plan; and a reporter prints the plan. The planner is pure so it can be tested against fabricated metadata without touching disk.
Prerequisites
- Node.js 20 LTS or later
- Understanding of file metadata and relative paths
- Familiarity with recursive directory traversal
Dependencies
- node >=20 — File system traversal, copying, and the CLI entry point
- vitest ^2.1.0 — Planner tests over fabricated trees
Directory structure
dir-sync/
├── src/
│ ├── scan.js
│ ├── plan.js
│ ├── execute.js
│ ├── report.js
│ └── cli.js
├── test/
│ ├── plan.test.js
│ └── cli.test.js
└── package.json
Phases
1. Tree scanner
Walk a directory and build a map of relative path to size and modification time without following out-of-tree links.
Files: src/scan.js
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;
}Expected behavior: Scanning a nested tree returns relative paths with sizes and times, and a symbolic link is skipped rather than followed.
2. Sync planner
Diff the source and target maps into a sorted list of copy, update, and delete actions.
Files: src/plan.js
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));
}Expected behavior: A source with one new and one changed file and a target with one extra file yields exactly one copy, one update, and one delete.
3. Executor
Apply the plan by copying files with directory creation and deleting extras, creating every parent directory first.
Files: src/execute.js
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;
}Expected behavior: After execution the target contains exactly the source files, and a deleted file is gone from the target.
4. Reporter
Print a readable summary of the plan and a final count of copied, updated, and deleted files.
Files: src/report.js
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;
}Expected behavior: An empty plan prints already in sync, and a populated plan prints one prefixed line per action with correct counts.
5. CLI surface
Parse source, target, and flags, run a dry run by default when asked, and refuse to run if the target is inside the source.
Files: src/cli.js
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)));
}Expected behavior: A dry run prints the plan and changes nothing, a real run applies it, and a target nested in the source exits with code 1.
6. Tests and hardening
Cover the planner with fabricated trees and the CLI with temporary directories, then fix failures.
Files: test/plan.test.js
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' },
]);
});Expected behavior: The planner suite passes, and an integration test confirms the target mirrors the source after a real run.
Testing
Run npx vitest. Planner tests construct in-memory maps for cases like a new file, a changed file, and an extra target file, and assert the exact action list. An integration test builds two temporary trees, runs the sync, and asserts the target now mirrors the source file for file, including a deletion.
Troubleshooting
The tool deletes everything in the target
Guard against a target inside the source and require both paths to exist; a typo that points the target at an empty directory should be caught before any deletion runs.
Unchanged files are copied every run
Compare both size and modification time and treat an equal size with an older-or-equal mtime as unchanged; copying on mtime alone makes a fresh checkout look different from its source.
Symbolic links cause infinite recursion
Skip symbolic links entirely during scanning, or track visited real paths; following links out of the tree is both slow and a path-traversal risk.
Deployment
Run from a scheduled job or a deploy script, always with a dry run first in production. Because the plan is deterministic, a dry run in CI can assert the expected change set before the real run applies it.
Known limitations
- Comparison trusts modification time and size rather than content hashes, so a regenerated file with the same size may be skipped.
- Sync is one-way only; running it in both directions would need conflict rules this project does not define.
Project overview
A sync tool is a lesson in planning before acting. Computing the full set of changes first makes the operation reviewable, lets a dry run preview it, and keeps the executor a simple loop over a pure plan.
This project mirrors one directory into another, copies and updates changed files, removes extras, and prints exactly what it intends to do before it does it.
The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.
The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.
It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.
The planner is pure and total: given two trees it returns the complete list of actions, which is what makes dry-run and execution share one code path:
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));
}
Because the plan is sorted and pure, the same function backs the dry run and the real run, so what the preview shows is what will happen.
Setup
No runtime dependencies are required; scanning, copying, and comparing all use the standard library.
Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.
Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | File system traversal, copying, and the CLI entry point |
| vitest | ^2.1.0 | Planner tests over fabricated trees |
Create the project and install:
mkdir dir-sync && cd dir-sync
npm init -y
npm install -D vitest@^2.1.0
Wire the CLI and tests in package.json:
{
"name": "dir-sync",
"type": "module",
"scripts": {
"sync": "node src/cli.js",
"test": "vitest run"
}
}
Tests build temporary trees with mkdtemp so they never depend on or pollute a real directory.
Phase 1: Tree scanner
Walk a directory and build a map of relative path to size and modification time without following out-of-tree links. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.
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;
}
Acceptance for this phase: Scanning a nested tree returns relative paths with sizes and times, and a symbolic link is skipped rather than followed.
Phase 2: Sync planner
Diff the source and target maps into a sorted list of copy, update, and delete actions. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.
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));
}
Acceptance for this phase: A source with one new and one changed file and a target with one extra file yields exactly one copy, one update, and one delete.
Phase 3: Executor
Apply the plan by copying files with directory creation and deleting extras, creating every parent directory first. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.
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;
}
Acceptance for this phase: After execution the target contains exactly the source files, and a deleted file is gone from the target.
Phase 4: Reporter
Print a readable summary of the plan and a final count of copied, updated, and deleted files. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.
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;
}
Acceptance for this phase: An empty plan prints already in sync, and a populated plan prints one prefixed line per action with correct counts.
Phase 5: CLI surface
Parse source, target, and flags, run a dry run by default when asked, and refuse to run if the target is inside the source. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.
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)));
}
Acceptance for this phase: A dry run prints the plan and changes nothing, a real run applies it, and a target nested in the source exits with code 1.
Phase 6: Tests and hardening
Cover the planner with fabricated trees and the CLI with temporary directories, then fix failures. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.
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' },
]);
});
Acceptance for this phase: The planner suite passes, and an integration test confirms the target mirrors the source after a real run.
Design trade-offs
Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.
Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.
Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.
Testing strategy
Run npx vitest. Planner tests construct in-memory maps for cases like a new file, a changed file, and an extra target file, and assert the exact action list. An integration test builds two temporary trees, runs the sync, and asserts the target now mirrors the source file for file, including a deletion.
Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.
Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.
Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.
The dry-run test asserts that running with the flag produces the same report but leaves both trees byte-identical to before, which is the property that makes the preview trustworthy.
Extending the project
Hashing instead of trusting mtime makes comparisons robust to regenerated files; a filter list excludes patterns; and a bidirectional mode adds conflict detection on top of the same plan structure. Each is a change to the planner, not the executor.
The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.