Skip to content

giant-task - the task runner

giant-task is the official task-runner porcelain - a separate binary that adds named commands (“tasks”) on top of Giant’s build engine. Dispatched automatically via giant task <name> (Giant itself doesn’t ship a task subcommand; the dispatcher execs giant-task on PATH).

Terminal window
$ giant task deploy
· building 2 dep(s)
✓ 0 built · 2 cached in 1ms
▶ deploy to staging
deployed

The engine doesn’t know what a task is - giant-task asks the engine which packages exist, then re-reads each package’s giant.yaml with its own schema, looking only at the tasks: and services: blocks. If you don’t install giant-task, those fields in your config are simply ignored. Other porcelains can claim their own top-level keys the same way.

Terminal window
# From crates.io (once published)
cargo install giant-task
# From source
cargo install --path crates/giant-task --git https://github.com/giantdotbuild/giant

The binary just needs to be on PATH. Verify:

Terminal window
$ giant-task --version
giant-task 0.1.0
$ giant task --version # via the dispatcher
giant-task 0.1.0

Add a tasks: block to your giant.yaml:

workspace:
name: my-monorepo
targets:
- name: "server"
inputs: ["cmd/server/**/*.go"]
outputs: ["//bin/server"]
cwd: "//"
command: "go build -o bin/server ./cmd/server"
tasks:
serve:
command: "./bin/server"
description: "Run the server locally"
deps: ["//:server"]

deps: reference targets by their label - here //:server, the server target in the root package. In a split repo you’d write the full path, e.g. deps: ["//crates/giant:giant"].

Then:

Terminal window
$ giant task serve
· building 1 dep(s)
✓ 1 built · 0 cached in 240ms
▶ Run the server locally
listening on :8080

