Feature/ddev addon - #28
Conversation
Adds `ddev tryout worktree`, which keeps multiple TYPO3 Core checkouts as git worktrees sharing one object store, and `worktree serve`, which gives each one its own hostname, PHP version, database and composer root inside the same container. typo3-core becomes a symlink to the active typo3-core-<name>, so composer's path repository and CORE_DIR keep working untouched. Served sites get a generated vhost, an extra php-fpm master on its own socket, and their own sites/<name>/ tree; the site-scoped commands default to the primary so existing usage is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
tryout was distributed as a template repository: clone it, strip its git history, and the scaffold *was* the project. That made it impossible to add to an existing TYPO3 project, impossible to update once cloned, and invisible to `ddev add-on list`. It is now installable with `ddev add-on get bmack/tryout`, updatable by running that again, and removable with `ddev add-on remove tryout`. The add-on is project-scoped, not global: `ddev add-on get` always targets a project, and everything tryout manages — Core checkout, patch list, served sites, database — is per-project state. The payload moves to the repo root, which is where DDEV reads it from, and is namespaced under tryout/ so it cannot collide with another add-on inside the shared .ddev/ directory. Every shipped file carries #ddev-generated, the marker DDEV uses to decide what it may overwrite and remove. Composer is the substantive change. sync-composer.php used to rewrite the project's own composer.json and delete its lock file, which would destroy the dependencies of any real project. It now writes composer.tryout.json, selected via COMPOSER= in config.tryout.yaml, with composer-merge-plugin pulling the user's composer.json in as an include — so their dependencies resolve and their file is never touched. config.yaml is no longer shipped; the user creates it with `ddev config`. Files outside .ddev/ (the overlay, config/system/additional.php, .gitignore) ship as templates and are copied by post_install_actions, guarded so a hand-edited file is never clobbered and mirrored by removal_actions. `ddev cs` becomes `ddev tryout cs`, since a two-letter command installed into arbitrary projects is too likely to collide. Also fixes a latent bug this exposed: with Mutagen a freshly created worktree or site directory was not yet visible in the container, so composer ran against a path that did not exist there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
Rewrites the README around `ddev add-on get` instead of clone-and-strip-git, documents the Composer overlay and the #ddev-generated ownership contract, and adds a migration section for people already running the old template layout. The bats suite covers what the conversion has to keep true: the payload lands under .ddev/, an existing composer.json is byte-for-byte untouched, a re-install leaves a file alone once the user removes its marker, and remove takes everything back out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
Replaces the five hand-rolled tests with 50 across three suites, split by cost
because every DDEV-backed test builds and destroys a whole project:
tests/unit.bats 20 tests, seconds, no containers. Sources functions.sh
against a fake project root to cover the pure helpers —
site/database/hostname derivation, worktree name
validation, branch resolution — plus a lint pass over
every shipped script and an install.yaml/disk check.
tests/test.bats 23 tests. Install from directory and release, idempotent
reinstall, the container environment, config layering,
the Composer overlay, the guarded copies outside .ddev/,
command dispatch and its error paths, and removal.
tests/lifecycle.bats 7 tests. The slow path: clone Core, set up TYPO3, switch
branches, cherry-pick from Gerrit, serve worktrees on
their own PHP versions and databases.
setup.sh/teardown.sh/health_checks.sh are shared and loaded by each file, so
every install variant asserts the same post-conditions.
DDEV publishes a machine-readable spec for add-ons
(https://ddev.com/s/addon-update-checker.sh); this brings the repo in line with
it: the canonical bats skeleton, ddev/github-action-add-on-test@v2, the issue
and PR templates, .editorconfig, export-ignore rules, and a v1.24.10 floor. The
one check left failing is its Apache-2.0 suggestion — this project is MIT.
That spec also caught a real bug. Every project_files entry must carry
#ddev-generated, and config.tryout-patches.yaml deliberately did not, so that a
user's patch list survived updates — but DDEV then warned "NOT overwriting" on
every single update. It is no longer a project_files entry: it ships as
tryout/patches.yaml and is copied once by a post_install_action with the marker
stripped, so it is genuinely the user's file. Update and removal are now silent
and an edited patch list still survives both.
Also fixes the fast suite triggering the post-start hook, which clones ~600 MB
of TYPO3 Core; it restarts with --skip-hooks, and that path is covered
deliberately in lifecycle.bats instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
`ddev tryout` carried a `## AutocompleteTerms:` header, which DDEV turns into cobra's `ValidArgs` — one flat list offered at every argument position. So `ddev tryout cs <TAB>` re-offered the top-level verbs instead of setup/doctor/uninstall, and nothing completed a worktree, a site or a branch. The list was also wrong: it advertised `serve` and `unserve` as top-level commands when both are `worktree` subcommands. DDEV's other mechanism is a sibling script at commands/host/autocomplete/<name>, which it runs on every TAB with the command line as argv. That gives real per-position completion, and it now covers: ddev tryout <TAB> the verbs the dispatch case accepts ddev tryout cs <TAB> setup doctor uninstall help ddev tryout worktree use <TAB> the worktrees on disk ddev tryout checkout <TAB> branches already fetched into typo3-core/ ddev tryout exec <TAB> @primary and every served worktree The two mechanisms are mutually exclusive, which is not documented: ValidArgs makes cobra reject any second argument during completion, so ValidArgsFunction is never called and the script completes nothing. The header is therefore gone, with a comment saying why and a unit test asserting it stays gone. Three further constraints shape the script, all verified against ddev v1.25.2 rather than taken from the docs: - It receives no DDEV_* variables, because the completion path never calls DockerEnv(). It resolves the project root from $0, falling back to a walk up from the cwd — which cannot assume a plain .ddev/config.yaml exists. - Its stderr is merged into the candidate list and a non-zero exit silently drops every suggestion. It therefore breaks the repo's `set -euo pipefail` rule deliberately, silences all diagnostics and always exits 0. - An empty word arrives as the literal two-character string ''. Branch completion reads refs already fetched (`for-each-ref`) rather than asking origin, so a TAB never waits on the network; `list_remote_core_branches` and `list_local_core_branches` replace a `ls-remote` block that was duplicated in cmd_checkout. README gains a tab-completion section and, separately, instructions for installing the add-on from a local checkout, a branch or a commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
`ddev tryout worktree` can run several Core checkouts side by side, but the terminal side of that was manual: a Claude session and a shell per worktree, opened by hand every time. herdr (https://herdr.dev) is a multiplexer built around coding agents, and this wires the two together. ddev tryout herdr one workspace per Core worktree, each with a claude pane and a shell rooted at it ddev tryout herdr new [<name>] create a worktree and open it ddev tryout herdr setup-keys bind two keys in the user's herdr config ddev tryout worktree add --herdr the same, from the worktree side ddev tryout worktree adopt move stray checkouts into the project It runs in a session of its own, `tryout-<project>`, started on demand so Core worktrees never land among the user's everyday workspaces, and attaches to it when it can. Re-running is safe: a worktree already open anywhere is skipped. herdr is optional throughout. The add-on installs nothing, and without `herdr` and `jq` on the host the command exits with a hint and changes nothing. setup-keys binds prefix+shift+G to a new-worktree popup and prefix+shift+T to a menu of the tryout commands, and links a small plugin. All three exist because of the same constraint: herdr's plugin manifest has six sections and no `keys`, and a plugin action is invocable only by a keybinding or a link click — it can never appear in herdr's own menus. So the GUI is a popup we draw ourselves, and the keys must be written into the user's config rather than shipped. That config is outside .ddev/, holds the user's own settings, and cannot be reached by removal_actions, so the write is backed up first, delimited by markers, and reversible; a unit test asserts the setup/unsetup round trip is byte-exact across five file shapes, including a file with no trailing newline. The plugin closes the one route a keybinding cannot: herdr's built-in "New worktree", still reachable from the sidebar right-click menu, checks out under worktrees.directory as <repo>/<branch-slug> — invisible to every tryout command, named from a generated word list, and not redirectable, since that is its only setting and no worktree.* event fires before creation. Its worktree.created hook therefore relocates the checkout with `git worktree move`. The hook fires for every worktree herdr creates on any repository, so it does nothing at all unless the owning repo sits under a .ddev/tryout/functions.sh; that bail-out is the first thing tested and is called out in CLAUDE.md so it survives future edits. Notes on the herdr surface, all established against the 0.8.2 binary and its own API schema rather than the docs: - `--session` must precede the subcommand; after it herdr silently ignores the flag and talks to the default session. HERDR_SESSION is no better — it reports the right name but resolves the default socket. Every call goes through one `herdr_cli` wrapper, and a test asserts no bare call remains. - A DDEV host command has no stdin/stdout tty, but /dev/tty still reaches the real terminal, which is how attaching works at all. - `worktree open --path` registers a checkout with real git provenance; `workspace create` does not, and is kept only as a fallback. - `agent start` is retried: workspace creation returns before the pane's shell reaches its prompt. `agent_not_ready` means the agent is up and waiting on its own folder-trust prompt, not that it failed. - Popups are modal and singleton, so the menu's submenus live in the same process and anything slow is pushed to a pane with pane split + pane run. Both popups resolve the project from the cwd, never $0: the keys are global, so $0 would point at whichever project last ran setup-keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
`worktree adopt` had no menu entry: it was added after the menu was written, and the coverage test only asserted that each *top-level* verb appeared, so `worktree` matched and the missing subcommand went unnoticed. `cs` was thinner still — only `doctor` was reachable, with `setup` and `uninstall` CLI-only. The worktree submenu gains `adopt`, and `cs` becomes a submenu of its own with doctor, setup and uninstall. `cs setup` probes Gerrit over SSH so it runs in a pane rather than the modal popup, and `cs uninstall` asks for confirmation like the other destructive entries. The test now walks the `cmd_worktree` and `cmd_cs` case labels as well, so a subcommand that never reaches the GUI fails the suite. Verified by removing the new `adopt` entry again and watching it fail. Flags remain deliberately CLI-only: the menu prompts for names, not options. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
The menu launched commands and forgot them. `run_in_pane` split a pane, called
`herdr pane run`, printed "✓ running in a new pane" without checking anything,
and exited; `run_here` discarded `ddev tryout`'s exit status and always exited 0.
A failed `serve` — 2-5 minutes across 8 stages, any of 6 of which can fail and
leave a half-built site that `site_is_served` still reports as served — looked
exactly like a successful one.
Two things close that gap.
`start_tryout_job` gives a command a pane named after it and wraps it so it
records its own exit code in .ddev/.tryout-jobs/, then raises a notification
saying whether it finished or failed. Deliberately not herdr's pane.exited event:
a wrapper needs no daemon and survives the popup that launched it closing.
`ddev tryout herdr jobs` reports ✓ / ✗ / ⟳ per job.
`tryout/herdr-dashboard.sh` is a live view — worktrees with branch, dirty state,
PHP, database and URL; patch count; composer and TYPO3 state; the vendor/Core
mismatch and stray-checkout warnings; and the running jobs — refreshing every
five seconds, on prefix+shift+D or `ddev tryout herdr dashboard`.
It must never block, because it redraws on a timer in a pane the user is
watching: one `ls-remote`, `ddev exec` or SSH probe would freeze it. Every read
is local filesystem or local git, measured at ~334 ms per full render (≈7% duty
at a 5 s refresh). Unit tests grep it for network and container calls and for any
mutation — a comment would not have survived, and the first version of that test
was too weak to catch `git -C … fetch`, so it now matches the verb in any
argument order.
Four defects found while surveying, all fixed here:
- `run_in_pane` claimed success without checking `herdr pane run`.
- jq was required to parse the pane id but never checked, so a missing jq
silently ran a multi-minute command inside the modal popup.
- `delete` confirmed twice — the menu asked, then `cmd_delete` asked again in a
different pane where the prompt blocked unseen. `cmd_delete` now takes --yes.
- `reset` and `checkout` were not confirmed at all, though both run
`git reset --hard` + `clean -fd`.
Also: `cs doctor` moved off the "instant" path, since it can make two
container+network round-trips and a ~5 s SSH probe; and `herdr_session_name` now
falls back to the project config, because DDEV only exports DDEV_SITENAME to
commands it runs itself — the popups are launched by herdr, so they were
resolving to the session "tryout" and talking to a server that was not there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
The keybinding table already listed all three keys, but the text around it had drifted: it said setup-keys "binds two keys" above a three-row table and that it rebinds prefix+shift+G alone, `cs doctor` was still described as instant when it now runs in a pane, and `reset` and `checkout` were missing from the list of entries that ask for confirmation. The larger gap was that the menu's own keys were never shown — only described in prose — so there was no way to see what pressing ctrl+b shift+T actually offers without opening it. The main menu and both submenus are now laid out in full. Verified against the code rather than by eye: the three keys the block writes, which entries run in the popup versus a pane, all five confirm_destructive sites, and that every documented key exists in the menu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
The menu was a one-shot: all eleven of its paths ended in `exit`, so running two commands meant pressing ctrl+b shift+T twice, and once a job was launched the popup had already closed — leaving no way to see what it was doing without hunting for its pane. It is a session now. `main_menu` runs in a loop, every action returns to it, and only `q`, ESC or a failed guard closes the popup. Cancelling a prompt goes back to the menu instead of throwing the whole thing away. ESC was also a bug rather than a missing feature: it fell into the `*)` catch-all and closed the popup from a submenu, which is not what anyone means by it. One `read_key` helper now maps it to "back one level" everywhere. Output from an instant command, and from any job you choose to watch, fills the popup in a scrollable view — jk/arrows, g/G, r to refresh, ESC to go back. It is drawn by the script itself because a popup is a singleton session-modal terminal with no pane id and cannot contain real herdr panes; at ~30 rows it replaces the menu rather than sharing the space with it. A job's output is read back with `herdr pane read`, so the job keeps its own pane and stays watchable full-size in herdr. That call answers with a JSON error AND exit code 0 when the pane is gone, so the viewer matches on the error text and says the pane was closed — checking `$?` would have rendered the blob as output. A unit test pins that down, since the exit status lies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
The lifecycle suite checked served URLs with `curl -sfI`, a HEAD request that
proves the vhost routes and PHP returns 200. A misconfigured instance answers 200
with a broken page and the test could not tell the difference.
Every URL check now fetches the body and asserts the TYPO3 login <title>, which
is the cheap proof that TYPO3 booted, resolved its site configuration and
rendered. Both directions live in one helper, so the four call sites agree.
Three gaps closed while there:
- only one served worktree was exercised. A new test serves two alongside the
primary, asserts each runs its own Core version, and that dropping one leaves
the others serving.
- nothing checked that a site uses its OWN database, though a misrouted site
would still answer 200 while sharing the primary's tables. Asserted against
information_schema, since `ddev tryout exec` always prefixes a PHP binary and
flattens through `sh -c`, so a `php -r` env probe loses its quoting.
- `unserve` was checked for files but not for the hostname going away.
The URL assertions skip where *.ddev.site does not resolve. DDEV writes project
hostnames to /etc/hosts, which needs sudo, and the suite runs with
DDEV_NONINTERACTIVE=true — so a throwaway test project never gets an entry. That
was already failing before this change (verified by running the committed test),
reported only as a bare curl exit 6; it now says why and skips.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
The menu drained the stray Enter left by a single-key read with
`read -r -t 0.01`. Fractional timeouts are bash 4+; macOS still ships bash 3.2,
where that fails with "invalid timeout specification". The error was hidden by
`2>/dev/null || true`, so on macOS the drain never ran and the Enter was eaten by
the NEXT `read -r` — every prompt in the menu silently cancelled. That is the
phantom "cancelled" seen while testing the pickers.
Reading with `-s` instead means no Enter is left in the buffer at all, so nothing
needs draining; the chosen key is echoed back explicitly since -s suppresses it.
A unit test greps for fractional `read -t` and asserts bash 3.2 really rejects
it, because parsing alone cannot catch a runtime argument error.
The rest of the audit came back clean, and deliberately so: no `readlink -f`,
`grep -P`, `stat -c`, `date -d`, `timeout`, `sed -E` or bare `sed -i`; no bash 4
features; `cd`+`pwd -P` instead of `readlink -f`; `tr` instead of `${x,,}`. Two
tests now hold that line — one greps for GNU-only flags, one parse-checks every
shipped script with /bin/bash.
A missing herdr or jq now says how to install it for the platform in use rather
than only linking the docs. herdr gets `brew install herdr` on macOS and the
upstream installer everywhere, deliberately NOT an apt-get line, since no distro
packages it and the advice would fail. The platform comes from `uname -s` with
an OSTYPE fallback, because uname is not always on a stripped PATH.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
… real Add opt-in Playwright tests that log into the TYPO3 backend of every served worktree, and fix the bug they immediately found. A served site's generated nginx vhost never set `fastcgi_param HTTPS`, so PHP did not know the request arrived over DDEV's TLS terminator. TYPO3 then built http:// URLs and issued a session cookie the browser would not send back, and the login bounced to /typo3/login with "Please activate Cookies". The page still answered 200 throughout, which is why the existing curl checks — even the ones asserting the login page renders — could not see it. The primary site was unaffected, since it uses DDEV's own vhost, which sets the parameter. The `$` has to be escaped in the generator's unquoted heredoc. Unescaped, the shell eats it and nginx gets `fastcgi_param HTTPS ;`, which it rejects on startup — killing the whole web container, not just that one site. A unit test asserts the parameter is present AND escaped, and was verified against both failure modes. The browser tests live in tests/e2e/ and run only on request: the repository has no build step and no Node tooling, so package.json, node_modules and a browser download stay out of the default gate. Sites are discovered from `ddev tryout worktree list` rather than hard-coded, so serving another worktree is covered automatically, and everything skips with a message when TRYOUT_PROJECT is unset. Verified against a live project: three backends log in and reach /typo3/module/dashboard over https. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
`ddev tryout herd<TAB>` offered nothing and zsh fell back to file completion, suggesting directory names. Only a trailing space produced candidates. The script read the verb from $2, but the LAST argument is always the word being completed: for `ddev tryout herd<TAB>` DDEV passes `tryout herd`, so $2 was the partial word, not a verb. It matched no case, the script printed nothing, and cobra had nothing to filter. The verb now comes from the arguments BEFORE the last one, which is the only position that holds a completed word. Every existing completion test passed '' as the word being completed, so the partial case — the one a user actually hits, since they type before pressing TAB — was never exercised. A test now covers both forms at four positions, and was verified to fail against the old code. Filtering stays cobra's job: returning the whole list for a partial word is correct, and it narrows the display itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
`ddev tryout composer install` regenerated the Composer overlay and said nothing about `install`. The word was silently dropped, so a typo — or a reasonable guess at what the command does — looked like it had worked. The name invites that guess: `composer` does not run Composer, it regenerates composer.tryout.json from the Core sysexts. It now says so and points at `ddev composer install`, which is what someone typing that almost certainly meant. `status` and `help` had the same hole and now reject extra arguments too. `help` was dispatched without "$@", so it could not see them at all. Autocomplete was correct throughout: `composer` takes no arguments, so offering nothing is right. The reported symptom was a real problem, just not in the completion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
herdr's own New-worktree action invents a branch name (worktree/wilie-wonka) and the relocation guard derives the directory from it, as does `worktree adopt`. The result was a checkout stuck with whatever the branch happened to be called, with no way to change it short of removing and recreating the worktree. `ddev tryout worktree rename <old> <new>` renames the checkout and leaves the branch alone. The name reaches further than the directory, so all of it moves together: the typo3-core symlink when that worktree is active, and a served site's tree, vhost and database — unserved and re-served under the new name, keeping its database. `adopt` also takes an optional name now, so a single stray can be taken in under a name of the caller's choosing rather than the branch's. Deriving a name in the hook stays as it was: it runs unattended after herdr has already created the checkout, so it cannot ask, and landing somewhere sensible beats refusing. Renaming afterwards is the escape hatch. Verified against real strays: adopting `worktree/wilie-wonka` as `wonka`, then renaming to `experiment`, left the branch `wilie-wonka` throughout. A served worktree renamed from benni to benny rebuilt its site on db_benny and back again. The menu-coverage test caught the missing menu entry for rename before it shipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
A shellcheck pass plus a correctness review over ~4500 lines. shellcheck is now clean at warning level across every shipped script; the substantive findings: Job ids collided. `date +%s`-`$$` is constant for the menu process within a second, so two jobs launched together shared an id — the second overwrote the first's .rc, and a FAILED job could be reported as ok. That is precisely what job tracking exists to prevent. Ids now carry $RANDOM and retry on collision. A failed serve left the site marked as served. `.tryout-site` IS the definition of served, and it was written before every step that can fail: composer, the database, the vhost, the TYPO3 setup. A half-built site then looked real to `worktree list`, the dashboard, `delete --all` and `write_worktree_config`. A RETURN trap now removes it unless serve reaches the end. Verified by serving with an impossible PHP version: the site is correctly absent afterwards. `exec` destroyed argument boundaries. `cmd_exec` joined with "$*" and `site_exec` interpolated that unquoted into a container-side `sh -c`, so quoting was lost twice: `exec v13 typo3 config:set X "My Site"` arrived as two arguments. Each argument is now quoted with printf %q, and the internal callers that passed one pre-joined string pass real arguments instead. Two PHP scripts reported success after a failed write. `sync-composer.php` did not check json_encode or file_put_contents, then unconditionally deleted the lock file and printed "N system extensions written" — a truncated overlay with a cheerful exit 0. Both it and site-composer.php now fail loudly. Also: `open_worktree_in_herdr` validates its name (every caller already did, but the name becomes a path and a herdr label, so it should not depend on that); FPM_WRAPPER removed as genuinely dead; `ls`- and `find`-driven loops replaced with forms that cannot trip over odd filenames; and packages/ is now documented as deliberately surviving removal, since it holds the user's own extensions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
`serve` fell back to the project's PHP version when --php was omitted, which is
the wrong answer for a worktree on another branch: Core states its requirement
per branch, ^8.5 on main and ^8.2 on 13.4, and the project knows nothing about
either. A 13.4 worktree in an 8.5 project was served on 8.5 regardless.
The default now comes from require.php in that worktree's own composer.json, and
resolves to the highest version the web container actually provides that the
constraint permits. The command says which it picked and that --php overrides.
Three details worth keeping:
- The version list is read from the container (`ls /usr/bin/php8.*`), not
hardcoded. DDEV adds versions over time and does not reject an unknown
--php-version, so a stale list here would quietly select a PHP that is absent.
- require.php specifically, parsed with php rather than grepped: a naive search
for "php" finds config.platform.php first, which is a pinned build version and
not the constraint.
- Upper bounds are honoured. Reading only the floor of ">=8.2 <8.4" would hand
that branch 8.5, which it rejects; it now gets 8.3. A unit test covers the
capped, open and absent cases, and was verified to fail against a
floor-only implementation.
Falls back to the project version whenever the constraint cannot be read, so a
missing or unparsable composer.json never blocks a serve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
Tables were built by padding strings, prompts asked the user to type a worktree name from memory, and multi-minute operations gave no feedback. gum replaces all three, wrapped in ui_* helpers in functions.sh so the presentation rules live in one place rather than at every call site. The wrappers exist because two gum behaviours are traps: - `gum choose` cannot open a TTY in some contexts and then exits 0 with no output. Trusting that exit code turns a cancelled prompt into a bogus answer, so the helpers check the output and never the status. - `gum spin` writes escape sequences to a non-TTY, so it only spins on a terminal, and it must preserve the wrapped command's exit code — the error handling added in 1536ca6 branches on those codes. have_tty deliberately tests stdin and *stderr*, not stdout: every prompt is read as x="$(ui_choose ...)", which makes stdout a pipe by definition. Testing stdout would disable the chooser in the only place it runs, and the plain read it fell back to returned the arrow-key escape sequence as the chosen name. That fallback now strips escape sequences too. `worktree list` gains --plain as the machine-readable contract; the bordered default table matches none of the regexes that parse it, so the Playwright suite switches to the flag. gum becomes a hard requirement, checked in pre_install_actions beside git with a per-platform install hint, and installed in CI and by tests/setup.sh so the suites do not go red. Also fixes the herdr menu, which sourced functions.sh only inside subshells behind `|| fallback`: DDEV exports DDEV_APPROOT only to its own commands, so the library aborted under `set -u` and the failure was swallowed. The popup now exports it before sourcing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
gum only changes how output looks: the ui_* helpers already fall back to plain columns, a bold heading and a typed prompt when it is missing, and every command carries the same information either way. Refusing to install without it broke existing installs for a cosmetic dependency. pre_install_actions now notes its absence and carries on, and the test harness no longer gates on it. CI gains a fifth shard that runs the unit suite with gum absent. Without it only the styled path would ever be exercised, and the fallback is what a user without gum actually gets. The shard is a real added combination: `gum` is a base matrix dimension, so the include entry cannot silently merge into — and replace — the styled stable/unit job, which is what happens when an include only sets keys the matrix does not already define. Verified by hiding the gum binary outright rather than editing PATH: DDEV host commands do not inherit the caller's PATH, and a directory named `gum` does not shadow the real one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
Completion was a bare word list: nothing said what a verb does, a free-text position (worktree add <TAB>) left the shell listing files, flags were re-offered after being typed, `serve` suggested worktrees already served, `--php` completed nothing, `worktree list --plain` was missing, and a TAB on `worktree use` cost a third of a second. DDEV hands every output line to cobra verbatim (makeHostCompletionFunc, v1.25.2), so cobra's two candidate conventions work unchanged: `value<TAB>description` renders as two columns in zsh and fish (bash from 4.4), and `_activeHelp_ text` renders as a hint that is never inserted. Every candidate now carries a description taken from the command's own help text, and every free-text word gets a hint — DDEV always returns the Default directive, so file completion cannot be suppressed, and a hint above the file list is the best available answer. Candidates are filtered to what makes sense: serve offers unserved worktrees, unserve served ones, use/remove leave out the primary, flags already on the line are dropped, a typed `-` shows flags alone, --php offers the versions this Core's composer.json accepts with the default marked, patch offers the configured change numbers, adopt the stray checkouts, and branches list main, then releases newest first, then the pre-9 TYPO3_x-y refs. Names come from the typo3-core-* glob and the sites/*/.tryout-site markers instead of list_core_worktrees, whose per-worktree `git status` was the whole cost: 335 ms -> 38 ms for the script, 0.77 s -> 0.28 s for the TAB through DDEV. Tests assert that no bare candidate remains, that each filter holds, and bound the runtime; the existing ones read names through a helper that strips descriptions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
`ddev tryout worktree serve` with no name used to print a usage line and
exit. It now offers the worktrees that are not served yet and lets the
user pick; use/remove leave out the primary, unserve lists served ones,
checkout lists branches (main, then releases newest first), add and
herdr new ask for the name and then the branch, rename picks the old
name and asks for the new one, exec picks the site and asks for the
command. ESC cancels. Without a terminal — a script, a pipe — nothing
can be asked, so the commands print their usage line and exit 1 as
before; the install suite still covers that path.
The ask_* helpers live in functions.sh, next to the ui_* layer they use,
and every site follows one shape:
[ -n "${x}" ] || x="$(ask_…)" || { explain_missing "usage"; return 1; }
Two defects in the ui_* layer surfaced while proving this under a real
PTY, and both were invisible to piped tests:
- gum draws its whole interface on STDERR. ui_choose and ui_input
redirected that to /dev/null to hush "could not open TTY", so every
chooser — including the herdr menu's — had been working blind. The
have_tty guard already covers the no-terminal case; the redirect is
gone and a test forbids it.
- ui_spin gated on stdout being a terminal. DDEV pipes a host command's
stdout, always, so the spinner never showed under `ddev tryout`. It
now gates on stderr, where gum draws.
`gum filter` would have been nicer for the forty-branch checkout list,
but it cannot be cancelled with ESC, only Ctrl-C; a taller `gum choose`
keeps the one rule every prompt follows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
Test report: fresh install on macOS + ColimaInstalled this branch as an add-on into a clean project and it works end to end, but first Setup
Failure on first start Cause
Workaround that recovers fully Second run: Core detected, 36 sysexts synced, 118 composer packages, TYPO3 15.0.0-dev set up, Suggested fix After a successful clone in step 1, force a sync before anything runs in-container: git -C "${CORE_DIR}" remote add gerrit "${GERRIT_REMOTE}"
success "TYPO3 Core cloned"
ddev mutagen sync 2>/dev/null || true # propagate the clone into the web containerAlternatively clone into the container ( Happy to test a revision. |
…lone On a fresh install post-start.sh cloned TYPO3 Core on the host and immediately ran sync-composer.php in the web container. With Mutagen the clone was not yet visible there, so the script died with "typo3-core/typo3/sysext not found" until the user ran `ddev mutagen sync` by hand. sync_to_container exists for exactly this and was already used for worktrees and served sites, but neither Core clone path called it. post-start.sh now flushes once before step 3, which also covers the reset and cherry-picks of step 2, and `ddev tryout download` flushes after its first-time clone. A unit test asserts that every `git clone` in the shipped scripts is followed by sync_to_container before the next container-side call. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
|
Thanks for the thorough report, and for chasing it down to the exact ordering — that made this quick. Fixed in cd3af61.
I checked that DDEV's If you have a moment to re-test a fresh |
Re: the Mutagen race — confirmed fixed ✅Retested on a clean install with
While I was in there I exercised the rest of the toolkit. Most of it works well —
|
…oser runs A project on PHP 8.4 with Core main (^8.5) failed in post-start with Composer's resolver trace, followed by the hint to `download --reset`, which would only repeat the failure. The actual fix is `ddev config --php-version=8.5`. The constraint evaluator that picks a served worktree's default PHP is split into core_php_constraint, php_satisfies and matching_php_versions, and a new check_php_for_core compares a site's PHP against the Core it is built on. It names the constraint, the version in use and the concrete command that changes it, and skips when the constraint cannot be read, because Composer is the authority then. It guards every Composer run: post-start, rebuild_typo3 (checkout, download, reset) for the primary and served sites, and serve_worktree, where an explicit --php can still be one the branch rejects; there it refuses before a database and vhost exist for a site that could never install. A unit test asserts every composer install has the check in its function. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
`ddev tryout herdr` refused a project with only the plain typo3-core/ clone
("single Core checkout, not worktrees"), so a fresh project could not use it
at all until a worktree existed. It now opens that checkout as one workspace.
The checkout goes by its branch — `core-main` — which is the name
migrate_core_to_worktree_layout gives it when the first worktree is added, so
the herdr workspace keeps its label across the move. That naming now lives in
plain_core_name, shared by both; herdr_checkout_dir maps a name to the plain
clone or to typo3-core-<name> depending on the layout. Asking for another name
on the plain layout says which checkout there is and how to add a worktree.
Verified against a fresh project: the workspace is created with both panes in
typo3-core/, and a re-run skips it as already open.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
setup-keys put the project name in its marker and refused only a second block for the *same* project, so every tryout project appended its own block to ~/.config/herdr/config.toml, binding the same three keys again each time — and a block outlived its project as a dead key. The popups resolve the project from the pane's cwd, never from their own path, so one block serves every project. The marker is now project-free. setup-keys is a no-op only when exactly one global block exists and its scripts are still on disk; otherwise it folds whatever is there — legacy per-project blocks, a block whose project is gone — into one block pointing at the current project, after the usual backup. unsetup-keys strips blocks of both shapes through the shared herdr_strip_key_blocks, and the byte-exact round trip still holds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
Re-tested on
|
… Core switch Two failures reported against a fresh install on Colima: 1. `worktree serve --php 8.2` answered 502. The extra php-fpm master runs as the web user and bound /run/php-fpm-8.2.sock, but /run is root-owned on some providers, so the bind failed with "Permission denied" and supervisord reported a spawn error. The socket and pid now live under /run/php/, where the stock php-fpm writes its own pid and which is writable wherever DDEV runs; the vhost's fastcgi_pass follows, the daemon refuses with a clear message when the directory is not writable, and a unit test pins both sides to the same path. 2. `worktree use` from main to 13.4 died in Composer's pre-autoload-dump hook: the class-alias-loader v2 plugin already loaded from vendor/ ran against the v1 code it had just installed, and the primary served 500 until vendor/ was removed by hand. Both switch paths — worktree use and checkout, primary and served sites — now remove vendor/ before the rebuild. The removal runs in the container, because a host-side rm is not yet visible there when Composer starts a moment later under Mutagen. Verified on a fresh project: the 8.2 daemon binds and reports ready as the web user; main -> 13.4 -> main rebuilds cleanly and answers 200 on each. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
|
Thanks for the second pass — both are fixed in 37bedc0. 1. 2. Verified on a fresh project: the 8.2 daemon binds and reports "ready to handle connections" as the web user, and main → 13.4 → main rebuilds cleanly, Re the constraint check: correct, 13.4 accepts 8.5 so it stays silent — it only fires when the project's PHP is one the Core can't run, e.g. an 8.4 project against main. |
… relative paths Worktree metadata holds paths, and an absolute path is right on one side of the container boundary only. git >= 2.48 can write them relative, so host and container can share one worktree; trixie ships 2.47 and forky's package drags a newer libc, hence the source build in web-build/Dockerfile.tryout. ensure_relative_worktree_paths sets worktree.useRelativePaths on the Core repo and repairs existing worktrees; every clone, migration and worktree add calls it, and add_core_worktree refuses on a git that cannot write relative paths. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
…iner The host command keeps the terminal — prompts, gum, herdr, the delete confirmation — and hands each container-safe verb to tryout-container.sh through one ddev exec. In there git, composer, php, curl and the database clients are the container's own, so nothing calls ddev back: the helpers in functions.sh call the tools directly, the verb bodies live in commands.sh, and the post-start hook became an exec hook. worktree adopt, herdr and help stay on the host by nature; status answers 'not cloned' locally so it works before ddev start. A host-side Mutagen flush replaces the old container-side one for the one case that needs it: a worktree the container just created that herdr opens by its host path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
…th sides cs doctor and cs setup now add one line about the host's SSH agent after the container's report, and the no-key hint names ddev auth ssh where that is the fix. Installation notes a host git older than 2.48. The README describes where each verb runs, the git build in the web image, relative worktree paths, and why the Core .git must stay in the Mutagen sync. Also: the first-run TYPO3 setup ran run_typo3 through env, which cannot start a shell function; and delegate now flushes Mutagen after every verb that changes files, so 'worktree add x && cd typo3-core-x' cannot race the sync. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
…ly verbs Verified end to end in a scratch project: image build, in-container clone, worktree add readable by both gits, serve on PHP 8.4 answering 200, exec, unserve, remove, cs setup/uninstall, delete. Plan file removed, all stages done. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
… patch Without a change number, patch now lists the open changes for the Core branch in use and lets the user select one or several with gum. list-patches.sh runs in the container next to the other two Gerrit scripts (same 2/3 exit codes) and emits number/subject/owner/review-scores as TSV; the host renders those through ui_choose_multi, a gum choose --no-limit wrapper following the same TTY and exit-code rules as ui_choose. Picked changes are applied in order with one rebuild for the batch, which is why the site moved from the second positional argument to --site. Afterwards the host offers once to append the numbers to TRYOUT_PATCHES so they survive a reset or a fresh start. A configured patch list or a missing terminal short-circuits the picker, leaving ddev start and any script unchanged. Two things the picker depends on: the fetch spins and the pick does not, since gum spin and gum choose would fight for the screen; and pick_patches takes its rows as arguments, because reading them from stdin would swallow the answer the no-gum chooser needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
… gum Two rendering defects found by driving the real chooser under a PTY: printf pads with %-Ns, which counts bytes, so the multi-byte ellipsis list-patches.sh truncated with left every shortened subject two columns short. It now truncates with "...", and pick_patches pads through pad_display, which counts characters — an owner called "Frédéric" would have shifted its row the same way. The header also promised "space to select", but gum 2.0 toggles with x and draws its own footer saying so; no flag rebinds it, so the header no longer duplicates what gum already tells the user. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
"Add 93838 to your patch list?" says nothing about what 93838 is, and the answer writes it into a config file the user keeps. describe_patches looks the picked numbers up in the rows already fetched and prints one change per line as "<number> - <subject>"; a hand-typed number with no matching row still gets a line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
Bare, all three now ask which site they mean instead of assuming the primary, which stopped being the obvious answer once worktrees can be served. ask_site shows what each one is — name, URL and PHP version — and delete adds an "every site" entry, so wiping everything is a pick rather than a flag to remember. reset and delete only ask once something besides the primary is served, so a single-site project is unchanged. The list displays labels but answers with names, and with the "@primary" sentinel for the primary; cmd_delete blanks that again before delegating, since the container parses positional site names. An answer that is not an exact label falls back to its first word, so typing a name at the no-gum prompt works as well as picking one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
…choose The worktree subcommands already asked which checkout to act on, but offered a list of bare names — which says nothing about what any of them is. They now show branch, HEAD, clean/dirty and what the checkout serves, from a new worktree_labels; the answer is still the bare name, so a typed one works too. Each list stays filtered to what its command can act on. worktree_labels runs a git status per worktree, which core_worktree_names deliberately avoids. That is fine for a picker opened by hand and wrong for the dashboard or completion, which keep using the cheap helper. A bare adopt now offers the strays it found through ui_choose_multi rather than taking every one: herdr leaves behind checkouts the user may still be working in. With no terminal it adopts all of them, as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
core_worktree_names has no callers left outside its test now that the picker uses worktree_labels, but it is the helper completion and the dashboard must reach for — the comment now says so rather than leaving it looking unused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
ddev add-on get copies the payload once and never refreshes it, so a project installed before a change keeps the old command and the old completion script. Both still work — they offer the previous set of verbs and flags — which is indistinguishable from tab-completion being broken, and was. TRYOUT_VERSION in functions.sh is stamped into .ddev/tryout/.version by a post_install_action; addon_is_stale compares the two with two file reads, and cmd_status reports it on the host before delegating, so a stopped project still gets the answer. A missing stamp means an install predating the marker and is deliberately silent: warning on every one of those would train people to ignore the line. The completion script itself needed no change — verified correct at every position through ddev __complete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
ddev tryout herdr only ever added: it opened a workspace per worktree and skipped the ones already open, so removing a worktree left a workspace whose pane sat in a directory that no longer existed. A bare run now syncs both ways. herdr_orphan_workspaces lists the workspaces labelled core-<name> whose checkout is gone — one workspace list call, then a directory test each, since this runs on every bare invocation. Only that label marks a workspace as ours: the session is per project, but anything else opened in it must be left alone. The close is unconditional, with no prompt and no exception for a working agent, so the session always matches the project; a worktree rename is a remove plus an add to herdr and settles in one run. That is why the cleanup comes after the open loop, and why naming a worktree skips it entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
Closing orphans covered only half of keeping the session in step. A workspace can also sit in one of our worktrees under a label from before the core-<name> scheme — the open loop then skipped it by cwd but opened a duplicate under the right name — or point somewhere outside the project entirely. sync_herdr_workspaces runs before the open loop and adopts the first case by renaming it: closing would kill a live agent and leave a duplicate, while a rename keeps the pane and makes every other helper recognise it. A workspace outside the project is closed, since the session is named after the project. Anything else inside it — the root, packages/ — is left alone: not a Core worktree, and opened deliberately. The workspace's directory comes from its pane cwd, not worktree.checkout_path: that field exists only for worktree open, and the workspace create fallback records none, so it is absent exactly where an answer is still needed. Paths are compared resolved, for the same /var vs /private/var reason as list_foreign_core_worktrees. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
…from a list The prefix+shift+G popup asked for a name and a branch but never said what the name becomes, validated nothing, and took the branch as blind free text. It now sources functions.sh so it can do both in the popup itself — which matters because a popup closes when its script ends, taking any message from ddev with it — states that the folder becomes typo3-core-<name> in the project root, refuses a name that is invalid or already taken, and offers the branches this Core knows through ask_branch. One trap found by driving it under a PTY: the first version called ask_branch with 2>/dev/null and the chooser answered nothing, because gum draws its entire interface on stderr. functions.sh already had a test forbidding that on gum itself; it now covers every shipped script, since the same mistake one level up is just as invisible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
ddev tryout patch went straight to the change list, always for the project's own branch. With worktrees served that is the wrong question and often the wrong list: the site decides which Core is patched, so a 13.4 site was being offered main's open changes. The site is now resolved first, from the same picker reset and delete use, and the host repeats the container's site_core_dir + detect_detached_base_branch step so the fetch asks Gerrit about that site's base branch. A named site, an explicit change number, a configured patch list and a missing terminal all keep their previous behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
Creating a Core worktree is meant to be the same two questions everywhere: a
name, and a branch picked from a list. Three routes did not ask them that way.
In cmd_worktree add and cmd_herdr_new the ask_branch call sat inside the
`if [ -z "${name}" ]` guard, so naming a worktree silently opted out of the
branch question and fell back to ${BRANCH} — whatever Core happened to be
checked out. Since the prefix+shift+G popup always passes a name, that was the
normal path, not an edge case. The question now lives in
ask_new_worktree_branch and is asked outside the guard.
That helper leaves its answer in ASKED_BRANCH rather than on stdout, so a
caller can tell a cancel from an empty pick. Its no-terminal branch is
load-bearing: ui_choose returns non-zero without a TTY, so asking
unconditionally would abort ddev start and any script. It falls back to
${BRANCH} silently instead, and only a real cancel stops the caller.
The herdr menu's `worktree add` was the one creation route that never offered
a list at all — it read the branch as free text, so a typo only surfaced later
in a background job's output, when add_core_worktree rejected it against
origin.
relocate.sh is the one route where the user never names the folder, since the
worktree.created hook is past tense and derives the name from the branch. On a
collision it used to leave the checkout outside the project, where no tryout
command can see it — and two branches slugging alike (bugfix/12345 and
bugfix-12345) is not rare. It now suffixes to the first free name and says so,
naming the branch too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
The add-on grew a whole UI on top of herdr: a menu popup on prefix+shift+T, a new-worktree popup on prefix+shift+G, a live dashboard on prefix+shift+D, the job tracking under .ddev/.tryout-jobs/ that made a backgrounded command's outcome visible, a config.toml keybinding block written and restored by setup-keys/unsetup-keys, and a plugin whose worktree.created hook moved a stray checkout back into the project. All of it is gone. Every verb it exposed already existed as `ddev tryout <verb>` in a shell, so the layer was a second, thinner way to reach commands that were never hard to run — and it was the most expensive part of the add-on to keep correct, being the only code that had to reason about popup modality, pane ids, a global config file outside .ddev/, and herdr's own event timing. What survives is the part that does something a shell cannot: `ddev tryout herdr` still opens one workspace per Core worktree and reconciles them, and `herdr new` still creates a worktree and opens it. The plugin went with it, so a worktree created by herdr's own New-worktree action is no longer relocated automatically. `worktree adopt` was always the manual equivalent and cmd_status still warns when a stray exists, so the recovery path is intact — it is now the only one. Two notes for anyone reading the old docs. A config.toml written by a previous setup-keys keeps three bindings pointing at scripts that no longer exist; there is deliberately no migration, since removal_actions cannot reach a file outside .ddev/. And CLAUDE.md said herdr's New-worktree "cannot be redirected" — the worktree.create API method does take a path, it is only the TUI dialog that never passes one. Corrected while the surrounding text was being rewritten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
Two commands asked "[y/N]" with a hand-rolled `read -r -p`, and a third that
destroys more than either of them asked nothing at all.
The raw read was worse than plain: with no terminal it fails at EOF, leaves
${confirm} unset, and `set -u` then aborts cmd_delete with a bash error instead
of a message. A caller could not distinguish that from a user answering no.
ui_confirm returns three codes rather than two — 0 confirmed, 1 declined, 2 no
terminal to ask on — because "declined" and "nobody was there" need different
answers: "Aborted." for one, "→ ... --yes" for the other. That split is the same
one explain_missing already makes, and gum cannot make it for us, since without
a TTY `gum confirm` exits 1 whatever --default says. Hence the have_tty gate:
gum is never handed a terminal-less run in the first place.
Two gum behaviours had to be pinned down by driving the prompt under a PTY.
--default=false is load-bearing: gum preselects Yes, so a bare Enter confirmed a
database wipe in the first version. And a bare `gum confirm` under `set -e`
takes the whole script down, so the exit code is captured rather than branched
on. Both are covered by tests, in the gum and the plain rendering alike.
worktree remove is the new confirmation. It is deliberately conditional: a plain
remove still asks nothing, because git already refuses to drop a dirty tree and
says to retry with --force. That flag is what switches the guard off, and a
served worktree takes its site and database with it — those two ask, so the
common safe case keeps costing one keystroke.
Known gap, left for a follow-up: core_worktree_is_dirty reads only `git diff`
and `git diff --cached`, so a worktree whose only change is an untracked file is
described as clean and the prompt understates what --force will delete. git's
own refusal is stricter than the helper, so the non-forced path is unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
Two additions to the herdr integration, plus the removal of something that was in the way. The panel. herdr's own sidebar cannot be extended — "runtime action registration and native non-terminal plugin UI are not part of plugin v1" — so this does what the herdr-sidebar plugin does: opens a normal pane, docks it at the edge, and draws its own UI inside it. It lists the commands worth a click and runs the picked one in a herdr popup, which prompts with the ui_* helpers already here rather than reimplementing any of them. delete and worktree remove are deliberately absent: they destroy data, and their confirmation needs the room a full shell gives it. It is bash, not the Rust the sidebar uses, because install.yaml asks only for git on the host and a compiled binary would be a new class of dependency for an add-on shipped as source. herdr allows it: "a plugin can be a Bash script, JavaScript app, Lua script, Rust binary, or any other argv command". Three things about it cost real time. `read -t 0.05` is rejected outright by the bash 3.2 macOS ships, so the escape reader is bounded by a whole second instead — the existing test for that caught it. Left-click and wheel reach a pane app that asks for them, but right-click never does, since herdr keeps it for its own pane menu unless the user sets right_click_passthrough_modifier, so nothing here depends on it. And a pane must be split from the CALLING pane, through HERDR_PANE_ID: herdr warns that "omitting a target may use the UI-focused pane, which can belong to the user or another client", and it does — docking against the focused one put the panel in whichever workspace happened to hold focus, which is how it went missing during testing. Registering the plugin is optional and buys one thing, the popup; `ddev tryout panel` works without it and runs the command in the panel pane instead. The manifest ships to the project root because herdr resolves its relative paths against the directory it is pointed at. The tabs. A workspace opened with one claude pane in a tab herdr labels "1", which says nothing about what is in it. The first tab is now named for what it holds — Claude when an agent is actually running, Shell when it is not, so the label never claims an agent that failed to start — and a second tab labelled Terminal holds a plain shell. A tab is the right shape for it: the earlier split pane took width from the agent, which is why it was removed a few changes ago. Both are reconciled on a bare `ddev tryout herdr`, not only at first open: the open loop skips a worktree that is already open, so that is the only route which reaches workspaces predating this. Only herdr's default "1" is ever renamed, so a tab the user named themselves is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
`ddev tryout herdr` now docks a panel beside each workspace's Terminal shell, scoped to that workspace's worktree, and `ddev tryout panel` docks the same one on demand. Both routes produce an identical pane: same width, same working directory, same environment. They drifted apart twice while this was being built, so a test now compares them. The menu is built from what the worktree IS, because the verbs are not interchangeable. checkout, patch, reset and exec all need a site; on a bare checkout there is none, so offering them would be a row that fails — or worse, one that silently acts on the primary. Such a worktree is offered `worktree serve` and `worktree use` instead, which are the two ways to give it a site. composer is primary-only for the same reason: it rewrites the primary overlay whatever the panel is scoped to. Each verb also takes its site differently, and getting that wrong is invisible until it does the wrong thing. checkout and patch take --site, both added here; patch especially, because its first positional is a CHANGE NUMBER, so `patch benni` fetched a Gerrit change called "benni" and applied it to the primary. reset takes the site positionally and never blanks the @primary sentinel, so it must receive a real name. exec takes it positionally too and rejects a site that is neither primary nor served. The primary is named like any other worktree rather than left implicit. A bare command resolves through the typo3-core symlink at the moment it runs, so a panel that was the primary when it opened would switch whichever worktree is primary now — which is how checkout came to move the wrong checkout. The panel also re-reads its own state on every draw, since `worktree use` elsewhere can change what it is while it sits there. Four things about the terminal cost real time and are worth recording. Rows are drawn with plain newlines: `ESC[row;colH` addresses the SCREEN, so inside a split the rows landed outside the pane and only the first was visible. The selection is explicit white-on-black, never `ESC[7m`, which swaps the terminal's current colours and came out invisible on a dark theme. The popup does not inherit the pane's colours — it opens on a light ground — so it sets the terminal DEFAULTS with OSC 10/11 rather than an SGR pair, because ESC[0m follows every coloured span in `status` output and resets TO a default rather than away from it. And `plugin pane open` answers ok whether or not a popup appeared, so the panel waits for a marker file the popup writes before believing it; the verb travels in the environment, unreadable from outside on macOS, and the popup's parent is the herdr server rather than the panel, so nothing else identifies it. The popup's own cwd is the project root, never the worktree: the manifest runs it as a relative path that herdr resolves against --cwd, and there is no .ddev/ under a worktree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
`ddev tryout status` printed its labels and values with no colour of their own,
so they took the terminal's default foreground. That is fine in a pane and
unreadable in a herdr popup, which does not inherit the pane's colours: the
green tick and the cyan patch list showed, everything around them did not.
The first attempt repainted the popup with OSC 10/11. That was the wrong lever.
herdr ships eleven themes and an auto_switch that follows the host's light/dark
appearance, and exposes no way to ask which is active — so any pair of colours
picked here is wrong for somebody, including the same user after a theme change.
Output that states its own colour is right on every theme and every background,
in a popup or a plain shell, and needs nothing from herdr.
The subtlety is where the resets land. Wrapping a line in ${TEXT}...${NC} does
nothing for it if something inside already ended in ${NC} — the reset lands
mid-line and the rest goes bare again, which is exactly what the ✓/!/✗ icons
did. They now return to ${TEXT} rather than resetting, so only the line's final
reset is a reset.
Two lines outside the status body had the same shape and appear in the popup
after `delete` and `checkout`: the admin credentials and the site URL, both bare
after a bold label.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
A worktree was detached by default. That is fine for Gerrit — pushes go to
refs/for/<branch> from HEAD, never from a local branch — but there is nothing
to update from, and unpushed work is easy to lose.
`worktree add <name> [<base>]` now creates a branch named after the WORKTREE,
tracking origin/<base>. Named after the worktree because git allows one
worktree per branch, so a second checkout off main is refused outright. The
base survives only as the branch's upstream: BRANCH is read from
`branch --show-current`, which returns the worktree's own name here, so
origin/<name> does not exist and anything pulling or resetting would fail
without --track. `--detach` is the opt-out; `--branch` is accepted and does
nothing, since it is the default now.
`download` grows a site argument, so it updates the worktree you mean rather
than always the primary, rebasing onto the recorded upstream. The panel offers
it alongside checkout/patch/reset.
Two bugs this shook out:
- reset_core_to_main tried to check out ${BRANCH}, which is the BASE. On a
worktree that either collides with whoever holds it or fails because the
branch exists. `reset --hard` moves whatever is checked out, branch or
detached HEAD, which was the intent all along.
- `rev-parse --abbrev-ref @{upstream}` exits 128 with no upstream and killed
the command under `set -e` before the fallback could run — which every
worktree made before tracking was recorded hits.
And one found while reviewing this diff: download resolved CORE_DIR for the
named site but called reset_core_to_main/rebuild_typo3 bare, so it pulled the
worktree's Core and then ran composer install against the primary. Its hints
pointed at a siteless `download --reset` for the same reason.
TRYOUT_VERSION 9 -> 10: a new flag and a new completion candidate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
Bare, it opens the site of the worktree you are standing in, so from inside a checkout it takes no argument. Outside one it asks, listing each served site with the URL it would open. `--backend` goes to /typo3/ instead. Host-only, and one of the few verbs that is: there is no browser in the web container and nothing here needs one, so it never delegates. open_url picks open/xdg-open by OS and, when there is neither, prints the URL rather than failing — the URL was the useful part anyway. Two cases it has to get right: - An unserved worktree has no URL. It says so, with the two ways to give it one, instead of quietly opening some other site's. - The active worktree IS the primary and has no sites/<name>/ marker, so looking it up by name reads as "not served". Both the panel and anyone typing the name from `worktree list` pass exactly that, so the name maps to the primary — whose URL comes from DDEV_PRIMARY_URL, since a non-standard port lives there and not in the hostname. sync_herdr_workspaces had the path->worktree mapping inline; it moves to worktree_name_for_path so `launch` reads a cwd the same way. Its two traps now live in one place: resolving before comparing (macOS reaches the project through /var while other tools report /private/var), and how deep a match counts — `top` for herdr, which wants a workspace's own directory, and the default for a cwd, which can be anywhere inside the checkout. TRYOUT_VERSION 10 -> 11: a new verb, and new completion candidates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
Every panel row opened a herdr popup. For launch that is pure friction: the
command hands a URL to the browser and is done in ~200 ms, so the browser
comes forward and then a modal box appears in front of it saying "Opened
https://…" — which the user has to click back to the terminal to dismiss,
since the popup holds until a keypress or 60 seconds.
A fourth parallel array carries the exception. `add` takes an optional fourth
field and `run_selected` consults it before it mints the popup's marker file —
otherwise every Enter on such a row leaves one in TMPDIR that nothing collects.
The gate is deliberately narrow: fast, silent on success, needs no terminal.
launch is the only verb that passes it, and a test counts the tagged rows so a
second one has to re-argue the case. status is the near miss — fast and
read-only, but a screenful in a ~25-column strip, which is what the popup is
for.
Two things about run_direct cost real time:
- Its `2>&1 >/dev/null` keeps stderr and drops stdout, IN THAT ORDER. Reversed,
both streams go and every failure is silent — so success and "worktree not
served" become indistinguishable. A behavioural test drives the real function
rather than a copy of the redirection.
- A failure cannot simply be printed. The loop is render → blocking read and
render clears the pane first, so anything printed is wiped before it can be
read. It goes into NOTICE, which render shows once and then clears: no timer,
no key to dismiss, gone on the next keystroke.
Found while testing: `cd "${APPROOT}" && ddev …` short-circuits to rc 0 with no
output when the directory is missing, so a bad approot made every run quietly
do nothing and report success. The directory is now tested explicitly.
TRYOUT_VERSION 11 -> 12: a menu row behaves differently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
`--backend` already existed on the command; the panel offered only the frontend. It is now two rows, "launch frontend" and "launch backend", because the panel has no way to ask which you meant — a flag needs a prompt, and the whole point of these rows is that they run without one. They sit at the very end, after composer/worktree use: every row above acts on the checkout, these two only look at it. Both keep the direct path, so neither opens a popup. The tests that pinned "exactly one direct row" now pin two, and assert that nothing but launch claims that path. A new test pins the ordering — last, and frontend before backend — for both menu shapes that have a site, since nothing else would notice a row appended after them. TRYOUT_VERSION 12 -> 13: the panel's rows changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
The panel drives the worktree the agent is working in, so it belongs where you are looking rather than one tab away. This reverses the placement chosen when the Terminal tab was added — that argued the panel was width-stealing clutter next to the agent, which is a judgement about layout, and the call is the user's. It keys on the workspace's FIRST tab, never a label: ensure_first_tab_label names that tab "Claude" or "Shell" depending on whether an agent is running, and a user can rename it again, so any label would lose the panel's home the moment one of those happened. The pane it splits is the agent's own, explicitly excluding a panel already sitting in that tab — otherwise a second run would nest one panel inside another. A panel already docked in the old place is MOVED with `herdr pane move`, not closed and redocked: closing kills a running panel and flashes the pane. Every workspace opened before this has one there, so that is the common case, not the edge one. Verified live against a herdr session — the panel relocated with its process intact. Both placements pass --no-focus. `pane move` focuses the moved pane by default, which would have pulled focus off the agent on every reconcile pass; a test now counts the two against each other so a third placement cannot forget it. TRYOUT_VERSION 13 -> 14: the layout a user sees changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
`ddev tryout checkout 13.4` then failed its composer install: Source path "../../typo3-core-main/typo3/sysext/theme_camino" is not found for package typo3/theme-camino The generator scanned typo3/sysext/* — correctly and completely — and then appended typo3/theme-camino on top whenever the branch looked like main or v14+. On 13.4 that sysext does not exist, so the entry pointed a path repository at a missing directory, which composer install refuses. The special case was never needed: theme_camino/composer.json declares typo3/theme-camino, so the ordinary scan already finds it wherever it exists. Verified on both trees — main has it and keeps it, 13.4 has it not and no longer asks for it. A detached worktree made it worse. `branch --show-current` prints nothing there, so the fallback read EXT:core's branch-alias, got "main", and added camino to a 13.4 tree. Two of the six served sites in the test project were broken this way; only one had been noticed. $branch existed solely to gate this, so it goes with it — along with the detached-HEAD fallback that only made the wrong answer more reliable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
… rows Three things. The menu is now rebuilt by render, so on every draw. It used to be rebuilt only on the inline fallback path, which meant `worktree serve` run from the popup left the panel offering the rows of a worktree that no longer existed in that state. render is the only place this can happen: the popup path returns as soon as the popup STARTS, long before its command finishes, so there is no later moment to rebuild from. It is free — build_menu is the same two filesystem reads the header already needed, and a draw only happens on a keystroke. `worktree add` and `worktree remove` are opposites in scope, so each sits where it belongs. add is offered only where nothing is scoped: creating a worktree is project-level and the popup asks for the name and the branch. remove is offered only on a worktree's own panel — all three scoped shapes — where it names that worktree, so the popup has only to confirm. Both keep the popup: they prompt, and a confirmation cannot happen in a pane with no terminal. Removing the active worktree, or the one owning the git object store, is already refused by remove_core_worktree with a next step, so the row on the primary's panel fails safely rather than breaking the project. No new guard. The staleness test asserted that render called worktree_state directly, which the rebuild replaces. It now pins the outcome instead — unserving a worktree drops the rows that need a site — and greps with comments stripped, because this function's own comment names build_menu and a raw grep passed even with the call removed. TRYOUT_VERSION 14 -> 15: two new rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SK2S9XhqchzR2wX54EXYi5
Install this add-on:
What it it does:
ddev tryout herdr... install:brew install herdr