From b78861a29dac44c1b214158169e836fdd36a2b3b Mon Sep 17 00:00:00 2001 From: Goran Ninkovic Date: Sat, 18 Jul 2026 02:04:06 +0200 Subject: [PATCH 1/2] feat: detect down containers in preflight with a clear error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool pinned to a ddev/docker backend used to pass preflight (container tools were assumed available) and then fail mid-run with a raw `docker exec` error when the container was down. Preflight now verifies the container is running and fails fast with an actionable message — without starting the container, since a git hook must not spin up infrastructure. Host binary checks are unchanged. Container-running checks are memoized, so the added cost is at most one `docker inspect` per container per run; host preflight is a ~20us PATH lookup per tool. Release-As: 2.1.0 Co-Authored-By: Claude Opus 4.8 --- docs/architecture.md | 6 ++--- internal/forge/backend/backend.go | 27 ++++++++++++++++++++++ internal/forge/backend/backend_test.go | 27 ++++++++++++++++++++++ internal/forge/runner/review_fixes_test.go | 4 ++-- internal/forge/runner/runner.go | 12 +++++----- website/guide/hooks.md | 17 +++++++++----- 6 files changed, 76 insertions(+), 17 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d223f85..a0258f4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,8 +22,8 @@ flowchart TD ws -->|yes| member["run hook per member
(member forge.toml)"] ws -->|no| files["gather files
(staged, or all-tracked for --all-files)"] - files --> pre{"all enabled tool
binaries present?"} - pre -->|no| prefail["fail hook
(install / SKIP_* / disable)"] + files --> pre{"all enabled tools runnable?
host binary on PATH /
container running"} + pre -->|no| prefail["fail hook
(install / start / SKIP_* / disable)"] pre -->|yes| mode{"parallel mode?"} mode -->|no| seq["runHookCfg
(sequential)"] mode -->|yes| par["runHookCfgParallel
(waves by depends_on)"] @@ -57,6 +57,6 @@ flowchart TD - **Tool order** follows declaration order in `forge.toml`. Go maps don't preserve order, so `config.parseHookToolOrder` regex-scans the raw TOML and feeds `OrderedToolNames()` — never rely on map iteration for ordering. - **Backend resolution precedence**: per-tool `backend` → `[execution].default_backend` → DDEV auto-detect (if the container is running) → host. -- **Preflight before execution**: every enabled (non-skipped) tool's binary must resolve before any tool runs; a missing host binary aborts the whole hook. Container backends are assumed to provide their own binaries. +- **Preflight before execution**: every enabled (non-skipped) tool must be runnable before any tool runs — host binaries must resolve on `PATH`, and container (ddev/docker) tools must have a running container (forge never starts one). Any failure aborts the whole hook. Container-running checks are memoized per run. - **Mutations happen only on success**: `restage` and `stage_outputs` run after a tool passes (never in `--check` mode); the run cache is only written for passing tools. - **Config precedence**: `FORGE_CONFIG` → repo `forge.toml`, with the global user config (`~/.config/forge/config.toml`) merged underneath — repo values always win. diff --git a/internal/forge/backend/backend.go b/internal/forge/backend/backend.go index cbf631a..d83d2f9 100644 --- a/internal/forge/backend/backend.go +++ b/internal/forge/backend/backend.go @@ -281,6 +281,33 @@ func (e *BackendAvailabilityError) Error() string { return fmt.Sprintf("tool %q not available via backend %q", e.Tool, e.Backend) } +// PreflightTool reports why a tool cannot run under its backend, or nil if it +// can. For container backends it checks that the container is running (it does +// NOT start it — a hook must not spin up infrastructure); for the host it +// checks that the binary resolves. Container-running checks are memoized, so +// this is cheap even when many tools share a container. +func PreflightTool(repoRoot, resolvedCmd string, b Backend) error { + switch bk := b.(type) { + case *DdevBackend: + name, err := ddevContainerName(repoRoot) + if err != nil { + return fmt.Errorf("ddev backend unavailable: %w", err) + } + if !isDockerContainerRunning(name) { + return fmt.Errorf("ddev container %q is not running — start it with `ddev start`", name) + } + case *DockerBackend: + if !isDockerContainerRunning(bk.container) { + return fmt.Errorf("container %q is not running", bk.container) + } + default: + if !ToolBinaryAvailable(repoRoot, resolvedCmd, b) { + return fmt.Errorf("binary not found: %s", resolvedCmd) + } + } + return nil +} + // ToolBinaryAvailable reports whether the resolved command path is accessible. func ToolBinaryAvailable(repoRoot, resolvedCmd string, backend Backend) bool { if _, isDdev := backend.(*DdevBackend); isDdev { diff --git a/internal/forge/backend/backend_test.go b/internal/forge/backend/backend_test.go index bc97ed9..db256f6 100644 --- a/internal/forge/backend/backend_test.go +++ b/internal/forge/backend/backend_test.go @@ -278,6 +278,33 @@ func TestResolveCommandForBackend_SystemType_ReturnsCommandAsIs(t *testing.T) { } } +// ---------- PreflightTool ---------- + +func TestPreflightTool_DockerContainerDown(t *testing.T) { + // A container name that cannot be running (also covers docker-not-installed). + b := &DockerBackend{container: "forge-test-no-such-container-zzz"} + err := PreflightTool(t.TempDir(), "phpstan", b) + if err == nil { + t.Fatal("expected error when the docker container is not running") + } + if !strings.Contains(err.Error(), "not running") { + t.Errorf("error should explain the container is down, got: %v", err) + } +} + +func TestPreflightTool_HostBinaryMissing(t *testing.T) { + err := PreflightTool(t.TempDir(), "forge-test-no-such-binary-zzz", &HostBackend{}) + if err == nil || !strings.Contains(err.Error(), "binary not found") { + t.Errorf("expected 'binary not found' error, got: %v", err) + } +} + +func TestPreflightTool_HostBinaryPresent(t *testing.T) { + if err := PreflightTool(t.TempDir(), "echo", &HostBackend{}); err != nil { + t.Errorf("expected nil for a present host binary, got: %v", err) + } +} + // ---------- ToolBinaryAvailable ---------- func TestToolBinaryAvailable_DdevBackend_AlwaysTrue(t *testing.T) { diff --git a/internal/forge/runner/review_fixes_test.go b/internal/forge/runner/review_fixes_test.go index 45c354e..d800fbc 100644 --- a/internal/forge/runner/review_fixes_test.go +++ b/internal/forge/runner/review_fixes_test.go @@ -106,8 +106,8 @@ func TestPreflightFailsOnMissingTool(t *testing.T) { if err == nil { t.Fatal("expected hard failure for a missing tool binary") } - if !contains(err.Error(), "missing tool") || !contains(err.Error(), "ghosttool") { - t.Errorf("error should name the missing tool, got: %v", err) + if !contains(err.Error(), "ghosttool") || !contains(err.Error(), "binary not found") { + t.Errorf("error should name the missing tool and reason, got: %v", err) } } diff --git a/internal/forge/runner/runner.go b/internal/forge/runner/runner.go index e3995bf..d48c5ad 100644 --- a/internal/forge/runner/runner.go +++ b/internal/forge/runner/runner.go @@ -172,7 +172,7 @@ func RunHookWithOptions(hookName string, editFile string, opts RunOptions) error // or disable the tool in forge.toml. func preflightTools(root string, hookCfg config.HookConfig, exec config.ExecutionConfig, toolNames []string) error { allowedGroups := parseAllowedGroups() - var missing []string + var problems []string for _, name := range toolNames { tool := hookCfg.Tools[name] if shouldSkipTool(name) || shouldSkipGroup(tool.Group) { @@ -188,13 +188,13 @@ func preflightTools(root string, hookCfg config.HookConfig, exec config.Executio } b := backend.ResolveBackend(root, tool, exec.DefaultBackend) resolvedCmd := backend.ResolveCommandForBackend(root, tool, b) - if !backend.ToolBinaryAvailable(root, resolvedCmd, b) { - missing = append(missing, fmt.Sprintf("%s (%s)", name, resolvedCmd)) + if err := backend.PreflightTool(root, resolvedCmd, b); err != nil { + problems = append(problems, fmt.Sprintf("%s — %s", name, err)) } } - if len(missing) > 0 { - return fmt.Errorf("missing tool binaries:\n - %s\ninstall them, set SKIP_=1 to skip, or disable the tool in forge.toml", - strings.Join(missing, "\n - ")) + if len(problems) > 0 { + return fmt.Errorf("cannot run hook, some tools are unavailable:\n - %s\ninstall/start them, set SKIP_=1 to skip, or disable the tool in forge.toml", + strings.Join(problems, "\n - ")) } return nil } diff --git a/website/guide/hooks.md b/website/guide/hooks.md index 2de0619..d15e2c2 100644 --- a/website/guide/hooks.md +++ b/website/guide/hooks.md @@ -111,21 +111,26 @@ HOOKS_ONLY=format git commit -m "..." ## Missing tools fail the hook -Before running anything, forge checks that every enabled tool's binary is available. If any is missing, the whole hook aborts **before** a single tool runs — so a mistyped command or an uninstalled linter can never silently pass as "all checks green". +Before running anything, forge checks that every enabled tool can actually run. If any can't, the whole hook aborts **before** a single tool runs — so a mistyped command or an uninstalled linter can never silently pass as "all checks green". ``` -run failed: missing tool binaries: - - eslint (node_modules/.bin/eslint) -install them, set SKIP_=1 to skip, or disable the tool in forge.toml +run failed: cannot run hook, some tools are unavailable: + - eslint — binary not found: node_modules/.bin/eslint +install/start them, set SKIP_=1 to skip, or disable the tool in forge.toml ``` You then have three ways forward: -- **Install** the tool. +- **Install** the tool (or start its container). - **Skip it for one run**: `SKIP_ESLINT=1 git commit …`. - **Disable it**: comment out or remove the tool's block in `forge.toml`. -Tools you've already skipped (`SKIP_*`, `--skip-tool`, a non-matching `HOOKS_ONLY` group) are exempt from the check. Tools running through a DDEV/Docker backend are assumed present in the container — only host binaries are verified. Run `forge doctor` to see availability without triggering a commit. +What's checked: + +- **Host tools** — the binary must resolve on `PATH` (or as a `vendor/bin` / `node_modules` path). +- **DDEV / Docker tools** — the container must be **running**. forge does **not** start it for you (a commit shouldn't spin up infrastructure); it fails fast with `container "…" is not running` so you can `ddev start` and retry. + +Tools you've already skipped (`SKIP_*`, `--skip-tool`, a non-matching `HOOKS_ONLY` group) are exempt. Run `forge doctor` to see availability without triggering a commit. ## See also From 3d1c81e6b336ee2104f55947c993dce8e3aed64f Mon Sep 17 00:00:00 2001 From: Goran Ninkovic Date: Sat, 18 Jul 2026 02:05:47 +0200 Subject: [PATCH 2/2] test: cover ddev container-down preflight branch Adds a fixture test for the *DdevBackend path (.ddev/config.yaml parsing + the `ddev start` guidance message), which the DockerBackend test didn't reach. Addresses review. Co-Authored-By: Claude Opus 4.8 --- internal/forge/backend/backend_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/forge/backend/backend_test.go b/internal/forge/backend/backend_test.go index db256f6..550c224 100644 --- a/internal/forge/backend/backend_test.go +++ b/internal/forge/backend/backend_test.go @@ -292,6 +292,23 @@ func TestPreflightTool_DockerContainerDown(t *testing.T) { } } +func TestPreflightTool_DdevContainerDown(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, ".ddev"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".ddev", "config.yaml"), []byte("name: myproj\n"), 0o644); err != nil { + t.Fatal(err) + } + err := PreflightTool(dir, "phpstan", &DdevBackend{}) + if err == nil { + t.Fatal("expected error when the ddev container is not running") + } + if !strings.Contains(err.Error(), "ddev container") || !strings.Contains(err.Error(), "ddev start") { + t.Errorf("error should mention the ddev container and how to start it, got: %v", err) + } +} + func TestPreflightTool_HostBinaryMissing(t *testing.T) { err := PreflightTool(t.TempDir(), "forge-test-no-such-binary-zzz", &HostBackend{}) if err == nil || !strings.Contains(err.Error(), "binary not found") {