dt.iki.fi

Dotfiles

The term usually refers to a (shareable) collection of all sorts of user configuration files, often through a VCS such as git.

A collection of dotfiles might help to set up a fresh system, incorporate ideas & changes from different (but similar) systems, or to share these files with other Linux users.

Thoughts and Recommendations for Collecting Dotfiles in a Git Repository-up-

  • You should backup things regularly, but this is not the same as a backup.
  • Do not try to automate the process; add files manually, one by one, sparingly. Consider if you really need to track that particular file. Once a file is added there's very little work involved.
  • Do not add whole directories, even if that means much more initial work.
  • Do not add constantly changing files - they will mess up your repo.
  • Do not add sensitive data (e.g. anything from ~/.ssh) - especially not if you're sharing these files publicly!
  • I have not found a way to track the files directly (unless one wants to turn their whole home folder into a git repository, which I don't), so it will be necessary to replace chosen config files with symlinks to files inside the repository

Create the Local Repository-up-

Create a dedicated directory, e.g. ~/.config/.dotfiles and git init it.

Start copying files into the repo, replacing the sources with symlinks - I use this script:

#!/bin/bash

red(){
   tput setaf 9
   echo -e "$@"
   tput sgr0
}
echo_exit_1(){
   red "$@"
   exit 1
}
usage(){
red -e "$@"
cat <<EOF

dotfile (verb, transitive)

Takes a list of files on the command line, separates them into readables and
writeables, and adds them to a git repo accordingly:
 - read only: file is copied to repo, no further action
 - read/write: file is copied to repo, original file is moved to a backup in the
   same directory, then replaced with a symlink to the repo file

Only regular files are eligible, no directories, no symlinks, no funny stuff.
EOF
exit 1
}
(( $# == 0 )) && usage Must supply at least one file

sep="$(for ((i=0 ; i<$(tput cols); i++)); do printf ':'; done)"

repo="$HOME/.config/.dotfiles"

id="${0##*/}.$(date +%Y%m%d-%H%M%S)"

# two arrays to be filled with files that are writeable
# - those will be added to the repo and replaced by symlinks
# and files that are read-only
# - those will be added to the repo as copies
writeables=()
readables=()

not=()
backup=()
commit=0

echo "Dotfile repo: $repo"
[ -d "$repo/.git" ] && git -C "$repo" status || echo_exit_1 "Directory \"$repo\" does not appear to be a git repository's root, or something else is wrong."
echo "$sep"

if [ -d "$repo.bak" ]; then
   answer=''
   while [[ "$answer" != [yn] ]]; do read -rp "Do you want to replace the previous backup $repo.bak? [y/n] " answer; done
   [[ "$answer" == y ]] && rm -rf "$repo.bak" && cp -ra "$repo" "$repo.bak"
else
   cp -ra "$repo" "$repo.bak"
   echo "First backup of whole repo created at $repo.bak"
fi
echo "$sep"

echo -e "$# files to check/add:\n$@\n$sep"

for file in "$@"; do
   # if file is a symlink, or NOT a regular file (not a directory or socket etc.), or NOT (at least) readable, then move on
   if [ -L "$file" ] || ! [ -f "$file" ] || ! [ -r "$file" ]; then
      not=( "${not[@]}" "$file" )
   else
      # is it also writeable?
      if [ -w "$file" ]; then
         writeables=( "${writeables[@]}" "$file" )
      else
         readables=( "${readables[@]}" "$file" )
      fi
   fi
done

(( ${#not[@]} > 0 )) && red "${#not[@]} files were not eligible:\n${not[@]}" && echo "$sep"

if (( ${#writeables[@]} > 0 )); then
   echo -e "${#writeables[@]} files are writeable and will be replaced by symlinks:\n${writeables[@]}\n$sep"

   for file in "${writeables[@]}"; do
      file="$(realpath "$file")"
      [ -d "${file%/*}" ] && mkdir -vp "$repo${file%/*}"

      echo -n "cp: " && cp -aiv "$file" "$repo$file"

      # only when files are identical, move original to backup and create symlink instead
      if cmp --quiet "$file" "$repo$file"; then
         echo -n "mv: " && mv -v "$file" "$file.$id" && \
         echo -n "ln: " && ln -sv "$repo$file" "$file" && \
         backup=( "${backup[@]}" "$file.$id" ) && commit=1 || red \
         "Something went wrong moving and/or symlinking $file"
      else
         red "Files $file and $repo$file are not identical"
      fi
      echo
   done

   echo "$sep"
   if (( ${#backup[@]} > 0 )); then
      echo -e "${#backup[@]} backups created:\n${backup[@]}"
      answer=''
      while [[ "$answer" != [yn] ]]; do read -rp "Do you want to delete ALL these backups? [y/n] " answer; done
      [[ "$answer" == "y" ]] && rm -I "${backup[@]}"
      echo "$sep"
   fi
fi

if (( ${#readables[@]} > 0 )); then
   echo -e "${#readables[@]} files are readable only and will be copied to the repo:\n${readables[@]}\n$sep"

   commit=1
   for file in "${readables[@]}"; do
      file="$(realpath "$file")"
      [ -d "${file%/*}" ] && mkdir -p "$repo${file%/*}"
      echo -n "cp: "; cp -aiv "$file" "$repo$file"
   done

   echo "$sep"
fi

if [[ "$commit" == 1 ]]; then
   git -C "$repo" status
   echo "$sep"
   read -rp "Do you want to add, commit & push now? " answer
   if [[ "$answer" = [yY]* ]]; then
      git -C "$repo" add .
      git -C "$repo" status
      echo "$sep"
      answer=""
      read -rp "Commit message: " answer
      [[ "$answer" == "" ]] && answer="$id" || answer="$answer - $id"
      git -C "$repo" commit -m "$answer"
      git -C "$repo" push origin master
   fi
fi

Caveat-up-

I noticed that some applications replace the symlinks with actual files when writing out their configuration - well, at least parcellite does that. I even tried using a hard link, but after a configuration change in parcellite the files don't match anymore.

Upload the Repo so that it can be Pushed & Pulled-up-

There's variouss way to do it, but they all feel clunky to me. Here's how I did it this time:

  • scp the whole local dotfile repo to its central destination
  • Make the remote a bare repo by executing this in place: git config --bool core.bare true
  • Add the remote to your local repo: git remote add origin git:dotfiles.git (this works because "git" is a configured Host in ~/.ssh/config)
  • After making a few changes to the local repo, git push origin master should work. Subsequently, git push should be enough.

Clone the Repo onto another Machine-up-

Just git clone + whatever remote you defined.
You should create a new branch immediately, and switch to it, e.g.:
git checkout -b laptop
Changes made to it can be pushed too:
git push origin laptop
If something changes in the master branch, and you want to incorporate it into your laptop branch:

git checkout master
git pull
git checkout laptop
git rebase master
# or maybe rather
git merge master

Working with branches-up-

This sort of "collaboration" is still new to me. I find it beyond tricky to merge changes from one branch into another - selectively, without messing things up.

I think it's better to always push & pull to the desired branch explicitely, e.g.

git checkout master; git push origin master

It's also possible to fetch a branch without switching to it (don't use pull here - why I don't know, but it messed things up and I had to undo):

git checkout master; git fetch origin laptop:laptop

It might also be good to know how to undo almost anything with git.

Merging-up-

A nice GUI: install meld and try git mergetool - or better don't try it: by default git will merge branches if it thinks they are mergeable. How exactly this logic works I do not know, but it failed for me - obviously different files were overwritten from another branch without asking, but some files I was asked about (that's where meld came in handy). I'm sure there's command line options to do exactly what I want but I haven't investigated yet.

Here's how to merge a single file from a different branch:

git checkout master; git checkout --patch laptop on/laptop/branch/some.file

The file does not have to exist yet on the current branch.

Also see-up-

My list of git tricks.