Dotfiles Part 2: A Bare Repo Approach to storing Home Directory Config Files in Git

Jonathan Bowman Created: January 27, 2021 Updated: July 10, 2023 [Dev] #dotfiles #commandline #git ~/.*

We can make life easier by using Git to store and version configuration files that reside in a system’s home directory (aka “dotfiles”). But how do we do so selectively and non-invasively, so that only the desired files are committed to version control? This article explores one such method: using a “bare” git repo to track the files.

If your needs are straightforward, I highly favor the method decribed in the previous article in this series. There I describe the simple approach of making the entire home directory a local git repo, and disabling tracking of (or just ignoring) all files by default. Then, one selectively adds the desired files, such as .bashrc, .zshenv, .vimrc, etc.

That article has significant overlap with this one. You may welcome reviewing some of the introductory information there.

Just need a quick up-and-running approach? You might appreciate the easy article. Also feel free to browse the whole series.

There is an oddity to these approaches that do not use a bare repo: before you initialize a Git repo in a subdirectory, that subdirectory is considered part of the home repo. Try mkdir newfolder and then git status newfolder. Didn’t get the expected “fatal: not a git repository”? Yeah, exactly. While weird, for the most part I don’t find it too troubling. Once you git init or git clone in a subdirectory, it becomes a new repo in its own right.

But if this bothers you, or if you are considering a layered approach with multiple repos or branches, then the bare repo method detailed here may appeal.

🔗Advantages of the bare repo method

The bare repo approach is a little more complex than the aforementioned strategy. In a nutshell, the symptom of that complexity surfaces in the need to append --git-dir=$HOME/.dotfiles --work-tree=$HOME to every git command. We’ll get to options for easing that pain, though, in a moment.

In spite of the complexity, I see two advantages to the bare repo:

🔗Summary commands

Feel free to read the full article for detailed explanation and options. As a quick summary, the following commands offer an introduction. (The url for your Git repo should be assigned to or substituted for the $REPO variable.)

git clone --bare $REPO $HOME/.dotfiles
git --git-dir=$HOME/.dotfiles/ config --local status.showUntrackedFiles no

# If non-default branch in repo, or naming the initial branch before first push:
git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME switch -c base

# If first-time push to empty repo, add and commit some files, then push
# Just adding ".profile" in the following example
git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME add .profile
git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME commit -m "initial commit"
git --git-dir=$HOME/.dotfiles/ push -u origin base

# If instead pulling an already populated repo, simply:
dtf checkout
# Deal with conflicting files, or run again with -f flag if you are OK with overwriting

🔗Convenience functions

As can be seen in the command summary above, there is a whole lot of --git-dir=$HOME/.dotfiles --work-tree=$HOME going on. Let’s start by making a convenience function.

For Bash/Zsh/Ash, add the following to ~/.bashrc or ~/.zshrc or ~/.profile, depending on your shell:

