Skip to content
carlassmannPublic

About

Parallel git worktree workflows: per-workspace commands, stable .localhost URLs, tmux-parged agents, one CLI.

Resources

Stars

7 stars

Watchers

0 watching

Forks

Latest commit

 

History

25 Commits

Folders and files

Repository files navigation

work

license node status deps

A workflow CLI for parallel git worktrees.

The problem

You hit "new worktree" in Codex, or start Claude Code with --worktree. Now you want to actually see what it built. So:

  • install deps — easy
  • get the env vars right — maybe
  • get the supporting services (db, queue, sync server, …) isolated from the other worktrees — meh
  • and now those isolated services need their own URLs and credentials threaded back into the env — urgh
  • open a terminal for each long-running process — times every worktree you're juggling

Your agent runs for 30+ minutes, so you'd like to spin up the next worktree in parallel. Instead you have a pile of terminals open per workspace and you've lost track of which one is which.

The individual pieces are already solved:

Tool Solves
git worktree coexisting checkouts per branch
portless stable *.localhost URLs, no port collisions

work is the glue: a per-workspace setup script, a config of long-running commands, a tiny supervisor, and a single CLI to drive it.

Zero runtime dependencies — just Node ≥ 20.12 stdlib. The full CLI ships as one ~30 KB JS file.

What you get

work create feature-x       # create a worktree without setup or processes
work create feature-x --remote origin  # fetch + track a remote branch
work up feature-x --create   # create worktree, run setup, start configured servers
work urls feature-x          # see where everything is reachable
work logs -f web             # tail one service
work down feature-x          # tear it all down

Routed commands get a stable URL of the form:

{command}-{workspace}-{project}.localhost
# e.g. web-feature-x-tilly.localhost, sync-feature-x-tilly.localhost

Logs and state live as plain files in ~/.work-cli/ so you can cat, tail, jq them.

Install

Not on npm yet — clone and link locally with Bun:

git clone https://github.com/ccssmnn/work-cli.git
cd work-cli
bun install
bun run build
bun link

Optional but recommended: install portless (for route: true commands). Verify with work doctor.

Shell integration (completion + work cd) — add to ~/.zshrc or ~/.bashrc:

eval "$(work shell-init zsh)"    # or: bash

Remote HTTPS with Cloudflare Tunnel

Opt in per invocation; existing commands and setup scripts stay unchanged:

work setup --cloudflare
work up --cloudflare

Every routed command gets one stable HTTPS label:

https://{machine}-{command}-{workspace}-{project}.example.com

WORK_URL, every WORK_<ID>_URL, WebSocket variants, and both JSON maps use these URLs. Normal commands remain on *.localhost.

One-time machine setup:

brew install cloudflared
cloudflared tunnel login
cloudflared tunnel create work-cbook

Put the settings in a gitignored .env.local at the project root:

WORK_CLOUDFLARE_DOMAIN=example.com
WORK_CLOUDFLARE_TUNNEL_ID=<tunnel UUID>
# Recommended: owner + random 64-bit token + hostname
WORK_CLOUDFLARE_MACHINE=carl-7f3a91c8d2e4b6a8-cbook

Generate the random token once with openssl rand -hex 8, keep the resulting machine value stable, and use a different value per machine. This makes URLs impractical to guess while preserving stable origins. It is obscurity, not authentication: anyone who learns a URL can open it. Do not publish the value in a public repository.

work loads .env.local from the project root, then the selected workspace; the invoking shell overrides both. It removes all WORK_CLOUDFLARE_* values before starting setup scripts or dev servers. Other Cloudflare variables, such as CLOUDFLARE_API_TOKEN, remain available to commands that need them. Once a workspace is running through Cloudflare, work run, work restart, and work up inherit that mode without another flag. Run work down before switching the workspace back to local mode. cloudflared tunnel create stores credentials in its standard directory; work discovers them automatically.

The domain must use Cloudflare DNS. Keeping the complete work name in one label, directly below the zone, allows standard Universal SSL to cover it. work creates each exact DNS CNAME and keeps one machine-wide connector synchronized with active commands. DNS records remain stable after commands stop.

In WorkOS, allow CORS origin https://*.example.com and the required non-default redirect wildcard, such as https://*.example.com/callback. Published Tunnel hostnames remain internet-reachable; the random label only makes discovery difficult.

