A one-way directory synchronizer: watches source_dir for changes and mirrors them into
dest_dir.
dropsync runs three concurrent workflows for the lifetime of the process:
- Watch (
watchinmain.rs) — recursively watchessource_dirfor filesystem events using a debouncednotifywatcher, and turns each event into aChangeMessagequeued onto an async channel. - Track (
track_source_changesinmain.rs) — consumes that channel and applies eachChangeMessagetosource_index, an in-memory (and on-disk) record of what's insource_dir. - Synchronize (
synchronize/run_periodic_syncinsynchronize.rs) — periodically diffssource_indexagainstdest_indexand replays the differences ontodest_dir, then updatesdest_indexto match.
These three run as separate tokio tasks so that watching, indexing, and syncing don't
block one another; source_index is the only state shared between them (via
Arc<tokio::sync::Mutex<Index>>), since track_source_changes writes to it while
synchronize reads it.
source_dir dest_dir
│ ▲
│ inotify/fsevents events │ copy / mkdir / remove
▼ │
watch() ──ChangeMessage──▶ track_source_changes() │
│ │
▼ │
source_index ──diff──▶ synchronize() ──▶ dest_index
(Arc<Mutex<Index>>) (every sync_delay_secs)
src/main.rs— startup sequencing (validate directories, build/load indexes), the filesystem watcher (watch), and the live event-to-index pipeline (track_source_changes). Also definesChangeEvent/ChangeMessage, the normalized representation of a filesystem change, decoupled fromnotify's own event types.src/cli.rs— CLI argument parsing (parse_args, usage/help text) into aConfig, sourced from-f/--fileor (if that's not given)<dropsyncdir>/config.yaml.src/config.rs— theConfigstruct,default_dropsyncdir, andexpand_path(~/env var expansion for path-like config fields).src/index.rs—Index, an on-disk-backed record of every file/directory under one root (source_dirordest_dir), andFileMetaData, what's tracked about each entry.src/synchronize.rs— diffs twoIndexes and replays the difference onto a destination directory.
Each of source_dir and dest_dir gets its own Index, keyed by path relative to that
root (so the same relative key means "the same logical file" in both indexes, even though
their absolute paths differ). Internally an Index wraps a radix_trie::Trie<PathBuf, FileMetaData> — a trie over path components rather than a flat hash table.
FileMetaData per entry:
| field | meaning |
|---|---|
is_directory |
file vs. directory |
content_hash |
BLAKE3 hex digest of file contents (streamed, not loaded into memory); None for directories |
mtime |
filesystem last-modified time |
last_synced_at |
Some(now) once this entry has actually been reconciled by synchronize; None otherwise (including right after a build/load, or after a live source-side edit that hasn't been synced yet) |
Persistence. Every mutating method on Index (on_created, on_modified,
on_deleted, on_renamed, mark_synced) commits the whole index to disk immediately
after applying its change, serialized as YAML. Index::load_or_build checks for an
existing index file first and loads it instead of re-walking the directory (via
walkdir); if the file is missing or fails to parse, it falls back to a fresh walk and
saves the result. Index files live in the "dropsync directory" (source.index /
dest.index), which defaults to ~/.dropsync (see "CLI & configuration" below).
Live updates. track_source_changes calls on_created/on_modified/on_deleted/
on_renamed on source_index as filesystem events arrive — dest_index is never touched
this way; it's only ever updated by synchronize after a change has actually been applied
to dest_dir.
Uses notify-debouncer-full (not the raw notify watcher) so that a rename's From/To
events are correlated over a real time window rather than assumed to arrive back-to-back.
This also means a rename that never gets a matching pair within that window — e.g. moving
a file to the trash, which relocates it outside the watched tree — is correctly flushed as
a standalone event instead of silently vanishing.
Raw debounced events are normalized into four ChangeEvent kinds (Created, Modified,
Deleted, Renamed) by queue_batch/queue_event, which also collapses a
notify-specific quirk: many editors and tools "safely save" by writing a temp file and
renaming it over the original. Because the original is typically read/stat-ed right before
that rename, the debouncer treats it as overwriting a tracked file and synthesizes a
Remove immediately followed by a Create for the same path in one batch —
queue_batch recognizes that same-path pattern and reports it as a single Modified
instead of a spurious delete+create.
diff(source_index, dest_index) walks both indexes and produces a list of DiffActions:
Create— present insource_index, missing fromdest_index.Update— present in both, but they disagree (different type, or a file whosecontent_hashdiffers).Delete— present indest_index, missing fromsource_index.
The list is sorted ascending by relative path so that creates/updates apply
parent-directory-first. synchronize then applies each action to dest_dir (copying
files, creating directories, removing deleted entries, clearing out a wrong-typed
destination entry before replacing it) and calls dest_index.mark_synced/on_deleted to
keep the destination index in step with what's actually on disk.
Delta copy. A brand-new file (Create, or Update where the destination doesn't yet
have a same-named file) is just fs::copy'd in full — there's nothing on the destination
side to reuse. For an Update where the destination file already exists, copy_with_delta
avoids rewriting the whole file: it splits both files into CHUNK_SIZE-byte chunks
(chunk), hashes each with BLAKE3, and indexes the destination's chunks by hash
(index_by_hash). delta_reconstruct then walks the source file chunk-by-chunk, reusing a
destination chunk wherever its hash matches instead of copying the source's bytes; on a
miss, it slides the chunk boundary forward one byte at a time (re-hashing each shifted
window) until a match resumes or the file ends, copying the mismatched source bytes it
skipped over in between. The result is written to a temp file next to the destination and
renamed into place, so a crash or error mid-write can't leave a partial file behind.
run_periodic_sync runs synchronize immediately at startup, then again every
sync_delay_secs (default 5) for as long as the process runs.
dropsync [--dropsyncdir <dir>] [--dry-run] (-f | --file) <config.yaml>
dropsync [--dropsyncdir <dir>] [--dry-run]
dropsync (-v | --version)
dropsync (-h | --help)
source_dir/dest_dir are only ever read from a YAML config file — there's no positional
form. Pass one explicitly with -f/--file, or omit it and rely on the config described
below.
| Flag | Meaning |
|---|---|
-f, --file <config.yaml> |
Load source_dir/dest_dir (and optionally dropsyncdir/sync_delay_secs) from a YAML file |
--dropsyncdir <dir> |
Where source.index/dest.index live. Default ~/.dropsync. Can also be set via a dropsyncdir key in a --file config; the CLI flag wins if both are given |
--dry-run |
Delete any existing index files and rebuild them from scratch before starting |
-v, --version |
Print the version |
-h, --help |
Print usage |
Default config. Every run ensures <dropsyncdir>/config.yaml exists — dropsyncdir
being wherever this run's --dropsyncdir points, or ~/.dropsync by default — seeding it
from this project's own bundled config.yaml (embedded into the binary at compile time via
include_str!) if nothing is there yet (ensure_default_config in src/config.rs). It's
never overwritten once present, so hand edits persist across runs. A run given -f/--file
uses that config instead and never writes it back — -f only ever affects that one run. A
run given neither loads <dropsyncdir>/config.yaml as its Config.
source_dir, dest_dir, and dropsyncdir (whether given via --dropsyncdir or in a
--file/default config) all go through expand_path (src/config.rs), which expands a
leading ~ and any $VAR/${VAR} environment variable references — e.g. ~/Documents or
$HOME/Documents. A reference that can't be expanded (e.g. an unset variable) is left as
the literal string, so it fails later as an ordinary "no such file or directory" instead of
aborting config parsing.
Config-file-only key: sync_delay_secs (default 5) — seconds between destination sync
passes.
All output goes through the log crate (log::info!/log::error!), initialized via
env_logger targeting stdout (both info and error records — env_logger otherwise
defaults errors to stderr). Default level is Info, overridable at runtime via RUST_LOG.
- Renames are only recognized as such on the live watch path (
ChangeEvent::Renamed, handled byIndex::on_renamed). The periodic diff-basedsynchronizestep has no rename detection: a file moved on the source side shows up to it as a plainDelete(old path)Create(new path), which works but re-copies the full file instead of a cheap rename on the destination side.
content_hashinFileMetaDatais still whole-file, sodiffcan only tell that a file changed, not where. The byte/block-level diffing that avoids re-copying unchanged regions happens later, incopy_with_deltaduringapply_create_or_update.- Sync is one-directional (
source_dir→dest_dir); changes made directly indest_dirare not reflected back and will be overwritten/removed to matchsource_dir.