No description
  • C++ 79.2%
  • Shell 11.1%
  • Roff 8.3%
  • Makefile 1.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-21 15:03:54 +08:00
service first commit 2026-09-21 07:46:45 +08:00
src updated 2026-09-21 15:03:54 +08:00
tests updated 2026-09-21 15:03:54 +08:00
.gitignore first commit 2026-09-21 07:46:45 +08:00
.tideignore.example first commit 2026-09-21 07:46:45 +08:00
deploy.sh updated 2026-09-21 15:03:54 +08:00
LICENSE first commit 2026-09-21 07:46:45 +08:00
Makefile updated 2026-09-21 15:03:54 +08:00
README.md updated 2026-09-21 15:03:54 +08:00
tide.1 first commit 2026-09-21 07:46:45 +08:00

tide

A small two-way file synchroniser in C++17, in the spirit of MegaSync or Dropbox: point two or more machines at the same directory and they converge through a server you control. No libraries, no database, no daemon to babysit — a single ~1800-line binary that is both the client and the server.

machine A  ──┐                      ┌── machine B
             ├──  your server  ─────┤
machine C  ──┘   ~/tide/<share>   └── machine D

Changes, deletions and conflicts propagate in both directions, files are compared by SHA-256 rather than timestamps, and no version is ever silently discarded.

Build

make                 # -> ./tide
make install         # -> ~/.local/bin + man page   (sudo make install PREFIX=/usr/local for system-wide)
make test            # end-to-end suite: two machines, conflicts, deletes, auth

Requires only a C++17 compiler and its standard library. Tested with GCC on glibc and Clang/libc++ on musl. make static produces a single file you can copy to any x86-64 Linux box.

Quick start

1. Put tide on the server. It needs no root, no open port and no service:

./deploy.sh myserver          # [user@]host, or a Host alias from ~/.ssh/config
./deploy.sh myserver 2222     # ...with a non-default SSH port

This builds tide on the server (or copies a static binary if it has no compiler), installs it to ~/bin/tide and creates ~/tide.

2. Sync, from each machine:

# one pass
tide sync ~/Documents --ssh myserver --share docs --remote-bin '~/bin/tide'

# stay running, re-checking every 5 seconds
tide sync ~/Documents --ssh myserver --share docs --remote-bin '~/bin/tide' --watch

Run the same command on every machine with the same --share. Each share is a separate directory on the server, created on first use. Keep --remote-bin in single quotes so the tilde is expanded by the server's shell, not yours.

Useful flags: --interval N (seconds between passes), -n (dry run: report what would move, touch nothing), -v, --remote-root DIR (server-side share root, default ~/tide), --ssh-port N.

See man tide for the full reference.

How it decides what to do

Every pass compares three things: the local directory, the server's directory, and a record of how they looked when the last pass finished (kept in .tide/state-<share> inside the synced directory). That third input is what lets it tell "you created this file" apart from "someone else deleted it", so creates, edits and deletes all travel in both directions instead of deleted files coming back from the dead.

situation result
changed on one side copied to the other
deleted on one side deleted on the other
deleted on one side, edited on the other the edit wins — a delete never beats real work
edited on both sides conflict: both versions kept (see below)
file replaced by a directory the change propagates; the displaced version is kept

On a conflict your copy is renamed to name.conflict-<host>-<timestamp>.ext and uploaded as a new file, while the server's copy keeps the original name. Both versions end up on every machine, and you resolve it by hand. Nothing is ever overwritten without a copy surviving.

Files are compared by content hash, so touching a file transfers nothing. Downloads land in a temp file inside .tide/tmp and are renamed into place, so an interrupted transfer cannot leave a half-written file where your real one was. Permission bits and modification times are preserved.

Not synced: symlinks, and anything matching a glob in .tideignore at the root of the synced directory. See .tideignore.example.

Performance

Hashes are cached by (size, mtime, nanoseconds), so an unchanged tree costs a stat per file and no reads. On a 2000-file, 24 MB tree over a local transport:

time
first full sync 1.8 s
idle pass, nothing to do 0.03 s
propagate one changed file 0.03 s

Over SSH each pass additionally pays the connection handshake, so --watch — which opens one connection and keeps it for every pass — is much cheaper than repeated one-shot runs. If you do run one-shot syncs often, let SSH multiplex them:

Host myserver
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m

Running it at login

Run it as your normal user, never as root. tide needs no privileges: it reads and writes files in your own directory and authenticates with your own ~/.ssh key. As root it would create root-owned files inside your home and look for the key in /root/.ssh, where it is not.

The only environment it needs is HOME, so ssh can find your config and key. Forgetting that is the usual reason a service crash-loops.

Without a supervisor, a user crontab is enough — see service/crontab-line.txt:

crontab -e        # as your normal user, not root

With runit, service/sv/tide-docs/ is a ready service that drops privileges with chpst and sets HOME:

sudo cp -r service/sv/tide-docs /etc/sv/
sudo ln -s /etc/sv/tide-docs /etc/service/
sv status tide-docs

Edit the settings at the top of the run script, and copy the directory once per share. Note that --watch already retries on its own when the network drops, so a supervisor only buys start-at-boot and restart-after-crash.

Transports

SSH (recommended). --ssh HOST runs ssh HOST tide serve --stdio and talks over the pipes, the way git and rsync do. Encrypted and authenticated by SSH itself: no listening port, no service to supervise, and no credentials of tide's own. Anyone who could abuse it could already read those files over sftp, so tide adds no attack surface.

Plain TCP. For a trusted LAN, or inside a VPN or an ssh -L tunnel:

tide keygen > ~/.tide.key      # same file on server and clients, chmod 600
tide serve --root ~/tide --key-file ~/.tide.key            # on the server
tide sync ~/Documents --host 192.168.1.10 --share docs --key-file ~/.tide.key

Clients prove they hold the key with an HMAC-SHA256 challenge-response, so it never crosses the wire, and a server started without --key-file warns loudly. The traffic itself is not encrypted. Do not expose this port to the internet; use the SSH transport there.

Safety properties

  • Share names and every path arriving from the network are validated: no .., no absolute paths, no writing outside the share root, no touching .tide.
  • A share is locked with flock for the duration of a client's pass, so two machines syncing at the same moment queue instead of interleaving.
  • The server hashes files as it writes them and caches hashes by (size, mtime, nanoseconds), so a same-second rewrite of a same-size file is still detected — the trap that catches most naive syncers.
  • Writes are atomic (temp file, fsync, rename) and deletions are ordered bottom-up, so an interrupted pass leaves a consistent tree.

Layout

path what
src/common.cpp SHA-256/HMAC, framing, directory scanning, state files
src/server.cpp share hosting, locking, TCP and stdio modes
src/client.cpp transports, three-way reconciliation, conflict handling
src/main.cpp CLI
tide.1 manual page
tests/run.sh end-to-end suite (29 checks)
tests/hashtest.cpp SHA-256/HMAC vectors
deploy.sh install onto a server
service/ runit service and crontab templates

License

MIT. See LICENSE.

Limitations

Whole files are transferred, not block deltas — changing one byte of a large file resends it. Changes are found by polling on an interval, not inotify. Renames are seen as a delete plus a create. No symlink support. Linux and macOS; no Windows.