Replacing Makefiles With just for Project Tasks
The Makefile that runs the project's tasks produces Makefile:14: *** missing separator. Stop. after someone's editor converted tabs to spaces, make test prints make: 'test' is up to date. because a directory called test/ exists and nobody declared .PHONY, and passing an argument to a task requires the incantation make test ARGS="--grep checkout". None of these are problems with Make as a build tool; they are what happens when a build tool is used as a command runner. This page migrates such a Makefile to just, keeping make working as a shim until the team has switched, as part of task runners and an internal developer CLI.
just keeps a Make-like syntax, so the migration is mostly mechanical, and it removes the three classes of bug above by design.
Diagnostic
Find the Make behaviours the current file depends on and the bugs they are causing:
#!/usr/bin/env bash
set -euo pipefail
grep -nP '^ +[^ #]' Makefile | head -5 || echo "no space-indented recipe lines"
for t in $(grep -oE '^[a-zA-Z0-9_-]+:' Makefile | tr -d ':'); do
if [ -e "$t" ] && ! grep -qE "^\.PHONY:.*\b$t\b" Makefile; then echo "target '$t' shadowed by a file or directory"; fi
done
grep -nE '\$\(MAKECMDGOALS\)|ARGS\?=|\$\(filter-out' Makefile || echo "no argument hacks found"
make -n test 2>&1 | head -3
Expected bad output:
14: npm run lint
target 'test' shadowed by a file or directory
target 'docs' shadowed by a file or directory
22:ARGS ?=
make: 'test' is up to date.
A space-indented recipe line, two targets shadowed by directories of the same name, and an ARGS variable workaround for passing arguments.
Root cause
Make decides whether to run a target by comparing timestamps of files named after targets and prerequisites. When a target is not a file — test, lint, up — it must be declared .PHONY, or any file or directory with that name makes Make consider it up to date. Recipes must start with a literal tab because that is how Make distinguishes recipe lines from rule definitions, a choice from 1976 that modern editors routinely undo. Each recipe line runs in a new shell, so cd and export do not carry over between lines. And command-line arguments are interpreted as more targets, so passing flags through needs workarounds. just has none of these semantics: every recipe always runs, indentation is flexible, parameters are part of the recipe signature, and a recipe can be one script with a shebang line.
Resolution
- Install just and pin its version with the rest of the toolchain:
#!/usr/bin/env bash
set -euo pipefail
command -v mise >/dev/null && mise use [email protected] || brew install just
just --version
- Translate targets into recipes. Keep names identical so muscle memory and documentation still work:
set dotenv-load := true
set shell := ["bash", "-euo", "pipefail", "-c"]
# Show available recipes
default:
@just --list --unsorted
# Install dependencies
install:
npm ci
# Run tests; extra args go to the test runner, e.g. just test --grep checkout
test *ARGS: install
npm test -- {{ARGS}}
# Lint and type-check
lint:
npm run lint
npm run typecheck
# Start the stack for a given profile (default: core)
up profile="core":
docker compose --profile {{profile}} up -d --wait
# Build docs in one shell so cd persists
docs:
#!/usr/bin/env bash
set -euo pipefail
cd docs
npm ci
npm run build
test *ARGS: install declares both a variadic parameter and a dependency on install. up profile="core" gives a parameter with a default. The shebang recipe runs as a single script, so cd docs applies to the following lines.
- Keep
makeworking as a shim during the transition, so nobody is blocked and CI can switch separately:
.PHONY: $(MAKECMDGOALS)
$(MAKECMDGOALS):
@echo "make is deprecated here; running: just $@"
@just $@
This catch-all forwards any target to just. Remove it after a few weeks, once CI and documentation use just directly.
- Update CI and the README to call
justrecipes, then delete the old Makefile content:
#!/usr/bin/env bash
set -euo pipefail
grep -rlE '\bmake (test|lint|up|docs|install)\b' .github README.md docs 2>/dev/null \
| xargs -r sed -i -E 's/\bmake (test|lint|up|docs|install)\b/just \1/g'
git diff --stat
Expected output
$ just
Available recipes:
default
install # Install dependencies
test *ARGS # Run tests; extra args go to the test runner, e.g. just test --grep checkout
lint # Lint and type-check
up profile="core" # Start the stack for a given profile (default: core)
docs # Build docs in one shell so cd persists
$ just test --grep checkout
npm ci
npm test -- --grep checkout
checkout
✓ applies discount codes
$ make test
make is deprecated here; running: just test
Every recipe is listed with its description and parameters, arguments pass straight through, the test/ directory no longer shadows anything, and make test still works during the transition.
The listing is also the new onboarding document for the repository's operations: a new hire runs just and sees, in one screen, what they can do and what each task expects. That replaces the README section that used to explain each Make target, and unlike that section, it cannot drift from the implementation because it is generated from it.
Prevention
- Fail CI if a recipe lacks a comment, so
just --liststays self-documenting:
#!/usr/bin/env bash
set -euo pipefail
just --dump --dump-format json | jq -r '.recipes | to_entries[] | select(.value.doc == null and (.key | startswith("_") | not)) | .key' \
| { if read -r first; then echo "recipes without descriptions: $first $(cat | tr '\n' ' ')"; exit 1; else echo "all recipes documented"; fi; }
Run
just --fmt --check --unstablein CI to keep formatting consistent, which avoids noisy diffs.Keep Make for real file builds if the project has them — generating assets from sources, compiling C code — and let
justcallmakefor those. Each tool then does what it is good at.
Platform caveats
Windows (native):
justneeds a shell to run recipes. Setset windows-shell := ["powershell.exe", "-NoLogo", "-Command"]for PowerShell recipes, or require Git Bash and keep recipes POSIX. Shebang recipes need the interpreter onPATH.
macOS: the shim relies on GNU Make features available in the system's Make 3.81, so it works without installing a newer Make.
WSL2: install
justinside the distribution; a Windows-sidejust.exeinvoked from WSL runs recipes with the wrong shell and paths.
Apple Silicon (ARM64): Homebrew and mise install native arm64 builds of
just.
Rollback
The Makefile is still in git history; restore it and delete the justfile:
#!/usr/bin/env bash
set -euo pipefail
git checkout "$(git log --format=%H -1 --diff-filter=M -- Makefile)~1" -- Makefile
git rm -q justfile
make -n test
Frequently Asked Questions
Is just a replacement for Make as a build system?
No. just always runs recipes and has no notion of files being up to date. For builds that should skip work when inputs are unchanged, keep Make or use Task's sources and generates. just replaces Make only for task running.
Can just read our existing .env file?
Yes. set dotenv-load := true loads .env from the working directory into recipe environments, which removes the include .env and export boilerplate Makefiles often carry.
How do recipes pass arguments to underlying commands?
Declare parameters in the recipe signature: test *ARGS collects any number of arguments, up profile="core" takes one with a default. Refer to them as {{ARGS}} and {{profile}} in the body.
What if some developers keep typing make?
The catch-all shim forwards every target to just with a deprecation message. Keep it until usage stops appearing in shell history or CI, then remove it.