Знакомьтесь, Synx: синхронизация файлов для удалённой разработки

Удалённая разработка всегда сходится к одной и той же форме, и если вы через это прошли, вы узнаете её по одной фразе: машина, на которой вы думаете, и машина, на которой выполняется работа, — это разные машины.

Ноутбук — там, где вы думаете. Там редактор, чьи горячие клавиши живут у вас в пальцах, ваша тема, ваш fuzzy finder, ваш инструмент для диффов, ваш буфер обмена, ваш language server, настроенный ровно так, как вам нравится, ваш AI-агент, наведённый на этот проект. Это единственная машина, на которой вы действительно быстры.

Сервер — там, где выполняется работа. Шестьдесят четыре ядра вместо восьми. Память, или GPU, или то самое ядро, под которое собирается ваш драйвер, или приватная сеть, из которой достижима база. Это та машина, где release-сборка заканчивается за девяносто секунд, а не за одиннадцать минут, где тесты растекаются по всем ядрам, где контейнер совпадает с продакшеном, потому что собран из того же образа.

Ни одна не может делать работу другой, и вы этого и не хотите. Вы хотите продолжать править здесь и продолжать собирать там.

Что сводит всю удалённую разработку к одному неблагодарному вопросу: как файлы попадают с одной стороны на другую — непрерывно, в обе стороны, достаточно быстро, чтобы вы забыли, что это вообще происходит? Сделайте это правильно — и вся конструкция исчезает: вы печатаете, жмёте сохранить, переключаетесь в терминал, который случайно находится на другом континенте, и оно собирается. Сделайте это неправильно — и вы весь день работаете курьером при собственном исходном коде.


Ответы, которые не выдерживают

Просто работать на машине. Зайти по SSH, открыть vim — и проблема испаряется, копия всегда одна. Кто-то здесь по-настоящему счастлив, и я не собираюсь их переубеждать. Но цена — всё ваше локальное окружение: конфиг редактора, расширения, ваш GUI-клиент git, буфер обмена, инструмент для скриншотов, AI-ассистент, работающий на вашей же машине. И каждое нажатие клавиши теперь за сетью. На оптике это нормально. На гостиничном Wi-Fi вы чувствуете, как приземляется каждый символ.

Смонтировать удалёнку по сети. SSHFS превращает удалённый каталог в локальный путь — звучит ровно как то, что нужно, пока вы не заметите, что теперь идёт по проводу: каждый stat(). Файловый наблюдатель редактора, индексатор вашего language server, ваш git status — каждый обходит тысячи путей, и каждый обход — это round trip. Отзывы стабильны: производительность обваливается в тот момент, когда что-нибудь трогает файлы массово — git checkout или npm install. На macOS хуже, потому что с FUSE там уже много лет всё неловко. Абстракция чудесна ровно до того момента, когда задержка делает её непригодной.

rsync в цикле. Честный костыль, который каждый хоть раз да написал: fswatch | rsync или while true; sleep 1. Для односторонних снимков он корректен. В живом режиме он деградирует: либо срабатывает на каждое нажатие клавиши и забивает канал, либо батчится и отстаёт от ваших сохранений. Он односторонний по устройству, так что если вы хотите, чтобы сгенерированные файлы или результаты сборки возвращались, вы запускаете второй — и теперь два процесса без общего представления об истине гоняются по одному дереву. Люди правильно нервничают: rsync с неправильным флагом в неправильную сторону удаляет настоящую работу.

Отдать это IDE. VS Code Remote-SSH и JetBrains Gateway решают задачу как надо — для редактора: серверный компонент работает на удалёнке, интерфейс остаётся локальным. Если весь ваш мир внутри одной IDE, это хороший ответ. Но файлы по-прежнему существуют только на той стороне, поэтому всё, что снаружи редактора, слепо: ваш терминал, ваш отдельный клиент git, ваши локальные скрипты, кодовый агент, работающий у вас на ноутбуке. Вдобавок вы подписались на удалённый протокол одного вендора, переустановили расширения на той стороне и согласились, что удалёнке нужно доверять достаточно, чтобы их там запускать.

Mutagen. Вот кто взял правильную модель: настоящая копия с обеих сторон, дельты синхронизируются непрерывно, правишь локально, выполняешь удалённо. Это хороший софт, и репутацию он заслужил. Но трение, о котором люди рассказывают, стабильно, и происходит оно из формы, а не из качества: это демон и менеджер сессий, а не команда.

