How to Build Your Own Linux Dotfiles Manager from Scratch

How to Build Your Own Linux Dotfiles Manager from Scratch

As you start using Linux and customizing your system, you‘ll notice that many programs store their settings and configurations in special files that begin with a dot (.). These are known as "dotfiles".

Some common examples of dotfiles include:

  • .bashrc – configures the Bash shell
  • .vimrc – customizes the Vim text editor
  • .gitconfig – sets user info and preferences for Git

Over time, you‘ll tweak these files to suit your needs and preferences. But what happens if you need to set up a new machine and want all those settings? Or if you accidentally delete an important config?

That‘s where a dotfile manager comes in. By storing your dotfiles in a Git repository, you can easily sync them across machines and keep a version history of changes.

In this tutorial, we‘ll learn how to make our own simple dotfile manager from scratch using shell scripting. Let‘s call it "dotman" (dotfile manager).

Here‘s a high-level overview of how dotman will work:

  1. Check if this is the first time running dotman
  2. If first run, ask user for the Git repo URL to use for dotfiles and where to clone it locally
  3. Find all dotfiles in the user‘s home directory
  4. Compare the dotfiles in the home dir with the ones in the local Git repo
  5. Show a diff of what‘s changed
  6. Allow user to push changes to the remote Git repo
  7. Allow user to pull latest changes from the remote Git repo

To make this work, we‘ll need a few dependencies:

  • Bash – We‘ll write our script using the Bash shell, which comes standard on most Linux distros. We only need version 3.x or higher.

  • Git – We‘ll use Git to store the dotfiles and sync them with a remote repo. Make sure Git is installed with git --version.

Now open up your favorite text editor and let‘s start coding!

Setting Up the Script

First we need to let the system know this is a Bash script with a shebang line:

#!/usr/bin/env bash 

This runs Bash through the env command, which looks for it in the user‘s PATH, making the script more portable.

Next, we define our main() function which will control the flow of the script:

main() {
    # Check if first run, otherwise show menu
    if [[ -z $DOT_REPO || -z $DOT_DEST ]]; then
        initial_setup
    else
        show_menu
    fi
}

Here we check if the $DOT_REPO and $DOT_DEST environment variables are empty using -z. These will store the URL of the dotfiles repo and the local directory to clone it to.

If either one is empty, we assume it‘s the first time running dotman and call the initial_setup function. Otherwise, we show the main menu with show_menu.

Let‘s implement those next.

First Time Setup

initial_setup() {
    echo "First time running dotman. Let‘s set it up!"

    read -p "Enter the URL of your dotfiles repo: " DOT_REPO
    read -p "Enter the directory to clone to: " DOT_DEST

    [[ ! -d $DOT_DEST ]] && mkdir -p $DOT_DEST

    git clone $DOT_REPO $DOT_DEST

    # Export env vars for future runs
    export DOT_REPO DOT_DEST
    echo "export DOT_REPO=$DOT_REPO" >> ~/.bashrc
    echo "export DOT_DEST=$DOT_DEST" >> ~/.bashrc
}

Here we prompt the user to enter the Git repo URL and local clone directory, defaulting to the home directory if left blank.

The [[ ! -d $DOT_DEST ]] && mkdir -p $DOT_DEST line checks if the directory doesn‘t exist and creates it if needed.

We use git clone to clone the repo to the specified directory. Finally, we export the env vars and append them to ~/.bashrc so they persist for future runs.

The Main Menu

show_menu() {
    echo "dotman - Dotfile Manager"
    echo "------------------------"
    echo "d - Show diff of dotfiles"
    echo "p - Push changes to remote" 
    echo "l - Pull latest from remote"
    echo "q - Quit"

    read -p "Enter your choice: " choice

    case $choice in 
        d)
            dotdiff
            ;;
        p)
            dotpush
            ;;
        l) 
            dotpull
            ;;
        q)
            echo "Goodbye!"
            exit 0
            ;;
        *)
            echo "Invalid choice"
            show_menu
            ;;
    esac
}

The menu presents the user with 4 options:

  • d – Runs dotdiff to compare local dotfiles with those in the repo
  • p – Runs dotpush to push local changes to the remote repo
  • l – Runs dotpull to get latest changes from the remote
  • q – Quit the program

We use a case statement to match the user‘s choice and run the appropriate function or action.

If an invalid choice is made, we notify the user and show the menu again.

Now let‘s implement the core functions of dotman.

Finding and Comparing Dotfiles