giant-task first asks giant build to materialize the deps, then runs the task’s command via sh -c in the task’s package directory (here the root, //).

Tasks are packaged like targets. A tasks: block in any package’s giant.yaml - not just the root - defines tasks labelled //<package>:<name>, and each task’s command runs in that package’s directory by default. So in a monorepo, a subproject keeps its own tasks next to its code:

my-repo/
├── giant.yaml # workspace root (//)
├── blackmetal/giant.yaml # tasks //blackmetal:test, //blackmetal:fmt
└── cryosleep/giant.yaml # tasks //cryosleep:test, //cryosleep:fmt
blackmetal/giant.yaml
tasks:
test:
command: "go test ./..." # runs in blackmetal/

Both subprojects can define test without colliding - the labels //blackmetal:test and //cryosleep:test are distinct. giant task list from anywhere shows the whole tree, grouped by package.

You rarely type the full label. A bare giant task test resolves from your current directory:

  • Inside a package, the nearest enclosing package wins. From blackmetal/, giant task test runs //blackmetal:test even if other packages also define test.
  • Unique across the workspace, it runs wherever it lives. If only blackmetal defines deploy, giant task deploy works from anywhere.
  • Shared by several packages with none enclosing your cwd (e.g. at the repo root with test in two subprojects), it’s ambiguous and giant-task asks you to qualify it:
Terminal window
$ giant task test
giant-task: task 'test' is defined in several packages; qualify it,
e.g. `giant task //blackmetal:test` (candidates: //blackmetal:test, //cryosleep:test)

A //pkg:name label always resolves directly, from any directory.

needs:, finally:, and services: entries resolve within the task’s own package when written bare, or across packages when written as a label. A //blackmetal:test task with needs: ["lint"] runs //blackmetal:lint; to reach another package, write the full label (needs: ["//cryosleep:gen"]).

tasks:
<name>:
command: "..." # shell command or #! script body; optional
# if `services:` is set (foreground supervise)
description: "..." # optional; shown in `giant task list`
deps: ["..."] # target labels (//pkg:name) to build before running
needs: ["..."] # task names (same package) or //pkg:name labels,
# run before command
services: ["..."] # service names (same package) or labels; start
# before, stop after
finally: ["..."] # task names or labels to run after command (always)
args: # optional; ordered list, bound positionally
- name: env
default: "..." # present => optional; absent => required
choices: ["a", "b"] # constrained set; default must be in choices
description: "..." # shown in completion + help
- name: rest
variadic: true # trailing only; collects the rest into $@
env: # extra env vars
KEY: "value"
cwd: "..." # package-relative (// escapes to root);
# default = the task's package directory
timeout_secs: 300 # kill after N seconds; default = no timeout
inputs: ["..."] # optional; extra watch globs for files no
# target owns (consulted by --watch)

giant task <name> --watch runs the task once, then re-runs it whenever a relevant input changes. Ctrl-C exits.

This is dep-aware, and giant-task does no file watching itself. It opens a giant session and subscribes to the task’s dependencies with watch.subscribe { targets: deps, globs: inputs }. The engine watches the inputs of those deps: targets - and their transitive deps - plus any path matching the task’s inputs: globs, and notifies giant-task (a watch.changed event) when one changes. So editing a file that a dependency target consumes retriggers the task, even though the task never named that file.

Terminal window
$ giant task test:unit --watch
· initial run
· watching via engine - Ctrl-C to exit
· change detected, re-running
FlagDefaultDescription
--watchoffRe-run on changes, watched by the engine.

What gets watched:

  • deps: - the engine expands these through the graph and watches every input they (transitively) depend on.
  • inputs: - extra globs for files no target owns (e2e sources, fixtures).
  • Neither declared - falls back to the whole workspace (minus .git/, the state dir, and the cache dir). Handy as a smoke loop but noisy; declare deps: / inputs: where you can.

Because the watching lives in core, a task and a giant build --watch see the same change signal from one shared file-watching implementation.

Task names follow the same rules as workspace names (alphanumeric, hyphen, underscore; no leading digit), and need only be unique within their package - two packages can both define test. Any valid name is allowed; a task named build or test is fine. Tasks are always invoked as giant task <name>, so they never collide with a giant command like giant build (which runs the build porcelain); the two namespaces are separate.

services:
<name>:
command: "..." # required; shell command (the daemon)
description: "..." # optional
deps: ["..."] # target labels (//pkg:name) to build before starting
needs: ["..."] # other services to bring up (ready) first
ready: # optional readiness probe
command: "..." # shell snippet; exit 0 = ready
period_secs: 1 # poll interval (default 1)
timeout_secs: 30 # give up after this (default 30)
env: # extra env vars
KEY: "value"
cwd: "..." # package-relative (// escapes to root);
# default = the service's package directory

A service is a long-lived process. When a task brings up services, the supervisor starts them in dependency order: a service with needs: waits for each dependency’s ready probe to pass before it starts (services with satisfied needs start concurrently). The transitive needs closure is pulled in automatically, so listing api brings up the db it needs. Cleanup is automatic: when the task exits (any reason

  • success, failure, signal), services are sent SIGINT, then SIGTERM if they don’t exit in 2s, then SIGKILL after another 3s.

Dev environments: a task that is its services

Section titled “Dev environments: a task that is its services”

A task with services: and no command: supervises those services in the foreground - the giant dev shape. It brings the stack up dependency-ordered, streams their prefixed logs, and holds until Ctrl-C (or until a service exits), then shuts everything down.

services:
db:
command: "postgres -D ./data"
ready: { command: "pg_isready" }
api:
command: "./bin/api"
needs: ["db"] # api starts once db is ready
worker:
command: "./bin/worker"
needs: ["db"]
tasks:
dev:
services: ["api", "worker"] # no command → foreground supervise
Terminal window
$ giant task dev
· starting services: db, api, worker
[db] listening on 5432
[api] serving on :8080
[worker] ready
^C
· interrupted
· stopping services: db, api, worker

This is the dev-loop slice of process-compose. It deliberately stops there: no daemon/background mode, no process scaling, no restart policies, no REST control. For those, use process-compose.

1. build deps → giant build <labels>
2. start services → dependency-ordered, each gated on its `ready` probe
3. run needs → sequential, declared order
4. run command → the task's own command (or supervise, if absent)
5. run finally → sequential, declared order; ALWAYS runs
6. stop services → SIGINT → SIGTERM → SIGKILL

If a step fails:

FailureEffect
deps buildstop. Nothing else runs.
services not ready in timeout_secsstop already-started services, skip needs/command/finally.
needs taskskip command, still run finally, still stop services.
command non-zero exitstill run finally, still stop services. The exit code is what the task returns.
finally tasklogged, doesn’t change the task’s exit code.

finally and service teardown run on SIGINT or SIGTERM, so a terminal Ctrl-C, a pkill, or a systemctl stop all trigger them. When a task has services or a finally, giant-task installs a handler: a signal - whether from Ctrl-C, pkill, systemctl stop, or a parent supervisor - interrupts the running command (forwarded to its process group), then the lifecycle falls through to finally and stops services as usual. The task exits 130 (SIGINT) or 143 (SIGTERM). Once teardown starts it runs to completion; a second signal won’t cut it short. A bare command with nothing to clean up keeps the default behavior - the signal just kills it.

To make whole-subtree teardown work, a command with services or a finally runs in its own process group. Interactive commands still work: giant-task hands the terminal to that group while the command runs - the way a shell does for a foreground job - so a sudo or ssh password prompt reads the terminal normally, then giant-task takes it back. Off a real terminal (CI, pipes) this is a no-op.

A worked example with all four hooks:

services:
db:
command: "docker run --rm -p 5432:5432 postgres:16"
ready:
command: "pg_isready -h localhost -p 5432"
tasks:
run-test:
command: "go test ./..."
deps: ["//:server"] # build first
services: ["db"] # spin up DB
needs: ["migrate"] # run schema migrations first
finally: ["wipe-test-data"] # always clean up test rows
migrate:
command: "./bin/migrator up"
services: ["db"] # migrate also needs the DB up
wipe-test-data:
command: "psql -c 'TRUNCATE …'"
services: ["db"]
Terminal window
$ giant task run-test
· starting services: db
· need: migrate
▶ migrate
[migrate] applied 4 migrations
▶ go test ./...
[run-test] ok example/internal/store 0.123s
· finally: wipe-test-data
▶ wipe-test-data
· stopping services: db

Tasks support named, validated args and arbitrary forwarding - you don’t have to choose. Named args make a task discoverable (they show up in --list, per-task --help, and completions) and validated (choices, required). Anything you don’t declare can still be forwarded to the command.

args: is an ordered list. Values bind positionally in declaration order. An arg with no default is required; one with a default is optional; choices constrains it; a trailing arg may be variadic: true to collect the rest. Each scalar arg is exported before the command runs as GIANT_ARG_<NAME> (uppercased) and a plain $name; the variadic arg, and anything forwarded, becomes the command’s positional parameters ($@). Set an arg by name with --arg name=value (the scriptable form).

Whether leftover args reach $@ depends on what the task declares. Three example tasks cover every case:

tasks:
serve: # no args → pass-through wrapper
command: 'npx astro dev "$@"'
deploy: # one validated arg, no variadic → strict
command: 'kubectl apply -f k8s/$env/ $@'
args:
- { name: env, choices: ["staging", "prod"] }
test: # named arg + variadic tail
command: 'cargo test -p $pkg $@'
args:
- { name: pkg }
- { name: flags, variadic: true }
Terminal window
# `serve` declares no args, so it forwards everything (flags included):
$ giant task serve --host --port 4321 # → npx astro dev --host --port 4321
# `deploy` is strict: the named arg binds, a stray extra is treated as a typo…
$ giant task deploy prod # env=prod
$ giant task deploy nowhere # error: not one of [staging, prod]
$ giant task deploy prod --force # error: 1 extra ["--force"], use --
$ giant task deploy prod -- --force # env=prod, and kubectl also gets --force
# `test` has a variadic tail, so extras land in $@ with no `--` needed:
$ giant task test core --nocapture # pkg=core, $@=[--nocapture]
$ giant task test core -- --nocapture # same (the `--` is optional here)

The rules in one line each:

  • No declared args: → the task is a pass-through wrapper; all args forward to $@. (This is why docs-dev / docs-preview need no args:.)
  • Declared args, no variadic → strict; an unexpected extra is rejected as a likely typo. Add a variadic: true arg to accept extras on purpose.
  • -- → forwards everything after it to $@ verbatim, from any task, the cargo / npm idiom. Use it when a flag you’re forwarding could be mistaken for one of giant-task’s own.

Giant-task’s own flags (--watch, --config, …) come before the task name (giant task --watch deploy), the same rule git and cargo use.

giant task <name> --help prints that task’s signature - its arguments, which are required, their defaults and choices:

Terminal window
$ giant task deploy --help
deploy - deploy the app
usage: giant task deploy <env> [tag=latest]
env staging|prod target environment
tag =latest

(giant task --help, with no task name, prints giant-task’s own help.)

If a task’s command begins with a #! shebang line, the whole body is written to a temp file and exec’d directly, so you can write a task in any language. Declared args are in the environment; variadic/passthrough values are the script’s arguments.

tasks:
report:
args: [{ name: since, default: "HEAD~20" }]
command: |
#!/usr/bin/env python3
import os, subprocess
since = os.environ["GIANT_ARG_SINCE"]
print(subprocess.check_output(["git", "log", "--oneline", since]).decode())

A body without a shebang runs under sh -c as usual.

giant task list (or giant-task --list) shows every task in the workspace, grouped by package:

Terminal window
$ giant task list
tasks (my-monorepo)
//
serve Run the server locally
//blackmetal
test Run the test suite
//cryosleep
test Run the test suite

If you have no tasks declared, this prints a single dim “no tasks defined” note instead.

--format selects how the list is rendered (it works before the task name or after list):

FormatOutput
text (default)Human-readable, grouped by package.
labelsOne //pkg:name per line - pipe into a fuzzy finder.
jsonStructured: workspace name, and each task’s label, package, description, and declared arg schema.
Terminal window
$ giant task list --format labels
//:serve
//blackmetal:test
//cryosleep:test

The json form carries each task’s args (names, defaults, choices, variadic), so an external picker can show or fill a task’s arguments. A fuzzy-finder binding, for example, can list labels and preview each task’s signature with giant task <label> --help:

Terminal window
giant task "$(giant task list --format labels | fzf \
--preview 'giant task {} --help')"

Giant deliberately ships no built-in fuzzy finder or task TUI - the list output is designed to feed the picker you already use.

By default the dep build is collapsed into a one-line summary. A 50-target dependency pull doesn’t fill the terminal before the actual task command runs:

Terminal window
$ giant task serve
· building 50 dep(s)
✓ 3 built · 47 cached in 1.24s
▶ Run the server locally
listening on :8080

On failure, the failing target’s stderr is replayed inline (capped at 50 lines per target):

Terminal window
$ giant task broken
· building 1 dep(s)
✗ //:bad
going to fail
this line too
✗ 1 failed · 0 built · 0 cached in 2ms
giant-task: dependency build failed (exit code 1)

--verbose (-v) restores the full streamed giant build output - useful when you want the per-target lines.

giant-task has its own completion script generator, separate from giant’s. Both binaries support bash, zsh, fish, PowerShell, elvish, and nushell. Pipe the script into your shell’s completion directory:

Terminal window
# bash
giant-task --completions bash > ~/.local/share/bash-completion/completions/giant-task
# zsh
giant-task --completions zsh > "${fpath[1]}/_giant-task"
# fish
giant-task --completions fish > ~/.config/fish/completions/giant-task.fish
# nushell
giant-task --completions nushell >> ~/.config/nushell/completions.nu

Dynamic completion of task names works at TAB time - giant-task scans the workspace and returns the matching tasks (bare names plus their //pkg:name labels), including their descriptions. Same idea for giant itself: target labels from the merged giant.yaml package files.

The giant-task binary doesn’t reach into Giant’s internals. The contract:

  • Build deps: spawned as giant build <labels…> --events ndjson, the output parsed event-by-event so the porcelain can render a compact summary instead of streaming everything.
  • Package discovery: giant-task calls the engine’s workspace scan to learn which packages exist and where their config files are - the same scan core emits as package.described events over the protocol, including directories that define only tasks.
  • Config schema: each package’s giant.yaml is parsed by giant-task directly with its own narrow TopLevel { tasks, services } shape. Core’s wider config parsing isn’t involved.

If you want to write your own porcelain (giant-deploy, giant-bench, anything), see Porcelains for the dispatch mechanism and the NDJSON event protocol for the wire format.

VariableMeaning
GIANT_ARG_<NAME>Set per declared scalar arg (uppercased name).
$<name>Plain-name binding for the same arg (lowercase as declared).
GIANT_TASK_BUILD_BINOverride the giant binary used for giant build subprocess calls. Useful in tests; rarely needed otherwise.
NO_COLORDisable ANSI colors in giant-task’s own output (giant build’s output is also subject to this).

A short list, deliberately:

  • No service restart policies. If a service dies during your task, the task fails. (The right default for tests; if you want auto-restart during a dev loop, run process-compose directly.)
  • No service-to-service dependency ordering. Express order by wrapping in a task that declares both services and the order it needs. Or use process-compose for nested service graphs.
  • No after DAG / log ring buffers / parallel needs. needs: is sequential, finally: is sequential, and that’s it.
  • No watch-mode service auto-restart on file changes. Use watchexec or process-compose.
  • No nested tasks (giant db migrate). Use flat names (giant task db-migrate) or namespace via prefix.
  • No outputs: / caching on tasks. Tasks run every time. If you want caching, declare a build target with outputs: and an exists: check; giant build handles it cleanly.
  • No HTTP/TCP probe shortcuts. ready.command: covers everything (curl -fs http://..., nc -z host port). HTTP/TCP sugar may arrive if heavy users ask.

Service supervision uses the tokio-process-tools crate for the cross-platform tricky parts (signal escalation, broadcast output streams). The policy on top - schema, lifecycle, readiness loop - is giant-task’s own.