У этой формы есть вес. На вашей машине живёт постоянный демон плюс агент на каждый эндпоинт — работающие независимо от того, работаете ли вы, — а в трекере тянется длинная череда сообщений о том, как он жжёт процессор в периоды, когда файлы вообще не менялись, на больших деревьях и под WSL. Есть вес и на диске: в Docker-сценарии, под который он сделан, ваш проект существует дважды, и документация DDEV прямо велит следить за размером дублирующего тома — больше 5 ГБ это предупреждение, больше 10 ГБ — критично.

Дальше — операционная поверхность. Вы создаёте сессии, перечисляете их, учите mutagen sync terminate — и узнаёте, что он может зависнуть на стадии staging, а народное средство в тредах трекера — остановить демон, удалить lock-файл и запустить снова. Безопасный по умолчанию режим конфликтов останавливается и ждёт человека, и это правильное решение, но означает, что сессия может тихо перестать сходиться, пока вы считаете, что всё в порядке; давно висит запрос просто на надёжный способ понять, сломана сессия или нет. На больших деревьях начальное сканирование съедает минуты до того, как что-либо станет живым. Самый ясный признак того, сколько поверхности накопилось: DDEV, который сильно опирается на Mutagen, в итоге выпустил целую диагностическую подкомандуddev utility mutagen-diagnose — чтобы объяснять, почему ваша синхронизация несчастна.

Потом сдвинулась земля. В 2023 году Docker купил Mutagen. Mutagen Compose объявлен устаревшим напрямую — документация говорит это прямым текстом: «Mutagen Compose is now deprecated with the release of v0.18.0». Сам Mutagen не выпускал релиза с тегом с v0.18.1 в феврале 2025 года. В репозиторий по-прежнему изредка падают коммиты с обновлением зависимостей, так что он не мёртв. Но и не движется.

Ничего скандального в этом нет, и я хочу быть честным: этот вес — просто цена универсального движка синхронизации, которому приходится обслуживать локальные, SSH- и Docker-эндпоинты, на четырёх операционных системах, под оркестратором. Просто мне ничего из этого не было нужно. Мне нужны были каталог и путь.

Тогда я пошёл искать что-то, что было бы только этим, — и не нашёл. Есть Unison, чей CLI приходится переучивать каждый раз. Есть Syncthing, построенный вокруг устройств и общих папок, а не вокруг дерева исходников одного разработчика. Дальше — длинный хвост недоделанных проектов на GitHub. Ничего, что было бы просто: этот каталог, тот путь, поверх SSH, прямо сейчас, с уважением к моему .gitignore — и уйди с дороги.

Так что я написал сам.


Чего я от него хотел

Список требований был коротким, и каждая строка в нём выросла из того, что меня раздражало:

  • Одна команда, а не сессия. Никакого демона, за которым надо присматривать, никаких плясок со status / terminate / lock-файлами. Он работает, пока вы этого хотите, и ctrl+c означает «остановлено».
  • Ничего резидентного. Когда я не синхронизирую, процесса нет. Ничто не греет кэш, ничто не сканирует дерево, не о чем гадать в мониторе процессов в два часа ночи.
  • Никакого файла конфигурации. Два пути — это и есть аргументы. Нечего коммитить в репозиторий, нечего запоминать, нечему устаревать.
  • .gitignore — это закон. Ваш репозиторий уже объявил, что не является исходником. target/, node_modules/, .venv/ — каталоги, от которых любой наивный инструмент синхронизации начинает ползти, — вообще не должны попадать в манифест, ни в одном направлении.
  • Никогда не терять данные. Инструмент синхронизации, который удаляет то, что вы хотели сохранить, хуже, чем отсутствие инструмента. Всё остальное обсуждаемо; это — нет.
  • Дёшево на уже синхронизированном дереве. Второй запуск должен стоить обхода каталогов, а не полного перехеширования.
  • Только SSH. Ваши ключи, ваш ~/.ssh/config, ваш ProxyJump. Никакой новой поверхности аутентификации, никакого нового порта.

Перечитайте список и заметьте, чего в нём нет: контейнеров, оркестрации, проброса сети, Windows, GUI, схемы конфигурации, системы плагинов. Каждое из этого — разумное желание, и каждое из этого — причина, по которой инструмент синхронизации отращивает демона. Оставить их за бортом — не скромность, это и есть весь дизайн. Инструмент остаётся маленьким, потому что маленькой была задача, которую он согласился решать.

