The Bare Repo Approach to Storing Home Directory Config Files (Dotfiles) in Git using Bash, Zsh, or Powershell
Tags: programming, productivity, git, github
Published: 2021-01-27 00:00:18

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_, 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 first article in this series][dotfiles1]. 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][dotfiles1] has significant overlap with this one. You may welcome reviewing some of the introductory information there.

There is an oddity to that approach: 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 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:

- Logical separation. Git won't think your entire home directory is a repo unless you explicitly tell it to. That is what `--git-dir=$HOME/.dotfiles --work-tree=$HOME` is for.
- That separation eases a layered approach. Let's say you want to have a base layer for all your machines, then another layer (from another repo or branch) just for Linux machines with a window manager, then another layer for Windows machines, and another for Mac... You get the idea. This article will not dive into using layers or modules, but understanding the bare repo concept may help later.

## 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.)

```sh
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:

```sh
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`):

```powershell
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:

```sh
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:

```sh
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`:

```sh
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:

```sh
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:

```sh
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:

```sh
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:

```sh
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:

```sh
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:
        .bashrc
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:

```sh
DOTFILES="$HOME/.dotfiles"

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:

```powershell
$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
  if ($LASTEXITCODE) {
    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:

```sh
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="https://raw.githubusercontent.com/bowmanjd/dotfile-scripts/main/bare.sh"`

For a Powershell example, try something like:

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

For Powershell, feel free to try `$URL = "https://raw.githubusercontent.com/bowmanjd/dotfile-scripts/main/bare.ps1"`

Feel free to investigate [my dotfile-scripts repository on Github](https://github.com/bowmanjd/dotfile-scripts). 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.

[dotfiles1]: https://www.bowmanjd.com/dotfiles/dotfiles-1-simple-no-bare-repo/
[github]: https://github.com/
[gitlab]: https://gitlab.com/