dotdiff() {
    # Find all dotfiles and compare with repo versions

    echo "Looking for dotfiles in $HOME..."

    # Find file names starting with . in home dir
    home_dots=$(find $HOME -maxdepth 1 -name ".*")

    for dot in $home_dots; do
        [[ -f $dot ]] || continue
        fname=$(basename $dot)
        repo_dot="$DOT_DEST/$fname"

        echo "Checking $fname:"
        if [[ -f $repo_dot ]]; then
            diff -u "$repo_dot" "$dot"
        else
            echo "$fname doesn‘t exist in repo"
        fi
    done
}

To compare the dotfiles, first we use find to get a list of all files starting with . in the user‘s home directory, limited to the top level with -maxdepth 1.

We loop through each file, skipping any directories with [[ -f $dot ]] || continue.

For each file, we construct the path to its equivalent in the local repo directory. Then we use diff to compare the two versions.

The -u flag to diff produces a "unified" output format that shows the line differences between the files.

If the file doesn‘t exist in the repo, we print a message saying so.

This lets the user see what‘s changed between their live config files and the last version committed to the repo.

Pushing and Pulling Changes

To push local changes:

dotpush() {
    echo "Pushing changes to $DOT_REPO"
    cd "$DOT_DEST"

    if ! git diff-index --quiet HEAD --; then
        git add -A
        git commit -m "Updating dotfiles"
        git push origin main
        echo "Pushed commit to remote repo"
    else
        echo "No changes to push"
    fi
}

First we cd into the local repo directory. The git diff-index –quiet HEAD — command checks if there are any uncommitted changes.

If there are, we stage all changes with git add -A, create a commit, and push it to the remote repo using git push origin main.

To pull the latest changes from the remote:

dotpull() {
    echo "Pulling latest from $DOT_REPO"
    cd "$DOT_DEST"

    git pull origin main

    home_dots=$(find $HOME -maxdepth 1 -name ".*")

    for dot in $home_dots; do
        [[ -f $dot ]] || continue
        fname=$(basename $dot)
        repo_dot="$DOT_DEST/$fname"
        cp "$repo_dot" "$dot"
    done

    echo "Pulled and synced latest dotfiles"
}

We start by pulling any new commits from the remote repo‘s main branch to our local one.

Then we use the same find command from before to get a list of the dotfiles in the home directory.

We loop through and overwrite each one with its latest version from the local repo using cp. This syncs any changes to the user‘s live config files.

Adding Some Color

Finally, let‘s add some visual flair to dotman using tput.

# Define some colors
RED=$(tput setaf 1)
GREEN=$(tput setaf 2) 
YELLOW=$(tput setaf 3)
BLUE=$(tput setaf 4)
BOLD=$(tput bold)
NORMAL=$(tput sgr0)

dotpush() {
    ...
    echo "${GREEN}Pushed commit to remote repo${NORMAL}"
    ...
    echo "${YELLOW}No changes to push${NORMAL}"
    ...
}

The tput command lets us set the ANSI color codes for foreground colors like red/green/blue. We can also set text to bold. $NORMAL resets the text style.

We can use these color variables to emphasize important output like success and warning messages.

Feel free to go through and add color in other places! Just be sure to wrap the colored text in ${} and reset it afterwards.

Putting It All Together

Here‘s how the final dotman script looks:

#!/usr/bin/env bash

RED=$(tput setaf 1) 
GREEN=$(tput setaf 2)
YELLOW=$(tput setaf 3)
BLUE=$(tput setaf 4) 
BOLD=$(tput bold)
NORMAL=$(tput sgr0)

initial_setup() {...}
show_menu() {...} 
dotdiff() {...}
dotpush() {...} 
dotpull() {...}

main() {
    if [[ -z $DOT_REPO || -z $DOT_DEST ]]; then
        initial_setup
    else
        show_menu
    fi
}

main "$@"

I‘ve omitted the function bodies here for brevity, but they‘re the same as shown in the previous examples. The full code is available in the linked repo.

To run it, copy the code into a file called dotman.sh. Make it executable with chmod +x dotman.sh. Then run it with ./dotman.sh.

After the first run, you‘ll be able to simply run dotman from anywhere to bring up the menu.

Next Steps

Well, there you have it – a basic but functional dotfile manager in about 100 lines of Bash!

Some ideas to extend it:

  • Support for different Git remotes/branches
  • Interactively staging individual files
  • Resolving merge conflicts

I hope this has helped demystify the world of dotfile management and given you the tools to roll your own custom solution.

The full code is available on GitHub. Feel free to fork it and make it your own!

If you have any questions or feedback, reach out to me on Twitter or in the comments below.

Happy hacking!

Similar Posts