Private HTTPS on your tailnet with Tailscale

Open previews from your phone or tablet with a real certificate, without making them public:

work setup --tailscale
work up --tailscale

Every routed command gets a stable hostname on a domain you control:

https://{command}-{workspace}-{project}.dev.example.com

The name resolves to this machine's Tailscale address, so only devices in your tailnet can reach it. Because each workspace has its own hostname, sessions and cookies stay separate, and an OAuth provider needs a single wildcard redirect URI.

One-time machine setup, guided:

work tailscale setup

It asks for the domain, points *.dev.example.com at this machine's Tailscale address, gets a Let's Encrypt certificate with acme.sh (offering to install it with Homebrew), and saves the domain in ~/.config/work/config.json. With Cloudflare DNS it does all of this itself with an API token scoped to "Edit zone DNS" on that zone; with other providers it prints the record and the certificate command. It also offers to let Portless serve the domain on this machine (see below). Running it again only redoes what is missing or about to expire, and work doctor reports both.

To configure it by hand instead, put these in a gitignored .env.local:

WORK_TAILSCALE_DOMAIN=dev.example.com
WORK_TAILSCALE_CERT=~/path/to/fullchain.pem
WORK_TAILSCALE_KEY=~/path/to/key.pem
# WORK_TAILSCALE_PORT=47443   local proxy port

work starts a small HTTPS proxy on 127.0.0.1 and points tailscale serve's TCP port 443 at it, so no root is needed. Each request goes through the command's local Portless URL with X-Forwarded-Host and X-Forwarded-Proto set to the tailnet origin, so the app sees the same *.localhost host as locally. Renewed certificate files are picked up automatically. When the last Tailscale command stops, work stops the proxy and removes the port 443 forward.

WORK_URL and the other WORK_*_URL variables use the tailnet URLs, as with Cloudflare. The domain resolves to the machine's own tailnet address, which tailscale serve answers only for other devices. So that the same URLs open on the machine itself, work tailscale setup offers to reinstall the Portless service with the domain as a second TLD (Portless 0.15.1 or newer):

portless service install --tld localhost --tld dev.example.com

Portless then registers {command}-{workspace}-{project}.dev.example.com next to the .localhost name and points it at 127.0.0.1 in /etc/hosts. Without it, use the *.localhost URLs on the machine itself. Like Cloudflare mode, the workspace keeps Tailscale mode for later run, restart, and up calls; run work down before switching back. In WorkOS or another OAuth provider, allow https://*.dev.example.com and the callback path.

Example: a real workflow

A real work.config.js from tilly — an Astro PWA with a Jazz sync server. Each workspace gets its own isolated sync server, and the web app is told which sync URL to talk to via env var:

// tilly/work.config.js
export default {
  project: "tilly",
  worktrees: {
    dir: "../tilly.worktrees",
    setup: "bun scripts/work-setup.ts",
  },
  commands: {
    sync: {
      run: 'bunx jazz-run sync --port "$PORT" --host "$HOST"',
      autoStart: true,
      route: true,
    },
    web: {
      run: 'PUBLIC_JAZZ_SYNC_SERVER="wss://sync-${WORK_WORKSPACE}-tilly.localhost" astro dev --port "$PORT" --host "$HOST"',
      autoStart: true,
      route: true,
    },
  },
}

scripts/work-setup.ts does the per-worktree prep — copy .env.local, run codegen, whatever the workspace needs. It receives:

Env var What it points to
WORK_ROOT the workspace (worktree) being set up
WORK_SOURCE_ROOT the main repo — useful for copying .env.local etc.
WORK_WORKSPACE slugified branch name
WORK_PROJECT project slug from config
WORK_URL primary routed URL (the web command, if routed)
WORK_WEB_URL same as WORK_URL
WORK_SYNC_URL full URL for the sync command, if routed
WORK_SYNC_WS_URL WebSocket URL for the sync command, if routed
WORK_URLS JSON of all routed URLs, keyed by command id
WORK_WS_URLS JSON of WebSocket URLs, keyed by command id

WORK_SOURCE_ROOT always resolves to the main worktree via git worktree list, so it works the same whether you ran work from the main repo or from another worktree. Configured commands receive the same routed URL variables.

Set a global default in ~/.config/work/config.json:

