Skip to main content
Version: 1.0

The if command

dispat if chooses between shell scripts by testing a condition, so a stage can branch without depending on the shell it is running under. The condition asks the environment (CI, ENV=prod), the filesystem (-f data/report.json, -d build) or the repository (--changed, did the selected packages change). It plans no release and sweeps no packages.

It exists because everything dispat runs is a shell command. Stages, hooks and run scripts are all strings handed to /bin/sh -c, which works well until a script needs to do one of two ordinary things: branch on a variable, or call another script you already wrote.

Two small commands cover those.

$ dispat if CI --then 'make ci' --else 'make dev'
$ dispat exec build --for pkg:core

Neither one plans a release, sweeps your packages or touches the dependency graph. They run one script and get out of the way.

Which command do I want

You want toUse
run a script in every changed package, in orderdispat run <script>
run one declared script, once, right heredispat exec <script>
choose between shell commands based on a conditiondispat if <cond>

dispat run is the one that knows about your monorepo. It computes a plan, works out which packages changed and runs the script in each of them in dependency order. dispat exec does none of that. It looks up one script by name and runs it, which is what you want when you are already inside a stage script and just need to call something else.

dispat if

dispat if <cond> --then <script> [--elif <cond> --then <script>]... [--else <script>]
dispat if -f <path> --then <script> ...
dispat if -d <path> --then <script> ...
dispat if --changed [--since <rev>] [-p <pkg>] [-s <space>] [-g <group>] [--consumers] --then <script> ...

The leading condition takes the first --then. Each --elif takes the next one. --else runs when nothing else matched.

dispat if 'ENV=prod' --then 'deploy prod' \
--elif 'ENV=stage' --then 'deploy stage' \
--else 'echo nothing to deploy'

The first condition that holds wins, and the rest are skipped without being looked at. So a chain of --elif is a switch, and --else is its default case.

If nothing matches and you gave no --else, nothing runs and the command exits 0. That is deliberate: a guard that finds nothing to do has done its job.

The scripts are shell text, not script names. This is the shell's own if/elif/else, spelled so it fits on one line inside a JSON or YAML config file where a real if block would be unreadable.

The leading condition comes from exactly one place: the positional condition, --changed, --file or --dir. Giving two is a usage error, because two answers to "what does the first --then guard" would leave one silently ignored. Every --elif is an environment condition.

Environment conditions

ConditionTrue when
NAMEthe variable is set and not empty
!NAMEthe variable is unset, or set to nothing
NAME=valueit is exactly that value
NAME!=valueit is anything else
NAME~globit matches the pattern, where * matches anything
NAME!~globit does not match the pattern

"Set" means set and not empty, the same thing [ -n "$NAME" ] means in the shell. CI systems export empty variables all the time, and an empty value is almost never a yes. If you specifically want to ask whether a variable is empty, NAME= is the way, because an unset variable expands to nothing exactly as it would in a shell.

The value can contain anything, operators included. Only the first operator ends the variable name, so URL=a~b asks whether URL is the text a~b.

Globs use the same matcher as everywhere else in dispat, where * matches any run of characters including slashes:

dispat if 'BRANCH~release/*' --then 'dispat release'

These conditions read the environment the command was given, and nothing else. There is no config file to load and no repository to be standing in, so dispat if works anywhere.

File tests

--file (-f) and --dir (-d) ask the filesystem instead:

ConditionTrue when
-f <path>the path exists and is a regular file
-d <path>the path exists and is a folder
dispat if -f data/report.json --then 'npm run build-docs' --else 'echo no report yet'

A path that is absent, or there but the wrong kind, makes the condition false. It is never an error, exactly as [ -f ] and [ -d ] behave in the shell: the question was "is it there", and it is not. Symbolic links are followed, so a link to a file passes -f.

A relative path resolves against the folder the chosen script runs in, which is the invocation folder, or wherever --in points. The test and a path written inside the script text therefore always mean the same file. An absolute path is used as it is.

Like the environment conditions, file tests read no config file and need no repository.

Changed packages

--changed asks the repository: it holds when changed packages are selected. The selection works exactly like dispat run's, so a gate and the run it guards can never disagree about what changed.

