Keeping Context with Just
I find myself jumping between repositories a lot - whether dealing with scattered microservices at my day job, or riding the waves of inspiration and abandonment that comes from hobby projects. But when I find myself returning to these projects after a bit of an absence, I find myself struggling to remember the particular incantations needed to get this particular project working.
There are lots of tools that help couple your environment to your codebase, which is wonderful. Mise or asdf or nvm/rvm/pyenv for high level tooling switcheroos. .env
or .envrc
files for carrying around local configuration. Nix shell or Docker for spinning up dependent services, or even providing a sandbox within which to work. It's all great and wonderful and amazing.
Right, but...which one are we using here?
Task runners solve for this a little bit. Make everything a mix
task in your Elixir project, and you can just list them all out. Substitute cargo
, rake
or npm run
or Procfiles or jumbled bash scripts to taste. Pick something that matches the language of the majority of your tasks, and shell out from within that code for the rest. But again, it's not going to be consistent from project to project, and the friction of remembering "this one uses a gulpfile" can be really frustrating. The closest to something language/framework-agnostic we have is make
, but it's a bit arcane, and was originally intended as a build tool rather than a task runner.
Enter Just
I've settled on Just as my command runner of choice. It's fast; it's easy to install; it's a binary - so you don't need your JS or Ruby or whatever runtime available. It's make
-inspired, but a bit more modernized.
just deploy
I encourage you to read through the README for more hype - there's a lot to love. But there's one important gap that Just doesn't provide that I need to solve for.
Smuggling in your secret recipes
If you're the author of a repo, by all means, throw stuff in a Justfile and walk away happy. But often, you may have your own custom stuff that you'd like to keep in the repo but wouldn't be appropriate to commit to the project in full.
Create a user with my email and password 'password', do that weird user activation token step automatically, and seed it with five records. Oh, and configure this custom flag because I need that in order to get the thingamabob to work...
Just runs off a single Justfile. That file can import dependencies, but right now we can't just --justfile=Justfile --justfile=mycustomtasks.just
Thankfully, that's nothing a little bash can't solve for us! Within our .bashrc
or .zshrc
, or some file sourced therein, we can write our own just
wrapper.
just() {
if [ -f ./local.just ]; then
if command just --justfile ./local.just -n "$@" >/dev/null 2>&1; then
command just --justfile ./local.just "$@"
else
command just "$@"
fi
else
command just "$@"
fi
}
A little hard to read, but let's break it down. If a local.just
file exists in our current directory, we run:
command just --justfile ./local.just -n "$@"
command
is a way to ensure we don't recursively call our function, since our alias has the same name as the underlying binary. We're passing the justfile explicitly, and then using the -n
flag to instruct Just to treat this as a dry-run. Why do this? This way we check if the local.just
would do anything with the args ("$@"
) we've passed in. If we're calling just deploy
, which is a task defined in the project's justfile
, we likely don't have our own custom version.
If the dry-run succeeds, we'll run it for real. If not, we'll defer to the main justfile
.
Now we can have a justfile
and a local.just
file living side-by-side, and we can execute just fix-my-local-branch
from our custom tasks, while preserving access to project-level tasks that may already exist.
Don't forget to add local.just
to your ~/.config/git/ignore
or ~/.gitignore
, and you'll never have to worry about accidentally slipping it into your projects.
But what can we actually do?
Last task is making sure we can actually remember what affordances we have in our repo. just -l
will list out the available tasks, but that doesn't quite work for us, since we're splitting our tasks across two different files. That's where we can leverage tempfiles. First, let's replicate the logic for figuring out what just
would default to as its default justfile:
function find_justfile() {
dir="$PWD"
while [ "$dir" != "/" ]; do
if [ -f "$dir/justfile" ]; then
echo "$dir/justfile"
return 0
elif [ -f "$dir/Justfile" ]; then
echo "$dir/Justfile"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
We'll just walk up the tree looking for one until we find it or give up (a more sophisticated solution might look at args, but this gets us 90% of the way there).
Then we can build a temporary combined file with all our tasks in it, solely for the purpose of reporting things out:
base_justfile="$(find_justfile)"
tmpfile="$(mktemp)"
[[ -n "$base_justfile" ]] && cat "$base_justfile" >> "$tmpfile"
[[ -f ./local.just ]] && cat ./local.just >> "$tmpfile"
if [ ! -s "$tmpfile" ]; then
printf "\033[1m\033[31merror:\033[0m\033[1m No justfile found\033[0m\n" >&2
rm "$tmpfile"
return 1
fi
command just --justfile "$tmpfile" "$@"
rm "$tmpfile"
When we call just --justfile /tmp/our-constructed-file -l
, we'll now get all the tasks. Our default justfile tasks plus our custom ones. And remember that just
will show the comment from above your task in the list output. so you get a lovely readable...
❯ just -l
Available recipes:
build # Build the local environment
haaalp # Try that reset trick 🙏
It may not solve all the problems of hopping back and forth between repos, but a nice framework for saving out local aliases is at least a start to maintaining our sanity!
The full shell script (plus adding local.just
to your global gitignore, and installing Just):
function find_justfile() {
dir="$PWD"
while [ "$dir" != "/" ]; do
if [ -f "$dir/justfile" ]; then
echo "$dir/justfile"
return 0
elif [ -f "$dir/Justfile" ]; then
echo "$dir/Justfile"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
just() {
if [[ " $* " == *" -l "* ]]; then
base_justfile="$(find_justfile)"
tmpfile="$(mktemp)"
[[ -n "$base_justfile" ]] && cat "$base_justfile" >> "$tmpfile"
[[ -f ./local.just ]] && cat ./local.just >> "$tmpfile"
if [ ! -s "$tmpfile" ]; then
printf "\033[1m\033[31merror:\033[0m\033[1m No justfile found\033[0m\n" >&2
rm "$tmpfile"
return 1
fi
command just --justfile "$tmpfile" "$@"
rm "$tmpfile"
else
if [ -f ./local.just ]; then
if command just --justfile ./local.just -n "$@" >/dev/null 2>&1; then
command just --justfile ./local.just "$@"
else
command just "$@"
fi
else
command just "$@"
fi
fi
}