Chezmoi - a very cool tool to manage your dotfiles

Posted August 11, 2022 by Adrian Wyssmann ‐ 7 min read

Today, managing your dotfiles should be easy, so that you quickly can setup your environment and have it working as you would expect to. It all starts with a git-repo - with the benefit that you can share your files to others.

My first approach

Up to now, I had a very simple approach: Checkout files form a git-repo and create symlinks of the managed files. For the creation of the symlinks, I used stow - a manager for symlinks. I organized the files in a way, so that I have different “profiles” so I can have one for my personal environment and one for work. This script took care of the setup:

#!/bin/bash
# read the option and store in the variable, $option
TARGETDIR=~/
DEFAULTPROFILE=personal
RESTOW=
ADOPT=

# Function: Print a help message.
usage() {
  echo "Usage: $0 [ -p PRFOILE ] -R PACKAGNAME|all" 1>&2 
}

# Function: Exit with error.
exit_abnormal() {
  usage
  exit 1
}

while getopts "aRDp:" option; do
   case ${option} in
      a )
         echo "do an 'adopt'"
         ADOPT="--adopt --override='.*'"
         ;;
      R )
         echo "do a 'restow' i.e. stow -D followed by stow -S"
         RESTOW="-R"
         ;;
      D )
         echo "delete i.e. stow -D followed by stow -S"
         DELETE="-R"
         ;;
      p )
         PROFILE=${OPTARG}
         echo "profile '$PROFILE' selected\n"c
         ;;
      \? )
         exit_abnormal
      ;;
      *)
         exit_abnormal
      ;;
    esac
done

if [ ! $PROFILE ]; then
   if [ $DEFAULTPROFILE ]; then
      echo "using default profile '$DEFAULTPROFILE'"
      PROFILE=$DEFAULTPROFILE
   else
      echo "-p PROFILE was not specified"
      exit_abnormal
   fi
fi

SOURCE=$(pwd)/$PROFILE
if [ ! -d "$SOURCE" ]; then
   echo "Invalid profile '$PROFILE' (path '$SOURCE' missing)"
   exit_abnormal
fi

pushd $SOURCE

shift $(($OPTIND - 1))
if [ ! $1 ]
then
   echo "no packages specified. syou can use 'all' if you want to install all from the profile or use one of these:"
   echo $(ls)
   exit_abnormal
else
   PACKAGE=$1
   if [ $PACKAGE == "all" ]
   then
      echo "Install all available packages"
      for filename in $(find . -maxdepth 1 -mindepth 1 -type d -printf '%f\n'); do
         echo stow $RESTOW $DELETE $ADOPT $filename -t $TARGETDIR
         stow $RESTOW $DELETE $ADOPT $filename -t $TARGETDIR
      done
   elif [ -d "./$PACKAGE" ]
   then
      echo stow $RESTOW $DELETE $ADOPT $PACKAGE -t $TARGETDIR
      stow $RESTOW $DELETE $ADOPT $PACKAGE -t $TARGETDIR
   else
      echo "package '$PACKAGE' missing"
   fi
fi

popd

The approach is simple but has some drawbacks

  • if you want to manage a file, you first have to move it from it’s original location to the checked our repo manually, so that you can create the symlink
  • you have to be very carefully to not include private stuff - for that I had a separate, private repo in parallel
  • the approach with “profiles” leads to potential duplication of a lot of content due to differences of private and work files, e.g.
    • user.mail and user.name in .gitconfig
    • environment specific config like proxy etc.

Why chezmoi?

It worked, but I never was very happy until I stumbled upon chezmoi which sounds very promising, what it offers and how it compares to other tools out there. Most importantly for me

  • No bootstrap requirements
  • Private and encrypted files
  • Templating, which allows for machine-to-machine differences
  • Windows support (yeah I have to use Windows sometimes)

So I decided to move from my original approach to chezmoi. The result can be found in dotfiles. I won’t go into details about how it works, as the official docu is very good and informative. But I want to describe what I did, and show how I solved some “problems” I consider important to me.

So what did I do?

I decided to use a new git-repo, as the structure of chezmoi is different. So I ran a chezmoi init without specify a repo. chezmoi created an empty git repo under ~/.local/share/chezmoi. As mentioned above, my approach relied on symlinks. So in order to [add] the files to chezmoi, using the --follow flag - e.g. chezmoi add ~/.zshrc --follow added the actual file as ~/.local/share/chezmoi/dot_zshrc. The content in ~/.local/share/chezmoi represents the state, whereas the naming of the files uses so called source state attributes.

I started to go trough all my dotfiles until I stumbled on .muttrc which contains my mail information as well as passwords for the smtp access, I have two choices

  • encrypt the whole file
  • grab the password from my password manager, when state is applied

I decided to encrypt the whole file for now.

bootsrapping and encryption

While chezmoi does not have any requirements and installation is pretty easy, if you want to have encryption, you need either a gpg key or a passphrase. I decided to use a passphrase rather than a gpg-key, which skips the manual distribution of the gpg-key - cause as far as I have seen you have to use the same key on all machines. As I am lazy, I don’t want to enter the passphrase for each operation. Luckily the documentation provides an easy way to create the chezmoi.toml config file upon initialization:

If a file called .chezmoi.$FORMAT.tmpl exists then chezmoi init will use it to create an initial

