Skip to content

fix: retrospective review fixes for merged PRs #1/#2/#3/#4/#5/#6/#7/#8/#14/#16/#17#84

Merged
ikeikeikeike merged 14 commits into
mainfrom
fix/pr-review-sweep-0703
Jul 3, 2026
Merged

fix: retrospective review fixes for merged PRs #1/#2/#3/#4/#5/#6/#7/#8/#14/#16/#17#84
ikeikeikeike merged 14 commits into
mainfrom
fix/pr-review-sweep-0703

Conversation

@ikeikeikeike

Copy link
Copy Markdown
Member

Retrospective /code-review sweep of merged PRs that shipped without pre-merge review (see repo history — most foundational PRs predate the review workflow). Wave 1 + wave 2 of a staged sweep; wave 3/4 (remaining PRs) to follow in a separate PR.

Highlights: gRPC context cancellation was killing every nix-backend engine's subprocess seconds after start (mysql/postgres/redis/elasticsearch); bough create always failed to materialize an engine-provider-role repo's worktree; usingDockerBackend could misdetect backend and let Cleanup() delete a live engine's datadir; bough remove's hardcoded graceful-timeout default silently overrode every engine's own tuned shutdown budget.

Fixes #41.

Design-level gaps this sweep surfaced but did not fix in-place (need maintainer sign-off): #78, #79, #80, #81, #82, #83.

Each commit is scoped to one plugin/area with its own rationale in the message. Full findings/fix/issue breakdown: see commit log.

…LUGIN_LOG

Retrospective /review of #16 (conformance suite, w62ukm4ju) flagged two
cheap items among its unfixed findings:

