Presentamos Synx: sincronización de archivos para desarrollo remoto
El desarrollo remoto siempre converge en la misma forma, y si lo has vivido la reconocerás en una frase: la máquina en la que piensas y la máquina en la que se ejecuta el trabajo no son la misma máquina.
El portátil es donde piensas. Tiene el editor cuyos atajos viven en tus dedos, tu tema, tu fuzzy finder, tu herramienta de diffs, tu portapapeles, tu language server afinado exactamente como te gusta, el agente de IA que has apuntado a este proyecto. Es la única máquina en la que de verdad eres rápido.
El servidor es donde ocurre el trabajo. Sesenta y cuatro núcleos en lugar de ocho. La RAM, o la GPU, o el único kernel contra el que compila tu driver, o la red privada desde la que se alcanza la base de datos. Es la máquina donde una compilación de release termina en noventa segundos en vez de once minutos, donde la batería de tests se reparte por todos los núcleos, donde el contenedor coincide con producción porque está construido desde la misma imagen.
Ninguna puede hacer el trabajo de la otra, y tampoco quieres que lo hagan. Quieres seguir editando aquí y seguir compilando allá.
Lo que reduce todo el desarrollo remoto a una pregunta nada glamurosa: ¿cómo llegan los archivos de un lado al otro, de forma continua, en ambas direcciones, lo bastante rápido como para que olvides que está pasando? Acierta con eso y el montaje desaparece: escribes, guardas, saltas a una terminal que da la casualidad de estar en otro continente, y compila. Fállalo y te pasas el día haciendo de mensajero de tu propio código.
Las respuestas que no aguantan
Trabajar directamente en la máquina. Entras por SSH, abres vim, y el problema se evapora: solo hay una copia. Hay gente genuinamente feliz aquí y no voy a intentar convencerla de lo contrario. Pero el precio es todo tu entorno local: la configuración de tu editor, tus extensiones, tu cliente gráfico de git, tu portapapeles, tu herramienta de capturas, el asistente de IA que corre en tu propia máquina. Y ahora cada pulsación está detrás de la red. Con fibra, bien. Con el wifi de un hotel, notas aterrizar cada carácter.
Montar el remoto por red. SSHFS convierte el directorio remoto en una ruta local, que suena exactamente a lo que quieres hasta que ves lo que va por el cable: cada stat(). El observador de archivos de tu editor, el indexador de tu language server, tu git status — cada uno recorre miles de rutas, y cada recorrido es una ida y vuelta. Los relatos coinciden: el rendimiento se despeña en cuanto algo toca archivos en masa, como un git checkout o un npm install. En macOS es peor, porque el asunto de FUSE lleva años siendo incómodo. La abstracción es preciosa justo hasta que la latencia la vuelve inservible.
rsync en bucle. El apaño honesto, y todo el mundo lo ha escrito al menos una vez: fswatch | rsync, o un while true; sleep 1. Para instantáneas en una dirección es correcto. En vivo se degrada: o dispara en cada pulsación y satura el enlace, o agrupa y se queda por detrás de tus guardados. Es unidireccional por diseño, así que si quieres que vuelvan los archivos generados o la salida de la compilación ejecutas un segundo, y ahora tienes dos procesos sin una noción compartida de la verdad corriendo sobre el mismo árbol. La gente hace bien en estar nerviosa: rsync con la bandera equivocada en la dirección equivocada borra trabajo real.
Que lo haga el IDE. VS Code Remote-SSH y JetBrains Gateway resuelven esto correctamente para el editor: un componente servidor corre en el remoto y la interfaz se queda local. Si tu mundo entero está dentro de un IDE, es una buena respuesta. Pero los archivos siguen existiendo solo al otro lado, así que todo lo que está fuera del editor es ciego: tu terminal, tu cliente de git independiente, tus scripts locales, el agente de código que corre en tu portátil. Además te has casado con el protocolo remoto de un fabricante, has reinstalado tus extensiones allí, y has aceptado que el remoto tiene que ser lo bastante de fiar como para ejecutarlas.
Mutagen. Este es el que acertó con el modelo: una copia real en ambos lados, deltas sincronizados de forma continua, editar en local, ejecutar en remoto. Es buen software y se ganó su reputación. La fricción que la gente reporta, sin embargo, es consistente, y viene de la forma más que de la calidad: es un demonio y un gestor de sesiones, no un comando.
Esa forma pesa. Hay un demonio residente en tu máquina más un agente por endpoint, vivos trabajes o no, y el gestor de incidencias acumula una larga serie de reportes sobre consumo de CPU en periodos sin ningún cambio de archivos, en árboles grandes y bajo WSL. También pesa en disco: en el flujo de Docker para el que está pensado, tu proyecto existe dos veces, y la propia documentación de DDEV te dice que vigiles el volumen duplicado — por encima de 5 GB es un aviso, por encima de 10 GB es crítico.
Luego está la superficie operativa. Creas sesiones, las listas, aprendes mutagen sync terminate — y aprendes que puede colgarse mientras prepara archivos, con el remedio popular en los hilos de incidencias siendo parar el demonio, borrar el archivo de bloqueo y arrancarlo de nuevo. Su modo de conflictos seguro por defecto se detiene y espera a un humano, que es la decisión correcta, pero significa que una sesión puede dejar de converger en silencio mientras tú das por hecho que va bien; hay una petición de hace años pidiendo solo una forma fiable de saber si una sesión está rota. En árboles grandes el escaneo inicial se lleva minutos antes de que nada esté vivo. La señal más clara de cuánta superficie se acumuló es que DDEV, que se apoya mucho en Mutagen, acabó publicando todo un subcomando de diagnóstico — ddev utility mutagen-diagnose — para contarte por qué tu sincronización está descontenta.
Entonces se movió el terreno. Docker adquirió Mutagen en 2023. Mutagen Compose fue descontinuado sin rodeos — la documentación lo dice claramente: "Mutagen Compose is now deprecated with the release of v0.18.0". El propio Mutagen no ha publicado una versión etiquetada desde v0.18.1 en febrero de 2025. El repositorio sigue recibiendo algún commit de dependencias ocasional, así que no está muerto. Pero tampoco se mueve.
Nada de esto es un escándalo, y quiero ser justo: ese peso es sencillamente lo que cuesta un motor de sincronización de propósito general en cuanto tiene que servir endpoints locales, SSH y Docker, en cuatro sistemas operativos, bajo un orquestador. Simplemente yo no necesitaba nada de eso. Necesitaba un directorio y una ruta.
Así que salí a buscar algo que fuera solo eso, y no lo había. Está Unison, cuyo CLI vuelves a aprender cada vez que lo tocas. Está Syncthing, construido alrededor de dispositivos y carpetas compartidas más que del árbol de fuentes de un desarrollador. Más allá, una larga cola de proyectos de GitHub a medio terminar. Nada que fuera simplemente: este directorio, esa ruta, sobre SSH, ahora mismo, respetando mi .gitignore — y luego quítate de en medio.
Así que lo escribí.
Lo que quería de él
La lista de requisitos era corta, y cada línea salió de algo que me había molestado:
- Un comando, no una sesión. Ningún demonio al que cuidar, ningún baile de
status/terminate/ archivos de bloqueo. Corre mientras tú quieras, yctrl+csignifica parado. - Nada residente. Cuando no estoy sincronizando, no hay proceso. Nada calentando una caché, nada escaneando un árbol, nada sobre lo que preguntarse en el monitor de actividad a las dos de la mañana.
- Sin archivo de configuración. Las dos rutas son los argumentos. Nada que subir al repo, nada que recordar, nada que se quede desactualizado.
- El
.gitignorees la ley. Tu repositorio ya declara lo que no es código fuente.target/,node_modules/,.venv/— los directorios que ponen de rodillas a cualquier herramienta de sincronización ingenua — no deberían entrar siquiera en el manifiesto, en ninguna dirección. - Nunca perder datos. Una herramienta de sincronización que borra algo que querías conservar es peor que ninguna herramienta. Todo lo demás es negociable; esto no.
- Barato sobre un árbol ya sincronizado. La segunda ejecución debería costar un recorrido de directorios, no un rehasheo completo.
- Solo SSH. Tus claves, tu
~/.ssh/config, tuProxyJump. Ninguna superficie de autenticación nueva, ningún puerto nuevo que abrir.
Relee esa lista y fíjate en lo que no está: contenedores, orquestación, reenvío de red, Windows, una interfaz gráfica, un esquema de configuración, un sistema de plugins. Cada una de esas cosas es razonable de querer, y cada una es la razón por la que una herramienta de sincronización acaba criando un demonio. Dejarlas fuera no es modestia — es todo el diseño. La herramienta se mantiene pequeña porque el problema que aceptó es pequeño.
Eso es Synx: un único binario de código abierto en Rust — versión 0.1.2 hoy — que refleja un directorio local en una ruta remota sobre SSH y mantiene ambos al mismo paso mientras trabajas. Un proceso, corriendo solo mientras trabajas, haciendo un trabajo. Es temprano. Y ya hace el trabajo que yo necesitaba que hiciera cada día.
Qué es Synx
Lo ejecutas en tu máquina local y lo apuntas a un destino remoto:
synx ./src dev@beefy:/srv/app/src
Esa es toda la interfaz. El mismo binario corre en ambos extremos — en local es el cliente, y en remoto se ejecuta en un modo oculto --agent que el cliente lanza por ti vía SSH. No hay servicio que instalar, nada que registrar, ni YAML.
Hace una sincronización inicial de reconciliación y luego entra en un modo de observación en vivo donde cada cambio en cualquiera de los dos lados llega al otro en un par de cientos de milisegundos:
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
Funciona en macOS y Linux. El transporte es SSH puro — tus claves, tu agente, tu ~/.ssh/config, tu ProxyJump, todo. Synx no inventa un esquema de autenticación nuevo; toma prestado el que ya usas y en el que ya confías.
Modos de sincronización y gestión de conflictos
La dirección es un único flag. El valor por defecto es bidireccional.
| Modo | Dirección | Regla de conflicto | Sincronización inicial |
|---|---|---|---|
push |
local → remoto | gana siempre local | envía archivos solo-locales o distintos |
pull |
remoto → local | gana siempre remoto | trae archivos solo-remotos o distintos |
both (por defecto) |
bidireccional | gana el mtime más reciente | fusiona, sin borrados |
# bidireccional (por defecto)
synx ./src dev@host:/srv/app/src
# push en una dirección
synx ./build host:/var/www --mode push
# pull en una dirección
synx ./nginx host:/etc/nginx --mode pull
Voy a ser honesto sobre el modelo de conflictos porque importa: en modo both, cuando el mismo archivo cambió en ambos lados, gana la fecha de modificación más reciente. Eso es todo. No hay fusión a tres bandas consciente del ancestro, ni marcadores de conflicto. Esto es fiable exactamente mientras los relojes de ambas máquinas estén bien — así que si ves archivos rebotando, lo primero que hay que revisar es la desviación de reloj (NTP lo arregla), y si no puedes fiarte de los relojes, elige una dirección explícita push o pull.
La otra decisión deliberada: la sincronización inicial nunca borra nada. Si apuntas Synx a una ruta remota obsoleta, no aniquilará datos — fusiona. Los borrados solo se propagan una vez que Synx está vivo y observando, y solo cuando tiene una baseline — una instantánea registrada de lo que ambos lados acordaron por última vez. La baseline vive en tu directorio de caché y le permite a Synx distinguir entre "este archivo se borró" y "este archivo simplemente nunca existió en el otro lado". Una ejecución nueva sin baseline lo conserva todo; los borrados empiezan a propagarse a partir de la segunda sincronización. Esa asimetría es intencionada. Perder datos por una herramienta de sincronización demasiado entusiasta es el único modo de fallo que me niego a publicar.
Las reglas de ignorado son la autoridad
Synx carga todos los .gitignore bajo tu raíz de sincronización — los anidados a cualquier profundidad — más un .synxignore opcional con sintaxis idéntica. Cualquier cosa que coincida no se sincroniza nunca, en ninguna dirección. Esto no es un filtro de mejor esfuerzo aplicado en el origen; se hace cumplir en tres puntos:
- La exploración inicial — los archivos ignorados ni siquiera entran en el manifiesto.
- El manifiesto remoto — los archivos que el remoto reporta y que coinciden con tus reglas de ignorado locales se filtran antes de calcular el plan de diferencias, de modo que un
target/onode_modules/que por casualidad exista en el remoto nunca se descarga. - Eventos en vivo — tanto las aplicaciones entrantes como las notificaciones salientes omiten las rutas ignoradas.
Algo que sorprende a la gente: los archivos ocultos (dotfiles) no son especiales. .env, .vscode/, .git/ — todos se sincronizan como cualquier otra cosa salvo que los excluyas. Si no quieres que tu directorio .git/ se refleje, dilo:
echo '/.git' >> .synxignore
Hablando de .git/ — Synx es cuidadoso con él. Git trata ese directorio como estado transaccional, y renombrar atómicamente objetos a medio escribir o archivos de bloqueo sobre el par a mitad de un commit corrompe el repositorio. Por eso Synx vigila los marcadores de operación en curso de git (index.lock y compañía) y pausa la sincronización de las rutas de .git/ mientras una operación de git está en marcha, reproduciendo los cambios diferidos cuando git termina. Tu árbol de trabajo sigue sincronizándose todo el tiempo; solo .git/ espera. Si vas a reflejar un repositorio vivo, esto lo quieres.
Hay una protección en el sentido contrario también. Si tu .git/ local es un resto de una operación interrumpida y el remoto no tiene ninguno, Synx refleja esa eliminación solo cuando el baseline demuestra que .git/ formaba parte del último estado que ambos lados acordaron. Sin esa evidencia, tu .git/ son simplemente datos que nunca se sincronizaron: Synx los conserva y los envía al remoto.
Cómo funciona por dentro
┌─ 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 └──────────────────────────────┘
Vale la pena explicar algunas piezas, porque de ahí sale la velocidad.
Hashing paralelo con caché persistente. Ambos lados recorren su árbol en paralelo (con el explorador paralelo del crate ignore) y calculan el hash de cada archivo con blake3. Los archivos de ignorado anidados se descubren dentro de ese mismo recorrido en vez de en una pasada previa aparte, así que el arranque cuesta un recorrido del árbol y no dos — y el observador se activa antes de que empiece el recorrido, de modo que lo que guardes mientras todavía escanea se encola en lugar de perderse. Los hashes van a una caché persistente en el directorio de caché de tu plataforma (~/.cache/synx/ en Linux, ~/Library/Caches/synx/ en macOS), indexada por (ruta, tamaño, mtime). Volver a ejecutar Synx sobre un repo sin cambios omite por completo el rehasheo — la segunda sincronización de un árbol de cien mil archivos está limitada solo por el recorrido de directorios, que es alrededor de un segundo. Las lecturas de caché son inmutables y los resultados del explorador se agrupan por worker, así que no hay bloqueo global en el bucle caliente, y una caché que no cambió no se reescribe nunca a disco.
Transferencia delta para archivos grandes. Los archivos pequeños o que cambian por completo viajan enteros por el cable. Pero para los archivos de entre 256 KiB y 256 MiB en los que el par ya tiene una versión distinta, Synx hace un delta estilo rsync. Usa fast_rsync, una adaptación de librsync acelerada con SIMD: el lado receptor calcula una firma del archivo que ya tiene, el lado emisor hace el diff contra esa firma, y solo viajan los bloques cambiados. Como el hashing de bloques interno de librsync es anterior a los hashes criptográficos modernos, Synx verifica cada resultado de delta aplicado contra un hash blake3 fresco antes de confirmarlo. El cable nunca llega a mentirte.
Compresión y troceado. Los mensajes son postcard con prefijo de longitud (un formato binario compacto y mantenido activamente) y se comprimen con zstd cuando la compresión realmente ahorra espacio. Los formatos ya comprimidos — archivos comprimidos, medios, paquetes — se saltan zstd por su extensión, para no quemar CPU demostrando que no pueden encogerse. Los archivos por encima de 16 MiB se transmiten en trozos de 4 MiB a un archivo temporal, y luego se renombran atómicamente a su sitio preservando su modo y mtime originales — de modo que un fallo a mitad de transferencia nunca deja un archivo a medio escribir en la ruta real.
Supresión de eco basada en estado. Esta es la parte sutil de cualquier sincronización bidireccional. Cuando Synx aplica un cambio entrante, tu observador local está a punto de dispararse para esa misma ruta — y si lo reenvías ingenuamente, tienes un eco infinito. El arreglo perezoso es una ventana temporal ("ignora eventos durante los próximos N ms"), que también descarta ediciones legítimas que el usuario hizo durante esa ventana. Synx, en cambio, registra el estado resultante en disco (el mtime, o "borrado") y, cuando el observador se dispara, compara el archivo actual con lo que registró. Solo una coincidencia se trata como eco y se descarta. Si editaste el archivo entretanto, el evento fluye con normalidad. No hay ventana ciega en la que tus pulsaciones se traguen.
Reutilización de conexión. SSH corre con ControlMaster auto y una persistencia breve, de modo que varias invocaciones de Synx contra el mismo host comparten una sola conexión TCP en lugar de renegociar.
Pruébalo en cinco minutos
Necesitas Synx en ambos extremos — tu máquina y el remoto. El instalador de una línea es lo más rápido en cada uno:
# Linux y macOS, x86_64 + ARM64
curl -fsSL https://raw.githubusercontent.com/Muvon/synx/master/install.sh | sh
# o desde crates.io
cargo install synx
Si ya tienes una compilación de release local, basta con copiarla:
scp target/release/synx user@host:~/.local/bin/synx
ssh user@host 'chmod +x ~/.local/bin/synx'
Después arranca una sesión:
# sincronización bidireccional, en vivo
synx ./project dev@host:/srv/project
# mira el plan, sin cambiar nada
synx ./project dev@host:/srv/project --dry-run
# solo sincronización inicial, luego salir
synx ./project dev@host:/srv/project --once
Unos cuantos flags que usarás:
# puerto SSH no estándar (o cualquier argumento extra de ssh)
synx ./code host:/work --ssh-opts "-p 2222 -i ~/.ssh/devkey"
# synx no está en el PATH del remoto
synx ./code host:/work --remote-synx ~/.local/bin/synx
# sin compresión (más rápido en una LAN veloz con blobs incompresibles)
synx ./code host:/work --no-compress
# más logs
synx ./code host:/work -v # debug
synx ./code host:/work -vv # trace
Si el remoto no encuentra el binario, recibirás synx: command not found del shell de login — ese es el caso de --remote-synx, no un fallo. Y ambos extremos deben ejecutar la misma versión de protocolo; 0.1.x habla el protocolo v1 y es incompatible a nivel de cable con lo que venga después, así que actualiza ambos lados a la vez.
Lo que todavía no hace
Synx es 0.1.2. Prefiero contarte los bordes a que los descubras tú.
- Sin modo demonio. Corre en primer plano. Pásalo a segundo plano con
&, o vive entmux/screen. Unsynx status/synx stoppropio está en la lista. - Sin fusión de contenido a tres bandas. Las eliminaciones se apoyan en el baseline, pero los conflictos de contenido de archivo se resuelven por mtime más reciente, sin consciencia del ancestro. Se requieren relojes correctos.
- La caché de hashes se indexa por
(tamaño, mtime). Un archivo reescrito en su sitio con el mismo tamaño y la misma marca de tiempo no se volverá a hashear. Es la misma heurística que usa git, y en la práctica es correcta. - Reinicia para recoger nuevas reglas de ignorado. Cambia un
.gitignorea mitad de sesión y Synx no se enterará hasta que lo reinicies. - Solo macOS y Linux. Windows no está soportado.
Ninguna de estas es un bloqueo serio para el caso de uso central — edita aquí, ejecuta allá — que es exactamente para lo que lo construí y lo que hace bien hoy.
Código abierto
Synx está en GitHub bajo Apache-2.0. Es Rust porque la sincronización de archivos quiere corrección en tiempo de compilación, latencia predecible y nada de pausas de GC en mitad de una transferencia — y porque un único binario estático que puedes hacer scp a un servidor es la forma correcta para una herramienta así.
Lo construye el mismo equipo detrás de nuestras otras herramientas para desarrolladores; si tienes curiosidad por saber por qué seguimos publicando utilidades de código abierto pequeñas y afiladas en lugar de una gran plataforma, esa es una conversación más larga. Synx es la última de ellas, y la que yo personalmente más uso.
Si te resulta útil, el código está ahí mismo. Si se rompe, el rastreador de incidencias también — los reportes de errores de configuraciones reales de desarrollo remoto son la vía más rápida para que la 0.2 sea mejor que la 0.1.
— Vladimir
Synx es de código abierto bajo Apache-2.0. Consíguelo, lee el código fuente o abre una incidencia en github.com/Muvon/synx.



