launchd sometimes fails to refresh an agent's launch constraint ("Unable to get updated LWCR ...
No such process"); from then on every spawn of ws.poll exits 78 before ws starts, and the sync
stops until the agent is loaded again. The tray refreshes every minute, so it now checks the agent
and reloads it when it is stuck or its plist is outdated (never while a pass is running).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ws — workstation sync
Keep your ~/projects folder — every git repo in it, your uncommitted work, the files around the
repos, .env files and large data — the same on every machine you use. Close the laptop, sit down at
another machine, and carry on where you stopped, without committing or pushing anything by hand.
Machines never talk to each other. They only talk to a hub you already have: any git server (Gitea, Forgejo, GitHub, GitLab or plain git over ssh) plus any S3-compatible storage (MinIO, Garage, AWS S3, Cloudflare R2, Backblaze B2…). One config file, shared by all machines, says what goes where.
What it syncs
| What | Where it goes |
|---|---|
Git repos under ~/projects (own ones) |
their origin on the hub: fetched, fast-forwarded and pushed every pass |
| Client repos (origin elsewhere, e.g. a customer's GitLab) | a shadow copy on the hub with every machine's branches — ws never pushes to the client's server |
Repos without a remote (git init and a commit) |
the shadow copy; add an origin later, anywhere, and they keep syncing |
| Uncommitted work | a WIP snapshot refs/wip/<host>/<branch> on the hub, carried to the other machines (below) |
| Files outside repos (notes, scripts, small projects) | an auto-committed "root" repo on the hub |
Secrets: .env files, credentials, ssh keys outside git |
a secrets repo, encrypted with SOPS + age for every machine's key; .env files merge by key |
| Large data outside git | tar.zst snapshots in S3, one per path |
Any other folder (~/Documents/notes, …) |
its own auto-committed repo, or a three-way S3 mirror |
Repos you do not need on a machine can be offloaded (everything goes to the hub first, verified) and brought back with one key.
Uncommitted work follows you
ws never commits in your repos. Every pass snapshots uncommitted work to the hub, and another machine on the same branch and commit with a clean working tree applies it. Stop on the laptop, continue on the desktop:
- carried work left untouched follows its source — a newer snapshot replaces it, and it disappears once the source commits (the commit then arrives as a normal fast-forward) or discards it;
- edited on the second machine, it becomes that machine's work and travels back the same way;
- uncommitted work on both machines is never overwritten: the pass warns, and
ws wip take <path> <host>switches to that host's version, keeping yours on the hub.
Install
Download the binary for your system from the releases (ws_darwin_arm64,
ws_darwin_amd64, ws_linux_amd64, ws_linux_arm64), then:
chmod +x ws_darwin_arm64 && ./ws_darwin_arm64 install # → ~/.local/bin/ws + a timer: ws poll once a minute
The timer is a LaunchAgent on macOS and a systemd user timer on Linux. You also need git, and
sops + an age key if you sync secrets.
First machine:
ws config init, then fill in your hub and storage:ws ui→ Setup (or edit~/projects/.ws/config.yaml). Put tokens and keys into the credentials file (~/.sky/envby default,KEY=valuelines) and refer to them as${NAME}— ws refuses plain secrets in the config.ws machine add <name>,ws doctor, thenws now.
Every other machine: install the binary, copy ~/projects/.ws/config.yaml and the credentials file
once, create an age key (age-keygen -o ~/.config/sops/age/keys.txt), add its public key to
recipients.txt in the secrets repo and run ws secrets rekey on a machine that already has access.
Then ws machine add <name>, ws doctor, ws now — it clones everything and lays out secrets, data
and folders.
Updates
Set update.url to the page of the repo that publishes ws releases:
update:
url: https://github.com/<owner>/ws # or your Gitea/Forgejo: https://git.example.com/<owner>/ws
The config travels to every machine, and each of them installs new releases on its own (ws poll
checks hourly; the binary is verified against the release's checksums.txt before it is swapped in).
ws update installs the latest release right away. Binaries built from source are left alone.
Menu bar (macOS)
ws tray install # installs SwiftBar (Homebrew, or its GitHub release into ~/Applications) if needed
A cloud icon in the menu bar shows the sync state: ✓ in sync, ↻ a pass is running, ! warnings or no
finished pass for 30 minutes, ✗ the hub is unreachable. The menu lists the last pass, uncommitted and
unpushed repos and the warnings, and has Sync now, Open ws ui and Show log. The icon comes from
SwiftBar, which runs ws tray render once a minute and starts at login.
ws tray rm takes it away (SwiftBar stays).
Configuration
~/projects/.ws/config.yaml (or $WS_CONFIG) lives in the root repo, so all machines share it.
credentials: ~/.sky/env # KEY=value file; ${NAME} resolves from the environment, then from here
git:
provider: gitea # gitea | github | gitlab | plain (no API: shadow copies must exist)
api: https://git.example.com # https://api.github.com, https://gitlab.com …
ssh: ssh://git@git.example.com/ # clone prefix: "<owner>/<name>.git" is appended (git@github.com: works too)
lan: ssh://git@10.0.0.5/ # optional: the same server on the home network, used while it answers
owner: you # owns the ws repos (root, folders, secrets)
mirror_org: ws-mirror # where shadow copies of client repos go
token: ${GIT_TOKEN}
s3:
endpoint: https://s3.example.com
lan: "" # optional, like git.lan (checked to really be your bucket)
bucket: ws-data
region: us-east-1
path_style: true
access_key: ${S3_ACCESS_KEY}
secret_key: ${S3_SECRET_KEY}
machines: # skip: things this machine does not sync
- {name: laptop, label: MacBook}
- {name: server, label: home server, skip: [data]}
exclude: [temp] # never synced
ignore: ["*.log"] # hidden in every synced repo (.git/info/exclude), besides .DS_Store and ._*
folders: # auto-committed folders; "root" (the projects root) is required
- {id: root, path: ~/projects, repo: projects-root, git_dir: ~/projects/.root.git}
secrets: {repo: secrets, clone: ~/.local/share/sky/secrets}
data: {prefix: ""}
mirrors: [{id: blobs, path: ~/.sky/blobs, prefix: sky-blobs/}]
routes: # paths outside repos: git | s3 | secrets | ignore
- {path: own/game/server/world, to: s3}
update: {url: https://github.com/<owner>/ws}
Items for machines[].skip: repos, data, secrets, folder ids, mirror ids.
The LAN addresses are re-checked as a laptop moves between networks: away from home ws switches to the
public addresses on its own (and back), even in a running ws ui.
Commands
ws / ws ui the full-screen app. Files: every synced tree with where each path goes and
its state on each machine (r route, u upload, g/d bring back/offload repos,
s sync, / find, p problems). Machines: cards + comparison. Setup: the config.
Log: the sync log.
ws tree [path] [-p] [-f] the same trees printed
ws now sync now: a pass here, wake the other machines, wait for them
ws sync | status [-a] [--json] | compare | log [-w]
ws config [show|init|get|set|edit|test]
ws route <path> <git|s3|secrets|ignore|default> where a path outside repos goes
ws place [ls|add <path> [--git|--s3]|rm <id>] extra folders to sync
ws machine [ls|add|rm|skip|sync]
ws ls | stale | drop <pattern> | get <pattern> | rm <pattern> offload and bring back repos
ws wip <path> [host] | ws wip take <path> <host>
ws secrets sync|status|add|forget|resolve|rekey
ws data push|pull|status|rm
ws mani … embedded mani (https://github.com/alajmo/mani) over ~/projects/mani.yaml
ws install [--source] | update [--source] | doctor | version
ws tray install | rm the menu bar icon on macOS (SwiftBar)
The timer runs ws poll once a minute: one S3 request for the other machines' state; it starts a pass
when another machine pushed, something changed locally or on the hub, or 15 minutes passed. A quiet
pass fetches only what changed.
How it works
~/projects/mani.yamllists every repo (mani format; your own mani tasks in it are kept). A repo cloned orgit init-ed on one machine appears on the others.- Each machine publishes its state to S3 (
ws-status/<host>.json): repos, heads, WIP, warnings. The others use it to decide what to fetch and to show differences. ~/projects/.ws/<host>.listis a machine's profile: lines like-archive/*offload repos there.- Logs: macOS
~/Library/Logs/sky-ws.log, Linux~/.local/state/sky/ws.log; state in~/.local/state/sky/.
Building
go build -o ws . # or: go run . install --source (builds into ~/.local/bin, installs the timer)
go test ./...
.gitea/workflows/release.yml tests every push to main, builds the four binaries and publishes a
release v0.1.<run> with checksums.txt (Gitea Actions; the same file works on GitHub Actions with
gh release instead of the API calls). scripts/screenshots.sh rebuilds the screenshots above from a
throwaway demo (two fake machines, a local git daemon and S3).
cmd/ CLI (cobra)
internal/config the config file, ${NAME} resolution
internal/route LAN-or-public choice for the hub, re-checked as networks change
internal/gitx git: ssh multiplexing, the LAN route, push bookkeeping
internal/s3x S3 (minio-go)
internal/hub git provider APIs: Gitea, GitHub, GitLab
internal/repos repos: scan, clone, fetch/ff/push, shadow copies, WIP carrying
internal/autorepo auto-committed folders (the root and others)
internal/secrets SOPS + age, three-way merge, .env merge by key
internal/data tar.zst snapshots in S3
internal/blobs folder ↔ S3 mirrors
internal/syncer the pass, fetch plan, poll, machine status, comparison
internal/offload offload / bring back repos
internal/update release updates
internal/tree, ui the full-screen app (Bubble Tea)
internal/install install, timer, doctor
License
MIT — see LICENSE.