{ "maxTtlSeconds": 3600 }

With XDG_CONFIG_HOME set, work reads $XDG_CONFIG_HOME/work/config.json instead. Set maxTtlSeconds at the top level of the main worktree's work.config.js to override the global default for that project's workspaces. Use export WORK_MAX_TTL_SECONDS=3600 to override both for commands started from that shell. Precedence is env, project config, global config, then unlimited. Values must be finite, non-negative seconds; 0 disables TTL at any level.

The daemon captures TTL when each command starts and checks expiry every second. Automatic restarts and daemon restarts preserve the deadline. Explicit restarts reset it and read the current env and global default. Restarts from tracked state use the stored project setting. Changing a default leaves running commands on their original deadline. Expired commands stay stopped, even with restart: "on-exit". TTL requires a running daemon.

bun run build stamps the Git commit into the CLI. work --version shows it; -dirty means the build includes uncommitted changes. Builds without Git metadata show an unknown commit and skip update checks.

workd checks GitHub's main at startup and hourly, without blocking process supervision or daemon startup. work updates reads the result from ~/.work-cli/updates.json and shows when it was checked. Normal commands do no update-related disk or network I/O. Checks time out after five seconds per request; failed checks retain the last successful result and retry after an hour. A recent attempt also suppresses another check after a daemon restart. Cache entries belong to the installed commit, so another build cannot reuse an old "up to date" result.

Start workd with work daemon start to enable checks. Stop/start it after rebuilding to load the new build. WORK_NO_UPDATE_CHECK=1 disables checks when workd starts. Checks report available updates; they do not install them.

Day in the life:

# Codex finishes a worktree on the `chat-streaming` branch.
# Spin it up — creates ../tilly.worktrees/chat-streaming, runs setup, starts both servers.
work up chat-streaming --create

# Wait until the web app answers, then open it. Sync server is already wired up via the env var.
work wait chat-streaming web
open https://web-chat-streaming-tilly.localhost

# Meanwhile, the agent is grinding for 30 minutes. Start the next worktree in parallel.
work up image-uploads --create

# Or just create a clean worktree without setup or servers.
work create refactor-sidebar

# Need to debug? Tail the sync server logs.
work logs -f -w chat-streaming sync

# Done with this branch — stop everything.
work down chat-streaming

At any moment:

work ps           # what's running here
work ps -a        # what's running everywhere
work watch        # live-refresh the ps table
work watch -a     # live-refresh everything
work urls         # routed URLs for the current workspace
work doctor       # diagnose anything broken

For scripts and agents, work ps --json (or status --json) and work urls --json print arrays of { project, workspace, command, ... } objects. They leave out stored environments and credentials, and print [] when nothing matches.

Adopting a worktree created by another tool

If a worktree already exists — created by git worktree add directly, Codex, Claude Code, or anything else — cd into it and let work derive everything from the current branch:

cd /path/to/the/worktree
work setup        # run the per-workspace setup script against this worktree
work up           # start the configured servers (no --create needed)

The workspace name is the slugified branch name. The worktree root is $PWD. No path flag needed.

Checking out a remote branch

When the worktree doesn't exist yet, work create (and work up --create) resolves the branch like git checkout does:

  1. a local branch with that name → used as-is
  2. exactly one remote has the branch → fetched, then checked out as a local tracking branch
  3. otherwise → a new branch off HEAD

To grab a branch you've never fetched, name the remote — work fetches it first and fails if the branch doesn't exist there:

work create feature-x --remote origin
work up feature-x --create --remote origin   # same, plus setup + servers

Branch names keep their real spelling; only the workspace slug is normalized (feat/foo → workspace feat-foo, branch stays feat/foo).

Reference

work --help              # all subcommands
work <command> --help    # one subcommand
work help run            # same help, command form
work docs                # list built-in topics (config, urls, daemon, …)
work docs config         # full config field reference
work docs setup          # setup-hook env vars

Development

Requires Bun for the dev loop. Build targets Node ≥ 20.12 with zero runtime dependencies.

bun run dev -- doctor   # run the CLI from source
bun test                # node:test runner
bun run check           # typecheck + lint + knip
bun run build           # emit dist/

License

MIT © Carl Assmann

About

Parallel git worktree workflows: per-workspace commands, stable .localhost URLs, tmux-parged agents, one CLI.

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages