Computing and the Command Line

Your Dotfiles in Git

Dotfiles are the hidden settings files in your home folder. This project puts yours in a git repository: ~/.bash_aliases, ~/.inputrc, ~/.nanorc, a global gitignore, and shared git settings, plus a tested install script that symlinks each file into place, backs up anything in the way, includes the git settings from ~/.gitconfig, and is safe to run again. Testing it in a throwaway home folder, installing it for real, editing through the links, and pushing the repository to GitHub.

  • 12 min
  • 10 steps
  • 3 questions
  • Lesson 42 of 80

In this lesson

  1. What dotfiles are
  2. The plan
  3. The files
  4. The install script
  5. Test it somewhere safe
  6. Install it for real
  7. Day to day
  8. Put it on GitHub
  9. Your turn
  10. So

What dotfiles are

Most command-line programs read their settings from plain text files in your home folder whose names start with a dot, which is why ls hides them: dotfiles 1. You’ve already made several in this course and the Shell course: ~/.bash_aliases with aliases and a prompt, ~/.nanorc, ~/.gitconfig, and ~/.config/git/ignore.

Missing Semester’s advice is to keep them in their own folder, under version control, and symlinked into place by a script. That gets you four things 1:

  • Easy installation: a new machine is set up in a minute.
  • Portability: your tools work the same everywhere.
  • Synchronization: change a setting on one machine and pull it on the others.
  • Change tracking: a history of every setting, kept for years.

That’s this project. When you move to Linux, it’s the first thing to clone.

The plan

~/dotfiles/            a git repository
├── README.md          what this is, and how to install it
├── install.sh         links everything into place
├── bash_aliases   ->  ~/.bash_aliases
├── inputrc        ->  ~/.inputrc
├── nanorc         ->  ~/.nanorc
├── gitignore      ->  ~/.config/git/ignore
└── gitconfig          included from ~/.gitconfig

The files in the repository don’t start with a dot, so ls shows them. The install script makes each home-folder name a symbolic link (Shell course, module 2) to the file in the repository. A link and its target are the same file, so a program reading ~/.nanorc reads ~/dotfiles/nanorc, and an edit through either name changes the one in git.

Two choices keep it simple:

  • Leave ~/.bashrc alone. Ubuntu’s version already reads ~/.bash_aliases if it exists (Shell course, module 5), so your settings go there, and system updates to .bashrc never clash with yours.
  • Don’t link ~/.gitconfig. Keep it a real file on each machine, holding that machine’s name and email, and have it include the shared settings from the repository. Git reads an included file as if its contents were written where the include line is 2.
Left, the repository ~/dotfiles, a git repository with its whole history in .git, holding bash_aliases, inputrc, nanorc, gitignore, gitconfig, install.sh, and README.md. Right, your home folder. Symbolic links point from ~/.bash_aliases to bash_aliases, ~/.inputrc to inputrc, ~/.nanorc to nanorc, and ~/.config/git/ignore to gitignore. ~/.gitconfig is a real file with [user] name and email and an [include] path line that pulls in the repository's gitconfig. ~/.bashrc is Ubuntu's own and reads ~/.bash_aliases. Below, the install script's output: ok ~/.inputrc, a link already there, left alone; saved ~/.nanorc as .nanorc.backup-..., a real file in the way is kept; linked ~/.nanorc; safe to run again any time.
The repository holds the files; links and an include put them where programs look. Credit: StudyCorner diagram · CC BY 4.0 · Source

The files

Make the folder and the repository:

me@linuxbox:~$ mkdir ~/dotfiles
me@linuxbox:~$ cd ~/dotfiles
me@linuxbox:~/dotfiles$ git init
Initialized empty Git repository in /home/me/dotfiles/.git/

Copy in what you have (cp ~/.bash_aliases bash_aliases, cp ~/.nanorc nanorc), then fill them out. Here’s a set to start from; change anything you like.

bash_aliases, your shell settings. Ubuntu’s .bashrc reads this file in every interactive shell:

# ~/.bash_aliases: my shell settings. Ubuntu's ~/.bashrc reads this file.

# History: keep more, skip duplicates and lines starting with a space
HISTSIZE=10000
HISTFILESIZE=20000
HISTCONTROL=ignoreboth

# Ask before overwriting
alias cp='cp -i'
alias mv='mv -i'

# Shortcuts
alias ll='ls -alF'
alias up='sudo apt update && sudo apt upgrade'
alias gs='git status -s'

# Make a directory and move into it
mkcd() { mkdir -p -- "$1" && cd -- "$1"; }