dispat if --changed -p docs --since origin/main --then 'dispat run build-docs'

--since <rev> sets the window to what the commits since that revision address, with the same scope semantics dispat run --since uses: a commit's written scopes are authoritative, and only scopeless commits fall back to the files they changed. Without --since, the window is the release window, the packages with something pending, so a bare dispat if --changed asks "would a release do anything". --since all selects every package, changed or not.

--consumers expands the window downstream before the selection narrows it, so the gate asks whether the selection is among what the changes reach:

dispat if --changed -p web --consumers --since HEAD~1 --then 'dispat run e2e -p web'

This holds when web, or anything web transitively consumes, changed. Note that this is the one place --consumers composes in that order. A sweep like dispat run narrows first and expands after, asking for the selection's dependents; a gate that did the same would find --consumers unable to ever change its answer, since expanding a selection never empties it and never fills an empty one.

--package/-p, --space/-s and --group/-g narrow the answer the way they narrow every command, invocation folder included: run inside a package folder with no terms, the gate asks about that package alone. An empty selection is an honest false. A term that matches no package at all is an error, never a false, because a gate reading a typo as "no" would silently never fire.

Asking about the repository costs finding out: --changed reads the config file, walks the tags and parses the commits, the same work dispat status does, where every other condition reads nothing. A --changed that cannot be evaluated, a revision git cannot resolve or a configuration that cannot be loaded, exits 1. The selection flags belong to --changed, so giving any of them with another condition is a usage error.

Nesting

A branch is just shell text, so another dispat command is a perfectly ordinary thing to put in one:

dispat if CI --then 'dispat if TIER=gold --then "deploy gold" --else "deploy standard"'

Choosing the folder it runs in

The chosen branch runs where you are standing, so a relative path in it means what you meant. --in sends it somewhere else:

$ dispat if CI --then 'make ci' --in ./build
$ dispat if CI --then 'make ci' --in pkg:core

It takes a folder path, or any of the place names dispat exec takes. A relative --file or --dir path moves with it, so the test asks about the folder the script actually runs in.

There is one thing worth knowing here. dispat if reads no config file, which is what makes it cheap enough to call in a loop, and a path or cwd keeps it that way because your command line already said everything needed. Naming pkg:, space: or root does make it read your config, since there is no other way to find out where a package lives. You pay for that only when you ask.

A folder that does not exist stops the command with a message naming it.

Exit codes

Both commands hand back the exit code of the script they ran. dispat if CI --then 'exit 7' exits 7. That keeps them transparent in a pipeline: whatever you were gating on still works with a helper in the middle.

--on-failure changes that. It runs when the chosen script fails, and its own exit code becomes the command's:

$ dispat exec deploy --on-failure 'notify-slack "deploy failed"; exit 1'

The failure script runs even when the first one was killed by Ctrl-C, so a cleanup still gets its chance.

2 still means the command line itself did not make sense, which is worth knowing if your script also exits 2. Ending --on-failure with an explicit exit 1 removes the ambiguity.

A condition that is false runs its --else, or nothing, and exits accordingly; being false is not a failure. The one condition that can fail is --changed, when it cannot be evaluated at all: a revision git cannot resolve, or a configuration that cannot be loaded, exits 1.

Flags

FlagEffect
--then <script>The script the preceding condition runs. Repeatable, one per condition.
--elif <cond>Another condition, tried when every earlier one was false. Repeatable.
--else <script>The script to run when no condition held.
--file <path>, -fThe leading condition: the path exists and is a regular file.
--dir <path>, -dThe leading condition: the path exists and is a folder.
--changedThe leading condition: changed packages are selected.
--since <rev>With --changed: count changes from this revision instead of the release window; all selects every package.
--consumersWith --changed: expand the window to everything downstream of the changes.
-p, -s, -gWith --changed: narrow to the named packages, spaces or groups.
--in <folder>Run the chosen script in this folder: a path, or any place name.
--on-failure <script>Run this when the chosen script fails, and exit with its code instead.

Needs no config file and no git repository, unless --in names a package, a space or the root, or --changed asks about the repository itself. The shell is /bin/sh -c, since there is no config here to take a shell setting from.