Skip to content

Exit 99

 2 min Dotfiles that fast-forward themselves at login and re-exec the running shell, coordinated by one exit code.
Published

Apr 11, 2018

My dotfiles update themselves when I log in. The repo is my home directory—GIT_DIR points at ~/..., GIT_WORK_TREE at $HOME—and a startup script fetches, fast-forwards, and, when the config changed, replaces the running shell with a fresh login shell of the new world. The handoff between those two steps is an exit code:

# Subshell to avoid side effects. Return code determines next step.
(if [[ -x $(type -p git) && -f ${_ELLIPSIS}config && -z ${UID/#$EUID} ]]; then
  ...
  if git merge --quiet --ff-only $them; then
    us=$(git rev-parse HEAD)
    if [[ $us == $them ]]; then
      at=INFO say 'Upstream merged (reloading).'
      exit 99
    fi
  fi
  ...
fi)

case $? in
  (99)
    # Code updated. Re-exec!
    exec -l $ZSH_ARGZERO "${@}"
    ;;
esac

The entire updater runs in one parenthesized subshell. It cannot set the parent’s variables, but its exit status carries the next step upward. Inside, only one path exits 99—a fast-forward that moved HEAD to match upstream. Everything else, including “already current” and “merge refused,” exits normally. The parent reads 99 as a one-byte message: your configuration is stale; replace yourself.

exec -l $ZSH_ARGZERO "${@}" is the replacement. exec swaps the process image, and -l prepends a dash to argv[0]—the old Unix convention for a login shell—so the fresh zsh runs the full login startup against the just-merged files. The bash twin of this file does the same with $BASH. There is no “restart your shell to apply changes”; logging in is applying changes.

Two guards keep it from misbehaving. The merge is only attempted when the two heads differ and git merge-base --is-ancestor $them $us says upstream isn’t already contained in HEAD—which also breaks any re-exec loop, because after the re-exec the heads are equal and the whole block short-circuits. And it is --ff-only: a diverged worktree gets a warning naming the exact command to run (cd ~; git merge ...) rather than an automatic merge into a live home directory.

The fetch throttle is its own small trick: “when did I last fetch” is read from the mtime of FETCH_HEAD—a file git already maintains—with the next fetch twelve hours later. A failed merge backdates FETCH_HEAD to the epoch so the next shell retries immediately.