# Prompt: green user@host, blue directory
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]\$ '

# Settings for this machine only, not kept in git
if [ -f ~/.bash_local ]; then
    . ~/.bash_local
fi

The last block reads ~/.bash_local if it exists, for settings that belong to one machine only (next lesson).

inputrc, for Readline, the line editor that bash uses for typing, history, and Tab completion 3:

# ~/.inputrc: settings for Readline, the line editor bash uses.
# Having this file stops /etc/inputrc being read, so include it first.
$include /etc/inputrc

# Tab completion ignores case, and lists choices on the first Tab
set completion-ignore-case on
set show-all-if-ambiguous on

# Up and Down search history for lines starting with what's typed
"\e[A": history-search-backward
"\e[B": history-search-forward

Readline reads ~/.inputrc, and only falls back to the system’s /etc/inputrc when yours doesn’t exist, which is why the first line includes it 3. completion-ignore-case makes Tab treat doc and Doc alike, and show-all-if-ambiguous lists the choices at once instead of beeping 3. The last two lines bind the Up and Down arrows to history-search-backward and -forward: type git and press Up to step back through only the commands that started with git 3.

nanorc is the one from the nano lesson (Shell course, module 7).

gitconfig, the git settings from this course’s module 1 and module 4, minus your name and email:

# Shared git settings. install.sh includes this file from ~/.gitconfig.
# Name and email go in ~/.gitconfig itself, set on each machine.
[init]
    defaultBranch = main
[core]
    editor = nano
    autocrlf = input
[pull]
    rebase = true
[alias]
    st = status
    lg = log --oneline --graph --all

gitignore, the patterns ignored in every repository (module 2):

# Ignored in every repository (linked to ~/.config/git/ignore)
*~
*.swp
.DS_Store

And a short README.md saying what the repository is and the commands to install it. Next lesson’s new-machine steps are what goes in it.

Quick check

Why does this ~/.inputrc start with $include /etc/inputrc?

The install script

install.sh follows the layout from the Shell course’s backup script: a header comment, set -euo pipefail, constants, small functions, and main 4:

#!/usr/bin/env bash
#
# install.sh: put these dotfiles in place in $HOME.
#
# Links each file below into $HOME and includes gitconfig from ~/.gitconfig.
# Safe to run again: links already in place are left alone, and a real
# file in the way is moved aside to NAME.backup-DATE first.
#
# Usage: ./install.sh

set -euo pipefail

# The folder this script is in, as an absolute path
DOTFILES="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly DOTFILES

# file in this repository : where it goes, relative to $HOME
readonly LINKS=(
  "bash_aliases:.bash_aliases"
  "inputrc:.inputrc"
  "nanorc:.nanorc"
  "gitignore:.config/git/ignore"
)

link() {
  local src="$DOTFILES/$1"
  local dest="$HOME/$2"

  if [[ -L "$dest" && "$(readlink -- "$dest")" == "$src" ]]; then
    echo "ok       ~/$2"
    return
  fi
  mkdir -p -- "$(dirname -- "$dest")"
  if [[ -e "$dest" && ! -L "$dest" ]]; then
    local backup
    backup="$dest.backup-$(date +%Y%m%d-%H%M%S)"
    mv -- "$dest" "$backup"
    echo "saved    ~/$2 as ${backup#"$HOME"/}"
  fi
  ln -sfn -- "$src" "$dest"
  echo "linked   ~/$2"
}

include_gitconfig() {
  local file="$DOTFILES/gitconfig"

  if git config --global --get-all include.path | grep -qxF -- "$file"; then
    echo "ok       ~/.gitconfig includes gitconfig"
  else
    git config --global --add include.path "$file"
    echo "included gitconfig in ~/.gitconfig"
  fi
}

main() {
  local pair
  for pair in "${LINKS[@]}"; do
    link "${pair%%:*}" "${pair#*:}"
  done
  include_gitconfig
}

main "$@"

