A Simple Approach to storing Home Directory Config Files (Dotfiles) in Git using Bash, Zsh, or Powershell, without a Bare Repo
Tags: programming, productivity, git, github
Published: 2021-01-17 13:23:39

Configuration files that reside in your home directory are both precious and dynamic. Given this, storing them in a version control system like Git makes good sense. Due to concerns around complexity, security, and cleanliness, though, no one wants to manage their entire home directory with version control. Let's explore how to manage these important configuration files, also known as "dotfiles", by selectively committing only the desired files to version control.

Dotfiles? Because these files are often prefixed with a "." (period, full stop, what have you), they are sometimes called "dotfiles." Examples include `.bashrc`, `.zshenv`, `.vimrc`, and so on. Of course, they may include any configuration file, dot or not, such as `Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1` or `pyproject.toml` or `Library/Application Support/Code/User/settings.json`.

In the approach detailed in this article, we simply make the home directory a git repo, then add and commit handpicked files, pushing and pulling from the remote repository as desired.

## 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 init
git remote add origin $REPO

# Execute/uncomment one of the following 3 lines unless a .gitignore with '/**' already exists in the repo
git config --local status.showUntrackedFiles no
# echo '/**' >> .git/info/exclude
# echo '/**' >> .gitignore; git add -f .gitignore

# If first-time push to empty repo, add and commit some files, then:
git push -u origin HEAD

# Otherwise, if this is first-time pull from non-empty repo
# Set or replace $BRANCH with your branch name: master, main, base, dev, or other of your choosing
git fetch --set-upstream origin $BRANCH
git switch --no-overwrite-ignore $BRANCH # Complains if files of same name already exist
git switch -f $BRANCH # Only do this if you are comfortable overwriting existing files
```

Now `git add FILENAME` (use `git add -f` to force add if necessary, depending on which settings you chose above), `git commit`, `git push`, and `git pull` to your heart's content.

For a more detailed exploration, please read on...

## Create or locate a remote Git repository

If you do not already have a Git repository, then you will want to create one before undertaking the steps described here.

You can create such a repository in the way you prefer. For instance, create a new repo on [Github] or [Gitlab]. A private repository is safer than a public one, in case you accidentally or intentionally commit secrets or sensitive information. On the other hand, a public repository is far more convenient, as you don't need to worry about authentication when first cloning. You decide.

[Github has helpful instructions](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/create-a-repo) for creating a repo, and [so does Gitlab](https://docs.gitlab.com/ee/gitlab-basics/create-project.html#blank-projects).

You may also host a Git repository anywhere you like, such as another local directory (perhaps one that is synchronized with cloud storage somehow), or [on your own server with SSH access](https://git-scm.com/book/en/v2/Git-on-the-Server-Setting-Up-the-Server). In both of these scenarios, you will initialize a bare remote repository with `git init --bare`.

Once you have a remote Git repository that is empty, or already has your dotfiles in it, you can manage that repo from your home directory, with the methods described in this article.

> Note: the commands in this article have been tested with the following shells: [Bash](https://www.gnu.org/software/bash/), [Zsh](https://www.zsh.org/), [Ash](http://gondor.apana.org.au/~herbert/dash/), and [Powershell](https://docs.microsoft.com/en-us/powershell/). Unless you have configured things differently (good for you), Linux users will probably use Bash, Windows users Powershell, and Mac users Zsh. Feel free to let me know in the comments if there are additional tweaks needed for other shells.

## Initialize the Git repository

We will selectively manage dotfiles by making the entire home directory a git working directory, but ignore all files by default or disable tracking on files not already in the repo.

First, we initialize a Git repository in the home directory and configure the remote:

```sh
cd ~
git init
git remote add origin git@github.com:USERNAME/dotfiles.git
```

Where `USERNAME` is your Github username. Gitlab users would use `gitlab.com` in place of `github.com`. I use SSH, but if you prefer HTTPS, then the URL should look more like `https://github.com/USERNAME/dotfiles.git` but with your username. Thankfully, both Github and Gitlab make it easy to copy the entire clone URL on the repository page. Other platforms should have a similar option.

## Preliminary setup