dtf () {
  git --git-dir="$HOME/.dotfiles" --work-tree="$HOME" "$@"

And the same in Powershell (on Windows, add this to Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1):

function dtf {
  git --git-dir="$HOME\.dotfiles" --work-tree="$HOME" @Args

You don’t have to call the function dtf, of course. It could be dtfgit or dotfiles or homedirectoryconfigurationsforversioncontrol as long as you are consistent. It simply adds those two commandline options to the git command and passes through any other commands and options.

Now, when managing configuration files, instead of using git you would use dtf (or whatever you opted to call it).

I should note a cross-platform git-centered approach, using git aliases. On any platform (Windows, Mac, or Linux or BSD) you could do something like:

git config --global alias.dtf '!git --git-dir=$HOME/.dotfiles --work-tree=$HOME'

Then, instead of using git or dtf you would use git dtf (or whatever you opted to call it). Choices! You decide.

🔗Clone the Git repository

The first step, whether restoring an existing set of files or populating a new repo, is to clone the remote Git repository:

git clone --bare $REPO $HOME/.dotfiles

🔗Choose the Git branch

For most people the default Git branch is called main or master. For configuration files, I like to instead use the name base for the files I share across all systems. This allows for additional (orphan) branches later such as windows and linux/ui and linux/wsl, for example.

To explicitly select or create the branch base:

dtf switch -c base

The discerning eye will have noticed we could skip this step for existing, populated repos that already have the branch base, by specifying the branch when cloning, using the -b option:

git clone -b base --bare $REPO $HOME/.dotfiles

🔗Exclude untracked files from git status for readability

I periodically like to see what files I have changed by using dtf status but this will, by default, show every file in the home directory that is not tracked. This will be a mess in the terminal, and make it difficult to discern changes.

So, let’s tell Git not to share the status of untracked files. In other words, only files that have been explicitly added with dtf add $FILENAME will be shown with dtf status. To do so:

dtf config --local status.showUntrackedFiles no

🔗Populating an empty repository

(If you are downloading files from an already-populated repository, skip this step and proceed to “Working with existing dotfiles”, below.)

If this is the first time you are pushing files into an empty repository, then you will want to add and commit some files now. For instance, if you want to add your VSCode configuration on your Macbook, then try something like:

dtf add ~/Library/Application Support/Code/User/settings
dtf commit -m "Initial commit of VSCode config"

Once at least one file has been added and committed to version control, the repo is ready to be pushed and have the upstream set:

dtf push -u origin base

From now on, with the upstream configured on this machine, all you need is dtf push after committing new changes.

🔗Working with existing dotfiles

If you are setting up a new machine from an existing Git repo that already has your dotfiles, then all you need is:

dtf checkout

This will place your tracked files in your home directory from the bare .dotfiles repo. In some cases, you may have conflicts. For instance, if there is already a .bashrc in your home directory, and the remote repo also has this file, then you should see something like this:

error: The following untracked working tree files would be overwritten by checkout:
Please move or remove them before you switch branches.

If you don’t see that error, then you are done with setup! If you do see it, then deal with the files (backing up, erasing, etc.) or run dtf checkout -f to force overwriting.

🔗Setup and restore functions

So far, we have the one dtf function, for convenience. I suggest making sure you have available two additional convenience functions to wrap up the above steps in a repeatable way: dtfnew and dtfrestore (or whatever you would like to call them).

Here is a working example, for Bash, Zsh, or Ash:


dtf () {
  git --git-dir="$DOTFILES" --work-tree="$HOME" "$@"

dtfnew () {
  git clone --bare $1 $DOTFILES
  dtf config --local status.showUntrackedFiles no
  dtf switch -c base

  echo "Please add and commit additional files"
  echo "using 'dtf add' and 'dtf commit', then run"
  echo "dtf push -u origin base"

dtfrestore () {
  git clone -b base --bare $1 $DOTFILES
  dtf config --local status.showUntrackedFiles no
  dtf checkout || echo -e 'Deal with conflicting files, then run (possibly with -f flag if you are OK with overwriting)\ndtf checkout'

The equivalent, in Powershell form:

$DOTFILES = "$HOME\.dotfiles"

function dtf {
  git --git-dir="$DOTFILES" --work-tree="$HOME" @Args

function dtfnew {
  Param ([string]$repo)
  git clone --bare $repo $DOTFILES
  dtf config --local status.showUntrackedFiles no
  dtf switch -c base

  echo "Please add and commit additional files"
  echo "using 'dtf add' and 'dtf commit', then run"
  echo "dtf push -u origin base"

function dtfrestore {
  Param ([string]$repo)
  git clone -b base --bare $repo $DOTFILES
  dtf config --local status.showUntrackedFiles no
  dtf checkout
    echo "Deal with conflicting files, then run (possibly with -f flag if you are OK with overwriting)"
    echo "dtf checkout"

The function dtfnew $REPO will set up a new repo ready to be populated and pushed to an empty remote repository.

The function dtfrestore $REPO will accept an already-populated remote repository URL, and pull the files into your home directory.

I suggest customizing the above functions as you like, then placing them in a Git repo or Github Gist or Gitlab Snippet. Assign $URL appropriately, then use something like:

OUT="$(mktemp)"; wget -q -O - $URL > $OUT; . $OUT

The above works on Bash/Ash/Zsh, and even on Busybox-based distros. Feel free to try URL=""

For a Powershell example, try something like:

Set-ExecutionPolicy RemoteSigned -scope CurrentUser
iwr -useb $URL | iex

For Powershell, feel free to try $URL = ""

Feel free to investigate my dotfile-scripts repository on Github. The above files are available there, as well as files related to other articles in this series.

🔗A flexible approach

While the first strategy favors simplicity, this bare repo approach offers flexibility. We will explore this flexibility in a future article that engages a modular approach to dotfiles. Meanwhile, I hope the methods discussed here help you compose tools that work well for you.

Back to top