Worktree Workflow
Use git worktrees for issues that require extended development (multiple days, risky changes, or parallel feature work). Each worktree gets its own branch, dependencies, and a WordPress test site.
Quick Path: scripts/parallel-dev.sh
For the common case (worktree + Playground instance per issue), use the helper script instead of the manual steps below:
# Create worktree, npm install + build, start Playground on a free port (9400+).
# With Valet's .test resolution available (/etc/resolver/test), the instance
# gets a named canonical URL like http://issue-42-some-feature.test:9400
# (WP_HOME/WP_SITEURL set via blueprint — avoids 127.0.0.1 redirects and
# Site Editor CORS issues). Falls back to http://localhost:<port> otherwise.
scripts/parallel-dev.sh up issue-42-some-feature
# Premium is mounted + activated by default (add-ons main checkout, or
# $SH_PREMIUM_DIR). Opt out, or point at a premium worktree:
scripts/parallel-dev.sh up issue-42-some-feature --no-premium
scripts/parallel-dev.sh up issue-42-premium-thing --premium=/path/to/premium-worktree
# Overview of all worktrees: port, URL, running?, dirty?
scripts/parallel-dev.sh status
# Stop the instance / stop and remove the worktree (refuses if dirty)
scripts/parallel-dev.sh down issue-42-some-feature
scripts/parallel-dev.sh down issue-42-some-feature --remove
# Tail the Playground server log
scripts/parallel-dev.sh logs issue-42-some-feature
Per-instance state lives inside the worktree as .playground.json / .playground.log / .playground-blueprint.json (all gitignored).
REST API access (Basic auth)
Instances started by parallel-dev.sh get a fixed application password (provisioned through the core API by a blueprint runPHP step), and a mounted mu-plugin (scripts/playground-mu-plugins/sh-allow-basic-auth.php) lets REST and Basic-auth requests through Playground's auto-login redirect — so plain curl -u works. Read the credentials from .playground.json (app_user / app_password) rather than hardcoding them:
URL=$(jq -r .url .playground.json)
curl -u "$(jq -r '.app_user + ":" + .app_password' .playground.json)" \
"$URL/wp-json/simple-history/v1/events?per_page=5"
Caveats:
- Instances started before this feature existed, and worktrees set up via the manual steps below, have no app password. If
jq -r .app_password .playground.jsonreturnsnull, restart withdown+up. - The script sets
WP_ENVIRONMENT_TYPE=local(app passwords are unavailable over plain HTTP otherwise) unless a custom--blueprintdefines its own environment type — so environment-gated behavior differs from a default production site. - Don't authenticate REST calls with the WP auth cookies — cookie auth without a nonce makes REST return 401.
For wp-admin HTML (non-REST), the first request auto-logs in and sets cookies — use a curl cookie jar:
jar=$(mktemp)
curl -s -c "$jar" -o /dev/null "$URL/"
curl -s -b "$jar" "$URL/wp-admin/admin.php?page=simple_history_admin_menu_page"
Run Playwright against an instance from inside its worktree — read the URL from the state file, since the instance's canonical URL may be the named .test one and localhost would get canonical-redirected cross-origin:
PLAYWRIGHT_BASE_URL=$(jq -r .url .playground.json) WP_ADMIN_USER=admin WP_ADMIN_PASSWORD=password \
npx playwright test tests/playwright/<spec>.spec.js
The manual steps below remain useful for special setups (custom blueprints).
Adding a test plugin to the instance
To reproduce what another plugin does (e.g. change roles from init), add it with a custom blueprint rather than editing the mounted dirs. Copy the generated .playground-blueprint.json, append the steps, and restart with it (parallel-dev.sh strips and re-injects its own steps, so starting from the generated file is fine):
jq --rawfile php my-plugin.php '.steps += [
{"step":"mkdir","path":"/wordpress/wp-content/plugins/my-plugin"},
{"step":"writeFile","path":"/wordpress/wp-content/plugins/my-plugin/my-plugin.php","data":$php},
{"step":"activatePlugin","pluginPath":"my-plugin/my-plugin.php"}
]' .playground-blueprint.json > /path/to/scratchpad/blueprint.json
scripts/parallel-dev.sh down <slug>
scripts/parallel-dev.sh up <slug> --blueprint=/path/to/scratchpad/blueprint.json
writeFile does not create parent directories; without the mkdir step the blueprint fails and the instance never starts (the error is only in .playground.log).
REST calls authenticated with the application password have no current user during init. WordPress only accepts application passwords once REST_REQUEST is defined, which happens after init, and the REST server then clears the cached anonymous user (class-wp-rest-server.php). Code that runs on init sees a logged-out request even though the endpoint later runs as admin. Cookie-authenticated requests do have the user on init.
Checks and tests inside a worktree
Worktrees live under .claude/worktrees/<slug>/, and several tools treat .claude/ specially:
-
phpcs:
phpcs.xml.distused to exclude*/.claude/, which matched the worktree's own absolute path, so phpcs inside a worktree silently checked nothing and reported success. Fixed in issue 331 (two exclude patterns:.claude/exceptworktrees/anywhere, plusworktrees/relative to the scanned root). If a worktree branches from a commit before that fix, phpcs there is still blind: a clean run in a few tens of milliseconds is the tell. Piping a file through stdin (phpcs - < file.php) works around it but reports bogus "PHP syntax error" lines on files that are fine. -
phpstan: works as usual (
./vendor/bin/phpstan analyse --memory-limit=2G); its paths are relative. -
Codeception (wpunit etc.):
docker compose runfrom the worktree fails, becausecompose.yamlpins container names (simple-history-database) that the main checkout's stack already uses. Run against the main stack with-p wordpress-simple-historyfrom the worktree dir, so./mounts the worktree's code. Two more mounts are needed: the worktree'svendoris a symlink to the main checkout's absolute host path, which the container can't follow, andtests/plugins/(gitignored) is empty in a worktree:M=/path/to/main/checkout args=(-v "$M/vendor:/srv/vendor" -v "$M/vendor:/wordpress/wp-content/plugins/simple-history/vendor") for p in akismet jetpack wp-crontrol duplicate-post redirection enable-media-replace user-switching simple-history-premium; do args+=(-v "$M/tests/plugins/$p:/wordpress/wp-content/plugins/$p") done docker compose -p wordpress-simple-history run --rm "${args[@]}" php-cli vendor/bin/codecept run wpunit <TestName>Put this in a script file and run that. Claude Code's worktree isolation refuses inline commands that combine variables, loops and
docker/git.
When to Use Worktrees
- Issue has
size: 2-mediumor3-large - Issue has
complexity: branch - Work will span multiple sessions/days
- You want to test a feature in isolation without affecting the main branch
- You need to work on multiple features in parallel
Creating a Worktree
Step 1: Create the worktree
Use the EnterWorktree Claude Code tool (not a bash command) with a descriptive name based on the issue:
EnterWorktree(name="issue-name-short")
This creates a worktree at .claude/worktrees/<name> on branch worktree-<name>.
Step 2: Start Playground (mandatory — do this immediately)
From the main repo root (not the worktree), run:
cd "$(git rev-parse --git-common-dir)/.."
scripts/parallel-dev.sh up issue-name-short
This handles npm ci, npm run build, port allocation, and Playground startup in one command. Once it finishes, read .claude/worktrees/issue-name-short/.playground.json to get the URL and report it to the user:
jq -r .url .claude/worktrees/issue-name-short/.playground.json
Always report the URL immediately after worktree creation. The user needs it to preview the feature without merging.
Premium is mounted and activated by default. Opt out if the issue is core-only:
scripts/parallel-dev.sh up issue-name-short --no-premium
Multisite
If the issue involves network/multisite functionality, ask the user if they want a multisite install. If yes, add --multisite:
scripts/parallel-dev.sh up issue-name-short --multisite
You get a subdirectory network with two sites (/ and /site2/), Simple History network-activated (Premium too when it is mounted), and Network Admin at <url>/wp-admin/network/. Pass the flag on every up; Playground rebuilds from the blueprint each start.
Do not use Playground's enableMultisite blueprint step. It refuses any URL with a port ("WordPress multisites do not support custom ports"), and every parallel-dev URL has one. WordPress itself has allowed ports in multisite since 6.6; the flag replays what the step does after that outdated guard. Subdomain networks are not supported (wildcard DNS per slug, and impossible on the localhost fallback).
Copying Uncommitted Changes
If the user has uncommitted changes in the main repo that should be in the worktree:
# From the main repo, list changed/untracked files
git -C "$(git rev-parse --show-toplevel)" status --short
# Copy specific files to the worktree
cp path/to/file ./path/to/file
Important: Also copy any untracked files that are imported by modified files (e.g., new components).
Managing Worktrees
List all worktrees
git -C "$(git rev-parse --show-toplevel)" worktree list
Switch to an existing worktree
Just cd to its path. All git and npm commands work as usual.
Stop the Playground server
# Find the process
lsof -i :<port> | grep LISTEN
# Kill it
kill <pid>
Remove a worktree when done
# Stop any running Playground server first
# Then from the main repo:
git -C "$(git rev-parse --show-toplevel)" worktree remove .claude/worktrees/<name>
Or use the ExitWorktree tool if in a Claude Code session.
Merging Back
When the feature is complete and tested:
- Commit all changes in the worktree
- Switch to the main branch in the main repo
- Merge the worktree branch:
cd "$(git rev-parse --show-toplevel)" git merge worktree-<name> - Remove the worktree
Key Things to Remember
.gitis a file in worktrees (not a directory) — this is how you can tell you're in a worktreenode_modulesis not shared — each worktree needs its ownnpm install- Two worktrees cannot have the same branch checked out
- Build assets after copying files — always run
npm run buildafter setup - Docker dev site is separate — the main Docker-based dev site at port 8282 is unaffected by worktrees
- Auto-login needs clean cookies — if the browser visited the Playground URL before the blueprint was applied, old cookies can prevent auto-login. Use an incognito window or clear cookies for
localhost:<port>