An Awful Introduction to Make
I don't know make
very well. But I know enough to maybe help you get some use out of it The first thing you need to know about make
is that a target is executed only if one of its prerequisites has a more recent modified timestamp.
For me, the gap between understanding that concept and leveraging it was embarrassingly large. I think the problem is that a lot of us are using higher level tools that already do things like incremental build and where compiling and linking are abstracted away.
If we take a step back and look at make from the point of view of a C programming, we can see how make is useful:
main.o: main.c
gcc -c -o main.o main.c
hello: main.o
gcc -o hello main.o
What happens when we run make hello
? It'll only run the hello
target if the modified time of main.o
is newer than the modified time of the hello
file. Notice that there's also a target for main.o
with a prerequisite of main.c
which will follow the same rule. The first time you run make hello
it'll do what you expect: compile main.c into main.o and then generate the hello
binary. If you run make hello
again, nothing will happen (besides make
telling you that 'hello' is up to date.). But if we modify main.c
an then run make hello
, the main.o
and hello
targets will be run.
The import thing to note here is that the dependencies flow across multiple levels. Now this is a very simple example. Instead of explicitly making main.c
a prerequisite of main.o
we could use patterns, e.g %.o: %.c
.
The above is barely the tip of what we could do. But my goal isn't to document the depths of make's
power (especially since I can't). What I want to do is show how this depedency resolution can be used for simple and common tasks that don't involve compiling and linking.
Let's take this one step at a time. All of our projects have a make t
command (I like tea and I enjoying saying "make t"). This target just runs go test
or mix test
:
t:
mix test
Now, you might be thinking: but there's never a t
file. Without a t
file, our target will always run (which is what we want). But we can be more explicit and tell make
that this is a phony target:
.PHONY: t
t:
mix test
Phony targets aren't just nice-to-have. If we did have a t
file that had nothing to do with our t
target, using .PHONY: t
is how we tell make
to ignore the file and just always run the target (without it, make
would always tell us that t
is up to date.)
In Elixir you define your library dependencies in a mix.exs
file and it will generate a mix.lock
file with specific versions. If someone adds a dependency or updates a version, you need to run mix deps.get
to update your local environment. Knowing this, guess what the following does:
.deps: mix.exs
mix deps.get
echo 'generate by make' > .deps
.PHONY: t
t: .deps
mix test
First, our t
target will always run because it's a phony target. Phony targets can still have prerequisites though. Here, our .deps
prerequisite is itself a target which has a prerequisite on the mix.exs
file. The first time we run make t
the .deps
target will run (because the .deps
file doesn't exist). This target runs mix deps.get
and then generates the .deps
file. As long as mix.exs
isn't touched, the .deps
target won't run (because the .deps
file was modified after mix.exs
). But if mix.exs
is modified, then next time your run make t
the .deps
target will run and you'll get your updated dependencies.
We use this for library dependencies (as shown above), sql migrations, and generating models from protocol buffer definitions. It minimizes friction. Just pull and make t
will work. (Ok, it doesn't work 100% of the time, but we also have a make fix
that runs through a series of steps (such as clearing build folders) to try to reset a broken project environment).
There's a few other details that might be useful to know.
Lines that begin with tabs usually (always?) belong to a target. These are called recipes, but the important thing to know is that they're executed in a shell (a separate shell per line). By default, make
uses /bin/sh
. So a Makefile is really made up of Makefile-specific syntax as well as shell scripting. Honestly, I have no clue how to do complicated stuff with make
, especially with respect to conditional execution and variables. For this reason, I try to keep my targets to one line (which might be a custom bash script that does all the complex stuff).
(there's a way to make all the lines share the same shell, but as far as I understand that needs to be enabled for the entire Makefile)
By default make
will echo the recipe that's being execute. You can silence it by placing @
at the start of the reciple. Similarly, by default, if a recipe fails (non zero status code), the target stops. You can ignore errors on a per-recipe basis by placing -
at the start of the recipe. Combined, you could do something like:
make fix:
-@psql -X -d postgres -c "drop database app_test"
@psql -X -d postgres -c "create database app_test"
Prerequisites don't have to be statically defined. For example, here's our proto
target:
proto: $(shell find proto/schemas -name "*.proto")
The above uses make's
shell
function to execute our shell's find
command. For simpler cases (where you don't needed to search nested directories for example), you should use the built-in wildcard
function:
schema: $(wildcard *.sql)
You can pass named arguments (not sure what they're called) to make:
make t:
mix test $(F)
Which can then be called with:
make t F=test/input_test.exs
Again, all of this is pretty basic stuff. But hopefully it's helpful. Also, it's worth pointing out that Make's documentation is excellent.