What?

just is a command runner. You write a justfile next to your code, and every command the project needs — build, test, deploy, seed the database, regenerate the fixtures — lives there as a named recipe.

It borrows make’s syntax and almost none of its semantics. make is a build system: it compares file timestamps and decides what needs rebuilding. just never looks at a file. It runs the recipe you asked for, every time.

Why?

Every project accumulates a set of commands that live in three bad places: a teammate’s shell history, a scripts/ folder nobody documents, and the README under a heading called “Getting Started” that stopped being true a year ago.

A justfile makes them one thing, checked in next to the code, and — because just --list prints the recipes with their doc comments — self-describing. New joiners stop asking “how do I run this?”; they run just.

make gets pressed into this role constantly, which is where the pain comes from: tab-sensitive indentation, .PHONY on every target because nothing produces a file, and the quiet danger that a target named test doesn’t run because a directory called test already exists and looks up to date.

How?

A justfile

# Run the test suite
test:
    cargo test
 
# Build a release binary
build:
    cargo build --release
 
# Ship it
deploy: build test
    ./scripts/deploy.sh
just test        # run one recipe
just deploy      # runs build, then test, then deploy
just             # runs the first recipe in the file
just --list      # every recipe, with the comment above it as its description

The comment directly above a recipe becomes its description in --list, which is why the documentation stays true — it is sitting on top of the thing it documents.

Parameters

# Tag and push a release
release version:
    git tag -a v{{version}} -m "Release {{version}}"
    git push origin v{{version}}
 
# Defaults are allowed
serve port="8080":
    python3 -m http.server {{port}}
 
# Variadic: + needs one or more, * accepts none
test-only +patterns:
    cargo test {{patterns}}
just release 1.4.0
just serve            # 8080
just serve 3000

Variables and settings

set dotenv-load                      # read .env into the recipe environment
set shell := ["bash", "-euo", "pipefail", "-c"]
 
project := "sympathetic-engineering"
target  := "target" / project        # / joins paths
 
info:
    @echo "building {{project}} into {{target}}"

@ at the start of a line suppresses the echo of the command itself, so you see the output without the noise. Put it on the recipe name (@info:) to silence the whole recipe.

Each line is its own shell

This is the one make habit that carries over and bites:

broken:
    cd build          # this shell exits...
    ls                # ...so this runs in the original directory

For anything stateful, use a shebang recipe — the whole body runs as one script:

working:
    #!/usr/bin/env bash
    set -euo pipefail
    cd build
    ls

Shebang recipes are not limited to shell. Python, node, whatever is on PATH:

report:
    #!/usr/bin/env python3
    import json, pathlib
    print(json.loads(pathlib.Path("package.json").read_text())["version"])

Attributes

[private]                 # hidden from --list
_setup:
    mkdir -p tmp
 
[confirm("Wipe the database?")]
reset: _setup
    ./scripts/reset-db.sh
 
[macos]
open-coverage:
    open target/coverage/index.html
 
[linux]
open-coverage:
    xdg-open target/coverage/index.html

A recipe whose name starts with _ is private too, which is the older idiom for the same thing.

Worth knowing

  • just walks up the directory tree looking for a justfile, so recipes work from anywhere in the repo. --justfile overrides it; --working-directory decides where they run.
  • just --choose pipes the recipe list through a fuzzy finder (fzf and friends are in Awesome Engineering).
  • just -n (--dry-run) prints what would run without running it.
  • just --evaluate dumps the resolved variables, which is the fastest way to work out why an interpolation is not what you expected.
  • Recipe names allow -, so just db-reset reads better than just db_reset. Both work.
  • It is a single static binary — no runtime, no node_modules, nothing to bootstrap before the bootstrap script runs.
BLUESKY — START THE THREAD KO-FI / RSS