DEV Community

loading...
Cover image for The Bare Repo Approach to Storing Home Directory Config Files (Dotfiles) in Git using Bash, Zsh, or Powershell

The Bare Repo Approach to Storing Home Directory Config Files (Dotfiles) in Git using Bash, Zsh, or Powershell

Jonathan Bowman
Constantly learning to develop software. A Python enthusiast. Works at Candoris, helping clients use Salesforce effectively. Want to buy me coffee? ko-fi.com/bowmanjd
・7 min read

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

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

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
Enter fullscreen mode Exit fullscreen mode

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" "$@"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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:

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'
}
Enter fullscreen mode Exit fullscreen mode

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
  if ($LASTEXITCODE) {
    echo "Deal with conflicting files, then run (possibly with -f flag if you are OK with overwriting)"
    echo "dtf checkout"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Set-ExecutionPolicy RemoteSigned -scope CurrentUser
iwr -useb $URL | iex
Enter fullscreen mode Exit fullscreen mode

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

Discussion (1)

Collapse
bbenzikry profile image
Beni Ben zikry • Edited

Hi Jonathan, great post.
A simpler approach I use is

alias dotfiles="GIT_WORK_TREE=~ GIT_DIR=~/dotfiles"
$ dotfiles git status
$ dotfiles code-insiders ~
Enter fullscreen mode Exit fullscreen mode

This allows things to be a bit more readable and has the benefit of allowing other programs to get the same context if needed ( vscode etc. )