Home | GitHub | LinkedIn | UK

Sharing .zshrc Across Machines

I have a problem that I'm sure others have also faced. I have several different machines but I want my .zshrc to be the same for each of them. Not only this, but I also use Linux and macOS.

How do I Share the Config?

Git! This is the most obvious answer but I've made a git repo with just the .zshrc file inside it. I then added some configuration to the top of the file that instructs it on how to update itself from the repository:

# Config:
DOTFILES_REPO_URL="ssh://[email protected]:2222/alex.thomson/zshrc.git"
DOTFILES_DIR="${DOTFILES_DIR:-$HOME/.dotfiles}"      # local clone path
DOTFILES_REMOTE="${DOTFILES_REMOTE:-origin}"         # remote name
DOTFILES_BRANCH="${DOTFILES_BRANCH:-main}"           # branch to track
DOTFILES_ZSHRC_PATH="${DOTFILES_ZSHRC_PATH:-.zshrc}" # path inside repo

_mktempdir() {
  setopt localtraps
  local d
  d="$(mktemp -d "${TMPDIR:-/tmp}/dotfiles.XXXXXX")" || return 1
  trap 'rm -rf "$d"' EXIT
  print -r -- "$d"
}

zshrc_update_now() {
  [[ $- == *i* ]] || return 0

  local tmpdir
  tmpdir="$(_mktempdir)" || return 0

  git clone --quiet --depth=1 --branch "$DOTFILES_BRANCH" \
    "$DOTFILES_REPO_URL" "$tmpdir" || return 0

  repo_zshrc="$tmpdir/$DOTFILES_ZSHRC_PATH"
  [[ -f "$repo_zshrc" ]] || return 0

  if cmp -s "$repo_zshrc" "$HOME/.zshrc"; then
    return 0
  fi

  echo "~/.zshrc updated from dotfiles repo"

  cp -p "$HOME/.zshrc" "$HOME/.zshrc.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null

  tmpfile="$(mktemp "$HOME/.zshrc.XXXXXX")" || return 1
  cp "$repo_zshrc" "$tmpfile"
  mv -f "$tmpfile" "$HOME/.zshrc"

  echo "Reloading ~/.zshrc"
  source "$HOME/.zshrc"
}

This code works fine, but importantly there is nothing calling it yet. I don't want this code called every time I open up the terminal, so I instead opted to run it once per day when the terminal is opened. This is actually pretty simple:

ZSH_UPDATE_STAMP="${ZSH_UPDATE_STAMP:-$HOME/.cache/zshrc-selfupdate.stamp}"

zshrc_self_update_daily() {
  [[ $- == *i* ]] || return 0

  mkdir -p "${ZSH_UPDATE_STAMP:h}" 2>/dev/null

  local today last
  today="$(date +%Y-%m-%d)"
  last="$(cat "$ZSH_UPDATE_STAMP" 2>/dev/null)"
  [[ "$last" == "$today" ]] && return 0
  print -r -- "$today" >| "$ZSH_UPDATE_STAMP"

  zshrc_update_now
}

zshrc_self_update_daily || true

With this final piece of code, my .zshrc can now self-update itself each day, keeping my machines up-to-date and synchronized; but there's still a problem: how do I push changes?

Pushing Changes

I decided to add a function for editing my .zshrc. Since I've installed Neovim on each of my machine I decided to make the function open the configuration with nvim, then once editing was complete, check for changes, push, and source ~/.zshrc again:

zshrc_push_via_temp_clone() {
  # Validate config:
  [[ -n "${DOTFILES_REPO_URL:-}" ]] || { echo "DOTFILES_REPO_URL is not set"; return 1; }
  [[ -n "${DOTFILES_BRANCH:-}" ]]   || { echo "DOTFILES_BRANCH is not set"; return 1; }
  [[ -n "${DOTFILES_ZSHRC_PATH:-}" ]] || { echo "DOTFILES_ZSHRC_PATH is empty (set to .zshrc)"; return 1; }

  # Disallow absolute paths (e.g., /etc/...) inside the repo:
  if [[ "$DOTFILES_ZSHRC_PATH" == /* ]]; then
    echo "DOTFILES_ZSHRC_PATH must be a path inside the repo (not absolute): $DOTFILES_ZSHRC_PATH"
    return 1
  fi

  local tmpdir
  tmpdir="$(_mktempdir)" || return 0

  # Fresh clone of the branch we will push to:
  command git clone --quiet --depth=1 --branch "$DOTFILES_BRANCH" \
    "$DOTFILES_REPO_URL" "$tmpdir" || return 1

  repo_zshrc="$tmpdir/$DOTFILES_ZSHRC_PATH"
  mkdir -p "${repo_zshrc:h}" || return 1

  # Copy this zshrc into the repo clone:
  command cp "$HOME/.zshrc" "$repo_zshrc" || return 1

  # Nothing changed vs repo? Bail.
  if command git -C "$tmpdir" diff --quiet -- "$DOTFILES_ZSHRC_PATH"; then
    echo "No dotfiles changes to push."
    return 0
  fi

  # Perform syntax check before committing:
  command zsh -n "$repo_zshrc" || {
    echo "Refusing to push: zsh syntax check failed."
    return 1
  }

  command git -C "$tmpdir" add -- "$DOTFILES_ZSHRC_PATH" || return 1

  msg="Update .zshrc ($(hostname -s 2>/dev/null || hostname), $(date +%Y-%m-%d))"
  command git -C "$tmpdir" commit -m "$msg" --quiet || return 1
  command git -C "$tmpdir" push --quiet origin "$DOTFILES_BRANCH" || return 1
  echo "Pushed .zshrc to $DOTFILES_BRANCH"
}

# Opens an editor for this file, then pushes changes to git.
zshrc_edit() {
  local before after

  before="$(command shasum -a 256 "$HOME/.zshrc" 2>/dev/null | awk '{print $1}')"

  "${EDITOR:-nvim}" "$HOME/.zshrc"
  local rc=$?
  [[ $rc -eq 0 ]] || { echo "Editor exited non-zero; not pushing."; return $rc; }

  after="$(command shasum -a 256 "$HOME/.zshrc" 2>/dev/null | awk '{print $1}')"
  [[ -n "$before" && "$before" == "$after" ]] && {
    echo "~/.zshrc unchanged."
    return 0
  }

  # Push changes (if any) using a temp clone:
  zshrc_push_via_temp_clone || {
    echo "Push failed; leaving local ~/.zshrc as-is."
    return 1
  }

  # Reload after successful push:
  source "$HOME/.zshrc"
}

Now when ever I want to edit my .zshrc, I can just use that function and it will be replicated onto my other machines the next time I open a shell (providing I haven't already opened one that day). If I really want to get the latest changes, I can always manually call the zshrc_update_now function.