Это и есть Synx: один открытый бинарник на Rust — сегодня версия 0.1.2, — который зеркалит локальный каталог в удалённый путь поверх SSH и держит их в шаге друг с другом, пока вы работаете. Один процесс, живущий только пока вы работаете, делающий одну работу. Это ранняя стадия. И он уже делает ту работу, которая нужна мне каждый день.


Что такое Synx

Вы запускаете его на локальной машине и указываете на удалённую цель:

synx ./src dev@beefy:/srv/app/src

Это весь интерфейс. Один и тот же бинарник работает на обоих концах — локально это клиент, на удалёнке он запускается в скрытом режиме --agent, который клиент сам поднимает поверх SSH. Никакого сервиса для установки, ничего регистрировать, никакого YAML.

Он делает начальную сверочную синхронизацию, а затем переходит в живой режим наблюдения, где каждое изменение на любой из сторон долетает до другой за пару сотен миллисекунд:

synx  /Users/dk/proj  ◀─▶  dev@beefy:/srv/proj
✓ connected
• manifests:  local 1243  •  remote 1180 (47 ignored)
• plan: push 78 files (4.2 MiB) 6 dirs 0 links  •  pull 14 entries
✓ initial sync: 4.2 MiB sent, 312 KiB received in 1.4s
• watching for changes — ctrl+c to stop
  → src/main.rs  3.1 KiB
  ← README.md   824 B

Работает на macOS и Linux. Транспорт — чистый SSH: ваши ключи, ваш агент, ваш ~/.ssh/config, ваш ProxyJump, всё это. Synx не изобретает новую схему аутентификации; он заимствует ту, которой вы уже доверяете.


Режимы синхронизации и разрешение конфликтов

Направление — это один флаг. По умолчанию двустороннее.

Режим Направление Правило конфликта Начальная синхронизация
push локально → удалённо всегда побеждает локальный отправляет только-локальные или отличающиеся файлы
pull удалённо → локально всегда побеждает удалённый забирает только-удалённые или отличающиеся файлы
both (по умолчанию) двустороннее побеждает более свежий mtime сливает, без удалений
# двусторонняя (по умолчанию)
synx ./src dev@host:/srv/app/src

# односторонний push
synx ./build host:/var/www --mode push

# односторонний pull
synx ./nginx host:/etc/nginx --mode pull

Буду честен насчёт модели конфликтов, потому что это важно: в режиме both, когда один и тот же файл изменился с обеих сторон, побеждает более свежее время модификации. Вот и всё. Никакого трёхстороннего слияния с учётом общего предка, никаких маркеров конфликта. Это надёжно ровно до тех пор, пока часы обеих машин в порядке — так что если вы видите, как файлы «пинг-понгуют», первое, что надо проверить, — это рассинхронизацию часов (NTP это лечит), а если часам доверять нельзя — выберите явное направление push или pull.

Другое осознанное решение: начальная синхронизация никогда ничего не удаляет. Если вы направите Synx на устаревший удалённый путь, он не сотрёт данные — он сольёт. Удаления распространяются только когда Synx живой и наблюдает, и только когда у него есть baseline — записанный снимок того, о чём обе стороны договорились в прошлый раз. Baseline лежит в вашем каталоге кэша и позволяет Synx отличить «этот файл удалили» от «этого файла на другой стороне просто никогда не было». Свежий запуск без baseline сохраняет всё; удаления начинают распространяться со второй синхронизации. Эта асимметрия сделана намеренно. Потерять данные из-за чересчур ретивого инструмента синхронизации — единственный режим отказа, который я отказываюсь выпускать.


Правила игнорирования — это закон

Synx загружает каждый .gitignore под корнем синхронизации — вложенные на любой глубине — плюс необязательный .synxignore с идентичным синтаксисом. Всё, что совпадает, не синхронизируется никогда, ни в одном направлении. Это не фильтр «по возможности», применяемый у источника; он принудительно выполняется в трёх точках:

  1. Начальный обход — игнорируемые файлы вообще не попадают в манифест.
  2. Удалённый манифест — файлы, о которых сообщает удалёнка и которые совпадают с вашими локальными правилами игнорирования, отфильтровываются до построения плана различий, так что target/ или node_modules/, случайно оказавшиеся на удалёнке, никогда не скачиваются.
  3. Живые события — и входящие применения, и исходящие уведомления пропускают игнорируемые пути.

