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:
-
~/.files
-
~
.bash_profile
.bashrc
.vimrc
...
GNUmakefile
-
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