vianet-management / codesync
publiccodesync
codesync keeps your local git checkouts up to date as pushes land on your GitBlixt servers. It works like Dropbox and never polls. It holds one websocket per server. When someone pushes to a repository you can read, codesync finds your checkouts of that repository, runs git fetch in just those checkouts, and fast-forwards them when that is safe.
Install
Linux and macOS:
curl --proto '=https' --tlsv1.2 -fsSL https://gitblixt.com/willc0de4food/codesync/-/releases/permalink/latest/downloads/install.sh | sh
Windows (PowerShell):
irm https://gitblixt.com/willc0de4food/codesync/-/releases/permalink/latest/downloads/install.ps1 | iex
The installer downloads the latest release binary, puts it on your PATH, and runs codesync install. That writes a starting config, installs the per-user service, starts it, and opens the settings page in your browser. There you add each server and its token. To open the page again later, use the tray icon (Windows, Linux) or the codesync app (macOS); see Web UI. Run the same one-liner again to upgrade: codesync install replaces an existing service and keeps your config. The service starts at login:
- Linux: a systemd user unit at
~/.config/systemd/user/codesync.service, wanted bydefault.target. It restarts 10 s after a crash. On stop, it gives a running fetch or fast-forward up to 150 s to finish. The unit stores thePATHthat was set when you raninstall, so it can find git and your SSH tooling. If you move git or changePATH, runcodesync installagain. To keep it running while you are logged out, runloginctl enable-linger $USER. - macOS: a launchd agent at
~/Library/LaunchAgents/codesync.plist, withKeepAliveandRunAtLoadset. It also stores your currentPATH, so if that changes, runcodesync installagain. - Windows: codesync does not run as a Windows Service. A service would run as LocalSystem, which can't read your config or show you notifications. Instead,
installregisters a Scheduled Task calledcodesyncthat runscodesync.exe run --servicewhen you log on. It is created withschtasks /Create /SC ONLOGON, then adjusted withSet-ScheduledTaskso that it has no 72-hour run limit, starts on battery, and restarts if it fails. It is then started, so you don't have to log off and on again. IfinstallsaysAccess is denied, run it once from an elevated PowerShell. The task still runs as you, without admin rights.
codesync stop and codesync start stop and start the service (on Windows, they run and end the task). codesync uninstall stops it and removes it.
Fetches run without a terminal, so prompts are turned off (GIT_TERMINAL_PROMPT=0). Your remotes need to work without asking for anything:
- HTTPS remotes on a configured server authenticate with that server's token. An example is
https://git.acme.test/owner/repo.gitwhenhttps://git.acme.testis inservers. codesync runs the fetch with itself asGIT_ASKPASS, answeringtokenas the username and the token as the password. The token is passed in the fetch's environment, never on a command line, and only when the remote's scheme, host and port match the server's. The askpass only answers prompts for that exact origin. The fetch also runs without following redirects, without recursing into submodules, and without any configured credential helper, so the token can't reach another host. Other HTTPS remotes need a git credential helper. - SSH remotes need a key the service can use without a prompt. A systemd or launchd service usually has no SSH agent, so use a key without a passphrase (or one your system keychain unlocks), or switch the remote to HTTPS.
Manual install
Build or download a binary for your machine (make dist writes them to dist/), put it on your PATH, and run codesync install:
mkdir -p ~/.local/bin
cp dist/codesync-linux-amd64 ~/.local/bin/codesync # or linux-arm64, darwin-arm64, darwin-amd64
chmod +x ~/.local/bin/codesync
fish_add_path ~/.local/bin
codesync install
On Windows:
New-Item -ItemType Directory -Force "$env:LOCALAPPDATA\Programs\codesync" | Out-Null
Copy-Item dist\codesync-windows-amd64.exe "$env:LOCALAPPDATA\Programs\codesync\codesync.exe"
[Environment]::SetEnvironmentVariable("Path", [Environment]::GetEnvironmentVariable("Path", "User") + ";$env:LOCALAPPDATA\Programs\codesync", "User")
$env:Path += ";$env:LOCALAPPDATA\Programs\codesync"
codesync install
To try it in the foreground first, run codesync index to see what it found and codesync run; Ctrl-C stops it.
Logs
- Linux:
journalctl --user -u codesync -f - macOS:
tail -f ~/Library/Logs/codesync.log. The log starts over once it passes 10 MB. Crash output goes to~/Library/Logs/codesync.err.log. - Windows:
%LocalAppData%\codesync\codesync.log. The log starts over once it passes 10 MB. A console window flashes briefly when the task starts at logon. - Anything else that runs
codesync run, in a terminal or in the background (nohup codesync run &): stdout
Only the installed service logs to a file. When you run codesync run yourself, from a terminal or a script, it logs to stdout. install makes the service run codesync run --service, a flag that tells codesync the service manager started it. Run codesync install after upgrading from a version without it, or macOS and Windows will keep logging to stdout.
Each line is one action with a timestamp: connections, catch-ups, pushes received, fetches, fast-forwards and why one was skipped.
It never stashes, resets or merges. The only change it makes to a working tree is a fast-forward of the branch you have checked out, and only when no merge, rebase, cherry-pick, revert or bisect is under way and your local changes (staged or not) don't touch any file the fast-forward would change. Local changes to other files are kept as they are. In every other case it fetches and logs why it stopped there. It shows a desktop notification only when a push concerns the branch you have checked out in that checkout. Pushes to other branches, pushes to a detached HEAD, and failed fetches are only logged and shown in the web UI's activity list.
| Situation | What codesync does | Notification |
|---|---|---|
| Checkout already at the pushed commit (you pushed it) | nothing | none |
| On the pushed branch, no local changes | git merge --ff-only |
Updated owner/name main (3 commits) |
| On the pushed branch, but you have local commits | fetch only | owner/name: main has diverged from origin, fetched only |
| On the pushed branch, uncommitted changes to tracked files the fast-forward doesn't touch | git merge --ff-only, your changes stay |
Updated owner/name main (3 commits) |
| On the pushed branch, uncommitted changes to tracked files the fast-forward also changes | fetch only | Fetched owner/name main (local changes to README would be overwritten) |
| On another branch | fetch only | none (logged as on branch feature) |
| Detached HEAD | fetch only | none (logged as detached HEAD) |
| Merge, rebase, cherry-pick, revert or bisect under way | fetch only | Fetched owner/name main (operation in progress) |
| Unresolved merge conflicts in the index | fetch only | Fetched owner/name main (unresolved conflicts) |
The fast-forward would overwrite an ignored or untracked file (say, your .env, or a new file the push adds) |
fetch only | owner/name: fast-forward of main failed, fetched only |
| Tag push or branch deletion | fetch only | none |
| Fetch fails (network, authentication) | nothing | none (logged as an error) |
Pushes made while codesync wasn't running or the machine was asleep are caught up in two ways. Each server reports the ones it saw (see Protocol). On top of that, once codesync starts, once it wakes from sleep (the clock has jumped more than a minute past what it expected), and whenever a catch-up comes back truncated, it syncs every checkout after the next catch-up: for the branch you have checked out, it fetches the remote that branch tracks and fast-forwards it to its upstream, by the same rules as a push. That covers what a catch-up can't, such as a GitHub remote the server doesn't mirror, or more pushes than a catch-up returns. On waking it also reconnects to every server at once instead of waiting for the old connection to time out. A sync only notifies you about what its fetch brought in, so a diverged branch isn't reported again on every wake. Checkouts with nothing to sync (detached, or a branch without an upstream) are left alone. A fetch that fails is retried twice, a minute apart, since the network may not be up yet after a wake; checkouts that still fail show up as one error in the activity list.
Runs for the same repository never overlap, and linked worktrees of one repository count as the same repository. If more pushes arrive while a run is busy, they are combined: one fetch per remote, then a fast-forward decision per branch. Past 50 waiting pushes for one repository, codesync just fetches. At most 4 git processes run at a time.
Every git command codesync runs is hardened against what a push can bring in:
- Repository hooks never run. codesync points
core.hooksPathat an empty directory, which also silences husky-style hooks. - The fast-forward target is the full ref
refs/remotes/<remote>/<branch>, so a tag namedorigin/maincan't redirect it. - Shas and branch names from the server are validated before they reach git, and
--end-of-optionscomes before every ref. - git runs without terminal prompts (
GIT_TERMINAL_PROMPT=0). The only askpass it gets is codesync's own, and only for HTTPS remotes on a configured server (see below). It also runs without inherited repository variables (GIT_DIR,GIT_WORK_TREE,GIT_CONFIG_PARAMETERSand the rest ofgit rev-parse --local-env-vars), and with only the absolute entries ofPATH. - On shutdown, codesync lets a fetch or fast-forward that has started run to completion.
Auto-update
The installed service updates itself. A minute after it starts, and then every hour, it asks the gitblixt.com release API for the latest release, using the token of your https://gitblixt.com server; without that server it doesn't check. When the release differs from the running version, it downloads the binary for your platform next to the current one (so that directory must be writable by you, as the installers' is), checks it against the release's SHA256SUMS entry (after verifying the signature on that file), makes sure it runs and reports the new version, swaps it in, and exits so the service manager starts the new one (on Windows it runs the task again itself). The token is never sent to the storage host the download redirects to. A failed check or download is logged and retried an hour later. Releases ship SHA256SUMS.sig, an ed25519 signature of the release tag, a newline and SHA256SUMS, so an older release can't be passed off as the latest, made in the release job with the private key held in the CODESYNC_SIGNING_KEY CI variable. The public key is built into the binary, and an update is refused unless the signature checks out. Set "update": false in the config to turn this off. Development builds (codesync version prints codesync dev) and codesync run outside the service never update.
How checkouts are found
codesync looks in each configured root at two levels: root/* and root/*/*. Any directory with a .git directory or file counts as a checkout. Hidden directories are skipped, and codesync does not look inside a checkout for nested ones. For every checkout it reads git remote -v and turns each remote's fetch URL into a key of the form host/owner/name. The key is lowercase and drops the user, port, any credentials, and a trailing .git. So [email protected]:owner/name.git, ssh://[email protected]:2222/owner/name.git and https://user:[email protected]/owner/name.git all map to the same kind of key.
When a push arrives, codesync tries these keys in order: the push's SSH URL, its HTTP URL, then each GitHub or GitLab mirror source (github.com/<owner/name>, gitlab.com/<owner/name>). It fetches from the remote whose URL matched. If nothing matches, it rescans the roots (at most once a minute) and tries again. It also rescans every 6 hours on its own.
Run codesync index to see what it found.
Build
make dist # dist/codesync-{linux,darwin,windows}-{amd64,arm64}[.exe], static (CGO_ENABLED=0), and dist/SHA256SUMS
make build # ./codesync for this machine
make test
All six targets build with CGO_ENABLED=0.
Release
Development happens on the development branch, the default branch. master only moves by merging development into it, and every merge to master is a release.
Every merge to master releases a new version. The release job picks it: the highest vX.Y.Z release tag with its patch number bumped, or the one line in the VERSION file when that is higher, so to bump the minor or major version, raise VERSION on development before merging. If a release tag already points at the merged commit (a retried pipeline), the job reuses that version. The job in .gitlab-ci.yml runs on master, builds dist/, uploads every file in it, together with install.sh and install.ps1, to the GitBlixt package registry under codesync/<version>, then creates a release with one download link per file. If a release for that version already exists, the job does nothing. The job uses the GITBLIXT_TOKEN CI variable, a token with the api scope, and the masked, protected CODESYNC_SIGNING_KEY variable, the base64 of the 32-byte ed25519 seed that signs SHA256SUMS. To rotate the key, first ship a release with the new public key built in: old clients only trust the key they were built with, so a release signed with the new key is refused by them. The job runs only in the willc0de4food/codesync project on gitblixt.com; a mirror of this repository runs just the tests.
To release from your machine instead, run make release on a clean checkout of master. It picks the version the same way (set VERSION=v1.2.3 to override), refuses to run with uncommitted changes or untracked source files, and if the tag exists it must point at HEAD. It needs CODESYNC_SIGNING_KEY exported too (fish: set -x CODESYNC_SIGNING_KEY (cat ~/.secrets/codesync-signing-key)), and the same api-scope token, taken from GITBLIXT_TOKEN or from ~/.secrets/gitblixt-personal-pat (set GITBLIXT_TOKEN_FILE to use another file). The token goes to curl through a config file, never on the command line, so ps can't show it. GITBLIXT_URL and GITBLIXT_PROJECT change the server and project.
To see the curl commands without sending anything, run scripts/release.sh --dry-run. It prints the token as *** and the sign step instead of running it, so it needs neither token nor signing key, and it works on a dirty tree too.
Create a token
On each GitBlixt server, go to Settings → Personal Access Tokens (/settings). Create a token with the read_api scope. Use a normal personal token, not a repository-restricted one: codesync needs to hear about every repository you can read. Paste it into the settings page when you add the server. No file is needed. If you prefer a file, store the token in one only you can read and point token_file at it:
mkdir -p ~/.secrets
umask 077; and read -s -P 'token: ' tok; and printf '%s' $tok > ~/.secrets/gitblixt-personal-pat
Configure
codesync config init writes an example config if none exists yet and prints its path:
- Linux:
~/.config/codesync/config.json - macOS:
~/Library/Application Support/codesync/config.json - Windows:
%AppData%\codesync\config.json
You can use another file with --config <path> before or after any command.
{
"roots": ["~/Code"],
"servers": [
{"url": "https://gitblixt.com", "token_file": "~/.secrets/gitblixt-personal-pat"},
{"url": "https://git.acme.test", "token": "glpat-..."}
],
"notify": true,
"update": true,
"git": ""
}
roots: directories to search for checkouts.~means your home directory on every OS.exclude: optional checkout directories (absolute, or starting with~) that codesync leaves alone. An excluded directory that isn't a checkout itself hides every checkout below it.servers: each server needs eithertokenortoken_file. A token file is read once at startup, with surrounding whitespace removed. Server URLs must usehttps://; plainhttp://is only accepted forlocalhost,127.0.0.1and::1. A URL must not contain a user or password.- On Linux and macOS, codesync refuses to start if a file holding a token can be read by anyone but you. That covers every
token_file, and the config itself when it has an inlinetoken. Fix it withchmod 600 <file>. notify: desktop notifications. They default to on. If a notification fails (for example, there is no notification daemon), codesync logs it once and stops trying.update: let the installed service replace itself with the latest release (see Auto-update). Defaults to on.git: path to the git binary. If empty, codesync looks forgitonPATHand exits with an error if it can't find it.
You can also change roots, excluded checkouts, notifications and servers from the web UI while codesync runs.
The catch-up position for each server is saved in state.json in the same directory as the config file in use. If you use --config ~/other/codesync.json, the state goes in ~/other/state.json, so each config file keeps its own catch-up position.
The position only moves forward once codesync has finished handling a push, or every push in a catch-up. Pushes interrupted by a shutdown are picked up again by the next catch-up. If state.json can't be read, codesync logs a warning and catches up on the last 24 hours.
Web UI
The running daemon also serves a settings and status page. To open it:
- Windows and Linux: click the codesync icon in the system tray, or right-click it and choose Open settings. The menu also shows how many servers are connected and has Quit codesync. On Windows, Quit ends the task. On Linux, it stops the systemd unit, which would otherwise start codesync again. Either way codesync starts again at your next login, or when you run
codesync start. The icon only appears for the installed service, not forcodesync runin a terminal. On Linux, the tray uses StatusNotifierItem over D-Bus. KDE, Xfce, Cinnamon and most panels show it. Stock GNOME needs the AppIndicator extension. - macOS: open the codesync app in
~/Applicationsfrom Spotlight or Launchpad.codesync installwrites it, and the service adds it on start if it is missing, which covers installs that auto-updated from a version without it. A menu bar icon would need cgo, which the release builds don't use.codesync uninstallremoves the app. - Anywhere: run
codesync ui.
Each of these opens the page in your browser. codesync ui also prints its link. The page shows:
- each server with its connection state, the last error, and how long it has been in that state. You can add a server (URL and token) or remove one.
- the roots, which you can add by typing a path or by picking a folder in your home directory, or remove. The notification switch is here too.
- every checkout found, with an Exclude box that adds it to
exclude. - recent activity: pushes for your checkouts, fast-forwards, fetch-only runs and why, errors, and connects and disconnects. It refreshes every 3 seconds.
Changes are written to the config file and take effect at once, without a restart. A change to roots or exclusions rescans the roots. Other fields in the file are kept as they are, and so is the file's mode.
The page only listens on 127.0.0.1, on a random port picked at each start. The daemon writes the address and a token to ui.json (mode 600) beside the config file and deletes it on shutdown. codesync ui reads that file, sends the token to the daemon in a request header, and gets back a code that works once and only for 30 seconds. The link it opens carries that code after the #, never the token, since other users on the machine can read a browser's command line. The page trades the code for a session that signs that browser tab in for 24 hours; the code is then spent, so the link can't be used again. The session is kept in the tab (sessionStorage) and sent as a bearer header, never as a cookie, because browsers share cookies across every port on 127.0.0.1. Closing the tab ends it; run codesync ui again to open a new one. Each restart picks a new port and token, so run codesync ui again after one. If the daemon isn't running, codesync ui says so and exits with status 1.
A token you enter for a new server is stored in tokens/<host> beside the config file, with mode 600. The config file refers to it with token_file. The page never shows a token. Removing a server added this way also deletes its token file.
Protocol
This section is for anyone writing another client.
Connect. For the server URL https://gitblixt.com, open:
wss://gitblixt.com/socket/sync/websocket?vsn=2.0.0
Use ws:// for an http:// server. The personal access token goes in the Sec-WebSocket-Protocol header, as Phoenix 1.8's auth_token expects:
Sec-WebSocket-Protocol: base64url.bearer.phx.<base64 of the token, standard alphabet, no padding>
Never put the token in the URL. The server may answer without selecting a subprotocol. Don't follow redirects: treat a 3xx as a failed connect. codesync gives the handshake 30 s. Messages use the Phoenix Channels JSON serializer v2. Every frame is a JSON array [join_ref, ref, topic, event, payload], where join_ref and ref are strings or null.
Join.
["1","1","sync:pushes","phx_join",{}]
Reply:
["1","1","sync:pushes","phx_reply",{"status":"ok","response":{"server_time":"2026-10-16T12:00:00Z"}}]
If the status is "error", this attempt has failed: close the socket, back off and retry.
Heartbeat. Send this every 30 s:
[null,"<ref>","phoenix","heartbeat",{}]
The reply has topic phoenix and event phx_reply. If no reply arrives within 10 s, close the socket and reconnect.
Push event.
["1",null,"sync:pushes","push",{
"project_id": 42,
"path_with_namespace": "owner/name",
"ssh_url_to_repo": "[email protected]:owner/name.git",
"http_url_to_repo": "https://gitblixt.com/owner/name.git",
"ref": "refs/heads/main",
"before": "<sha, or 40 zeros for a new ref>",
"after": "<sha>",
"pusher": "username or null",
"pushed_at": "2026-10-16T12:00:00Z",
"mirror_sources": [{"provider": "github", "remote_repo_name": "owner/name"}]
}]
Catch-up. Send this right after every successful join:
["1","<ref>","sync:pushes","catch_up",{"since":"<RFC3339>"}]
Reply:
["1","<ref>","sync:pushes","phx_reply",{"status":"ok","response":{"pushes":[<push payloads>],"server_time":"..."}}]
Handle each push in the reply as if it had arrived live.
If the server had more pushes than it returned, the response also has "truncated": true. codesync then syncs every checkout it knows about, as it does after starting.
since is the newest time already handled for that server: the last catch-up's server_time, or the pushed_at of the last push, whichever is later. On the very first connect, with nothing saved, use now minus 24 hours. Save the newest server_time once every push in the catch-up has been handled, and a push's pushed_at once that push has been handled. Never save a time more than 5 minutes in the future.
If the reply to phx_join or catch_up doesn't arrive within 30 s, close the socket and reconnect.
Reconnect. On phx_error or phx_close for the topic, or when the socket closes, reconnect with exponential backoff: start at 1 s, double up to 60 s, and add jitter. Then rejoin and catch up again. The backoff resets only after a catch-up has succeeded, so a server that accepts the join and then fails keeps getting slower retries.