Skip to content

Feature/ddev addon - #28

Draft
ochorocho wants to merge 55 commits into
bmack:mainfrom
ochorocho:feature/ddev-addon
Draft

Feature/ddev addon#28
ochorocho wants to merge 55 commits into
bmack:mainfrom
ochorocho:feature/ddev-addon

Conversation

@ochorocho

@ochorocho ochorocho commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Install this add-on:

ddev add-on get https://github.com/ochorocho/tryout/tarball/feature/ddev-addon

What it it does:

  • Make tryout a ddev add-on
  • Make tryout worktree aware
  • Herdr: ddev tryout herdr ... install: brew install herdr

ochorocho and others added 22 commits September 3, 2026 18:10
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
@susannemoog

Copy link
Copy Markdown

Test report: fresh install on macOS + Colima

Installed this branch as an add-on into a clean project and it works end to end, but first ddev start fails on a Mutagen race in post-start.sh.

Setup

  • macOS (Apple Silicon), Colima --vm-type vz --mount-type virtiofs, DDEV v1.25.4
  • ddev config --project-type=typo3 --docroot=public --php-version=8.5
  • ddev add-on get https://github.com/ochorocho/tryout/archive/refs/heads/feature/ddev-addon.tar.gz
  • ddev start

Failure on first start

==> [1/5] Cloning TYPO3 Core repository...
==> TYPO3 Core cloned
==> [3/5] Syncing composer.tryout.json with Core sysexts...
Error: /var/www/html/typo3-core/typo3/sysext not found. Clone TYPO3 Core first.
Failed to run php /var/www/html/.ddev/tryout/sync-composer.php: exit status 1

Cause

post-start.sh runs as an exec-host hook. Step 1 does git clone into ${CORE_DIR} on the host; step 3 immediately calls ddev php .../sync-composer.php which runs in the web container. On Mutagen-based setups (the macOS default) the freshly cloned typo3-core/ hasn't propagated into the container yet — DDEV's pre-hook Mutagen flush already happened before the clone — so typo3/sysext reads as absent and the script exit 1s.

Workaround that recovers fully

ddev mutagen sync && ddev restart

Second run: Core detected, 36 sysexts synced, 118 composer packages, TYPO3 15.0.0-dev set up, ddev tryout status renders, backend login (admin / Password.1) authenticates (303 -> /typo3/main, be_typo_user cookie set). So only the first-run ordering is broken.

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 container

Alternatively clone into the container (ddev exec git clone ...), or gate step 3 with a retry/wait on typo3-core/typo3/sysext appearing in-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
@ochorocho

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough report, and for chasing it down to the exact ordering — that made this quick.

Fixed in cd3af61. functions.sh already had sync_to_container() for this (a ddev mutagen sync, gated on DDEV_MUTAGEN_ENABLED, used when worktrees and served sites are created), but neither Core clone path called it. Now:

  • post-start.sh flushes once before step 3, so it covers the fresh clone and the reset/cherry-picks of step 2 that touch the same tree. You'll see a Syncing to container... line between [2/5] and [3/5].
  • ddev tryout download flushes after its first-time clone as well.
  • A unit test asserts every git clone in the shipped scripts is followed by sync_to_container before the next container-side call, so it can't regress silently.

I checked that DDEV's Start() exports DDEV_MUTAGEN_ENABLED into the environment before running post-start hooks, so the gate holds on your setup.

If you have a moment to re-test a fresh ddev start from a clean project, that would be great.

@susannemoog

Copy link
Copy Markdown

Re: the Mutagen race — confirmed fixed ✅

Retested on a clean install with cd3af61 ("[BUGFIX] Flush Mutagen before running sync-composer on a fresh Core clone") in the branch:

  • macOS / Colima (vz + virtiofs), DDEV v1.25.4, fresh typo3 project
  • ddev add-on get <branch tarball>ddev start
  • First ddev start now completes end to end — the new ==> Syncing to container... step runs between the Core clone and sync-composer.php, so typo3/sysext is present when it's needed. No workaround required.
  • Verified: 36 sysexts synced, 118 composer packages, TYPO3 15.0.0-dev set up, backend login authenticates.

