Worktrees — one isolated site per branch
Audience: developers & AI agents · Scope: creating and running fully isolated development environments, one per branch · Last reviewed: 2026-07-31
TL;DR — Work in a worktree, not the main checkout. tools/worktree.sh create <slug> gives you a git
worktree and its own DDEV project: own containers, own database, own HTTPS host
(https://betacalco-<slug>.ddev.site), own copy of images/uploads/product store. Nothing you do there —
a migration, a settings change, a destructive test run, a live-data sync — can reach the main checkout's
site or database. It takes about a minute and, on APFS, costs almost no disk.
tools/worktree.sh create my-feature # or: ddev worktree create my-feature
cd .claude/worktrees/my-feature
open https://betacalco-my-feature.ddev.site
Why
A plain git worktree isolates code. It does not isolate the site: every worktree still pointed at
the one DDEV project, so all of them shared a single database and a single set of uploads. That made the
things worktrees are most useful for — trying a migration, changing settings to see what breaks, seeding
quotes, pointing a Playwright run at the site, syncing live data — unsafe, because the blast radius was the
main checkout everyone else was using.
The tooling here closes that gap. A worktree becomes a complete parallel installation of the site.
| Main checkout | Worktree <slug> | |
|---|---|---|
| Path | ~/PhpstormProjects/betacalco | .claude/worktrees/<slug> |
| DDEV project | betacalco | betacalco-<slug> |
| URL | https://betacalco.ddev.site | https://betacalco-<slug>.ddev.site |
| Database | its own container + volume | its own container + volume, seeded from the main one |
| Images / uploads / store | the originals | copy-on-write clones |
| Host MySQL port | 55013 (pinned) | a stable port in 55100–55899 |
Commands
Run from anywhere inside the repo — the script resolves the main checkout itself. Also available as
ddev worktree … (a DDEV host command, see .ddev/commands/host/worktree).
| Command | What it does |
|---|---|
create <slug> | New branch worktree-<slug> off master, new worktree, full environment, started and seeded |
provision <slug> | Same environment set-up for a worktree that already exists on disk |
list | Every worktree with its branch, DDEV status and URL |
info <slug> | Paths, URL, commits ahead of master, plus ddev describe |
sync-db <slug> | Re-seed that worktree's database from the main checkout (replaces it; asks first) |
remove <slug> | Delete the DDEV project and its database, then the worktree (asks first) |
Options for create / provision:
| Option | Default | Why you'd change it |
|---|---|---|
--base <ref> | master | Branch from something else |
--assets clone|copy|none | clone | none skips the 14 GB product store — fine for pure back-end work, but product images will 404 |
--db snapshot|none | snapshot | none leaves the database empty for a hand-picked ddev import-db |
--no-start | — | Provision the files but don't boot containers |
What provisioning actually does
Everything below is git-ignored, so a provisioned worktree still shows a clean git status.
.ddev/config.local.yaml— the whole trick. Settingname:in this override file makes DDEV treat the directory as a separate project: separate containers, separate database volume, separate hostname. A collision-checkedhost_db_portderived from the slug keeps a PhpStorm data source stable.docroot/configuration.php— generated from the committed.ddev/configuration.ddev.phptemplate (known-good container values), with$sitenamestamped asBeta-Calco (wt: <slug>)so the backend, page titles and outgoing mail say which environment you are looking at.$secretis copied from the main checkout, not taken from the template — see the gotcha below..ddev/.env— an absoluteBC_PROTECTED_FILES_DIRfor the plaintext ConfigBox engine mount. See the gotcha below.- Runtime data —
docroot/images,docroot/media/com_sppagebuilder,docroot/media/videos, the ConfigBoxdata/customeranddata/storetrees, andtests/node_modules, cloned from the main checkout withcp -c(APFS copy-on-write): instant, and no disk is consumed until one side is written to. Writes on either side never touch the other. - Git-ignored support files —
.ddev/.gitignore,.claude/settings.local.json,.claude/hooks/,.claude/secrets/,docroot/.htaccess(plus the root andtests/.gitignores, a no-op now that both are tracked). See the .gitignore section. tests/.env— copied withBASE_URLrewritten to the worktree's URL, sonpm testdrives this site.docs/diary/— a real directory whose children symlink to the main checkout's, so development diary entries written in a worktree survive the worktree being deleted. (It must be a real directory, not a symlink:.gitignorematches/docs/diary/, a directory-only pattern.)- The database —
ddev snapshotof the main project, restored into the worktree's. A snapshot is a physical backup rather than a SQL dump, so ~1 GB lands in seconds instead of minutes. If the main project is stopped, the newest existing snapshot in.ddev/db_snapshots/is used instead (with a warning).
Gotchas
.gitignore files a worktree does not get from git
Both the root .gitignore and tests/.gitignore are tracked, so a worktree gets them with its checkout.
That is recent: the root file's line 3 used to be a bare .gitignore, a pattern git matches at any
depth — so it hid every .gitignore in the tree, including itself. A fresh worktree then had none at all,
git status listed images, logs, configuration.php and the 14 GB product store as untracked, and one
git add -A would have committed the lot. tests/.env (which holds TEST_SUPPORT_SECRET) was unignored
for the same reason. Both are fixed; provisioning still copies them if they are ever missing again.
.ddev/.gitignore is different and always will be. DDEV generates it, and it ignores itself, so it
can never be committed — a fresh worktree has none until its first ddev start. Provisioning seeds a copy
from the main checkout so a --no-start worktree doesn't show its own .ddev/config.local.yaml as
untracked; ddev start then replaces it with one naming this project's own generated files.
$secret must match the database
Joomla encrypts data at rest with $secret from configuration.php — including every user's
multi-factor record in #__mfa.options. A worktree's database is a copy of the main checkout's, so its
MFA rows are encrypted with the main checkout's secret. Generate configuration.php from the DDEV
template alone and the worktree gets the template's secret instead; those rows then decrypt to nothing,
Joomla concludes the account has no MFA method enrolled, and the 2FA-forced
test-automation-admin@betacalco.com lands on the MFA onboarding page instead of the code challenge.
Backend login becomes impossible and the whole specs/backend/** suite fails with a misleading
"no TOTP method enrolled on TARGET=dev".
write_configuration_php() therefore carries the main checkout's $secret over, and provisioning says so:
✓ docroot/configuration.php generated (sitename: Beta-Calco (wt: <slug>), $secret from the main checkout)
If you hand-write a worktree's configuration.php, copy $secret across yourself. (Re-generate an
existing one by deleting the file and re-running tools/worktree.sh provision <slug> --db none --assets none --no-start.)
The protected-sources mount
.ddev/docker-compose.protected-sources.yaml mounts the
plaintext ConfigBox engine sources from a path relative to .ddev/. That path only resolves in the main
checkout. From .claude/worktrees/<slug>/.ddev/ it points at nothing — and Docker creates a missing bind
source rather than failing, so you get an empty mount, the engine silently falls back to the ionCube
bundles, Xdebug can no longer step it, and a stray configbox-protected-files/ directory appears next to
your worktrees. The compose file therefore interpolates ${BC_PROTECTED_FILES_DIR:-<relative default>}, and
provisioning writes an absolute path into the git-ignored .ddev/.env. Confirm it worked — ddev start
prints which engine path is active:
ddev post-start: engine → plaintext /var/protected_files (Xdebug-friendly); ionCube 15.5 installed as fallback.
Git commands inside the container don't work
A worktree's .git is a file pointing at <main checkout>/.git/worktrees/<slug>, an absolute host path
that does not exist inside the container. The gs / gd / gl aliases from
homeadditions/.bash_aliases therefore fail in a worktree's
ddev ssh. Run git on the host.
The database is a snapshot, not a live mirror
It is a point-in-time copy. It does not follow the main checkout afterwards, and changes never flow back.
Re-sync deliberately with tools/worktree.sh sync-db <slug> — which replaces the worktree's database.
Crons don't run in a worktree
The scheduled jobs in docs/scheduled-jobs.md are driven by the host crontab against
the main checkout. A worktree's containers run no crons, so anything cron-driven (the Pipedrive deal-update
export, the quote-stage-sync sheet export) will not fire there — including
send-mode e2e specs that wait on them. Trigger those scripts by hand with
ddev exec php docroot/cli/<script>.php.
Each worktree is a full container set
Web + db + memcached per worktree. Three running at once is three of everything. ddev stop the ones you
aren't using; tools/worktree.sh remove <slug> when you're done with a branch.
Xdebug uses one host port
Port 9003 is shared. Enable Xdebug in one project at a time (ddev xdebug on / off).
Finishing a worktree
The work lives on the branch; the worktree is only a place to do it. So land the branch first, then throw the environment away. From the main checkout:
cd ~/PhpstormProjects/betacalco
git merge --ff-only worktree-my-feature # branch is the deliverable
tools/worktree.sh remove my-feature --delete-branch
If the merge isn't a fast-forward, master has moved on — rebase the branch onto master in the worktree
(git rebase master) and merge again. Don't skip straight to remove: --delete-branch on an unmerged
branch throws the work away. The command warns and asks first, but the warning is the only thing
standing between you and a lost afternoon.
Run the teardown from the main checkout, not from inside the worktree. Removing the directory you are
standing in succeeds, but leaves your shell in a path that no longer exists and every subsequent command
fails confusingly. The command detects this and prints the cd you need — it can't run it for you, since a
script cannot change its parent shell's directory.
What remove deletes: the DDEV project (containers and its database volume) and the worktree directory
(code plus its copies of images, uploads and the product store). The branch survives unless you pass
--delete-branch. Diary entries survive either way — they live in the main checkout.
Takes about 20 seconds.
Just stepping away from it
Not finished, but not using it either? Don't remove it — stop the containers and leave the rest:
cd .claude/worktrees/my-feature && ddev stop
The checkout, its database and its data stay on disk and ddev start brings it back in seconds. Each
running worktree costs three containers, so stopping the idle ones is the cheap habit; removal is for when
the branch is done.
Related
- environment.md — the local stack itself: DDEV, the database, SEF settings, deploy tools
.ddev/README.md— DDEV setup, everyday commands, the ionCube wrinkle- migrations.md — worktrees are the safe place to try a migration
- ../testing/README.md — pointing the Playwright suite at a worktree