# Blitz
<p align="center">
<img src="assets/blitz.svg" alt="Blitz logo" width="200" />
</p>
<p align="center">
<a href="https://hex.pm/packages/blitz"><img src="https://img.shields.io/hexpm/v/blitz.svg" alt="Hex.pm Version" /></a>
<a href="https://hexdocs.pm/blitz/"><img src="https://img.shields.io/badge/hex-docs-blue.svg" alt="HexDocs" /></a>
<a href="https://github.com/nshkrdotcom/blitz"><img src="https://img.shields.io/badge/github-nshkrdotcom/blitz-8da0cb?style=flat&logo=github" alt="GitHub" /></a>
</p>
Parallel command runner for Elixir tooling and Mix workspaces.
`Blitz` has two layers:
- `Blitz.run/2` and `Blitz.run!/2` for low-level parallel command fanout
- `Blitz.MixWorkspace` for config-driven `mix` orchestration across many child
projects
It stays intentionally local and predictable. `Blitz` is not a job system,
workflow engine, or distributed scheduler.
## Features
- Runs isolated OS commands concurrently with `Task.async_stream/3`
- Prefixes streamed output with a stable `id | ...` label
- Preserves input ordering in the returned result list
- Raises with aggregated failure details in `run!/2`
- Accepts per-command working directories and environment overrides
- Ships a reusable `Blitz.MixWorkspace` layer for Mix monorepos
- Supports config-driven parallelism with task weights, auto machine scaling,
optional pinned multipliers, and per-task overrides
- Keeps child projects isolated with per-project deps/build/lockfile/Hex paths
## Installation
Add `blitz` to your dependencies.
Default install:
```elixir
def deps do
[
{:blitz, "~> 0.1.0"}
]
end
```
Use this when your project is happy to treat `blitz` like a normal dependency.
Tooling-only install for monorepo roots, internal Mix tasks, or workspace
helpers:
```elixir
def deps do
[
{:blitz, "~> 0.1.0", runtime: false}
]
end
```
Use `runtime: false` when `blitz` is only there to power tooling such as:
- `mix blitz.workspace ...`
- root-level Mix aliases
- custom Mix tasks
- repo-local helper modules that orchestrate child projects
This keeps `blitz` out of your runtime application startup while still making
its modules available to compile and run your tooling.
Do not automatically move `blitz` to `only: [:dev, :test]`.
That is usually too narrow for workspace tooling, because repo-level commands
such as CI, docs, compile, or Dialyzer may still need `blitz` outside a local
test-only flow. If your project uses `blitz` for root tooling, `runtime: false`
is usually the right default. Add `only: ...` only when you are certain the
dependency is never needed outside those environments.
### Dialyzer Note For Tooling-Only Installs
If you install `blitz` with `runtime: false` and your project keeps a narrow
Dialyzer PLT, you may need to add `:blitz` explicitly to `plt_add_apps`.
This commonly matters when your project:
- calls `Blitz` or `Blitz.MixWorkspace` directly from Mix tasks or helper modules
- uses `plt_add_deps: :apps_direct`
- uses a small explicit `plt_add_apps` list
Example:
```elixir
def project do
[
app: :my_workspace,
version: "0.1.0",
deps: deps(),
dialyzer: dialyzer()
]
end
defp deps do
[
{:blitz, "~> 0.1.0", runtime: false}
]
end
defp dialyzer do
[
plt_add_deps: :apps_direct,
plt_add_apps: [:mix, :blitz]
]
end
```
Why this is needed:
- `runtime: false` means `:blitz` is not treated as a runtime application
- a restricted PLT may therefore omit `:blitz`
- Dialyzer can then report `unknown_function` warnings for calls like
`Blitz.MixWorkspace.root_dir/0`
If your Dialyzer setup already includes all needed deps or apps, no extra
configuration is required.
## Quick Start
Build command structs with `Blitz.command/1` and execute them with `Blitz.run/2`
or `Blitz.run!/2`.
```elixir
commands = [
Blitz.command(id: "root", command: "mix", args: ["test"], cd: "/repo"),
Blitz.command(id: "core/contracts", command: "mix", args: ["test"], cd: "/repo/core/contracts")
]
Blitz.run!(commands, max_concurrency: 2)
```
Each command streams output with a stable `id | ...` prefix and `run!/2` raises
with an aggregated failure summary if any command exits non-zero.
## Mix Workspaces
`Blitz.MixWorkspace` moves the common Mix-monorepo concerns out of repo-local
wrapper code:
- project discovery
- per-task `mix` args
- preflight `deps.get` for projects that still need deps
- isolated `MIX_DEPS_PATH`, `MIX_BUILD_PATH`, `MIX_LOCKFILE`, and `HEX_HOME`
- task-specific env hooks
- configurable parallelism per task family
Configure it in your root `mix.exs`:
```elixir
def project do
[
app: :my_workspace,
version: "0.1.0",
deps: deps(),
aliases: aliases(),
blitz_workspace: blitz_workspace()
]
end
defp aliases do
[
"monorepo.test": ["blitz.workspace test"],
"monorepo.compile": ["blitz.workspace compile"]
]
end
defp blitz_workspace do
[
root: __DIR__,
projects: [".", "apps/*", "libs/*"],
parallelism: [
env: "MY_WORKSPACE_MAX_CONCURRENCY",
base: [deps_get: 3, format: 4, compile: 2, test: 2],
multiplier: :auto,
overrides: [dialyzer: 1]
],
tasks: [
deps_get: [args: ["deps.get"], preflight?: false],
format: [args: ["format"]],
compile: [args: ["compile", "--warnings-as-errors"]],
test: [args: ["test"], mix_env: "test", color: true]
]
]
end
```
Then run:
```bash
mix blitz.workspace test
mix blitz.workspace test -j 6
mix blitz.workspace test -- --seed 0
mix monorepo.test
mix monorepo.test --seed 0
mix monorepo.test -j 6
```
`color: true` injects `--color` for tasks that support it, which restores ANSI
output such as the normal ExUnit colors from `mix test`.
For tooling-root workspaces, the most common dependency shape is:
```elixir
{:blitz, "~> 0.1.0", runtime: false}
```
If that project also keeps a narrow Dialyzer PLT, add `:blitz` to
`plt_add_apps` as shown in the installation section above.
## Parallelism Model
`Blitz.MixWorkspace` keeps concurrency policy explicit and predictable.
The intended model is:
- `base` describes relative task weight
- `multiplier` describes machine size
- `overrides` handles exceptional tasks
If you omit `multiplier`, `Blitz` defaults to `:auto`.
Each workspace task gets one effective `max_concurrency` value. Resolution
order is:
1. `-j N` or `--max-concurrency N` on the current invocation
2. the configured environment override from `parallelism.env`
3. the per-task value in `parallelism.overrides`
4. `round(base * resolved_multiplier)` from `parallelism.base` and
`parallelism.multiplier`
5. fallback `1` if the task has no configured base count
The formula is:
```text
resolved_multiplier =
multiplier == :auto ? autodetect_multiplier() : multiplier
effective(task) =
cli_override
|| env_override
|| per_task_override
|| round(base[task] * resolved_multiplier)
|| 1
```
`autodetect_multiplier()` uses the lower of a CPU class and a memory class:
- CPU classes: `8 => 2`, `16 => 3`, `24 => 4`, `32 => 6`
- Memory classes: `16 GiB => 2`, `48 GiB => 3`, `96 GiB => 4`, `192 GiB => 6`
That keeps auto-scaling simple and legible:
- a machine with more schedulers but not enough RAM does not get an inflated
multiplier
- a machine with lots of RAM but modest CPU does not scale only on memory
Example with a pinned multiplier:
```elixir
parallelism: [
env: "MY_WORKSPACE_MAX_CONCURRENCY",
multiplier: 2,
base: [
deps_get: 3,
format: 4,
compile: 2,
test: 2,
credo: 2,
dialyzer: 1,
docs: 1
],
overrides: []
]
```
That produces these defaults:
```text
deps_get = 6
format = 8
compile = 4
test = 4
credo = 4
dialyzer = 2
docs = 2
```
Then:
- `mix blitz.workspace test` uses `4`
- `MY_WORKSPACE_MAX_CONCURRENCY=10 mix blitz.workspace test` uses `10`
- `mix blitz.workspace test -j 12` uses `12`
Example with auto mode:
```elixir
parallelism: [
base: [
deps_get: 3,
format: 4,
compile: 2,
test: 2,
credo: 2,
dialyzer: 1,
docs: 1
],
multiplier: :auto
]
```
On a machine with `24` schedulers and `160 GiB` RAM, `autodetect_multiplier()`
returns `4`, so that same policy becomes:
```text
deps_get = 12
format = 16
compile = 8
test = 8
credo = 8
dialyzer = 4
docs = 4
```
`Blitz` does not hardcode task-family counts. The library provides the auto
machine scaler and the precedence rules; your workspace still owns the task
weights. If you want a fixed policy, pin `multiplier` to a number in
`mix.exs`.
Why not make every task flat by default? Because the base counts are meant to
describe task weight, while the multiplier describes machine size. In most
workspaces:
- `deps.get` and `format` are relatively cheap
- `compile`, `test`, and `credo` already create meaningful CPU, IO, or service
pressure on their own
- `dialyzer` and `docs` are usually the heaviest on memory and code loading
You can absolutely choose a flatter policy for a stronger machine. `Blitz`
does not prevent that. The defaults simply encode that these task families are
not equal in cost.
Workspace config keys:
- `root` sets the workspace root. It defaults to the current directory.
- `projects` is an ordered list of literal paths and glob patterns. Only entries
containing a `mix.exs` file are included.
- `tasks` defines the named workspace tasks that `mix blitz.workspace <task>`
can run.
- `parallelism` configures computed concurrency per task family.
- `isolation` controls which child-project paths and env vars are isolated.
Task config keys:
- `args` is the child `mix` argv list, such as `["test"]` or
`["compile", "--warnings-as-errors"]`.
- `mix_env` selects the isolated build-path suffix used for the task. Use
`:inherit` to derive it from the current `MIX_ENV` or fall back to `dev`.
- `color: true` injects `--color` unless `--color` or `--no-color` is already
present in the extra args.
- `preflight?` controls whether the task first runs `deps.get` for projects that
have a `mix.lock` but no `deps` directory. It defaults to `true` for normal
tasks and `false` for `deps_get`.
- `env` adds task-specific environment overrides via a callback. Use it for
values such as `MIX_ENV`, database names, or credentials.
`env` callbacks may be provided as:
- `fn context -> ... end`
- `{Module, :function}`
- `{Module, :function, extra_args}`
The callback receives a context map with:
- `:project_path`
- `:project_root`
- `:root`
- `:task`
- `:task_config`
Example task env hook:
```elixir
defp blitz_workspace do
[
root: __DIR__,
projects: [".", "apps/*"],
tasks: [
deps_get: [args: ["deps.get"], preflight?: false],
test: [
args: ["test"],
mix_env: "test",
color: true,
env: &test_database_env/1
]
]
]
end
defp test_database_env(%{project_path: project_path}) do
[
{"PGDATABASE",
Blitz.MixWorkspace.hashed_project_name("my_workspace_test", project_path)}
]
end
```
Isolation defaults:
- `MIX_DEPS_PATH` => `<project>/deps`
- `MIX_BUILD_PATH` => `<project>/_build/<mix_env>`
- `MIX_LOCKFILE` => `<project>/mix.lock`
- `HEX_HOME` => `<project>/_build/hex`
- `HEX_API_KEY` is unset by default
Override or disable them with `isolation`:
```elixir
blitz_workspace: [
root: __DIR__,
projects: [".", "apps/*"],
isolation: [
deps_path: true,
build_path: true,
lockfile: true,
hex_home: "_build/hex",
unset_env: ["HEX_API_KEY", "AWS_SESSION_TOKEN"]
],
tasks: [
deps_get: [args: ["deps.get"], preflight?: false],
test: [args: ["test"], mix_env: "test", color: true]
]
]
```
To override concurrency from the shell without changing `mix.exs`, set
`parallelism.env`:
```elixir
parallelism: [
base: [test: 2, compile: 2],
multiplier: :auto,
env: "BLITZ_MAX_CONCURRENCY"
]
```
Then run with:
```bash
BLITZ_MAX_CONCURRENCY=8 mix blitz.workspace test
```
## Example Output
```text
==> root: mix test
==> core/contracts: mix test
root | ...
core/contracts | ...
<== core/contracts: ok in 241ms
<== root: ok in 613ms
```
## Command Shape
`Blitz.command/1` accepts a map or keyword list with these fields:
- `:id` - required stable label for logs and results
- `:command` - required executable name or absolute path
- `:args` - optional list of CLI arguments
- `:cd` - optional working directory
- `:env` - optional environment overrides as a keyword list or map
Example with environment overrides:
```elixir
command =
Blitz.command(
id: "lint",
command: "mix",
args: ["format", "--check-formatted"],
cd: "/workspace/apps/core",
env: %{"MIX_ENV" => "test", "CI" => "true"}
)
```
## Run Options
`Blitz.run/2` and `Blitz.run!/2` accept these options:
- `:max_concurrency` - defaults to `System.schedulers_online()`
- `:announce?` - prints start and completion lines when `true`
- `:prefix_output?` - prefixes command output lines when `true`
- `:timeout` - per-task timeout passed to `Task.async_stream/3`
## Return Values
`Blitz.run/2` returns:
```elixir
{:ok, [%Blitz.Result{}, ...]}
```
on success, or:
```elixir
{:error, %Blitz.Error{}}
```
when one or more commands fail.
Each `Blitz.Result` contains:
- `id`
- `command`
- `args`
- `cd`
- `exit_code`
- `duration_ms`
Results are returned in the same order as the input command list even though the
commands themselves run concurrently.
## Failure Handling
Use `run/2` when your caller wants to branch on success or failure:
```elixir
case Blitz.run(commands, max_concurrency: 4) do
{:ok, results} ->
IO.inspect(results, label: "parallel run complete")
{:error, error} ->
IO.puts(Exception.message(error))
end
```
Use `run!/2` when failure should stop execution immediately:
```elixir
Blitz.run!(commands, max_concurrency: 4, timeout: 30_000)
```
## Typical Use Cases
- Running `mix test` across multiple umbrella children or sibling repos
- Fanning out format, lint, or docs generation tasks in internal tooling
- Building lightweight orchestration around shell scripts without introducing a job system
- Keeping monorepo command output readable during local development or CI
## Design Notes
- Output is streamed as commands run instead of buffered until completion
- Failures are aggregated into a single `Blitz.Error` structure
- Missing executables or command launch errors are treated as failures
- Per-command `cd` and `env` values keep tasks isolated from each other
- `Blitz.MixWorkspace` keeps repo-specific policy in `mix.exs`, not in bespoke
runner modules
## Development
```bash
mix test
mix credo --strict
mix dialyzer
```
## License
`Blitz` is released under the MIT License. See [LICENSE](LICENSE).