From bbc863f2a58c91b46789935b6e492b57443dd475 Mon Sep 17 00:00:00 2001 From: KailasMahavarkar Date: Wed, 22 Jul 2026 12:49:26 +0530 Subject: [PATCH] feat(worktree-isolation): refresh the wtg reference to the current engine The reference implementation had drifted well behind the working tool. Brings across the engine improvements, keeping the same shape: a CONFIG block of pure data and a stack-agnostic engine that hardcodes no role, port, or path. New surface: - deps: declare a dep dir per role (node_modules, .venv) and the command that builds it. A worktree is only a checkout, so without this a fresh member starts and immediately dies. new/add now seed config and build deps, leaving a runnable tree; --no-env/--no-deps opt out. - bind: creating a group from the current tree is now explicit. A bare `wtg ` binds only into an existing group, so a typo such as `wtg downfoo` can no longer create a group, rebind the tree away from its real one, and overwrite its dev.sh. - --base : per-run start point. Repos are fetched first and a bare name resolves to origin/, so a worktree is cut from the latest remote tip rather than a stale local branch. - st: one table carrying branch, clean/DIRTY, upstream, commits behind base, port, config and dep state, followed by a warnings block that prints the exact command to fix each problem. --fetch refreshes first. - up/down: members launch in their own process group and down kills the group. Killing only the recorded pid left the pnpm->vite and uvicorn-reloader children alive holding the ports, so every later up died with EADDRINUSE while orphans kept serving stale code. - WTG_WORKDIR, WTG_LAUNCHER_ARGS, WTG_OAUTH_ROLES, and WTG_API_ROLE split from WTG_PRIMARY_ROLE, since the backend a frontend calls is not always the tree the agent opens in nor the one holding the OAuth redirect. Seeded config is written with install -m 600 rather than cp. These files carry live secrets and cp takes the caller's umask, which on a common 0002 leaves them world-readable at 0644. Defaults stay tool-agnostic: XDG paths, a generic api/web example, and no launcher arguments. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/worktree-isolation/SKILL.md | 9 +- skills/worktree-isolation/references/SETUP.md | 72 ++- .../references/multi-repo-feature-groups.md | 26 +- .../references/wtg.reference.sh | 443 +++++++++++++----- 4 files changed, 417 insertions(+), 133 deletions(-) diff --git a/skills/worktree-isolation/SKILL.md b/skills/worktree-isolation/SKILL.md index ff91ea7..37a2e5d 100644 --- a/skills/worktree-isolation/SKILL.md +++ b/skills/worktree-isolation/SKILL.md @@ -221,7 +221,12 @@ Runtime hazards when several members run at once: secrets) is never created in a fresh worktree, but the services read it - so a new member boots mis-configured until its config is seeded from the repo's main checkout. Provide an idempotent seed step and run it before starting the - stack. + stack. Write those files with an explicit `0600` mode rather than a plain + copy, which inherits the caller's umask and can leave secrets world-readable. +- **Dependencies not carried either.** A worktree has no `node_modules` / + `.venv`, so a new member starts and dies. Declare per role the dep dir and the + command that builds it, and build at creation time - creation should leave a + runnable tree, not one that fails on first run. - **Port collisions.** Give each group a port slot and derive every service's port from it (e.g. `base + 10*slot`). If a service hardcodes its port in source instead of reading an env var, the slot must be applied on the launch @@ -263,6 +268,8 @@ be a shell launcher, not a startup hook. | No package.json/Cargo.toml | Skip dependency install | | Feature spans multiple repos | One worktree per repo, bind them in a group registry | | Branch names differ per repo | Bind explicitly - never auto-match by branch | +| New member starts then dies | Its config and dep dir were never seeded - both are gitignored | +| Seeding config with secrets | Write with an explicit `0600` mode, not a umask-dependent copy | ## Cleanup diff --git a/skills/worktree-isolation/references/SETUP.md b/skills/worktree-isolation/references/SETUP.md index 0f5b36f..fab0bee 100644 --- a/skills/worktree-isolation/references/SETUP.md +++ b/skills/worktree-isolation/references/SETUP.md @@ -43,15 +43,24 @@ declare -A WTG_PORT=( # role -> base port (final = base + 1 [api]=8080 [web]=3000 ) declare -A WTG_RUN=( # role -> launch command written into dev.sh [api]='SERVER_PORT=%PORT% go run ./cmd/server' - [web]='VITE_API_URL=http://localhost:%PRIMARY_PORT% npm run dev -- --port %PORT%' ) + [web]='VITE_API_URL=http://localhost:%API_PORT% npm run dev -- --port %PORT%' ) +declare -A WTG_WORKDIR=( # role -> run subdir inside the worktree (optional) + [web]='apps/web' ) declare -A WTG_ENV=( # role -> env files to seed (space-sep); default ".env" [web]="apps/web/.env" ) - -WTG_PRIMARY_ROLE="api" # the OAuth-bearing role; the tree `wtg go` opens in +declare -A WTG_DEPS=( # role -> dep dir a fresh worktree lacks + [web]='node_modules' ) +declare -A WTG_SETUP=( # role -> how to build it, run at the worktree root + [web]='npm install' ) + +WTG_PRIMARY_ROLE="api" # the tree `wtg go` opens in (siblings are --add-dir'd) +WTG_API_ROLE="api" # backend role %API_PORT% resolves to (what a frontend calls) +WTG_OAUTH_ROLES="api" # roles that get the OAuth port-swap prelude in dev.sh WTG_OAUTH_PORT=8080 # port your OAuth redirect_uris are registered against -WTG_OAUTH_VARS="PUBLIC_BASE_URL OAUTH_REDIRECT_URL" # host:port URLs to port-swap for the primary +WTG_OAUTH_VARS="PUBLIC_BASE_URL OAUTH_REDIRECT_URL" # host:port URLs to port-swap WTG_WORKROOT="$HOME/work" # where worktrees are created -WTG_BASE_BRANCH="origin/main" # branch new worktrees fork from +WTG_BASE_BRANCH="main" # start-point for new worktrees; per-run: --base +WTG_LAUNCHER_ARGS="" # extra argv for `wtg go` (word-split) ``` Run-command placeholders, substituted when `dev.sh` is generated: @@ -59,11 +68,20 @@ Run-command placeholders, substituted when `dev.sh` is generated: | placeholder | becomes | |---|---| | `%PORT%` | this member's port for the group's slot | -| `%PRIMARY_PORT%` | the primary role's port (e.g. wire a frontend to its backend) | +| `%API_PORT%` | `WTG_API_ROLE`'s port (e.g. wire a frontend to its backend) | | `%OAUTH_PORT%` | the OAuth-registered port | -Only `WTG_PRIMARY_ROLE` receives the OAuth port-swap prelude and is the tree -`wtg go` opens in; every other role simply runs its command. +`WTG_PRIMARY_ROLE`, `WTG_API_ROLE` and `WTG_OAUTH_ROLES` are often the same role. +Split them when they differ - e.g. the agent opens in a worker service while the +UI calls a separate api, and only the api carries the OAuth redirect. + +`WTG_BASE_BRANCH` takes a bare name: repos are fetched first and the name +resolves to `origin/`, so a new worktree is always cut from the latest +remote tip rather than a stale local branch. `origin/x`, a tag or a sha are used +as given, and `--base ` overrides per run. + +> Seeded config is written with `install -m 600`, not `cp` - these files carry +> live secrets, and `cp` would take the caller's umask and leave them 0644. ## 4. Registry @@ -76,19 +94,30 @@ source of truth; safe to hand-edit. Back it up if you like; it is small. Using the example roles `api` / `web` from the config above (substitute yours): ```bash -wtg new checkout api web # a worktree + feat/checkout branch per repo, bound, with dev.sh -wtg env checkout # seed each repo's base env into the new worktrees +wtg new checkout api web # worktree + feat/checkout branch per repo, bound, dev.sh, + # .env seeded and deps installed - a RUNNABLE tree wtg up checkout # run the whole stack (tmux windows, or background) -wtg st checkout # branch / dirty / ahead-behind / port per member +wtg st checkout # full table + a warnings block with the fix for each wtg go checkout # open one agent session across the worktrees wtg down checkout # stop the stack ``` +`new` and `add` seed config and build deps for you, so there is no separate +setup step; `--no-env` / `--no-deps` opt out, and `wtg env` / `wtg deps` re-run +either on demand (`--force` to overwrite / rebuild). + Second concurrent feature gets the next port slot automatically: ```bash wtg new billing api web # slot 1 -> api :8090, web :3010 -wtg claim-oauth billing # when you need billing's OAuth: move it onto the primary port +wtg claim-oauth billing # when you need billing's OAuth: move it onto the registered port +``` + +Cut from somewhere other than the default base: + +```bash +wtg new hotfix --base release/2.1 api # bare name resolves to origin/release/2.1 +wtg add hotfix web # grow an existing group, reusing its slot ``` ## 6. Optional: Claude Code statusline integration @@ -122,16 +151,23 @@ exit; it never touches your shell prompt. | command | does | |---|---| -| `wtg new ` | create a new group: worktree + branch + bind + dev.sh per role | -| `wtg add ` | add member(s) to an existing group (reuses its slot) | -| `wtg [role]` | bind the current worktree into a group | -| `wtg env [--force]` | seed base `.env` into the group's worktrees | +| `wtg new [--base ] [--no-env] [--no-deps] ` | create a group: worktree + branch + bind + dev.sh + env + deps per role | +| `wtg add [--base ] ` | add member(s) to an existing group (reuses its slot) | +| `wtg [role]` | bind the current worktree into an **existing** group | +| `wtg bind [role]` | same, but may create a new group (explicit, so typos cannot) | +| `wtg env [--force]` | (re)seed base config into the group's worktrees | +| `wtg deps [--force]` | (re)build each member's dep dir | | `wtg up ` / `wtg down ` | run / stop the whole stack | -| `wtg st [group]` | cross-repo branch / dirty / ahead-behind / port | +| `wtg st [group] [--fetch]` | full table: branch, clean/DIRTY, upstream, behind base, port, env, deps + warnings | | `wtg go [group]` | launch one agent session across the set (menu if no group) | | `wtg claim-oauth ` | move a group onto the OAuth-registered port | | `wtg rm` | unbind the current worktree (keeps the checkout) | -| `wtg` / `wtg ls` | current group status / list all groups | +| `wtg` / `wtg ls` / `wtg help` | current group status / list all groups / command reference | + +A bare `wtg ` only binds into a group that already **exists**. That guard +matters: without it a typo like `wtg downfoo` silently creates a group `downfoo`, +rebinds the current tree away from its real group, and overwrites its `dev.sh`. +Use `wtg bind` to create one deliberately. ## 8. Testing your config safely diff --git a/skills/worktree-isolation/references/multi-repo-feature-groups.md b/skills/worktree-isolation/references/multi-repo-feature-groups.md index 1cdd227..e39b114 100644 --- a/skills/worktree-isolation/references/multi-repo-feature-groups.md +++ b/skills/worktree-isolation/references/multi-repo-feature-groups.md @@ -36,16 +36,22 @@ subdirectory. | verb | does | |---|---| -| `new ` | create a worktree + feature branch per role, bind them, assign the next free slot, generate a run script each | +| `new ` | create a worktree + feature branch per role, bind them, assign the next free slot, generate a run script, seed config, build deps | | `add ` | add member(s) to an existing group, reusing its slot | | `bind [role]` | fold the current worktree in, inheriting the group's slot | | `env ` | seed gitignored config (`.env`, secrets) from each repo's main checkout into the worktrees | +| `deps ` | build each member's dependency dir (see "Dependencies" below) | | `up` / `down ` | run / stop every member's run script as one stack | -| `st ` | cross-repo status: branch, dirty, ahead/behind, port per member | +| `st ` | cross-repo status: branch, dirty, ahead/behind, behind-base, port, config, deps | | `rm` | unbind the current worktree (keep the checkout) | | `claim-oauth ` | move a group onto the OAuth-registered port (see below) | | `go [group]` | open one agent/editor session across the whole set | +Bind-by-bare-name must resolve to an **existing** group. Otherwise a mistyped +verb (`wtg downfoo`) silently creates a group, rebinds the current tree away from +its real one, and overwrites its run script. Creating a group from the current +tree should be its own explicit verb. + ## Port slots (running several groups at once) Give each group an integer `slot`. Derive every service's port from it so groups @@ -91,6 +97,22 @@ local secrets) is never created in a fresh worktree, but services read it - so a new member boots mis-configured until you seed its config from the repo's main checkout. Make the seed step idempotent (skip existing files unless forced). +Write seeded config with an explicit mode (`install -m 600`), not a plain copy. +These files hold live credentials, and a copy inherits the caller's umask - on a +common `0002` umask that leaves secrets world-readable at `0644`. + +## Dependencies are not carried either + +The same gap applies to build output: a worktree has no `node_modules`, +`.venv`, or equivalent, so a freshly created member starts and immediately dies. +Declare per role both the directory that must exist and the command that builds +it, then run it at creation time. Skip a dir that is already present unless a +force flag is passed, and log failures with the tail of the build log - a silent +dependency failure looks identical to a broken run script later. + +Creation should therefore leave a **runnable** tree: worktree + branch + run +script + config + dependencies. Anything less defers a failure to first run. + ## Grouped session (working the set as one) Open one agent/editor session in the group's primary worktree and grant it diff --git a/skills/worktree-isolation/references/wtg.reference.sh b/skills/worktree-isolation/references/wtg.reference.sh index eb6842d..90cbf7c 100644 --- a/skills/worktree-isolation/references/wtg.reference.sh +++ b/skills/worktree-isolation/references/wtg.reference.sh @@ -4,8 +4,10 @@ # the "Multi-Repo Feature Groups" pattern (see multi-repo-feature-groups.md). # # Stack-agnostic: roles are arbitrary and every stack-specific behaviour lives in -# the CONFIG block as data. The implementation below knows nothing about any -# particular language, framework, or service. +# the CONFIG block as data. The engine below knows nothing about any particular +# language, framework, or service, and hardcodes no role, port, or path. +# Registry: $WTG_FILE (grouproleworktree-toplevelslot). slot 0 = canonical ports. +# Commands: new / add / bind / [role] / env / deps / up / down / st / rm / claim-oauth / go / ls / help # # INSTALL: copy this file, edit the CONFIG block, then in your shell rc: # source /path/to/wtg.reference.sh (requires bash 4+ for associative arrays) @@ -14,52 +16,62 @@ # ============================================================================ # CONFIG - EDIT FOR YOUR STACK. Roles are yours to name; nothing below assumes any. # ============================================================================ -WTG_FILE="${WTG_FILE:-$HOME/.config/wtg/worktree-groups.tsv}" # registry location -WTG_WORKROOT="${WTG_WORKROOT:-$HOME/work}" # where worktrees are created -WTG_BASE_BRANCH="${WTG_BASE_BRANCH:-origin/main}" # branch new worktrees fork from +WTG_FILE="${WTG_FILE:-$HOME/.config/wtg/worktree-groups.tsv}" # registry (grouproletoplevelslot) +WTG_WORKROOT="${WTG_WORKROOT:-$HOME/work}" # where worktrees are created +WTG_BASE_BRANCH="${WTG_BASE_BRANCH:-main}" # start-point for new worktrees; per-run override: --base # The roles a feature can span. One role = one repo. Order is cosmetic. WTG_ROLES=(api web) -# role -> main-checkout path of that role's repo. -declare -A WTG_REPO=( +declare -A WTG_REPO=( # role -> that repo's main-checkout path [api]="$HOME/work/api" - [web]="$HOME/work/web" -) - -# role -> base port. Final port = base + 10 * slot (so groups never collide). -declare -A WTG_PORT=( + [web]="$HOME/work/web" ) +declare -A WTG_PORT=( # role -> base port. Final = base + 10*slot, so groups never collide [api]=8080 - [web]=3000 -) - -# role -> launch command written into dev.sh. Placeholders substituted at generation: -# %PORT% this member's port for the group's slot -# %PRIMARY_PORT% the primary role's port (e.g. a frontend pointing at its backend) -# %OAUTH_PORT% the OAuth-registered port -declare -A WTG_RUN=( + [web]=3000 ) +declare -A WTG_RUN=( # role -> launch command written into dev.sh (run via `env`, so a VAR=val prefix is fine) [api]='SERVER_PORT=%PORT% go run ./cmd/server' - [web]='VITE_API_URL=http://localhost:%PRIMARY_PORT% npm run dev -- --port %PORT%' -) - -# role -> space-separated relative env files to seed with `wtg env`. Default ".env". -declare -A WTG_ENV=( - [web]="apps/web/.env" -) + [web]='VITE_API_URL=http://localhost:%API_PORT% npm run dev -- --port %PORT%' ) +declare -A WTG_WORKDIR=( # role -> run subdir relative to the worktree (optional) + [web]='apps/web' ) +declare -A WTG_ENV=( # role -> space-separated relative env files to seed. Default ".env" + [web]='apps/web/.env' ) +declare -A WTG_DEPS=( # role -> dep dir a fresh worktree lacks (git checks out tracked files only) + [web]='node_modules' ) +declare -A WTG_SETUP=( # role -> how to build that dep dir; run at the worktree root on new/add + [web]='npm install' ) -WTG_PRIMARY_ROLE="api" # the OAuth-bearing role; also the tree `wtg go` opens in -WTG_OAUTH_PORT=8080 # the port your OAuth redirect_uris are registered against -WTG_OAUTH_VARS="PUBLIC_BASE_URL OAUTH_REDIRECT_URL" # host:port URLs to port-swap for the primary role +WTG_PRIMARY_ROLE="api" # role `wtg go` opens in (siblings are --add-dir'd) +WTG_API_ROLE="api" # backend role %API_PORT% resolves to (what a frontend calls) +WTG_OAUTH_ROLES="api" # roles that get the OAuth port-swap prelude in dev.sh +WTG_OAUTH_PORT=8080 # the port your OAuth redirect_uris are registered against +WTG_OAUTH_VARS="PUBLIC_BASE_URL OAUTH_REDIRECT_URL" # host:port env vars to port-swap for OAuth roles +WTG_LAUNCHER="${WTG_LAUNCHER:-claude}" # binary `wtg go` execs +WTG_LAUNCHER_ARGS="${WTG_LAUNCHER_ARGS:-}" # extra launcher argv, word-split (e.g. --dangerously-skip-permissions) +# PRIMARY / API / OAUTH are often the same role; split them when they differ - e.g. +# the agent opens in a worker service but the UI calls a separate api, and only the +# api carries the OAuth redirect. A bare name in WTG_BASE_BRANCH resolves to +# origin/ after fetch, so a new worktree is cut from the latest remote tip. # ============================================================================ # END CONFIG. Generic, stack-agnostic implementation below - no need to edit. # ============================================================================ _wtg_repo() { echo "${WTG_REPO[$1]}"; } _wtg_port() { local base=${WTG_PORT[$1]:-9000}; echo $(( base + ${2:-0} * 10 )); } -_wtg_primary_port() { _wtg_port "$WTG_PRIMARY_ROLE" "$1"; } +_wtg_api_port() { _wtg_port "$WTG_API_ROLE" "$1"; } +_wtg_is_oauth_role() { case " ${WTG_OAUTH_ROLES:-$WTG_API_ROLE} " in *" $1 "*) return 0;; esac; return 1; } -# reponame -> role (reverse of WTG_REPO), for binding a worktree without an explicit role -_wtg_role() { +# base ref -> a start-point that exists in this repo. A bare name prefers origin/, +# so a worktree cut straight after `fetch origin` is always on the latest remote tip. +_wtg_base_ref() { # repo base + local repo=$1 base=$2 c + for c in $(case "$base" in origin/*|refs/*) echo "$base";; *) echo "origin/$base $base";; esac); do + git -C "$repo" rev-parse --verify --quiet "$c^{commit}" >/dev/null 2>&1 && { echo "$c"; return 0; } + done + return 1 +} + +_wtg_role() { # reponame -> role (first WTG_REPO whose basename matches) local r for r in "${WTG_ROLES[@]}"; do [ "$(basename "${WTG_REPO[$r]}")" = "$1" ] && { echo "$r"; return; } @@ -73,27 +85,28 @@ _wtg_next_slot() { _wtg_group_slot() { awk -F'\t' -v g="$1" '$1==g && NF>=4 && $4!=""{print $4; exit}' "$WTG_FILE" 2>/dev/null; } _wtg_group_at_slot() { awk -F'\t' -v s="$1" 'NF>=4 && $4==s{print $1; exit}' "$WTG_FILE" 2>/dev/null; } -# generic dev.sh: substitutes the role's run command, and for the primary role -# emits the OAuth port-swap prelude. No role names are special-cased. +# generic dev.sh: substitutes the role's run command; OAuth-roles get the port-swap prelude. _wtg_write_devsh() { # role path port grp slot - local role=$1 path=$2 port=$3 grp=$4 slot=$5 f="$2/dev.sh" run pport - pport=$(_wtg_primary_port "$slot") - run=${WTG_RUN[$role]:-"echo 'wtg: no run command configured for role $role (port $port)'"} - run=${run//%PORT%/$port}; run=${run//%PRIMARY_PORT%/$pport}; run=${run//%OAUTH_PORT%/$WTG_OAUTH_PORT} + local role=$1 path=$2 port=$3 grp=$4 slot=$5 f="$2/dev.sh" run aport wd + aport=$(_wtg_api_port "$slot") + run=${WTG_RUN[$role]:-"echo 'wtg: no run command for role $role (port $port)'"} + run=${run//%PORT%/$port}; run=${run//%API_PORT%/$aport}; run=${run//%OAUTH_PORT%/$WTG_OAUTH_PORT} + wd=${WTG_WORKDIR[$role]} { echo '#!/usr/bin/env bash' echo "# wtg: role '$role' for group '$grp' (slot $slot) on :$port." - if [ "$role" = "$WTG_PRIMARY_ROLE" ] && [ "$slot" -ne 0 ]; then + if _wtg_is_oauth_role "$role" && [ "$slot" -ne 0 ]; then echo "#" echo "# WARNING slot $slot: :$port is NOT the OAuth-registered port ($WTG_OAUTH_PORT)." - echo "# Provider redirect_uri will mismatch; OAuth flows fail here. Use: wtg claim-oauth $grp" + echo "# Provider redirect_uri mismatch; OAuth 'connect' flows fail here. Use: wtg claim-oauth $grp" fi echo 'cd "$(dirname "$0")" || exit 1' - if [ "$role" = "$WTG_PRIMARY_ROLE" ] && [ -n "$WTG_OAUTH_VARS" ]; then + if _wtg_is_oauth_role "$role" && [ -n "$WTG_OAUTH_VARS" ]; then echo "_g(){ grep -E \"^\$1=\" .env 2>/dev/null | head -1 | cut -d= -f2-; }" echo "for v in $WTG_OAUTH_VARS; do cur=\"\$(_g \"\$v\")\"; [ -n \"\$cur\" ] && export \"\$v=\${cur/:$WTG_OAUTH_PORT/:$port}\"; done" fi - echo "exec $run" + [ -n "$wd" ] && echo "cd $wd || exit 1" + echo "exec env $run" } > "$f" chmod +x "$f" } @@ -102,24 +115,42 @@ _wtg_dedup() { [ -f "$WTG_FILE" ] || return 0 awk -F'\t' -v p="$1" '$3!=p' "$WTG_FILE" > "$WTG_FILE.tmp" && mv "$WTG_FILE.tmp" "$WTG_FILE" } + +_wtg_bind_here() { # group [role] — bind the current worktree into + local grp=$1 top repo role slot port r + top=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "wtg: not in a git worktree" >&2; return 1; } + repo=$(basename "$(dirname "$(readlink -f "$(git rev-parse --git-common-dir)")")") + role=${2:-$(_wtg_role "$repo")} + for r in "${WTG_ROLES[@]}"; do + [ "${WTG_REPO[$r]}" = "$top" ] && { echo " ! note: $(basename "$top") is the MAIN checkout, not a feature worktree — 'wtg up $grp' will run dev.sh here"; break; } + done + slot=$(_wtg_group_slot "$grp") + _wtg_bind "$grp" "$role" "$top" "$slot" + if [ -n "$slot" ]; then + port=$(_wtg_port "$role" "$slot"); _wtg_write_devsh "$role" "$top" "$port" "$grp" "$slot" + echo "wtg: $(basename "$top") -> group '$grp' as '$role' (slot $slot, :$port, dev.sh)" + else echo "wtg: $(basename "$top") -> group '$grp' as '$role'"; fi +} _wtg_bind() { mkdir -p "$(dirname "$WTG_FILE")"; touch "$WTG_FILE"; _wtg_dedup "$3"; printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" >> "$WTG_FILE"; } _wtg_group_exists() { awk -F'\t' -v g="$1" '$1==g{f=1} END{exit !f}' "$WTG_FILE" 2>/dev/null; } -_wtg_add_member() { # group role slot branch - local grp=$1 role=$2 slot=$3 branch=$4 repo path port +_wtg_add_member() { # group role slot branch base + local grp=$1 role=$2 slot=$3 branch=$4 base=$5 repo path port start from repo=$(_wtg_repo "$role") { [ -z "$repo" ] || [ ! -d "$repo" ]; } && { echo " ! $role: no repo ($repo) — check WTG_REPO — skipped"; return 1; } path="$WTG_WORKROOT/$grp-$role" [ -e "$path" ] && { echo " ! $role: $path exists — skipped"; return 1; } - git -C "$repo" fetch origin --quiet 2>/dev/null - if git -C "$repo" worktree add "$path" -b "$branch" "$WTG_BASE_BRANCH" >/dev/null 2>&1 \ - || git -C "$repo" worktree add "$path" "$branch" >/dev/null 2>&1; then - _wtg_bind "$grp" "$role" "$path" "$slot" - if [ -n "$slot" ]; then - port=$(_wtg_port "$role" "$slot"); _wtg_write_devsh "$role" "$path" "$port" "$grp" "$slot" - echo " + $role :$port $path (dev.sh)" - else echo " + $role $path (bound, no slot)"; fi + git -C "$repo" fetch origin --quiet || echo " ! $role: fetch failed — '$base' may be stale" + start=$(_wtg_base_ref "$repo" "$base") \ + || { echo " ! $role: base '$base' not found in $(basename "$repo") — skipped"; return 1; } + if git -C "$repo" worktree add "$path" -b "$branch" "$start" >/dev/null 2>&1; then from="from $start" + elif git -C "$repo" worktree add "$path" "$branch" >/dev/null 2>&1; then from="existing branch $branch, base ignored" else echo " ! $role: git worktree add failed — skipped"; return 1; fi + _wtg_bind "$grp" "$role" "$path" "$slot" + if [ -n "$slot" ]; then + port=$(_wtg_port "$role" "$slot"); _wtg_write_devsh "$role" "$path" "$port" "$grp" "$slot" + echo " + $role :$port $path ($from, dev.sh)" + else echo " + $role $path ($from, bound, no slot)"; fi } _wtg_regen_group() { # group newslot @@ -137,30 +168,184 @@ _wtg_copy_env() { # src dst force [ -f "$src" ] || { echo " ! no base env at $src (skipped)"; return 0; } [ "$src" = "$dst" ] && return 0 if [ -f "$dst" ] && [ -z "$force" ]; then echo " = $dst exists (use --force)"; return 0; fi - mkdir -p "$(dirname "$dst")" && cp "$src" "$dst" && echo " + seeded $dst" + # install -m 600, not cp: these carry live secrets (.env, service-account + # JSON, tfvars) and cp would take the caller's umask, leaving them 0644. + mkdir -p "$(dirname "$dst")" && install -m 600 "$src" "$dst" && echo " + seeded $dst" +} + +_wtg_tilde() { echo "${1/#$HOME/\~}"; } + +# Build a member's dep dir (node_modules / .venv / ...). git worktree gives you a +# checkout, not a runnable tree — without this, dev.sh starts and dies. +_wtg_deps_member() { # group role path [force] + local grp=$1 role=$2 path=$3 force=$4 dep=${WTG_DEPS[$role]} cmd=${WTG_SETUP[$role]} log + [ -z "$cmd" ] && { echo " = $role: no deps needed"; return 0; } + [ -d "$path" ] || { echo " ! $role: worktree missing ($path)"; return 1; } + if [ -e "$path/$dep" ] && [ -z "$force" ]; then echo " = $role: $dep/ already there (--force to rebuild)"; return 0; fi + log="$(_wtg_run_dir "$grp")/setup-$role.log"; mkdir -p "$(dirname "$log")" + echo " · $role: $cmd" + if ( cd "$path" && eval "$cmd" ) > "$log" 2>&1; then + echo " + $role: $dep/ ready" + else + echo " ! $role: setup FAILED — last lines of $(_wtg_tilde "$log"):" + tail -5 "$log" | sed 's/^/ /' + return 1 + fi +} + +_wtg_env_state() { # role path -> ok | MISS | have/want + local role=$1 path=$2 rel have=0 want=0 + for rel in ${WTG_ENV[$role]:-.env}; do + want=$((want + 1)); [ -f "$path/$rel" ] && have=$((have + 1)) + done + if [ "$have" -eq "$want" ]; then echo ok + elif [ "$have" -eq 0 ]; then echo MISS + else echo "$have/$want"; fi +} +_wtg_dep_state() { # role path -> ok | MISS | - (- = role needs no dep dir) + local dep=${WTG_DEPS[$1]} + [ -z "$dep" ] && { echo '-'; return; } + [ -e "$2/$dep" ] && echo ok || echo MISS +} + +# The one renderer behind `st`, `status`, `go` and `new`/`add`: everything you need +# to know before you start working in a group, in one pass. +_wtg_table() { # group [--fetch] + local grp=$1 fetch=$2 slot top g role path s br dirty sync behind port envs deps mark base_ref n=0 + local -a w_env w_dep w_dirty w_gone w_behind + slot=$(_wtg_group_slot "$grp") + top=$(git rev-parse --show-toplevel 2>/dev/null) + + printf '\n┌─ %s slot %s\n│\n' "$grp" "${slot:--}" + printf '│ %-1s %-4s %-26s %-6s %-9s %-13s %-6s %-5s %-5s %s\n' \ + '' role branch state upstream "behind $WTG_BASE_BRANCH" port env deps path + printf '│ %-1s %-4s %-26s %-6s %-9s %-13s %-6s %-5s %-5s %s\n' \ + '' ──── ────────────────────────── ────── ───────── ───────────── ────── ───── ───── ────────────────────── + while IFS=$'\t' read -r g role path s; do + [ "$g" = "$grp" ] || continue + n=$((n + 1)); mark=' '; [ "$path" = "$top" ] && mark='›' + if [ ! -d "$path" ]; then + w_gone+=("$role") + printf '│ %-1s %-4s %-26s %-6s %-9s %-13s %-6s %-5s %-5s %s\n' \ + "$mark" "$role" '(worktree missing)' - - - - - - "$(_wtg_tilde "$path")" + continue + fi + [ -n "$fetch" ] && git -C "$path" fetch origin --quiet 2>/dev/null + + br=$(git -C "$path" symbolic-ref --short HEAD 2>/dev/null || git -C "$path" rev-parse --short HEAD 2>/dev/null || echo '?') + if git -C "$path" diff --quiet 2>/dev/null && git -C "$path" diff --cached --quiet 2>/dev/null + then dirty=clean; else dirty=DIRTY; w_dirty+=("$role"); fi + + sync=$(git -C "$path" rev-list --left-right --count 'HEAD...@{upstream}' 2>/dev/null) + if [ -n "$sync" ]; then sync="+${sync%%[[:space:]]*}/-${sync##*[[:space:]]}"; else sync='unpushed'; fi + + behind='-' + base_ref=$(_wtg_base_ref "$path" "$WTG_BASE_BRANCH") \ + && behind=$(git -C "$path" rev-list --count "HEAD..$base_ref" 2>/dev/null || echo '?') + [ "$behind" != '-' ] && [ "$behind" != '0' ] && [ "$behind" != '?' ] && w_behind+=("$role:$behind") + + port='-'; [ -n "$s" ] && port=":$(_wtg_port "$role" "$s")" + envs=$(_wtg_env_state "$role" "$path"); [ "$envs" = ok ] || w_env+=("$role") + deps=$(_wtg_dep_state "$role" "$path"); [ "$deps" = MISS ] && w_dep+=("$role") + + printf '│ %-1s %-4s %-26.26s %-6s %-9s %-13s %-6s %-5s %-5s %s\n' \ + "$mark" "$role" "$br" "$dirty" "$sync" "$behind" "$port" "$envs" "$deps" "$(_wtg_tilde "$path")" + done < "$WTG_FILE" + [ "$n" -eq 0 ] && { printf '│ (no members)\n└─\n'; return 0; } + printf '│\n' + + local issues=0 + if [ ${#w_gone[@]} -gt 0 ]; then + printf '├─ ! worktree missing: %s — re-create with: wtg add %s %s\n' "${w_gone[*]}" "$grp" "${w_gone[*]}"; issues=1 + fi + if [ -n "$slot" ] && [ "$slot" -ne 0 ]; then + printf '├─ ! OAuth: slot %s puts %s on :%s, not the registered :%s — connect flows fail.\n│ claim it with: wtg claim-oauth %s\n' \ + "$slot" "$WTG_API_ROLE" "$(_wtg_api_port "$slot")" "$WTG_OAUTH_PORT" "$grp"; issues=1 + fi + if [ ${#w_env[@]} -gt 0 ]; then + printf '├─ ! .env not seeded: %s — worktrees do not inherit gitignored files.\n│ seed with: wtg env %s\n' "${w_env[*]}" "$grp"; issues=1 + fi + if [ ${#w_dep[@]} -gt 0 ]; then + for role in "${w_dep[@]}"; do + path=$(awk -F'\t' -v g="$grp" -v r="$role" '$1==g && $2==r{print $3; exit}' "$WTG_FILE") + printf '├─ ! %s: %s/ missing — dev.sh will not start.\n│ build it with: cd %s && %s\n' \ + "$role" "${WTG_DEPS[$role]}" "$(_wtg_tilde "$path")" "${WTG_SETUP[$role]:-}" + done; issues=1 + fi + [ ${#w_dirty[@]} -gt 0 ] && { printf '├─ · uncommitted changes: %s\n' "${w_dirty[*]}"; issues=1; } + [ ${#w_behind[@]} -gt 0 ] && { printf '├─ · behind %s: %s — rebase before you build on it\n' "$WTG_BASE_BRANCH" "${w_behind[*]}"; issues=1; } + [ "$issues" -eq 0 ] && printf '├─ ready: env seeded, deps built, clean, up to date\n' + [ -z "$fetch" ] && printf '│ (counts are vs the last fetch — wtg st %s --fetch to refresh)\n' "$grp" + printf '└─\n\n' } wtg() { - local top repo role grp slot port path branch other src_repo force rel + local top repo role grp slot port path branch other src_repo force rel base + local -a _roles case "$1" in - new) - grp=$2; shift 2 2>/dev/null - { [ -z "$grp" ] || [ $# -eq 0 ]; } && { echo "usage: wtg new (roles: ${WTG_ROLES[*]})"; return 1; } - _wtg_group_exists "$grp" && { echo "wtg: group '$grp' already exists — use 'wtg add $grp '"; return 1; } - branch="feat/$grp"; slot=$(_wtg_next_slot) - echo "wtg new: group '$grp' -> slot $slot, branch $branch" - for role in "$@"; do _wtg_add_member "$grp" "$role" "$slot" "$branch"; done - [ "$slot" -ne 0 ] && echo "note: slot $slot is offset — OAuth needs 'wtg claim-oauth $grp'" - echo "run: wtg up $grp" + new|add) + local cmd=$1; grp=$2; shift 2 2>/dev/null + base="$WTG_BASE_BRANCH"; _roles=() + local do_env=1 do_deps=1 + while [ $# -gt 0 ]; do + case "$1" in + --base|-B) base=$2; shift; [ $# -gt 0 ] && shift ;; + --base=*) base=${1#*=}; shift ;; + --no-env) do_env=""; shift ;; + --no-deps) do_deps=""; shift ;; + *) _roles+=("$1"); shift ;; + esac + done + { [ -z "$grp" ] || [ ${#_roles[@]} -eq 0 ] || [ -z "$base" ]; } && { + echo "usage: wtg $cmd [--base ] " + echo " roles: ${WTG_ROLES[*]} base default: $WTG_BASE_BRANCH (bare name resolves to origin/ after fetch)" + return 1; } + if [ "$cmd" = new ]; then + _wtg_group_exists "$grp" && { echo "wtg: group '$grp' already exists — use 'wtg add $grp '"; return 1; } + slot=$(_wtg_next_slot) + else + _wtg_group_exists "$grp" || { echo "wtg add: no group '$grp' — use 'wtg new $grp '"; return 1; } + slot=$(_wtg_group_slot "$grp") + fi + branch="feat/$grp" + echo "wtg $cmd: group '$grp'${slot:+ -> slot $slot}, branch $branch, base $base" + local added=0 + for role in "${_roles[@]}"; do + _wtg_add_member "$grp" "$role" "$slot" "$branch" "$base" && added=$((added + 1)) + done + if [ "$added" -eq 0 ]; then + echo "wtg $cmd: no members added" + [ "$cmd" = new ] && echo " group '$grp' was not created" + return 1 + fi + if [ -n "$do_env" ]; then + echo "seeding .env (worktrees do not inherit gitignored files):" + for role in "${_roles[@]}"; do + path="$WTG_WORKROOT/$grp-$role"; src_repo=$(_wtg_repo "$role") + [ -d "$path" ] || continue + for rel in ${WTG_ENV[$role]:-.env}; do _wtg_copy_env "$src_repo/$rel" "$path/$rel" ""; done + done + fi + if [ -n "$do_deps" ]; then + echo "installing deps (one-off per worktree; a fresh node_modules/venv takes a minute):" + for role in "${_roles[@]}"; do + path="$WTG_WORKROOT/$grp-$role" + [ -d "$path" ] && _wtg_deps_member "$grp" "$role" "$path" "" + done + fi + _wtg_table "$grp" + echo "next: wtg up $grp → wtg go $grp" ;; - add) - grp=$2; shift 2 2>/dev/null - { [ -z "$grp" ] || [ $# -eq 0 ]; } && { echo "usage: wtg add "; return 1; } - _wtg_group_exists "$grp" || { echo "wtg add: no group '$grp' — use 'wtg new $grp '"; return 1; } - branch="feat/$grp"; slot=$(_wtg_group_slot "$grp") - echo "wtg add: -> group '$grp'${slot:+ (slot $slot)}, branch $branch" - for role in "$@"; do _wtg_add_member "$grp" "$role" "$slot" "$branch"; done + deps) + grp=$2; force=""; [ "$3" = "--force" ] && force=1 + [ -z "$grp" ] && { echo "usage: wtg deps [--force]"; return 1; } + _wtg_group_exists "$grp" || { echo "wtg deps: no group '$grp'"; return 1; } + echo "wtg deps: building dep dirs for '$grp'${force:+ (force rebuild)}" + while IFS=$'\t' read -r g role path _; do + [ "$g" = "$grp" ] || continue + _wtg_deps_member "$grp" "$role" "$path" "$force" + done < "$WTG_FILE" ;; env) grp=$2; force=""; [ "$3" = "--force" ] && force=1 @@ -178,7 +363,7 @@ wtg() { grp=$2 [ -z "$grp" ] && { echo "usage: wtg up "; return 1; } _wtg_group_exists "$grp" || { echo "wtg up: no group '$grp'"; return 1; } - local -a _mrole _mpath; local i session rd + local -a _mrole _mpath; local i session rd key pid sid; local -A _seen=() while IFS=$'\t' read -r g role path _; do [ "$g" = "$grp" ] || continue [ -x "$path/dev.sh" ] || { echo " ! $role: no dev.sh in $path (run 'wtg new/add')"; continue; } @@ -195,9 +380,18 @@ wtg() { echo "wtg up: '$grp' -> ${#_mpath[@]} tmux windows. Attach: tmux attach -t $session" else rd=$(_wtg_run_dir "$grp"); mkdir -p "$rd" + sid=""; command -v setsid >/dev/null 2>&1 && sid=setsid # own process group, so `down` can kill the whole tree for i in "${!_mpath[@]}"; do - ( cd "${_mpath[$i]}" && nohup ./dev.sh > "$rd/${_mrole[$i]}.log" 2>&1 & echo $! > "$rd/${_mrole[$i]}.pid" ) - echo " + ${_mrole[$i]} pid $(cat "$rd/${_mrole[$i]}.pid" 2>/dev/null) log $rd/${_mrole[$i]}.log" + key=${_mrole[$i]}; _seen[$key]=$(( ${_seen[$key]:-0} + 1 )) + [ "${_seen[$key]}" -gt 1 ] && key="$key${_seen[$key]}" + pid=$(cat "$rd/$key.pid" 2>/dev/null) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo " = $key already running (pid $pid) — wtg down $grp first"; continue + fi + # `;` not `&&`: with `cd && cmd &` the whole list backgrounds and $! is a + # wrapper bash, not dev.sh — killing it leaves the real server tree alive + ( cd "${_mpath[$i]}" || exit 1; $sid nohup ./dev.sh > "$rd/$key.log" 2>&1 & echo $! > "$rd/$key.pid" ) + echo " + $key pid $(cat "$rd/$key.pid" 2>/dev/null) log $rd/$key.log" done echo "wtg up: '$grp' in background (no tmux). Stop: wtg down $grp" fi @@ -213,33 +407,27 @@ wtg() { if [ -d "$rd" ]; then for pf in "$rd"/*.pid; do [ -f "$pf" ] || continue - pid=$(cat "$pf"); kill "$pid" 2>/dev/null && echo " killed $(basename "$pf" .pid) (pid $pid)"; rm -f "$pf"; stopped=1 + pid=$(cat "$pf") + if kill -- "-$pid" 2>/dev/null; then echo " killed $(basename "$pf" .pid) (pgid $pid)" + elif kill "$pid" 2>/dev/null; then echo " killed $(basename "$pf" .pid) (pid $pid — no process group, children may survive)" + else echo " · $(basename "$pf" .pid) (pid $pid) was not running"; fi + rm -f "$pf"; stopped=1 done fi [ "$stopped" = 0 ] && echo "wtg down: nothing running for '$grp'" ;; st) - grp=$2 + grp=$2; force="" + [ "$2" = "--fetch" ] && { grp=""; force=1; } + [ "$3" = "--fetch" ] && force=1 if [ -z "$grp" ]; then top=$(git rev-parse --show-toplevel 2>/dev/null) grp=$(awk -F'\t' -v p="$top" '$3==p{print $1; exit}' "$WTG_FILE" 2>/dev/null) - [ -z "$grp" ] && { echo "usage: wtg st "; return 1; } + [ -z "$grp" ] && { echo "usage: wtg st [--fetch]"; return 1; } fi _wtg_group_exists "$grp" || { echo "wtg st: no group '$grp'"; return 1; } - local br dirty sync ab p - echo "group: $grp" - printf " %-6s %-30s %-6s %-9s %s\n" role branch state ahead/beh port - while IFS=$'\t' read -r g role path slot; do - [ "$g" = "$grp" ] || continue - if [ -d "$path" ]; then - br=$(git -C "$path" symbolic-ref --short HEAD 2>/dev/null || git -C "$path" rev-parse --short HEAD 2>/dev/null || echo '?') - if git -C "$path" diff --quiet 2>/dev/null && git -C "$path" diff --cached --quiet 2>/dev/null; then dirty=clean; else dirty=DIRTY; fi - ab=$(git -C "$path" rev-list --left-right --count HEAD...@{upstream} 2>/dev/null) - if [ -n "$ab" ]; then sync="+${ab%%[[:space:]]*}/-${ab##*[[:space:]]}"; else sync=-; fi - else br='(missing)'; dirty=-; sync=-; fi - p=-; [ -n "$slot" ] && p=":$(_wtg_port "$role" "$slot")" - printf " %-6s %-30s %-6s %-9s %s\n" "$role" "$br" "$dirty" "$sync" "$p" - done < "$WTG_FILE" + [ -n "$force" ] && echo "fetching origin for each member..." + _wtg_table "$grp" "$force" ;; claim-oauth) grp=$2 @@ -254,7 +442,8 @@ wtg() { echo "wtg: '$grp' now on :$WTG_OAUTH_PORT — OAuth works. Restart its stack." ;; go) - local -a _groups adddirs; local primary launcher="${WTG_LAUNCHER:-claude}" + local -a _groups adddirs largs; local primary launcher="$WTG_LAUNCHER" + read -ra largs <<<"$WTG_LAUNCHER_ARGS" grp=$2 [ -f "$WTG_FILE" ] || { echo "wtg go: no groups yet"; return 1; } if [ -z "$grp" ]; then @@ -272,21 +461,16 @@ wtg() { done < "$WTG_FILE" if [ -z "$primary" ] && [ ${#adddirs[@]} -gt 0 ]; then primary="${adddirs[0]}"; adddirs=("${adddirs[@]:1}"); fi [ -z "$primary" ] && { echo "wtg go: group '$grp' has no live worktrees"; return 1; } - echo "wtg go: '$grp' -> $primary${adddirs:+ +add-dir: ${adddirs[*]}}" - if [ ${#adddirs[@]} -gt 0 ]; then ( cd "$primary" && "$launcher" --add-dir "${adddirs[@]}" ) - else ( cd "$primary" && "$launcher" ); fi + _wtg_table "$grp" + echo "wtg go: $(_wtg_tilde "$primary")${adddirs:+ +add-dir: $(_wtg_tilde "${adddirs[*]}")}${largs:+ [${largs[*]}]}" + if [ ${#adddirs[@]} -gt 0 ]; then ( cd "$primary" && "$launcher" "${largs[@]}" --add-dir "${adddirs[@]}" ) + else ( cd "$primary" && "$launcher" "${largs[@]}" ); fi ;; ""|status) top=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "wtg: not in a git worktree" >&2; return 1; } grp=$(awk -F'\t' -v p="$top" '$3==p{print $1; exit}' "$WTG_FILE" 2>/dev/null) [ -z "$grp" ] && { echo "wtg: $(basename "$top") is not in a group"; return 0; } - slot=$(_wtg_group_slot "$grp") - echo "group: $grp${slot:+ (slot $slot)}" - while IFS=$'\t' read -r g role path s; do - [ "$g" = "$grp" ] || continue - port=""; [ -n "$s" ] && port=":$(_wtg_port "$role" "$s")" - printf " %-6s %-6s %s%s\n" "$role" "$port" "$path" "$([ "$path" = "$top" ] && echo ' <- current')" - done < "$WTG_FILE" + _wtg_table "$grp" ;; ls) [ -f "$WTG_FILE" ] || { echo "wtg: no groups yet"; return 0; } @@ -302,17 +486,52 @@ wtg() { repo=$(dirname "$(readlink -f "$(git rev-parse --git-common-dir)")") echo " full teardown (deletes the checkout): git -C $repo worktree remove $top" ;; + help|-h|--help) + cat < [role] bind the current worktree into an EXISTING (role auto-derived from its repo) + wtg bind [role] same, but may create a new group (explicit, so typos can't create groups) + wtg [status] show the current worktree's group + members + wtg ls list all groups (name, slot, roles) + wtg st [--fetch] full table: branch, clean/DIRTY, upstream, behind $WTG_BASE_BRANCH, port, .env, deps + (--fetch refreshes the remote first; also shown by 'go', 'new' and 'add') + wtg rm unbind the current worktree from its group (checkout kept) + +create / grow new/add leave a RUNNABLE tree: worktree + .env seeded + deps installed + wtg new [--base ] [--no-env] [--no-deps] new group, one worktree per role + wtg add [--base ] [--no-env] [--no-deps] add roles to an existing group + wtg env [--force] (re)seed base .env files into the worktrees + wtg deps [--force] (re)build dep dirs: $(for r in "${WTG_ROLES[@]}"; do [ -n "${WTG_SETUP[$r]}" ] && printf '%s=%s ' "$r" "${WTG_DEPS[$r]}"; done) + + base: default '$WTG_BASE_BRANCH'. Repos are fetched first and a bare name resolves to origin/, + so you always cut from the latest remote tip, never a stale local branch. origin/x, a tag or a + sha are taken as given. Override the default with \$WTG_BASE_BRANCH. + +run + wtg up start each member's dev.sh (tmux windows, else nohup background) + wtg down stop a group's running dev servers + wtg claim-oauth move onto the OAuth-registered port (:$WTG_OAUTH_PORT, slot 0) + wtg go [group] open in '$WTG_LAUNCHER${WTG_LAUNCHER_ARGS:+ $WTG_LAUNCHER_ARGS}' from the '$WTG_PRIMARY_ROLE' role (+add-dir the rest) + + wtg help this message +EOF + ;; + bind) + [ -z "$2" ] && { echo "usage: wtg bind [role]"; return 1; } + _wtg_bind_here "$2" "$3" + ;; *) - top=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "wtg: not in a git worktree" >&2; return 1; } - grp=$1 - repo=$(basename "$(dirname "$(readlink -f "$(git rev-parse --git-common-dir)")")") - role=${2:-$(_wtg_role "$repo")} - slot=$(_wtg_group_slot "$grp") - _wtg_bind "$grp" "$role" "$top" "$slot" - if [ -n "$slot" ]; then - port=$(_wtg_port "$role" "$slot"); _wtg_write_devsh "$role" "$top" "$port" "$grp" "$slot" - echo "wtg: $(basename "$top") -> group '$grp' as '$role' (slot $slot, :$port, dev.sh)" - else echo "wtg: $(basename "$top") -> group '$grp' as '$role'"; fi + # typo guard: a bare name only binds into a group that already exists, + # so `wtg downfoo` can't silently create group 'downfoo' and rebind this tree + _wtg_group_exists "$1" || { + echo "wtg: no command or group '$1'" >&2 + echo " commands: new add bind env deps up down st claim-oauth go ls rm help" >&2 + echo " bind the current worktree into a NEW group: wtg bind $1 [role]" >&2 + return 1; } + _wtg_bind_here "$1" "$2" ;; esac }