Что удивляет людей: скрытые файлы (dotfiles) — не особые. .env, .vscode/, .git/ — всё синхронизируется как и любое другое, если только вы их не исключите. Если вы не хотите зеркалить каталог .git/, скажите об этом:

echo '/.git' >> .synxignore

Кстати о .git/ — Synx с ним осторожен. Git трактует этот каталог как транзакционное состояние, и атомарное переименование недописанных объектов или lock-файлов на пир посреди коммита портит репозиторий. Поэтому Synx следит за маркерами идущей операции git (index.lock и компания) и приостанавливает синхронизацию путей .git/, пока операция git выполняется, проигрывая отложенные изменения, когда git закончит. Ваше рабочее дерево синхронизируется всё это время; ждёт только .git/. Если вы собираетесь зеркалить живой репозиторий — это вам нужно.

Есть защита и на встречном направлении. Если ваш локальный .git/ остался от прерванной операции, а на удалёнке его нет, Synx зеркалит это удаление только когда baseline подтверждает, что .git/ входил в последнее состояние, о котором договорились обе стороны. Без этого доказательства ваш .git/ — просто данные, которые ещё не синхронизировались: Synx сохранит их и отправит на удалёнку.


Как это работает изнутри

┌─ local (client) ──────────────┐         ┌─ remote (agent) ─────────────┐
│  watcher (notify)             │   ssh   │  watcher (notify)            │
│  parallel walker (blake3)     │ ◀─────▶ │  parallel walker (blake3)    │
│  persistent hash cache        │  stdio  │  persistent hash cache       │
│  diff plan + executor         │ postcard│  message dispatcher          │
└───────────────────────────────┘  + zstd └──────────────────────────────┘

Несколько узлов стоит пояснить, потому что именно оттуда берётся скорость.

Параллельное хеширование с постоянным кэшем. Обе стороны обходят своё дерево параллельно (через параллельный обходчик из крейта ignore) и хешируют каждый файл с помощью blake3. Вложенные файлы игнорирования обнаруживаются в этом же обходе, а не отдельным предварительным проходом, так что старт стоит одного обхода дерева, а не двух — и наблюдатель поднимается до начала обхода, поэтому всё, что вы сохранили, пока он ещё сканирует, встаёт в очередь, а не теряется. Хеши идут в постоянный кэш в каталоге кэша вашей платформы (~/.cache/synx/ на Linux, ~/Library/Caches/synx/ на macOS), индексируемый по (путь, размер, mtime). Повторный запуск Synx на неизменном репозитории полностью пропускает перехеширование — вторая синхронизация дерева на сто тысяч файлов ограничена только обходом каталогов, а это около секунды. Чтение кэша неизменяемое, а результаты обходчика батчатся по воркерам, так что в горячем цикле нет глобальной блокировки, а не изменившийся кэш вообще не перезаписывается на диск.

Дельта-передача для крупных файлов. Маленькие или полностью изменившиеся файлы идут по проводу целиком. Но для файлов от 256 KiB до 256 MiB, у которых на другой стороне уже есть другая версия, Synx делает дельту в стиле rsync. Он использует fast_rsync, SIMD-ускоренный порт librsync: принимающая сторона вычисляет сигнатуру уже имеющегося у неё файла, отправляющая делает diff против этой сигнатуры, и по проводу едут только изменившиеся блоки. Поскольку внутреннее блочное хеширование librsync старше современных криптографических хешей, Synx проверяет каждый результат применённой дельты против свежего хеша blake3 перед фиксацией. Провод никогда не получает шанса вам соврать.

Сжатие и нарезка на чанки. Сообщения — это postcard с префиксом длины (компактный, активно поддерживаемый бинарный формат) и сжимаются zstd, когда сжатие действительно экономит место. Уже сжатые форматы — архивы, медиа, пакеты — обходят zstd по расширению, чтобы не жечь процессор, доказывая, что они не ужмутся. Файлы больше 16 MiB передаются чанками по 4 MiB во временный файл, а затем атомарно переименовываются на место с сохранением исходных прав и mtime — так что сбой посреди передачи никогда не оставит недописанный файл по реальному пути.