- AssertShellSafe only checked `()&;<>|`$` — missing whitespace, quotes,
  and backslash, all of which corrupt a plain `KEY=value` .env.local line
  the same way the existing v0.2.5 guard chars do. Extended the char
  class and added a regression test per character class.
- BOUGH_PLUGIN_LOG was a duplicated string literal in pluginhost.go and
  create.go. Promoted to pluginhost.EnvPluginLog and reused.

The other #16 findings (up-or-reuse loop gap, extractDialableAddrs
empty-port drop, missing postgres NativeProbe, universal
SkipDatadirPermission skip, ES chmod hard-fail edge case) are
design-level or cross-plugin-blast-radius — filed as issues #70-#74
instead of forced here.
…ckend

Retrospective /review of PR #8 (nix/docker auto-detect) found 6 confirmed
issues, all fixed here:

- hasDocker() shelled out to `docker info`, but every docker-backend
  plugin actually connects via pkg/dockerutil's Docker SDK client. The
  CLI resolves its endpoint from the active `docker context`, which can
  silently diverge from the SDK's client.FromEnv resolution (e.g. Colima
  switches context without exporting DOCKER_HOST) — causing both false
  positives (probe passes, plugin Up fails minutes later) and false
  negatives (SDK-reachable daemon misreported as absent because the
  `docker` CLI binary isn't installed). Switched hasDocker to probe via
  the same dockerutil.NewClient + Ping path plugins use.

- buildEngineExtras() let the once-per-invocation auto-detected backend
  silently overwrite a `backend` key an operator set directly through
  the free-form `extras:` map, even though extras are documented as
  copied verbatim. Auto-detect now only fills a genuine gap.

- Detect()'s two probes ran sequentially off one shared context
  deadline, so a caller with a tight external timeout (bough status
  caps at 3s) could have the docker probe starved by a slow nix probe,
  and worst-case latency was the sum of both timeouts rather than the
  max. Both probes now run concurrently.

- A backend-detection failure (before any plugin subprocess exists) was
  wrapped with a "re-run with BOUGH_PLUGIN_LOG=debug" hint that can
  never produce extra output for that failure mode. Skipped for
  errors.Is(err, backend.ErrNoBackend).

- The backend auto-detect call had no spinner, unlike every other
  multi-second step in `bough create`.

detect_test.go's TestHasDocker_pathPresence assumed the docker CLI's
presence on PATH gates the probe; replaced with a DOCKER_HOST-based
test matching the new SDK-only contract.

go build/test (incl. conformance) + golangci-lint (0 issues) + gofumpt green.
…e env_local failed

Retrospective /review of PR #14 (post_create .env.local env injection)
found 8 confirmed findings; fixed here:

- fileEnv was parsed once per repo before the post_create loop, so a
  line that derived and appended a new value (the documented
  derive-then-consume idiom, e.g. composing a DSN from a bough-
  allocated port) was invisible to every later line in that repo's
  post_create list — reintroducing the "expands to empty" failure
  class this PR was written to eliminate, just moved from bash
  `source` into bough's own line loop. Now re-read before every line.

- skipRepo was built only from materializeRepositories' worktree-add
  failures; a repo whose env_local render/write failed still had
  post_create run against it with an absent-or-stale .env.local, with
  no link back to the earlier failure. renderEnvLocals' failures now
  feed into skipRepo too. Its return type changed from []string
  (pre-formatted "repo (detail)" strings, unusable as skip keys) to a
  small envLocalFailure{Repo, Detail} struct.

- parseEnvLocal collapsed every os.ReadFile error into a silent nil,
  indistinguishable from the legitimate "no .env.local yet" case.
  Non-ErrNotExist failures are now logged with the repo name.

Two findings were investigated and NOT fixed as suggested:
- Multi-line value truncation and quote-stripping both trace back to
  .env.local's wire format having no quoting at all — a parseEnvLocal-
  only fix would guess at a convention the writer (envwriter.Write)
  never established, and blind quote-stripping risks corrupting a
  value that genuinely needs literal quote characters. Filed as issue
  #75 (needs a coordinated read+write format decision).
- Quote-stripping specifically would also contradict this PR's own
  tested contract (TestParseEnvLocal_ValuesWithShellMetachars asserts
  quoted values survive unchanged) — a deliberate design choice, not
  an oversight, per parseEnvLocal's doc comment.

post_create_env_test.go (all 6 of PR #14's original regression tests,
including the load-bearing shell-metachar guard) was wholesale deleted
by a later v0.9 reset commit despite that commit's own message listing
this logic as kept. Recreated with the original 6 tests plus 3 new
ones: a read-error-vs-missing-file distinction test, and two
runPostCreateHooks-level regression tests (later line sees an earlier
line's append; skip map actually suppresses post_create).

go build/test (incl. conformance) + golangci-lint (0 issues) + gofumpt green.
…scope, migrateLegacy overwrite

Retrospective /review of PR #17 (v0.4.0 DB plugin orchestrator -> engine
plugin orchestrator rename, largest diff in the sweep) found 9 confirmed
findings; 8 fixed here, 1 filed as issue #76 (real feature gap, not a
bundleable bug fix).

- verify's non-engine port check looked up the bare registry key
  (e.g. "api"), but create.go now writes "<kind>.main" and Load()
  upgrades every legacy bare key to that same dotted form — the bare
  key never exists post-rename, so `bough verify` reported permanent
  false-positive DRIFT on any config with a non-empty `ports:` section
  (the shipped example.yaml's api/gateway shape included).
- verify's engine check demanded a registry entry for EVERY role in
  eng.PortRanges, but allocateEngines only ever allocates/writes
  "main" (v0.4.0: single-port engines only). Any engine declaring more
  than one port_ranges role hit the same permanent false-positive
  DRIFT. Narrowed to "main" only, matching what create.go actually
  writes; filed the underlying "multi-port can't really be
  provisioned" gap as issue #76.
- verify/status/list/backfill built their registry Store straight from
  cfg.Registry.Path, while create/remove prefer the canonical
  .bough-ports.json via this PR's own resolveRegistryPath() helper —
  the two command families could read different registry files during
  the v0.4.x transition (docs/MIGRATION-v0.3-to-v0.4.md explicitly
  says leaving the YAML field stale is fine "at your convenience").
  All four now go through resolveRegistryPath too.
- conformance/mock_plugin's Up() rollback-on-sentinel-write-failure
  loop closed every listener in req.Ports, not just the ones bound
  during that call — a role reused from a prior successful Up
  (up-or-reuse) got torn down as a side effect of an unrelated later
  failure. This is the in-tree reference plugin future rabbitmq/kafka/
  nats plugins would copy the same regression from.
- config.migrateLegacy unconditionally overwrote c.Engines (already
  populated from an engines: section) with the databases: conversion
  whenever legacy databases: was non-empty, silently discarding any
  engines: entries declared in the same file — breaking the
  incremental v0.3->v0.4 migration story schema_version: 1 exists for.
  Now appends instead of replacing.

Cleanup:
- Makefile's `proto` target still regenerated a legacy db.proto path
  deleted from the repo (plugins/db/), so `make proto` always failed;
  removed the dead line + its dir var.
- config.Load's doc comment referenced a nonexistent LoadFromPath
  function; corrected to point at the real discovery logic
  (internal/cli/helpers.go::resolveConfigPath).
- plugins/engine/api/handshake.go's LegacyHandshake /
  LegacyDBProviderPluginKey were dead exported symbols pointing at a
  deleted plugins/db/api path (pluginhost.DiscoverFromBinary never
  tries them) — removed.

Tests added: verify_test.go (new file — verify.go had none), 3 cases
covering the dotted-key / main-only / canonical-registry-path fixes;
a config_test.go case for the engines:+databases: merge; a
mock_plugin main_test.go case for the scoped rollback.

go build/test (incl. conformance) + golangci-lint (0 issues) + gofumpt green.
…h race, port-conflict detection

Retrospective /review of PR #7 (v0.2.1: hardening - resume idempotency
+ orphan cleanup + port collision) found 11 confirmed findings; 5
fixed here, 1 filed as issue #77 (real design gap, not a bundleable
fix), the rest are stale-doc/test-coverage cleanup folded into the
same commit or already tracked.

- AddOrAttach's resume-idempotency check compared the caller's literal
  dst against git's symlink-resolved `worktree list --porcelain` path
  with a raw string ==, so on any host where dst traverses a symlink
  (stock macOS /tmp -> /private/tmp, /var -> /private/var - exactly
  what Go's own os.Getwd()/t.TempDir() return unresolved) the
  documented "second call is a no-op" contract silently broke: every
  `claude --worktree --resume` re-fired `git worktree add` against an
  already-existing dir and failed with exit 128. Resolve dst via
  filepath.EvalSymlinks before comparing (falls back to the literal
  path when dst doesn't exist yet, e.g. genuinely new worktrees).

- dockerutil.UpOrReuse's ContainerRemove call (for a container
  LookupByName found stopped) propagated ANY error verbatim, turning
  the benign race where a concurrent actor removes the container
  between LookupByName and this call (another `bough create` retry
  for the same worktree - the exact retry-heavy pattern Up's callers
  are built around - a parallel `bough remove`, or a manual `docker
  rm`) into a hard Up failure, even though "nothing there" is exactly
  what the id == "" branch one line earlier already treats as success.
  Now tolerates errdefs.IsNotFound.

- The ContainerStart-failure cleanup + "port is already allocated"
  rewrap block was duplicated verbatim (engine name aside) across all
  4 engine plugins' docker.go, and only matched one of the two real
  daemon error shapes for a taken host port - a plain host-level
  listener (not another Docker container) produces "...bind: address
  already in use", which the bare substring check missed, silently
  falling through to a generic non-actionable error for exactly the
  case the hint exists for. Consolidated into
  dockerutil.StartOrCleanup + isPortConflictError, called identically
  from all 4 plugins - this incidentally fixes both the missed error
  shape and the duplication finding at once.

- mysql/docker.go's file-header doc comment still described the
  pre-PR7 "always remove and recreate" behavior; corrected to describe
  UpOrReuse.

Deferred: the redis-password / mysql-image / es-heap "config drift on
resume" family (PR7's UpOrReuse skip-check runs before any of the
three's desired values are computed, so a resumed container never
picks them up) is a real design gap spanning all 4 plugins, not a
bundleable fix - filed as issue #77 (redis password flagged as
security-relevant). The conformance-suite false-green finding
(IdempotentCount's loop always Downs, so UpOrReuse's reuse branch is
never actually exercised by any test) restates already-filed issue
#70 from the #16 retrospective sweep - no new issue.

Tests added: gitwt_test.go's resume-through-a-real-symlink case;
dockerutil container_test.go (new, no Docker needed) for
isPortConflictError's two error shapes; a container_integration_test.go
case pinning the errdefs.IsNotFound contract UpOrReuse's race
tolerance depends on.

go build/vet(-tags integration)/test (incl. conformance) +
golangci-lint (0 issues) + gofumpt green.
…tray-process match

Up() launched the detached `nix run -- up` process with the per-RPC gRPC
context; grpc-go cancels that context the instant Up() returns, and Go's
exec.CommandContext watchdog then SIGKILLs the still-starting nix process
regardless of Setsid, well before flake evaluation finishes. Reproduced
directly with a minimal grpc unary handler + exec.CommandContext: the
child was reaped ~6ms after the RPC returned. Switched to
context.WithoutCancel(ctx) plus a reaper goroutine so Down()'s existing
lsof-based teardown remains the only thing that stops the process.

Also anchored killStrayProcessCompose's cwd prefix match to a real path
separator boundary — a bare HasPrefix let tearing down one worktree
SIGTERM a sibling whose name is a literal prefix (e.g. auba-api-139 vs
auba-api-1394).

Surfaced by retrospective review of merged PRs #2/#3/#4/#6 (ikeikeikeike/bough).
…chor stray match

Same context-cancellation fix as mysql/redis/elasticsearch: Up() used the
per-RPC gRPC context for the detached `nix run -- up` process, so grpc-go's
cancellation on return let Go's exec watchdog SIGKILL it before flake
evaluation finished. Switched to context.WithoutCancel(ctx) + a reaper
goroutine.

Also: Up() was pre-creating req.Datadir itself before invoking nix, which
defeats services-flake's postgres init-detection (`[[ ! -d "$PGDATA" ]]`)
— initdb never ran against the already-existing empty directory, so the
nix backend could never actually bring up postgres on a fresh worktree.
Now only the parent directory is created, matching the postgres-specific
requirement (mysql's own init-detection tolerates a pre-created empty dir
via a marker-file check, postgres's does not).

Anchored killStrayProcessCompose's cwd prefix match to a path-separator
boundary, same as the other three plugins.

Surfaced by retrospective review of merged PRs #2/#4 (ikeikeikeike/bough).
…tray-process match

Same fix as mysql/postgres/elasticsearch: Up() used the per-RPC gRPC
context for the detached `nix run -- up` process, so grpc-go's
cancellation on return let Go's exec watchdog SIGKILL it before flake
evaluation finished. Switched to context.WithoutCancel(ctx) + a reaper
goroutine.

Anchored killStrayProcessCompose's cwd prefix match to a path-separator
boundary, same as the other three plugins.

Surfaced by retrospective review of merged PRs #3/#4 (ikeikeikeike/bough).
…ept both heap keys

Same context-cancellation fix as mysql/postgres/redis: Up() used the
per-RPC gRPC context for the detached `nix run -- up` process, so grpc-go's
cancellation on return let Go's exec watchdog SIGKILL it before flake
evaluation finished. Switched to context.WithoutCancel(ctx) + a reaper
goroutine.

Anchored killStrayProcessCompose's cwd prefix match to a path-separator
boundary, same as the other three plugins.

Also: the docker backend's pickHeap() only read extras["es.heap"], while
the nix backend's Up() has always read extras["heap"] for the identical
JVM heap-size setting — a value tuned on one backend silently stopped
mattering after switching to the other. pickHeap now accepts both keys.

Surfaced by retrospective review of merged PRs #4/#6 (ikeikeikeike/bough).
…ngine kinds in env_local

runCreate started engine plugins (which deploy their flake into
<worktreeRoot>/<engine-provider-repo-name>/...) before running `git
worktree add` for that same repo path. `git worktree add` fails outright
against a non-empty destination, so worktree materialization for any
role: engine-provider repository always failed — the operator ended up
inside a worktree where the DB-provider repo was never actually checked
out, with mysqld/etc. running against no schema. Swapped the phase order:
materialize repos first, then start engines.

Also: renderEnvLocals always built its env_local template context via
engineContextFor("mysql", engines), so envwriter.Context.Mysql was the
only field ever populated — any worktree whose only engine was postgres,
redis, or elasticsearch got a zero-value Mysql context (Port 0) and no
way to reference its own engine's connection info at all. Extended
envwriter.Context with Postgres/Redis/Elasticsearch DBCtx fields
(mirroring the existing Mysql field) and populate all four from the
configured engines.

Surfaced by retrospective review of merged PR #1 (ikeikeikeike/bough).
DeleteBranch treated every git exec.ExitError as the idempotent "no such
branch" case. `git branch -D` also exits 1 when the branch is checked out
in another worktree, so a genuine failure (branch survives, no worktree
touched) was silently reported as success with zero diagnostic trail.
Now inspects the command's combined output and only swallows the actual
"not found" message; anything else bubbles up.

Surfaced by retrospective review of merged PR #1 (ikeikeikeike/bough).
…ut_sec

The canonical example.yaml's env_local templates referenced
.Ports.Api/.Ports.Gateway (capitalized), but Ports is a map populated only
with the lowercase keys from the same file's own ports: section, and
Render parses with missingkey=error. Copying this documented example
verbatim into a real config made `bough create` abort with "map has no
entry for key \"Api\"" — fixed to reference the actual lowercase keys.

Also: ReadyTimeoutSec's validator only enforced a lower bound (min=1).
The value crosses the plugin RPC wire as an int32; an operator-set value
at or above 2^31 would silently wrap negative there and every plugin's
own timeoutSec<=0 guard would substitute its internal default with no
warning anywhere in the chain. Added max=86400 (24h, well under the
int32-safe range and far past any sane ready-check budget).

Surfaced by retrospective review of merged PRs #1/#5 (ikeikeikeike/bough).
… engine plugin

defaultRemoveGracefulSecs=10 backed both `bough remove`'s --graceful-timeout
default and the flagless WorktreeRemove hook dispatch. Every engine's
Down() only falls back to its own tuned budget (30s for mysql's InnoDB
recovery window, 60s for elasticsearch's translog flush) when
GracefulTimeoutSec<=0 — since the host always sent 10 (>0), every engine's
own budget was dead code on every single `bough remove`, silently
SIGTERMing databases with a fifth to a sixth of their intended grace
period. Changed the default to 0 so "operator didn't ask for anything
specific" actually means that; an explicit --graceful-timeout is still
honored as before.

Surfaced by retrospective review of merged PR #6 (ikeikeikeike/bough).
…ping wave1+2 review fixes

# Conflicts:
#	internal/cli/create.go
@ikeikeikeike
ikeikeikeike merged commit 17bb711 into main Jul 3, 2026
9 checks passed
@ikeikeikeike
ikeikeikeike deleted the fix/pr-review-sweep-0703 branch July 3, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant