make vs just - a detailed comparison

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 :nerd_face:

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! :x:

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, :sparkles:I’m not a fan:sparkles:.

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 execute pack, run, and cleanup, 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, but just has a snap (and you can technically run justfiles with uv instead of just).
  • 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! :speech_balloon:

4 Likes

You have some good arguments. I don’t love Make syntax and quirks, but its ubiquity is a real selling point – it’s basically not an extra dependency, whereas “just” is. That said, as you point out, it’s very easy to install from the snap, so maybe not a big deal in practice.

I’m waiting hopefully for uv itself to add a task runner. It’s somewhat likely (Using `uv run` as a task runner · Issue #5903 · astral-sh/uv · GitHub), with the main uv author Charlie Marsh saying in August “Yeah we plan to support something like this! We haven’t spent time on the design yet.” So fingers crossed uv itself will add something like this soon.

1 Like

Please do poe “Poe the poet” https://poethepoet.natn.io/ next :slight_smile:

It was originally written as a task runner for poetry (I think), but iut handles uv just as well: Usage without poetry - Poe the Poet

Not all of our projects use uv though, only charms; what about rocks and snaps? Wouldn’t that be an added dependency for those? What about charms that don’t use uv, like the ones set up with poetry?

uv will already be there only for some projects, not all of them. I don’t think we should pick a worse solution (which I think everyone agrees on) because we don’t want to replace our tox dependency with a different one. Fewer dependencies are nice, but not always: we’re now writing more and more Terraform bundles, isn’t that also an extra dependency?

I wouldn’t mind using a task runner from uv, but it still is an extra dependency for some repositories, and I’d rather use just than wait and commit to jumping on someone that’s not there yet.


Also, in case that point was lost in the text: you can run a Justfile without just being installed! Make it executable, add the shebang, and you can now run both just run and ./justfile run seamlessly :slight_smile:


Edit: If you don’t like having to execute a ./justfile, I can also see a future where a charmcraft run command could do uvx --from=rust-just just for you, keeping only uv as a dependency. That can be its own alias even:

alias just='uvx --from=rust-just just'

Edit 2: expanding on the thought of that theoretical charmcraft run command, imagine if charmcraft.yaml allowed you to specify a command to run your tasks, and you could charmcraft run fmt in any charm: unified experience, no extra dependencies required. You could set it to make, to uvx --from=rust-just just, but also use tox-uv, poetry, and so on.