There are three options for excluding the files that you don't want in the repo. (You may also have heard of [an approach involving setting up a bare Git repo][dotfiles2] in a separate directory, then pointing git to the home directory as a working directory. That is an option we explore in a separate article.)

### Option #1: disable status tracking of non-repo files

The first option, and my favorite, involves simply disabling status tracking on non-repo files. This way, `git status` will not list all of the files you _might_ add, but just the ones you already have chosen. This option assumes you can keep yourself from typing `git add .` so as not to accidentally add _every_ file in your home directory.

```sh
git config --local status.showUntrackedFiles no
```

Note that this is necessary when creating a new repository, and also when checking out the repository for the first time on a new machine. In other words, this setting is not restored automatically when fetching the repo; it must be run manually at every first-time setup.

When using this option, add additional files with `git add FILENAME`. **Do not use `git add .`**

### Option #2: `.gitignore`

The second option is to use a `.gitignore` file that ignores everything.

```sh
echo '/**' >> ~/.gitignore
```

Of course, you are welcome to use the text editor of your choice.

This `.gitignore` can then be included in your repository, so that when you clone it on another machine, the ignores will be checked out as well.

If you are choosing this option, and you are starting fresh with an empty repository, add the `.gitignore` to the repo, and commit the change:

```sh
git add ~/.gitignore
git commit -m "feat: Initial commit and .gitignore"
```

When using this option, add additional files by force-adding ignored files with `git add -f FILENAME`. Another way is the two-step approach of first allowing the file in the `.gitignore`, then either `git add FILENAME` _or_ `git add .` See below for further explanation.

### Option #3: `.git/info/exclude`

A third option, if you want to hide this away, and don't mind an extra configuration step every time you clone the repo, is to place the exclude line in `~/.git/info/exclude` instead:

```sh
echo '/**' >> ~/.git/info/exclude
```

Very clean, just a little more labor repetitive, as you will need to do it the first time you download your dotfiles to any new environment.

### Adding files with option #2 or #3

At this point, adding other files is possible. To add a `.bashrc` file, for instance, it either needs to be force-added with `git add -f .bashrc` or allowed in the `.gitignore` with a `!/.bashrc` line. This means do _not_ (`!` means "not") ignore the file `/bashrc`. Then you can add without forcing by simply using `git add .bashrc` or even just `git add .` to add every allowed file. My preference is to use the one-step process of force-adding rather than the two-step of adding to a file then adding again with git. So:

```sh
git add -f ~/.bashrc
git commit -m "feat: added Bash config"
```

I should note some advantages/disadvantages of the one-step vs two-step approach: if you like typing `git add .` and cannot keep yourself from doing so, then use the two-step approach: add a line including the filename in `.gitignore` then use `git add .` without fear. Easy, even if it is the two steps. But if you prefer the force method with `git add -f FILENAME` then whatever you do, do not use `git add -f .` as it will commit the entire contents of your home directory.

## Push to remote repository

Once files are committed to the local repo, we can set the upstream repo and push.

```sh
git push -u origin HEAD
```

From this point on, since the upstream repo is now set, a simple `git push` will upload your commits to the remote repository.

## Working with existing dotfiles

Once you have a remote repository populated with your dotfiles, these are the steps to download those to a new home directory:

First, repeat the above setup steps, making the same choices abotu how to exclude/include files. Something like this:

```sh
git init
git remote add origin $REPO

# Execute/uncomment one of the following 2 lines unless a .gitignore with '/**' already exists in the repo
# git config --local status.showUntrackedFiles no
# echo '/**' >> .git/info/exclude
```

Then,

```sh
git fetch
```

> My preferred branch is named `base`; use whatever yours is named in place of `base` in all the examples.