Make it executable with chmod +x install.sh. It passes ShellCheck with no warnings 5. How it works:

  • DOTFILES is the absolute path of the folder the script is in, wherever you run it from: dirname of the script’s own path, then cd there and pwd. Links need absolute paths, or they’d point somewhere relative to the link itself.
  • LINKS is an array of repository-file:home-path pairs. ${pair%%:*} keeps the part before the colon and ${pair#*:} the part after (Shell course, module 8). Adding a file later is one more line.
  • link checks three cases. If dest is already a link (-L) to the right file, it says ok and stops. If it’s a real file or folder (-e but not -L), it’s renamed with a timestamp, so nothing you had is lost. Then ln -sfn makes the link, replacing any old link. mkdir -p first makes sure folders like ~/.config/git exist.
  • include_gitconfig asks git for the include.path values already in ~/.gitconfig, and adds this one only if it’s missing (grep -qxF matches the whole line, literally, quietly). Settings in ~/.gitconfig itself stay as they are 2.

Running it twice changes nothing the second time. That property, being safe to rerun, is what makes the script trustworthy.

Commit everything:

me@linuxbox:~/dotfiles$ git add .
me@linuxbox:~/dotfiles$ git commit -m "Add my dotfiles and an install script"
[main (root-commit) 8ffb271] Add my dotfiles and an install script
 7 files changed, 150 insertions(+)
 create mode 100644 README.md
 create mode 100644 bash_aliases
 create mode 100644 gitconfig
 create mode 100644 gitignore
 create mode 100644 inputrc
 create mode 100644 install.sh
 create mode 100644 nanorc

Test it somewhere safe

Before letting the script loose on your real home folder, point it at an empty one. Everything in the script works relative to $HOME, so setting HOME for one command is a complete test (Shell course, module 5):

me@linuxbox:~/dotfiles$ export TESTHOME="$(mktemp -d)"
me@linuxbox:~/dotfiles$ HOME="$TESTHOME" ./install.sh
linked   ~/.bash_aliases
linked   ~/.inputrc
linked   ~/.nanorc
linked   ~/.config/git/ignore
included gitconfig in ~/.gitconfig
me@linuxbox:~/dotfiles$ HOME="$TESTHOME" ./install.sh
ok       ~/.bash_aliases
ok       ~/.inputrc
ok       ~/.nanorc
ok       ~/.config/git/ignore
ok       ~/.gitconfig includes gitconfig

mktemp -d makes a new empty folder under /tmp. The first run links everything; the second finds it all in place. Look around with ls -la "$TESTHOME" and cat "$TESTHOME/.gitconfig", then rm -r "$TESTHOME" when you’re done.

Quick check

You run install.sh a second time. What happens to links it already made?

Install it for real

On this first machine, ~/.gitconfig still has the shared settings from module 1. Open it with nano ~/.gitconfig and delete everything except the [user] section, since the repository’s gitconfig now provides the rest. Then:

me@linuxbox:~/dotfiles$ ./install.sh
saved    ~/.bash_aliases as .bash_aliases.backup-20261005-150752
linked   ~/.bash_aliases
linked   ~/.inputrc
saved    ~/.nanorc as .nanorc.backup-20261005-150752
linked   ~/.nanorc
linked   ~/.config/git/ignore
included gitconfig in ~/.gitconfig
me@linuxbox:~/dotfiles$ ls -l ~/.bash_aliases
lrwxrwxrwx 1 me me 30 Oct  5 15:07 /home/me/.bash_aliases -> /home/me/dotfiles/bash_aliases
me@linuxbox:~/dotfiles$ cat ~/.gitconfig
[user]
    name = Your Name
    email = you@example.com
[include]
    path = /home/me/dotfiles/gitconfig
me@linuxbox:~/dotfiles$ git config --show-origin --get pull.rebase
file:/home/me/dotfiles/gitconfig    true

Your old .bash_aliases and .nanorc were real files, so they were saved with a date before being replaced. ls -l shows the link, l first and an arrow to its target. --show-origin shows git reading pull.rebase from the included file 2. Open a new terminal to load the new shell settings; once you’re happy, delete the .backup- files.

Day to day

Edit your settings the normal way, through the home-folder names; the links take the changes into the repository:

me@linuxbox:~/dotfiles$ echo "alias gl='git log --oneline -10'" >> ~/.bash_aliases
me@linuxbox:~/dotfiles$ git status -s
 M bash_aliases
me@linuxbox:~/dotfiles$ git diff
diff --git a/bash_aliases b/bash_aliases
index 1a25ea7..ad41b23 100644
--- a/bash_aliases
+++ b/bash_aliases
@@ -24,3 +24,4 @@ PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]\$ '
 if [ -f ~/.bash_local ]; then
     . ~/.bash_local
 fi
+alias gl='git log --oneline -10'
me@linuxbox:~/dotfiles$ git commit -qam "Add gl alias"

Small, well-described commits (module 2) pay off here: in a year, git log -p bash_aliases tells you when and why each setting arrived. To add a new dotfile later, move it into ~/dotfiles without its dot, add a line to LINKS, rerun ./install.sh, and commit.

