My team has been considering (in our charms) moving away from tox
in favor of uv
+ a command runner. Two main alternatives came up: make
and just
.
This choice has ramifications, since we’d like to use the same tool for our other projects as well (snaps, rocks, etc.).
I’ve had lots of discussions and personal experiments with both, so I want to present their capabilities and focus on the differences. I personally believe just
is a much better choice, and I’ll explain why below
If you want a TL;DR, jump to the Conclusions section, but you should really read the whole thing!
What does the file look like?
It’s easy to write very minimal versions of a Makefile
and justfile
and conclude they don’t look very different. However, if we try to achieve functional parity, the difference becomes obvious even with small files.
The files below are written for a rock, and they assume the user has a utility installed to test them, named lucabot
:
# Makefile
.PHONY: pack test # Remember to add new recipes to .PHONY !
ifndef VERBOSE # Recipes are silent by default
.SILENT:
endif
# Require `lucabot`: I couldn't figure out how
# NOTE: Variable substitution (i.e. ${PWD##*/}) doesn't work
rock_name := $(shell echo ${PWD} | sed -E 's@.*/(.+)-rock@\1@')
# Pack a specific rock version
pack: # Make sure a 'version' has been defined, fail otherwise
ifndef version
echo "You need to specify a version."
exit 2
endif
cd "$version" && rockcraft pack
# Test a rock using `lucabot`
run: pack
echo "Testing $version!"
lucabot rock test "${version}/${rock_name}_${version}.rock"
# Justfile
set quiet # Recipes are silent by default
set export # Just variables are exported to environment variables
lucabot := `which lucabot` # `lucabot` is required
rock_name := `echo ${PWD##*/} | sed 's/-rock//'`
[private]
default:
just --list
# Pack a specific rock version
pack version:
cd "$version" && rockcraft pack
# Test a rock using `lucabot`
test version: (pack version)
echo "Testing $version!"
lucabot rock test "${version}/${rock_name}_${version}.rock"
just
is a little cleaner than make
, but now they do the same things, right?
Wrong!
Comparing the tools
User Experience: available commands
From a User Experience perspective, there are a lot of things to point out:
- how do you know which recipes exist and what they do?
- how do you know if a recipe accepts (or requires) arguments?
With make
, you’ll have to open the Makefile
: while figuring out which recipes exist is easy enough, figuring out if they accept or require arguments can be quite tricky, especially with more complex recipes - eyeballing things is very error-prone.
If you forget to guard for variable’s presence (the ifndef
above), commands can fail mid-recipe with unexpected consequences. If you forget to add your recipe to .PHONY
, it might fail on you the day you have a file with that name. Makefiles
have to patch up several shortcomings manually, and this makes them harder (or more annoying) to maintain.
With just
, you can simply run just
(or just --list
):
∮ just
Available recipes:
pack version # Pack a specific rock version
test version # Test a rock using `lucabot`
This help is not only telling you the available recipes, but also pointing out they require arguments. If you fail to specify one, you get a helpful error message:
∮ just run
error: Recipe `run` got 0 arguments but takes 1
usage:
just run version
Things are especially painful if you want version
to have a default value (e.g, the latest rock version). In that case, you won’t have the guard in your Makefile
, and you don’t have a way of surfacing to the user that run
accepts a version
parameter.
TL;DR: just
recipes are easier to run, better documented, and more clearly explained to the user.
User Experience: requiring tools and environment
With just
, it’s very easy to require a tool or an environment variable to be defined.
In the previous example, I’m using a hypothetical lucabot
to test a rock. If that’s not in my system, make
will fail only after packing the rock (maybe 15 minutes on a first pack?). If the recipe was doing some destructive operations and needed to be atomic (e.g., create a temporary file and delete at the end), this will leave the system in a dirty state.
If I run just test
without lucabot
installed, this is what happens:
∮ just test
error: Backtick failed with exit code 1
——▶ justfile:4:12
│
4 │ lucabot := `which lucabot` # `lucabot` is required
│
The same can be done for environment variables by simply fetching them or using env("REQUIRED_VARIABLE")
instead of the backticks.
TL;DR: just
recipes are more robust and can let you require tools and environment variables.
Developer Experience
I’ll break the serious character for a second. It’s 2024. We have emojis in commit messages. If a software crashes on me because I used spaces instead of tabs, I’m not a fan.
Jokes aside, make
has lots of quirks you need to be familiar with: we encountered some cases of output redirection (i.e., >
) not working, you have to $$ESCAPE_VARIABLES
, etc.
I found just
more consistent with plain Bash, and that makes justfiles
easier to write and to maintain.
TL;DR: just
is easier to write and maintain.
Software availability
This is the main (and possibly only) argument in favor of make
, as it’s pre-installed everywhere.
However, just
can be installed via snap (which to me is enough), Python (uv
, pipx
, etc.), cargo
, even apt
(though mind the version), and more. Considering that charms are currently using tox
, I don’t think changing a dependency for another is particularly bad, given the other advantages.
But that’s not all. If you have a dependency on uv
already (and you should, because it’s awesome), there’s an interesting strategy I came up with to be able to run a justfile
without installing just
:
#!/usr/bin/env -S uvx --from=rust-just just --justfile
set quiet # Recipes are silent by default
...
This shebang leverages uvx
to run just
from PyPi, and it’s so fast that (after the first 1-second execution) you won’t even notice it’s there - you just need to make the justfile
executable, and then you can ./justfile
away!
TL;DR: make
is ubiquitous, but just
has a snap (and you can technically run justfiles
with uv
instead of just
).
Other cool things
Here’s some other cool things you can do with just
:
- write recipes in whatever language, not only bash, with shebang recipes;
- group up recipes semantically, make them private (i.e., they don’t show up in the
--list
); - confirmation prompts for recipes, useful for destructive actions;
- ability to pass positional arguments to a recipe, and write it like a normal Bash script;
- running dependencies at the end of a recipe:
run: pack && cleanup
will executepack
,run
, andcleanup
, in that order.
TL;DR: just
has a lot of cool extra features.
Conclusions
Summing up my TL;DRs from the previous section:
just
recipes are easier to run, better documented, and more clearly explained to the user.just
recipes are more robust and can let you require tools and environment variables.just
is easier to write and maintain.make
is ubiquitous, butjust
has a snap (and you can technically runjustfiles
withuv
instead ofjust
).just
has a lot of cool extra features.
I ultimately think that make
is great at what it’s designed for - building very large projects. If we need a command runner, then we should use the right tool for the job, and just
has, in my opinion, proven itself to be a better choice.
What do you think? Please feel free to comment your thoughts on the matter!