At this point, the files have been "fetched," but not yet merged into the home directory. To do so, execute the following command. `$BRANCH` should be assigned or replaced with the main/default branch name, such as `main`, `master`, `base`, and so on. If unsure of your branch name, you can browse your repo online, or discover using [one of a variety of command-line methods](https://dev.to/bowmanjd/get-github-default-branch-from-the-command-line-powershell-or-bash-zsh-37m9).

```sh
git switch --no-overwrite-ignore $BRANCH
```

This _might_ work; however, if there are already files in your home directory with the same name as in the remote repository, Git will complain and refuse to overwrite them. Review the files now, make backups if appropriate, then try again.

If you are OK with overwriting existing files, you may use the force (`-f`) flag like so:

```sh
git switch -f $BRANCH
```

From this point on, you can `git add` any files you want to track, `git commit` to index those files, `git push` to upload them to the remote repo, and `git pull` to download any changes you may have made elsewhere.

## Convenience functions

You might wish to define shell functions for convenience. I use the following:

```sh
dtfnew () {
  git init
  git remote add origin $1

  # Uncomment one of the following 3 lines
  git config --local status.showUntrackedFiles no
  # echo '/**' >> .git/info/exclude
  # echo '/**' >> .gitignore; git add -f .gitignore

  echo "Please add and commit additional files, then run"
  echo "git push -u origin HEAD"
}

dtfrestore () {
  git init

  # Uncomment one of the following 2 lines unless repo has '/**' line in a .gitignore
  git config --local status.showUntrackedFiles no
  # echo '/**' >> .git/info/exclude

  git remote add origin $1
  git fetch
  git remote set-head origin -a
  BRANCH=$(git symbolic-ref --short refs/remotes/origin/HEAD | cut -d '/' -f 2)
  git branch -t $BRANCH origin/HEAD
  git switch --no-overwrite-ignore $BRANCH || echo -e "Deal with conflicting files, then run (possibly with -f flag if you are OK with overwriting)\ngit switch $BRANCH"
}
```

Or the Powershell equivalent:

```powershell
function dtfnew {
  Param ([string]$repo)
  git remote add origin $repo

  # Uncomment one of the following 3 lines
  git config --local status.showUntrackedFiles no
  # echo '/**' >> .git/info/exclude
  # echo '/**' >> .gitignore; git add -f .gitignore

  echo "Please add and commit additional files, then run"
  echo "git push -u origin HEAD"
}

function dtfrestore {
  Param ([string]$repo)

  git init

  # Uncomment one of the following 2 lines unless repo has '/**' line in a .gitignore
  git config --local status.showUntrackedFiles no
  # echo '/**' >> .git/info/exclude

  git remote add origin $repo
  git fetch
  git remote set-head origin -a
  $branch = ((git symbolic-ref --short refs/remotes/origin/HEAD) -split '/' | select -Last 1)
  git branch -t $branch origin/HEAD

  git switch --no-overwrite-ignore $branch
  if ($LASTEXITCODE) {
    echo "Deal with conflicting files, then run (possibly with -f flag if you are OK with overwriting)"
    echo "git switch $branch"
  }
}
```

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. Note some extra commands like `git remote set-head` and obtaining the default branch name using `git symbolic-ref`. These allow the function to adapt no matter what you have named the default branch of the remote repo.

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/basic.sh"`

For a Powershell example, try something like:

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

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

## Pros and cons of this approach

I like this approach because it leaves you with a repo that pretty much works the way repos are supposed to work. No need for extra `--git-dir` or `--work-tree` options that [the bare repo approach][dotfiles2] requires.

The only problem I see with this simplicity is that even when a subdirectory of your home directory is not configured as a git repo (with `git init` or `git clone`, for instance), it is considered a git repo.

In other words, if you start a new project and type `git status` you will notice that your fresh project is automatically a part of a pre-existing Git working tree. Of course, type `git init` or `git clone` or what have you, and all is well. Once a `.git` directory exists, then `git status` will know not to refer to the home directory repo.

If this is a concern, you may wish to consider [the bare repo approach][dotfiles2], although it does add a layer of complexity.

## Adapt, customize, learn

Hopefully this gives you some ideas for managing your configurations, thereby making your life easier. Admittedly, there are many options along the way, including opportunities to simply learn Git better, considering how it might serve your needs. Feel free to post in the comments if you have creative ideas and feedback!

See [additional articles][https://www.bowmanjd.com/dotfiles/] on this topic, including modular approaches, for when some environments have both shared and distinct configurations from other environments. And, of course, the bare repo approach. And, of course, [chezmoi](https://www.chezmoi.io/). So many possibilities.

[github]: https://github.com/
[gitlab]: https://gitlab.com/
[dotfiles2]: https://www.bowmanjd.com/dotfiles/dotfiles-2-bare-repo/