Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

20 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dropsync

A one-way directory synchronizer: watches source_dir for changes and mirrors them into dest_dir.

Overview

dropsync runs three concurrent workflows for the lifetime of the process:

  1. Watch (watch in main.rs) — recursively watches source_dir for filesystem events using a debounced notify watcher, and turns each event into a ChangeMessage queued onto an async channel.
  2. Track (track_source_changes in main.rs) — consumes that channel and applies each ChangeMessage to source_index, an in-memory (and on-disk) record of what's in source_dir.
  3. Synchronize (synchronize/run_periodic_sync in synchronize.rs) — periodically diffs source_index against dest_index and replays the differences onto dest_dir, then updates dest_index to 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)

Module layout

  • 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 defines ChangeEvent/ChangeMessage, the normalized representation of a filesystem change, decoupled from notify's own event types.
  • src/cli.rs — CLI argument parsing (parse_args, usage/help text) into a Config, sourced from -f/--file or (if that's not given) <dropsyncdir>/config.yaml.
  • src/config.rs — the Config struct, default_dropsyncdir, and expand_path (~/env var expansion for path-like config fields).
  • src/index.rsIndex, an on-disk-backed record of every file/directory under one root (source_dir or dest_dir), and FileMetaData, what's tracked about each entry.
  • src/synchronize.rs — diffs two Indexes and replays the difference onto a destination directory.

The index (src/index.rs)

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.

Watching (watch in src/main.rs)

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.

Synchronizing (src/synchronize.rs)

diff(source_index, dest_index) walks both indexes and produces a list of DiffActions:

  • Create — present in source_index, missing from dest_index.
  • Update — present in both, but they disagree (different type, or a file whose content_hash differs).
  • Delete — present in dest_index, missing from source_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.

CLI & configuration

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.

Logging

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.

Known limitations / not yet implemented

  • Renames are only recognized as such on the live watch path (ChangeEvent::Renamed, handled by Index::on_renamed). The periodic diff-based synchronize step has no rename detection: a file moved on the source side shows up to it as a plain Delete (old path)
    • Create (new path), which works but re-copies the full file instead of a cheap rename on the destination side.
  • content_hash in FileMetaData is still whole-file, so diff can only tell that a file changed, not where. The byte/block-level diffing that avoids re-copying unchanged regions happens later, in copy_with_delta during apply_create_or_update.
  • Sync is one-directional (source_dirdest_dir); changes made directly in dest_dir are not reflected back and will be overwritten/removed to match source_dir.

About

A utility written in rust to synchronize source and destination

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages