wsandClaude Opus 5.5 840d2bf601
release / release (push) Successful in 2m59s
Reload the poll LaunchAgent from the tray when launchd cannot spawn it
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>
2026-10-31 10:00:08 +03:00
2026-10-27 11:42:12 +03:00
2026-10-27 11:42:12 +03:00
2026-10-27 11:42:12 +03:00
2026-10-27 11:42:12 +03:00
2026-10-27 11:42:12 +03:00
2026-10-27 11:42:12 +03:00
2026-10-27 11:42:12 +03:00
2026-10-27 11:42:12 +03:00

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.

Files: every path with where it syncs and its state on each machine

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.

A repo: its origin, where its branches and WIP go, and every machine's state

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:

  1. 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/env by default, KEY=value lines) and refer to them as ${NAME} — ws refuses plain secrets in the config.
  2. ws machine add <name>, ws doctor, then ws 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.

Machines: a card per machine and a comparison

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.

Setup: the hub, storage, credentials and machines

How it works

  • ~/projects/mani.yaml lists every repo (mani format; your own mani tasks in it are kept). A repo cloned or git 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>.list is 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.

S
Description
Keep ~/projects (repos, uncommitted work, secrets, data) identical on every machine — through your own git server and S3
Readme MIT
2.3 MiB
0 Stars 1 Watchers 0 Forks
v0.1.8
Latest
2026-10-31 10:02:48 +03:00
Languages
Go 98.3%
Shell 1.7%