Подавление эха на основе состояния. Это тонкая часть любой двусторонней синхронизации. Когда Synx применяет входящее изменение, ваш локальный наблюдатель вот-вот сработает по тому же пути — и если наивно отправить его обратно, получится бесконечное эхо. Ленивое решение — временное окно («игнорировать события следующие N мс»), которое заодно отбрасывает законные правки, сделанные пользователем в это окно. Synx вместо этого записывает итоговое состояние на диске (mtime или «удалён») и, когда наблюдатель срабатывает, сравнивает текущий файл с тем, что записал. Только совпадение трактуется как эхо и отбрасывается. Если вы тем временем отредактировали файл, событие проходит как обычно. Нет слепого окна, в котором ваши нажатия проглатываются.

Переиспользование соединения. SSH работает с ControlMaster auto и коротким временем удержания, так что несколько вызовов Synx к одному хосту делят одно TCP-соединение вместо повторного согласования.


Попробуйте за пять минут

Synx нужен на обоих концах — на вашей машине и на удалёнке. Установщик в одну строку — самый быстрый путь на каждом:

# Linux и macOS, x86_64 + ARM64
curl -fsSL https://raw.githubusercontent.com/Muvon/synx/master/install.sh | sh

# или с crates.io
cargo install synx

Если у вас уже есть локальная release-сборка, просто скопируйте её:

scp target/release/synx user@host:~/.local/bin/synx
ssh user@host 'chmod +x ~/.local/bin/synx'

Затем запустите сеанс:

# двусторонняя синхронизация, в живом режиме
synx ./project dev@host:/srv/project

# посмотреть план, ничего не меняя
synx ./project dev@host:/srv/project --dry-run

# только начальная синхронизация, затем выход
synx ./project dev@host:/srv/project --once

Несколько флагов, к которым вы потянетесь:

# нестандартный SSH-порт (или любые дополнительные аргументы ssh)
synx ./code host:/work --ssh-opts "-p 2222 -i ~/.ssh/devkey"

# synx нет в PATH на удалёнке
synx ./code host:/work --remote-synx ~/.local/bin/synx

# без сжатия (быстрее в быстрой LAN с несжимаемыми блобами)
synx ./code host:/work --no-compress

# больше логов
synx ./code host:/work -v     # debug
synx ./code host:/work -vv    # trace

Если удалёнка не находит бинарник, вы получите synx: command not found от login-шелла — это случай --remote-synx, а не баг. И оба конца должны работать на одной версии протокола; 0.1.x говорит на протоколе v1 и несовместима на уровне провода с тем, что выйдет дальше, так что обновляйте обе стороны вместе.


Чего он пока не делает

Synx — это 0.1.2. Я лучше расскажу вам про острые края, чем дам вам наткнуться на них самим.

  • Нет режима демона. Работает на переднем плане. Уведите в фон через & или живите в tmux/screen. Нормальные synx status / synx stop — в списке планов.
  • Нет трёхстороннего слияния содержимого. Удаления опираются на baseline, а вот конфликты содержимого файлов решаются по более свежему mtime, без учёта общего предка. Нужны вменяемые часы.
  • Кэш хешей ключуется по (размер, mtime). Файл, переписанный на месте с тем же размером и той же меткой времени, не будет перехеширован. Это та же эвристика, что и у git, и на практике она работает.
  • Перезапуск ради новых правил игнорирования. Поменяете .gitignore посреди сеанса — Synx не заметит, пока вы его не перезапустите.
  • Только macOS и Linux. Windows не поддерживается.

Ни одно из этого не является серьёзным блокером для основного сценария — правь здесь, запускай там — а именно для него я его и строил, и именно это он делает хорошо уже сегодня.


Открытый код

Synx лежит на GitHub под Apache-2.0. Это Rust, потому что синхронизации файлов нужна корректность на этапе компиляции, предсказуемая задержка и никаких пауз сборщика мусора посреди передачи — и потому что единственный статический бинарник, который можно scp на сервер, — правильная форма для такого инструмента.

Его делает та же команда, что стоит за остальными нашими инструментами для разработчиков; если вам любопытно, почему мы продолжаем выпускать маленькие острые утилиты с открытым кодом вместо одной большой платформы, это долгий разговор. Synx — последняя из них и та, которой лично я пользуюсь больше всего.

Если она вам полезна — код вот он. Если она сломается — трекер задач тоже здесь; отчёты об ошибках из реальных сетапов удалённой разработки — самый быстрый способ сделать 0.2 лучше, чем 0.1.

— Vladimir

Synx — открытый код под Apache-2.0. Возьмите его, почитайте исходники или заведите задачу на github.com/Muvon/synx.