While I was in there I exercised the rest of the toolkit. Most of it works well — worktree add, patch <gerrit-id> (cherry-pick + rebuild), status/worktree list dashboards, and the context-aware tab completion are all solid. I did hit two separate issues; happy to open them as their own issues rather than clutter this PR:

  1. worktree serve --php 8.2 → 502. The extra web_extra_daemons FPM master can't start: unable to bind listening socket for address '/run/php-fpm-8.2.sock': Permission denied/run is root-owned and the daemon runs as the web user. Moving the socket + pid file to /run/php/ (where the stock pool already lives, and which is writable) fixes it.
  2. worktree use between incompatible majors (15 ↔ 13.4) corrupts the primary's vendor/. The class-alias-loader v2→v1 downgrade throws during autoload dump (Class "TYPO3\ClassAliasLoader\IncludeFile\SuffixToken" not found), and a 15→13.4→15 round-trip left stale vendor/composer/autoload_* pointing at a symfony/translation layout that wasn't on disk (HTTP 500). rm -rf vendor composer.tryout.lock && composer install recovers. A vendor/ wipe when the target Core's constraints are incompatible would avoid it.

ochorocho and others added 3 commits September 5, 2026 20:36
…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
@susannemoog

Copy link
Copy Markdown

Re-tested on 3e85d38#2 and #3 still reproduce

Ran the full pass again on branch HEAD (3e85d38, i.e. after 2a487ce PHP-vs-Core-constraint check and 3e85d38 herdr-for-single-checkout). Same macOS / Colima setup, fresh project.

Still good: the Mutagen fix holds — clean install, first ddev start completes end to end. Also re-verified worktree add/serve/unserve --drop-db/remove, patch <id>, exec, tab completion, cs doctor, destructive-op guards, and herdr scaffolding + herdr dashboard.

Still broken — unchanged by the two new commits:

  1. worktree serve --php 8.2 → 502. ddev restart reports webextradaemons:tryout-php-8.2: ERROR (spawn error); container log:

    ERROR: unable to bind listening socket for address '/run/php-fpm-8.2.sock': Permission denied (13)
    

    The daemon runs as the web user and /run is root-owned. Socket + pid need to live under /run/php/ (writable, where the stock pool already is), with the generated nginx fastcgi_pass following.

  2. worktree use 15 → 13.4 breaks the primary. composer install dies in the pre-autoload-dump hook:

    Class "TYPO3\ClassAliasLoader\IncludeFile\SuffixToken" not found
    at vendor/typo3/class-alias-loader/src/ClassAliasMapGenerator.php:113
    

    (class-alias-loader v2→v1 downgrade while its own plugin is loaded in-process). Command exits 1; a 15→13.4→15 round-trip leaves the primary serving HTTP 500. Recovery: rm -rf vendor composer.tryout.lock && composer install. Wiping vendor/ before installing against a Core whose constraints are incompatible would avoid it.

Note: 2a487ce's PHP-constraint check didn't fire for a 13.4 worktree on PHP 8.5 — 13.4 accepts 8.5, so that's correctly a no-op here; I couldn't trigger the guard.

Happy to open #1 and #2 as separate issues if that's easier to track.

… 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
@ochorocho

Copy link
Copy Markdown
Contributor Author

Thanks for the second pass — both are fixed in 37bedc0.

1. serve --php 8.2 → 502. Exactly your diagnosis: the extra php-fpm master runs as the web user and /run is root-owned on Colima. Socket and pid now live under /run/php/ (/run/php/php-fpm-<v>.sock / .pid), the generated vhost's fastcgi_pass follows, and the daemon exits with a clear message if that directory is ever not writable instead of letting nginx 502. A unit test pins the daemon script and the vhost generator to the same path so they can't drift apart again.

2. worktree use 15 → 13.4. Also as you read it: Composer loads the class-alias-loader v2 plugin from the existing vendor/ before resolving, then runs its hook against the v1 code it just installed. Both switch paths — worktree use and checkout, primary and served sites — now remove vendor/ before the rebuild (the lock was already dropped by sync-composer.php). The removal runs in the container, since a host-side rm isn't visible there yet under Mutagen when Composer starts a moment later. The command says so: Removing vendor/ — Core changed, a stale install cannot be updated in place.

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, typo3 --version showing 13.4.35-dev then 15.0.0-dev, with /typo3/ answering 200 on each. I couldn't route a served hostname on this machine (hosts-file edits need sudo here), so a re-test of your serve --php 8.2 case on Colima would be very welcome.

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.

ochorocho and others added 28 commits September 6, 2026 09:36
… 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants