Matheus Moreira

Managing dotfiles with Make

Make is an old tool, an assembly language of sorts for build systems. Many have tried to replace it. Many have tried to reinvent it. Most people prefer to avoid it if at all possible. So why use it to manage dotfiles of all things?

There's at least one good reason to do this: make is ubiquitous. Pretty much every machine that has ever compiled software will have a copy of this thing. Using make as a dotfile management tool eliminates the need to install yet another infrequently used program.

Another reason to use it is this turned out to be a surprisingly easy task.

File system structure

Make works best when everything is as simple as possible. It doesn't provide much functionality: the few path manipulation functions it includes are of the string matching and substitution variety.

The easiest way to achieve that simplicity is to mirror the structure of the home directory. Like this:

The ~ directory represents the current user's home directory. Configuration files in $HOME will be symbolic links to their corresponding files in the ~ directory of the repository. Make's job is to automatically create those symbolic links.

cd ~/.files/
make

Simplicity is good.

Writing the makefile

All link targets are rooted in the repository's ~ directory, so the first thing that must be done is find the repository itself.

Make always knows the location of the makefile. Since it is located in the root of the .files repository, it's possible to find the repository itself through it.

makefile := $(abspath $(lastword $(MAKEFILE_LIST)))
dotfiles := $(abspath $(dir $(makefile)))

A reference to the home directory is already available in make via the $HOME environment variable and ~ also works according to the documentation. An equally easy way to refer to the dotfiles repository's ~ directory would be nice.

~ := $(abspath $(dotfiles)/~)

Now it is possible to write ~ and $(~) for the user's home directory and for the repository's ~ directory respectively.

The symbolic linking rule

Combining these variables and the fact the repository structure mirrors the structure of the home directory, it becomes trivial to write the rule:

force:

~/% : $(~)/% force
	mkdir -p $(@D)
	ln -snf $@ $<
endef

Automatic variables are used in the recipe for maximum brevity. $@ and $< refer to the link target and link name. $(@D) refers to the directory of the new link, ensuring the whole tree exists before attempting to create it.

Generalizing and metaprogramming

GNU Make is surprisingly lisp-like in its metaprogrammability. It's possible to generate and evaluate code at runtime. So why not generalize the previous rule into a function that defines symbolic linking rules for any pair of directories with matching structure?

force:

define rule.template
$(1)/% : $(2)/% force
	mkdir -p $$(@D)
	ln -snf $$@ $$<
endef

rule.define = $(eval $(call rule.template,$(1),$(2)))

The rule.define function will generate and evaluate the original rule definition. It's really easy to use:

$(call rule.define,~,$(~))

Nice.

Phony targets for usability

By this point, the makefile already works with any dotfile inside the repository.

make ~/.bash_profile ~/.bashrc

Typing out all the file names is annoying though. Phony targets can be used to group them:

all += bash
bash : ~/.bash_profile ~/.bashrc

all : $(all)
.PHONY: all $(all)
.DEFAULT_GOAL := all

This sets up a phony target for bash, maintains a list of all phony targets and ensures they are declared as such, creates an all phony target that links everything and sets it as the default goal.

Now adding phony targets is easy:

all += git
git : ~/.gitconfig