A small TypeScript/NodeJS utility that watches one or more source folders and mirrors them to one or more destination folders.
It reads its jobs from a JSON config file, watches for changes, performs an initial full reconciliation, and supports .fmirror-ignore files with Git-style ignore syntax.
- Watches multiple source folders
- Syncs each source to one or more destinations
- Runs an initial full reconciliation before entering watch mode
- Supports one-shot reconciliation and analysis commands
- Deletes removed files from destinations
- Preserves empty directories
- Supports hidden files
- Supports one or more
.fmirror-ignorefiles inside each source tree - Coalesces repeated file changes on the same path before syncing
- Reserves
.fmirror/for internal operational files and always excludes it from mirroring - Stores operational data in
.fmirror/, includingsync-error.logandqueue.json - Can install the CLI globally in
~/fmirrorand bootstrap a source folder with.fmirror/config.json,.fmirror-ignore, and a macOS LaunchAgent .fmirror-ignoreuses the same pattern syntax you already know from.gitignore
Create a .fmirror-ignore file anywhere inside a watched source tree.
Examples:
node_modules/
dist/
.cache/
.env
*.log
coverage/Nested .fmirror-ignore files are supported. Each file applies to its own folder, like .gitignore.
See fmirror-ignore.template for more example patterns.
Each watched source root gets an automatically created .fmirror folder.
This folder is always ignored by the app, even without any .fmirror-ignore rule, so its contents are never mirrored to destinations.
Inside .fmirror, the app stores its own state.
The file .fmirror/sync-error.log stores operational errors raised during sync, full rescan, watcher handling, or queue recovery.
The file .fmirror/queue.json stores the in-memory file queue on disk so pending file events can be recovered and replayed after a restart.
- Node.js 22+
- Watchman available on
PATHfor watch mode - macOS, Linux, or Windows
fmirror requires Watchman.
See the official Watchman installation guide for platform-specific packages:
npm install
npm run install:localThis command:
- build fmirror
- copies the built bundle to
~/fmirror/fmirror.js - creates the launcher
~/fmirror/fmirror - copies the install templates to
~/fmirror/templates - appends
~/fmirrorto your shellPATHif needed
After that (and reloading your shell), set up a mirror:
fmirror setup -s /absolute/path/to/source -d /absolute/path/to/destinationThe setup command:
- creates
/absolute/path/to/source/.fmirror/config.json - creates
/absolute/path/to/source/.fmirror-ignoreif it does not already exist - on macOS, creates
~/Library/LaunchAgents/com.fmirror-source-folder-slug.plist - on macOS, reloads that LaunchAgent automatically
- on macOS, creates a symlink to the installed plist inside
/absolute/path/to/source/.fmirror/
The generated config always points to the source and destination you pass to setup.
If -s or -d is missing, setup stops with an error.
If the global CLI is missing, setup also stops with an error and asks you to run fmirror install first.
The install flow uses the built dist/fmirror.js only as the source bundle for the global installation, so npm run build must succeed first.
After the first install, reload your shell or open a new terminal if you want to use fmirror directly from PATH.
To run the generated config manually:
fmirror /absolute/path/to/source/.fmirror/config.jsonfmirror [config-path]: starts watch mode. This is the default command.fmirror -fast-start [config-path]: starts watch mode without the initial full reconciliation. Use this only when you explicitly want a faster startup and accept that destinations may stay stale until later events or a manual reconciliation.fmirror analyze [config-path]: scans all included source files, prints the included file count and total size, and writes.fmirror/source-analysis.loginside each source tree.fmirror reconcile [config-path]: performs a one-shot reconciliation. Missing files are copied to each destination and extra files are removed.fmirror install: installs or refreshes the global CLI in~/fmirrorand updates your shellPATHwhen needed.fmirror setup -s <source-path> -d <destination-path>: createssource/.fmirror/config.json, ensuressource/.fmirror-ignore, and on macOS installs a LaunchAgent for that source automatically.
Useful npm shortcuts:
npm run analyzenpm run reconcilenpm run install:local
By default, watch mode performs a full reconciliation before the watcher becomes active. During that pass, destinations are checked using name + size + mtime. Pass -fast-start or --fast-start only if you want to skip that initial sync.
Example fmirror.config.json:
{
"debounceMs": 400,
"fileDebounceMs": 1500,
"jobs": [
{
"name": "my-project",
"source": "/Users/your-user/dev/my-project",
"destinations": [
"/Users/your-user/Library/CloudStorage/GoogleDrive-your@email.com/My Drive/backups/my-project",
"/Volumes/NAS/backups/my-project"
],
"deleteMissing": true,
"watchHidden": true
}
]
}debounceMs: delay used when a.fmirror-ignorefile changes before running a full reconciliationfileDebounceMs: delay used to coalesce repeated file events on the same path before syncingjobs: list of sync jobs
Each job contains:
name: label shown in logssource: folder to watchdestinations: one or more target folders expressed as stringsdeleteMissing: iftrue, removes files from destinations when they are deleted from the sourcewatchHidden: iftrue, includes dotfiles during the initial sync and watcher processing
Example:
"destinations": [
"/Volumes/NAS/backups/my-project"
]Relative source and destinations paths are resolved from the directory that contains fmirror.config.json.
Source and destination roots must not overlap each other, and destinations must not overlap other destinations. This prevents mirror loops and accidental deletions.
If you want to avoid syncing node_modules into Google Drive, keep your real project outside Google Drive and use this tool to mirror only the files you want.
Example:
Source project folder:
/Users/your-user/dev/my-project
Destination inside Google Drive:
/Users/your-user/Library/CloudStorage/GoogleDrive-your@email.com/My Drive/backups/my-project
Inside your source project, create:
/Users/your-user/dev/my-project/.fmirror-ignore
With content:
node_modules/
dist/
.env
*.logThen run the watcher. The destination will stay updated without uploading ignored files.
The recommended way to mirror a Git repository is to mirror the working tree only, with a minimal files and folders from the .git directory needed for bootstrapping.
Recommended .fmirror-ignore content for Git repositories:
**/.git/**
!**/.git/
!**/.git/HEAD
!**/.git/config
!**/.git/objects
!**/.git/refs/
!**/.git/info/
!**/.git/info/sparse-checkoutThis configuration ignores all .git files and folders except the minimal set needed to restore the full .git directory in the mirrored copy.
After the first mirror, you can run the following commands inside the mirrored copy to restore a working Git repository:
git fetch --all --tags
git checkout -B main origin/main -fIf origin/main already exists locally, you can also use:
git reset --hard origin/mainReplace main with the correct default branch for your repository.
If the repository uses submodules, run:
git submodule sync --recursive
git submodule update --init --recursivefmirror setup -s <source-path> -d <destination-path> creates and installs the LaunchAgent automatically on macOS.
The installed file name is:
~/Library/LaunchAgents/com.fmirror-source-folder-slug.plist
The same file is also linked inside:
/absolute/path/to/source/.fmirror/fmirror-source-folder-slug.plist
If you want to edit the template used to generate it, start from launch-agent.template.plist.
For launchd, prefer absolute paths and do not rely on the shell PATH. The generated agent calls node directly with the globally installed ~/fmirror/fmirror.js bundle and the generated source/.fmirror/config.json.
For large trees on macOS, the generated LaunchAgent raises NumberOfFiles to 200000 so the watcher does not inherit the default low launchd open-files limit.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.fmirror.source-folder-slug</string>
<key>WorkingDirectory</key>
<string>/absolute/path/to/source</string>
<key>ProgramArguments</key>
<array>
<string>/absolute/path/to/node</string>
<string>/Users/your-user/fmirror/fmirror.js</string>
<string>/absolute/path/to/source/.fmirror/config.json</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/absolute/path/to/source/.fmirror/launchd.log</string>
<key>StandardErrorPath</key>
<string>/absolute/path/to/source/.fmirror/launchd-error.log</string>
</dict>
</plist>To run the same config interactively:
fmirror /absolute/path/to/source/.fmirror/config.jsonTo reload it after changes:
launchctl bootout "gui/$(id -u)" ~/Library/LaunchAgents/com.fmirror-source-folder-slug.plist
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.fmirror-source-folder-slug.plist- This tool mirrors files. It is not a bidirectional sync.
- Destination changes are not copied back to the source.
- If a
.fmirror-ignorefile changes, the app reloads ignore rules and performs a full reconciliation. - Watch mode also asks Watchman to suppress notifications for directories that are currently ignored, and refreshes that subscription when
.fmirror-ignorechanges. - Watch mode uses Watchman as the only watcher backend and fails fast when the
watchmancommand is not available. - Directory additions are mirrored by syncing the new subtree immediately, while directory removals and other structural changes still schedule a reconciliation so rename and move operations converge to the final tree state even when raw watcher events are noisy.
- Large bursts of file events also trigger a reconciliation fallback, which makes mass deletions and large refactors more reliable.
- When
deleteMissingistrue, startup and ignore reloads also remove stale files from destinations so they match the current mirrored source state. - Failed file events stay on the persisted queue and are retried automatically until they succeed.
- For very large trees, a full reconciliation after ignore changes may take some time.