Quick check

After installing, you add an alias to ~/.bash_aliases. Where does the change end up?

Put it on GitHub

Create an empty private repository called dotfiles on GitHub and push to it over SSH (module 4):

me@linuxbox:~/dotfiles$ git remote add origin git@github.com:you/dotfiles.git
me@linuxbox:~/dotfiles$ git push -u origin main
To github.com:you/dotfiles.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

Plenty of people publish their dotfiles, and reading other people’s is a good way to learn settings, though Missing Semester advises against copying configurations blindly 1. Private is the safer default: it’s easy to commit something personal without noticing.

Your turn

Project, part 1

  1. Create ~/dotfiles with at least bash_aliases, inputrc, nanorc, gitconfig, gitignore, install.sh, and a README.md. Copy in your existing settings first.
  2. Run shellcheck install.sh and fix anything it reports.
  3. Test the script twice in a mktemp -d home folder. Check that the second run prints only ok lines.
  4. Trim ~/.gitconfig to [user], run the script for real, open a new terminal, and check that your aliases, prompt, and git lg all work.
  5. Add a setting through ~/.bash_aliases, then see it in git diff inside ~/dotfiles and commit it.
  6. Push the repository to a private GitHub repository.
Hints
  1. ShellCheck’s most common catch is a missing pair of double quotes around a variable.
  2. If a second run prints linked again, compare readlink "$TESTHOME/.nanorc" with the path the script built: they must match exactly.
  3. type mkcd, alias gs, and git lg should all work in the new terminal. git config --show-origin --get init.defaultBranch should name the repository’s gitconfig.
  4. git status -s in ~/dotfiles shows M bash_aliases.

So

Your dotfiles now live in ~/dotfiles, under git. install.sh links each one into place with ln -sfn, saves any real file in the way, includes the shared git settings from ~/.gitconfig, and is safe to run again; testing it with HOME="$(mktemp -d)" proved that before it touched your home folder. Edits through the home-folder names land in the repository, ready to commit, and the repository is backed up on GitHub. Next: putting it on another machine.

Lesson complete

Nice work.

1day streak
0/1today's goal
–correct

Up next · 7 min

Setting Up a New Machine

Next lesson
Sources for this lesson
  1. 1
    Anish Athalye, Jon Gjengset, Jose Javier Gonzalez Ortiz. Command-line Environment (The Missing Semester of Your CS Education, 2026). MIT CSAIL. 2026. verifiedCC BY-NC-SA. How shell programs communicate: arguments, streams, environment variables, return codes, and signals; scripts with #!/usr/bin/env bash, $1 and [[ -f $1 ]], exit codes, errors to >&2; trap with a cleanup function to remove temporary files on exit; signals, job control with Ctrl-Z, bg, fg, and nohup; terminal multiplexers and SSH.
  2. 2
    git-config documentation. Git project (git-scm.com). verifiedReference for reading and writing git settings at the --system, --global, --local, and --worktree levels, with --list and --show-origin, and for every configuration variable, including user.name, user.email, init.defaultBranch, core.editor, core.autocrlf, and alias.*.
  3. 3
    Readline Init File (Bash Reference Manual). Free Software Foundation. verifiedReadline reads the file named by INPUTRC, default ~/.inputrc, and only if that doesn't exist /etc/inputrc. Variables include completion-ignore-case and show-all-if-ambiguous (both default off); $include reads another init file; history-search-backward/-forward search history for lines starting with the text before the cursor.
  4. 4
    Shell Style Guide. Google. verifiedBash is the only shell allowed for executables, which start with #!/bin/bash; executables on PATH need no extension, libraries take .sh and aren't executable. Every file starts with a comment describing it; functions get header comments. Error messages go to STDERR. Quote variables and prefer "${var}"; use "$@" for arguments; prefer [[ ]] over [ ] and $( ) over backticks; use local in functions; constants readonly and capitalized; put the program in a main function called last with main "$@". Scripts over about 100 lines, or with non-straightforward control flow, should be rewritten in a more structured language.
  5. 5
    Vidar Holen. ShellCheck: finds bugs in your shell scripts. shellcheck.net. verifiedA static analysis tool for sh and bash scripts that flags quoting problems, misused tests, unreachable or wrong logic, and portability issues, each with a numbered explanation (for example SC2086, double-quote to prevent word splitting). Installable with apt, dnf, brew, or pip (shellcheck-py), or usable in the browser. Version 0.11.0 checked the course's backup script clean on 2026-10-05.