Hence, I created the following .chezmoi.toml.tmpl which not only stores then passphrase, but also other machine specific data:

{{ $passphrase := promptStringOnce . "passphrase" "passphrase" -}}
{{ $username := promptStringOnce . "username" "username" -}}
{{ $email := promptStringOnce . "email" "email" -}}
{{ $signkey := promptStringOnce . "signkey" "signkey" -}}

encryption = "gpg"
[data]
    passphrase = {{ $passphrase | quote }}
    email = {{ $email | quote }}
    username = {{ $username | quote }}
    signkey = {{ $signkey | quote }}
[gpg]
    symmetric = true
    args = ["--batch", "--passphrase", {{ $passphrase | quote }}, "--no-symkey-cache" ]

While passphrase is used in the same config file, email, username and signkey are used to populate the .gitconfig file, using [templating][templates]-mechanism. So there is a file called dot_gitconfig.tmpl which contains the generic git stuff (aliases, etc) which I use on all machines, but with machine-specific instructions to add the variables from above:

...
[user]
    email = {{ .email | quote }}
    username =  {{ .username | quote }}
{{- if eq .chezmoi.hostname "clawfinger" }}
    signkey = {{ .signkey | quote }}
[commit]
    gpgsign = true
{{- end }}

As the encryption is setup now, I can easilly add ~/.muttrc encrypted by running chezmoi add --encrypt ~/.muttrc. which will add encrypted_dot_muttrc.asc to the state:

-----BEGIN PGP MESSAGE-----

jA0EBwMCJ/rDD1pg2Vb00sDXAY0QuCC7HOuNedu2bhKz1wuilfT/5ikyHh8rBMTb
whKvmo5EdOU7rPCKgXpXSEbgaop3mfJrvaPk8aNy4l2Ao9FJGG7Rs4Ctn6PgbuWY
diD5Jr8+pB4YS8dO5/vg0vgApzMALhrSWa0BbMuh4Nm8OoD1HROKketL2+kexIP4
R2pfTBWXPmeHRfeIXvyb0V0WPjhGfm8YoFa+bx/g3Npe7roNIOM/qBLrndY3c/j5
+8ItcQlT7lD1wcHS784nyg0xwz0gorGBTyItwDCJUBbHdMFe6oZs9tTFgB/UDD6M
puSNHh4Ippe0Btfrbz2RgzVBGISc6Vr8tEkXHzzzRg6hrOjMXW6z6vPb+y2mquF6
J/RctFb9hMwfCT7sxhscOtPmJjWgQ0HPddt2nEpr6bc27hOd0QUWtyJaETgGI/Ye
LOD0Qp9W6SFQP0o2M9WtbjHEcE7qJMAWyXYeKkqI8RA4ln5dZbUJnibQk3gULPuj
N921J8UYcewwXy664763Mh/P2ePH1s2zTEpjhw/luF9AJxcliBcBolU=
=pO9e
-----END PGP MESSAGE-----

Add my scripts

I have an additional repo with shell-scripts, which contains two folders:

The folders shall be mapped as follows:

  • scripts to ~ /.local/bin
  • nautilus to ~/.local/share/nautilus/

As chezmoi allows include files from elsewhere, I decided to use this feature. First I added .chezmoiexternal.toml with the following content:

[ ".local/scripts"]
    type = "git-repo"
    url = "https://gitlab.com/papanito/shell-scripts.git"
    exact = true
    stripComponents = 2
    refreshPeriod = "168h"

This checks out the repo to ~/.local/scripts. But how shall I map the subfolders as expected? Well also here chezmoi helps, as it offers a feature, that use scripts to perform actions. So I created a file run_once_nautilus_scripts.sh.tmpl which takes care of creating the necessary symlinks:

{{ if eq .chezmoi.os "linux" -}}
#!/bin/sh
stow nautilus -S -d ~/.local/scripts -t ~/.local/share/nautilus
stow scripts  -S -d ~/.local/scripts -t ~/.local/bin
{{ else if eq .chezmoi.os "darwin" -}}
#!/bin/sh
stow nautilus -S -d ~/.local/scripts -t ~/.local/share/nautilus
stow scripts  -S -d ~/.local/scripts -t ~/.local/bin
{{ end -}}

How to use it

As described here there are some simple commands to execute:

  • Pull the changes from your repo and apply them in a single command

    chezmoi update
    
  • If you want to see the changes before applying

    chezmoi git pull -- --rebase && chezmoi diff
    
  • Apply changes

    chezmoi apply
    

If you want to install chezmoi and your dotfiles on a new machine with a single command, run this:

sh -c "$(curl -fsLS https://chezmoi.io/get)" -- init --apply $GITHUB_USERNAME

For setting up transitory environments (e.g. short-lived Linux containers) you can install chezmoi, install your dotfiles, and then remove all traces of chezmoi, including the source directory and chezmoi’s configuration directory

sh -c "$(curl -fsLS https://chezmoi.io/get)" -- init --one-shot $GITHUB_USERNAME

Conclusion

chezmoi is a undoubtedly very cool to manage your dotfiles and as you can see very flexible. It’s also very easy to use. Once I am back at work, I will also migrate my work-files to it, so I have a single source of truth.

Btw. if you are using navi, you can find here my cheatsheet for chezmoi.