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.shjust 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 descriptionThe 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 3000Variables 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 directoryFor anything stateful, use a shebang recipe — the whole body runs as one script:
working:
#!/usr/bin/env bash
set -euo pipefail
cd build
lsShebang 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.htmlA recipe whose name starts with _ is private too, which is the older idiom for the same thing.
Worth knowing
justwalks up the directory tree looking for ajustfile, so recipes work from anywhere in the repo.--justfileoverrides it;--working-directorydecides where they run.just --choosepipes the recipe list through a fuzzy finder (fzfand friends are in Awesome Engineering).just -n(--dry-run) prints what would run without running it.just --evaluatedumps the resolved variables, which is the fastest way to work out why an interpolation is not what you expected.- Recipe names allow
-, sojust db-resetreads better thanjust db_reset. Both work. - It is a single static binary — no runtime, no
node_modules, nothing to bootstrap before the bootstrap script runs.