Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ flowchart TD
ws -->|yes| member["run hook per member<br/>(member forge.toml)"]
ws -->|no| files["gather files<br/>(staged, or all-tracked for --all-files)"]

files --> pre{"all enabled tool<br/>binaries present?"}
pre -->|no| prefail["fail hook<br/>(install / SKIP_* / disable)"]
files --> pre{"all enabled tools runnable?<br/>host binary on PATH /<br/>container running"}
pre -->|no| prefail["fail hook<br/>(install / start / SKIP_* / disable)"]
pre -->|yes| mode{"parallel mode?"}
mode -->|no| seq["runHookCfg<br/>(sequential)"]
mode -->|yes| par["runHookCfgParallel<br/>(waves by depends_on)"]
Expand Down Expand Up @@ -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.
27 changes: 27 additions & 0 deletions internal/forge/backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions internal/forge/backend/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,50 @@ 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_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") {
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) {
Expand Down
4 changes: 2 additions & 2 deletions internal/forge/runner/review_fixes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
12 changes: 6 additions & 6 deletions internal/forge/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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_<TOOL>=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_<TOOL>=1 to skip, or disable the tool in forge.toml",
strings.Join(problems, "\n - "))
}
return nil
}
Expand Down
17 changes: 11 additions & 6 deletions website/guide/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<TOOL>=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_<TOOL>=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

Expand Down
Loading