Skip to main content

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;DRWork 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 checkoutWorktree <slug>
Path~/PhpstormProjects/betacalco.claude/worktrees/<slug>
DDEV projectbetacalcobetacalco-<slug>
URLhttps://betacalco.ddev.sitehttps://betacalco-<slug>.ddev.site
Databaseits own container + volumeits own container + volume, seeded from the main one
Images / uploads / storethe originalscopy-on-write clones
Host MySQL port55013 (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).

CommandWhat 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
listEvery 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:

OptionDefaultWhy you'd change it
--base <ref>masterBranch from something else
--assets clone|copy|noneclonenone skips the 14 GB product store — fine for pure back-end work, but product images will 404
--db snapshot|nonesnapshotnone leaves the database empty for a hand-picked ddev import-db
--no-startProvision 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.

  1. .ddev/config.local.yaml — the whole trick. Setting name: in this override file makes DDEV treat the directory as a separate project: separate containers, separate database volume, separate hostname. A collision-checked host_db_port derived from the slug keeps a PhpStorm data source stable.
  2. docroot/configuration.php — generated from the committed .ddev/configuration.ddev.php template (known-good container values), with $sitename stamped as Beta-Calco (wt: <slug>) so the backend, page titles and outgoing mail say which environment you are looking at. $secret is copied from the main checkout, not taken from the template — see the gotcha below.
  3. .ddev/.env — an absolute BC_PROTECTED_FILES_DIR for the plaintext ConfigBox engine mount. See the gotcha below.
  4. Runtime datadocroot/images, docroot/media/com_sppagebuilder, docroot/media/videos, the ConfigBox data/customer and data/store trees, and tests/node_modules, cloned from the main checkout with cp -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.
  5. Git-ignored support files.ddev/.gitignore, .claude/settings.local.json, .claude/hooks/, .claude/secrets/, docroot/.htaccess (plus the root and tests/ .gitignores, a no-op now that both are tracked). See the .gitignore section.
  6. tests/.env — copied with BASE_URL rewritten to the worktree's URL, so npm test drives this site.
  7. 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: .gitignore matches /docs/diary/, a directory-only pattern.)
  8. The databaseddev snapshot of 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.