Contents

I've tried the "just" task runner. Is it worth it?

I was initially sceptical about just task runner. I wasn’t really convinced that it’s a tool solving a real problem and thought of it more as a gimmick. Finally, I decided to give it a try to form a more informed opinion. Below are some of my observations.

It’s just a command executor

In essence, that’s everything just is. You create a justfile in which you define tasks. Each task is a set of steps. Each step is executed in a separate shell instance. It’s possible to define dependencies between tasks. Here’s a basic justfile example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
build:
    meson compile -C bld

test:
    meson test -C bld --print-errorlogs

dockerise: test
    docker build -t myapp:latest .

deploy: dockerise
    docker push myapp:latest

Tasks from this file can be executed like so:

1
2
3
4
5
6
7
8
# run build task
just build

# will run test task and dockerise task afterwards
just dockerise

# won't test, just dockerise
just --no-deps dockerise

We all write either shell scripts or simple Makefiles just to make it more convenient to perform a common tasks within a project. The syntax is almost identical as in Makefile with a small important difference. make operates in terms of targets - this means that each target is meant to produce a tangible deliverable. In subsequent runs, make checks for presence of a target’s deliverable. Target’s absence triggers recipe execution. Recipe will be executed as well if the target is older than any of its source files - we all know how Makefiles work. That’s not the case with just.

Tip
Tasks dependencies are always executed unless you run the task with --no-deps switch.

That’s the main difference between make and just. The former is a build system, the latter is just a task executor. Subtle, but very important. If a task has dependencies - they will be run prior to the task itself.

What’s the extra value over Makefiles or shell scripts?

To me, it’s all in --no-deps. This flag gives you full control over how tasks are executed and is the main convenience that just brings to the table.

If you’re writing a shell script, the dependencies are rigid and can’t be dynamically altered.

1
2
3
4
5
6
7
8
9
dep_of_foo() {
    echo "dependency of foo"
}

foo() {
    dep_of_foo
}

foo

Without modifying the script, it’s impossible to skip some steps. Similarly, in case of a Makefile. Having a justfile:

1
2
3
4
5
dep_of_foo:
    @echo "dependency of foo"

foo: dep_of_foo
    @echo "foo"

You have full control over what to run:

1
2
3
4
5
6
$ just foo
dependency of foo
foo

$ just --no-deps foo
foo

Listing the contents

Often you just want to see a list of targets within a Makefile. Without opening the Makefile, it’s difficult to establish that (although this has recently changed. SV 64571 adds --print-targets to make). With just you can simply:

1
2
3
4
$ just --list
Available recipes:
    dep_of_foo
    foo

It’s even easier if you add a private (prefixed with _) default task to your justfile:

1
2
3
4
_default:
    @just --list

...

just runs the default recipe if none is provided in the command line:

1
2
3
4
$ just
Available recipes:
    dep_of_foo
    foo

Working directory

By default, recipes run with the working directory set to the directory that contains the justfile. This is great and a great improvement. I often find myself writing the following lines in my shell scripts:

1
2
declare -r SELF=$(readlink -f "${BASH_SOURCE[0]}")
declare -r SELFDIR=$(dirname "${SELF}")

With just I no longer have to do that as the working directory for each task is well defined.

Variables

just allows for definition of global variables. Task steps run in independent shells so, the variables have to be global (there’s a workaround for that). These variables can be exported to environment with:

set export

Additionally, just automatically consumes .env files with

set dotenv-load

Tasks can be parametrised (and parameters can have default values):

1
2
3
4
default_name := "John"

hello name=default_name:
    @echo "hello {{name}}"

The variables can be overridden in the command line:

1
2
3
4
$ just hello
hello John
$ just hello Tom
hello Tom

Positional parameters are supported too! There’s plenty more which I won’t cover since the documentation does that very well.

Shell scripts in tasks

As I already mentioned, each task step runs in an independent instance of a shell. The shell can be anything you want though:

1
2
3
4
set shell := ["python3", "-c"]

hello:
    @print("Hello from python")

Additionally, to preserve context between steps, tasks can be defined as a scripts:

1
2
3
4
hello:
    #!/usr/bin/env bash
    a="some variable"
    echo $a

Conclusion

I won’t hide that I was initially sceptical about just, assuming there’s little value that it adds, but after using it for a while, I must admit that it’s a great addition to my daily workflow. It’s a perfect replacement for clunky Makefiles meant to simplify some of the commands and feels more suitable for exactly that purpose.