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.

Makefile Pitfalls and Their just Equivalents Table mapping common Makefile problems for task running to how just avoids them. Makefile Pitfalls and Their just Equivalents Make pitfall Cause In just missing separator tabs required any indentation up to date file shadows target no file checks ARGS=... hacks no parameters recipe parameters per-line shells cd lost between lines shebang recipes
Each Make pitfall comes from build-system semantics that a command runner does not need.

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

  1. 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
  1. 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.

  1. Keep make working 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.

  1. Update CI and the README to call just recipes, 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
Migrating Without Breaking Anyone Four-stage flow from adding a justfile to removing the Makefile shim. Migrating Without Breaking Anyone add justfile same names make shim forwards to just CI and docs call just remove shim after weeks
The shim lets old commands keep working while documentation and CI move over.

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

  1. Fail CI if a recipe lacks a comment, so just --list stays 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; }
  1. Run just --fmt --check --unstable in CI to keep formatting consistent, which avoids noisy diffs.

  2. Keep Make for real file builds if the project has them — generating assets from sources, compiling C code — and let just call make for those. Each tool then does what it is good at.

Environment-Related Task Tickets per Quarter Bar chart of support tickets about project tasks before and after migrating to just. Environment-Related Task Tickets per Quarter before, Makefile 17 tickets after, justfile 4 tickets
Illustrative figures from one team; most removed tickets were tab, PHONY and argument problems.

Platform caveats

Windows (native): just needs a shell to run recipes. Set set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] for PowerShell recipes, or require Git Bash and keep recipes POSIX. Shebang recipes need the interpreter on PATH.

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 just inside the distribution; a Windows-side just.exe invoked 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.