3 # Dotfiles management script. For details, run
9 CHECK_WGET_TYPE_AND_ENCODING='no'
29 # usage: nonempty_option LOC NAME VALUE
30 function nonempty_option()
35 if [ -z "${VALUE}" ]; then
36 echo "ERROR: empty value for ${NAME} in ${LOC}" >&2
42 # usage: maxargs LOC MAX "${@}"
44 # Print and error and return 1 if there are more than MAX arguments.
50 if [ "${#}" -gt "${MAX}" ]; then
51 echo "ERROR: too many arguments (${#} > ${MAX}) in ${LOC}" >&2
56 # usage: get_selection CHOICE OPTION ...
58 # Check that CHOICE is one of the valid options listed in OPTION. If
59 # it is, echo the choice and return 0, otherwise print an error to
60 # stderr and return 1.
61 function get_selection()
66 if [ "${OPT}" = "${CHOICE}" ]; then
71 echo "ERROR: invalid selection (${CHOICE})" >&2
72 echo "valid choices: ${@}" >&2
76 function run_on_all_repos()
80 if [ -z "${REPO}" ]; then # run on all repositories
82 if [ "${REPO}" = '*' ]; then
83 break # no known repositories
85 "${COMMAND}" "${REPO}" "${@}" || return 1
93 DIR=$(nonempty_option 'list_files' 'DIR' "${1}") || return 1
95 if [ "${FILE}" = '.' ]; then
98 FILE="${FILE:2}" # strip the leading './'
100 done < <(cd "${DIR}" && find .)
103 # Global variable to allow passing associative arrats between functions
104 declare -A REPO_SOURCE_DATA
106 function set_repo_source()
108 REPO=$(nonempty_option 'set_repo_source' 'REPO' "${1}") || return 1
109 > "${REPO}/source_cache" || return 1
110 for KEY in "${!REPO_SOURCE_DATA[@]}"; do
111 echo "${KEY}=${REPO_SOURCE_DATA[${KEY}]}" >> "${REPO}/source_cache" || return 1
115 # usage: get_repo_source REPO
116 function get_repo_source()
118 REPO=$(nonempty_option 'get_repo_source' 'REPO' "${1}") || return 1
120 if [ -f "${REPO}/source_cache" ]; then
124 REPO_SOURCE_DATA["${KEY}"]="${VALUE}"
125 done < "${REPO}/source_cache"
127 # autodetect verson control system
129 REPO_SOURCE_DATA['repo']="${REPO}"
130 if [ -d "${REPO}/.git" ]; then
131 REPO_SOURCE_DATA['transfer']='git'
133 echo "ERROR: no source location found for ${REPO}" >&2
136 # no need to get further fields for these transfer mechanisms
140 function wget_fetch()
142 REPO=$(nonempty_option 'wget_fetch' 'REPO' "${1}") || return 1
143 # get_repo_source() was just called on this repo in fetch()
144 TRANSFER=$(nonempty_option 'wget_fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
145 URL=$(nonempty_option 'wget_fetch' 'URL' "${REPO_SOURCE_DATA['url']}") || return 1
146 ETAG="${REPO_SOURCE_DATA['etag']}"
148 HEAD=$("${WGET}" --server-response --spider "${URL}" 2>&1) || return 1
149 SERVER_ETAG=$(echo "${HEAD}" | "${SED}" -n 's/^ *etag: *"\(.*\)"/\1/ip') || return 1
150 if [ "${CHECK_WGET_TYPE_AND_ENCODING}" = 'yes' ]; then
151 TYPE=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-type: *//ip') || return 1
152 ENCODING=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-encoding: *//ip') || return 1
153 if [ "${TYPE}" != 'application/x-gzip' ] || [ "${ENCODING}" != 'x-gzip' ]; then
154 echo "ERROR: invalid content type (${TYPE}) or encoding (${ENCODING})." >&2
155 echo "while fetching ${URL}" >&2
159 if [ -z "${ETAG}" ] || [ "${SERVER_ETAG}" != "${ETAG}" ]; then
160 # Previous ETag not known, or ETag changed. Download new copy.
161 "${WGET}" --output-document "${BUNDLE}" "${URL}" || return 1
162 if [ -n "${SERVER_ETAG}" ]; then # store new ETag
163 REPO_SOURCE_DATA['etag']="${SERVER_ETAG}"
164 set_repo_source "${REPO}" || return 1
166 if [ -n "${ETAG}" ]; then # clear old ETag
167 unset "${REPO_SOURCE_DATA['etag']}"
168 set_repo_source "${REPO}" || return 1
171 echo "extracting ${BUNDLE} to ${REPO}"
172 "${TAR}" -xf "${BUNDLE}" -C "${REPO}" --strip-components 1 --overwrite || return 1
173 "${RM}" -f "${BUNDLE}" || return 1
175 echo "already downloaded the ETag=${ETAG} version of ${URL}"
179 # usage: link_file REPO FILE
181 # Create the symbolic link to the version of FILE in the REPO
182 # repository, overriding the target if it exists.
185 REPO=$(nonempty_option 'link_file' 'REPO' "${1}") || return 1
186 FILE=$(nonempty_option 'link_file' 'FILE' "${2}") || return 1
187 if [ "${BACKUP}" = 'yes' ]; then
188 if [ -e "${TARGET}/${FILE}" ] || [ -h "${TARGET}/${FILE}" ]; then
189 if [ "${DRY_RUN}" = 'yes' ]; then
190 echo "move ${TARGET}/${FILE} to ${TARGET}/${FILE}.bak"
193 mv -v "${TARGET}/${FILE}" "${TARGET}/${FILE}.bak" || return 1
197 if [ "${DRY_RUN}" = 'yes' ]; then
198 echo "rm ${TARGET}/${FILE}"
200 "${RM}" -fv "${TARGET}/${FILE}"
203 if [ "${DRY_RUN}" = 'yes' ]; then
204 echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
207 "${LN}" -sv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
214 # An array of available commands
222 CLONE_TRANSFERS=('git' 'wget')
224 function clone_help()
226 echo 'Create a new dotfiles repository.'
227 if [ "${1}" = '--one-line' ]; then return; fi
231 usage: $0 ${COMMAND} REPO TRANSFER URL
233 Where 'REPO' is the name the dotfiles repository to create,
234 'TRANSFER' is the transfer mechanism, and 'URL' is the URL for the
235 remote repository. Valid TRANSFERs are:
237 ${CLONE_TRANSFERS[@]}
241 $0 clone public wget http://example.com/public-dotfiles.tar.gz
242 $0 clone private git ssh://example.com/~/private-dotfiles.git
248 REPO=$(nonempty_option 'clone' 'REPO' "${1}") || return 1
249 TRANSFER=$(nonempty_option 'clone' 'TRANSFER' "${2}") || return 1
250 URL=$(nonempty_option 'clone' 'URL' "${3}") || return 1
251 maxargs 'clone' 3 "${@}" || return 1
252 TRANSFER=$(get_selection "${TRANSFER}" "${CLONE_TRANSFERS[@]}") || return 1
253 if [ -e "${REPO}" ]; then
254 echo "ERROR: destination path (${REPO}) already exists." >&2
259 case "${TRANSFER}" in
263 "${GIT}" clone "${URL}" "${REPO}" || return 1
269 echo "PROGRAMMING ERROR: add ${TRANSFER} support to clone command" >&2
272 if [ "${CACHE_SOURCE}" = 'yes' ]; then
273 REPO_SOURCE_DATA=(['transfer']="${TRANSFER}" ['url']="${URL}")
274 set_repo_source "${REPO}" || return 1
276 if [ "${FETCH}" = 'yes' ]; then
277 fetch "${REPO}" || return 1
286 function fetch_help()
288 echo 'Get the current dotfiles from the server.'
289 if [ "${1}" = '--one-line' ]; then return; fi
293 usage: $0 ${COMMAND} [REPO]
295 Where 'REPO' is the name the dotfiles repository to fetch. If it
296 is not given, all repositories will be fetched.
302 # multi-repo case handled in main() by run_on_all_repos()
303 REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
304 maxargs 'fetch' 1 "${@}" || return 1
305 get_repo_source "${REPO}" || return 1
306 TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
307 if [ "${TRANSFER}" = 'git' ]; then
308 "${GIT}" --git-dir "${REPO}/.git" pull || return 1
309 elif [ "${TRANSFER}" = 'wget' ]; then
310 wget_fetch "${REPO}" || return 1
312 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
324 echo 'Show differences between targets and dotfiles repositories.'
325 if [ "${1}" = '--one-line' ]; then return; fi
329 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
331 Where 'REPO' is the name the dotfiles repository to query. If it
332 is not given, all repositories will be queried.
334 By default, ${COMMAND} will list differences between files that
335 exist in both the target location and the dotfiles repository (as
336 a patch that could be applied to the dotfiles source).
338 With the '--removed' option, ${COMMAND} will list files that
339 should be removed from the dotfiles source in order to match the
342 With the '--local-patch' option, ${COMMAND} will create files in
343 list files that should be removed from the dotfiles source in
344 order to match the target.
351 while [ "${1::2}" = '--' ]; do
360 echo "ERROR: invalid option to diff (${1})" >&2
365 # multi-repo case handled in main() by run_on_all_repos()
366 REPO=$(nonempty_option 'diff' 'REPO' "${1}") || return 1
367 maxargs 'diff' 1 "${@}" || return 1
369 if [ "${MODE}" = 'local-patch' ]; then
370 mkdir -p "${REPO}/local-patch" || return 1
372 exec 3<&1 # save stdout to file descriptor 3
373 echo "save local patches to ${REPO}/local-patch/000-local.patch"
374 exec 1>"${REPO}/local-patch/000-local.patch" # redirect stdout
376 exec 1<&3 # restore old stdout
377 exec 3<&- # close temporary fd 3
379 exec 3<&1 # save stdout to file descriptor 3
380 echo "save local removed to ${REPO}/local-patch/000-local.remove"
381 exec 1>"${REPO}/local-patch/000-local.remove" # redirect stdout
382 diff --removed "${REPO}"
383 exec 1<&3 # restore old stdout
384 exec 3<&- # close temporary fd 3
389 if [ "${MODE}" = 'removed' ]; then
390 if [ ! -e "${TARGET}/${FILE}" ]; then
394 if [ -f "${TARGET}/${FILE}" ]; then
395 (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
399 $(list_files "${REPO}/src")
408 function patch_help()
410 echo 'Patch a fresh checkout with local adjustments.'
411 if [ "${1}" = '--one-line' ]; then return; fi
415 usage: $0 ${COMMAND} [REPO]
417 Where 'REPO' is the name the dotfiles repository to patch. If it
418 is not given, all repositories will be patched.
424 # multi-repo case handled in main() by run_on_all_repos()
425 REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
426 maxargs 'patch' 1 "${@}" || return 1
428 echo "copy clean checkout into ${REPO}/patched-src"
429 "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
431 # apply all the patches in local-patch/
432 for FILE in "${REPO}/local-patch"/*.patch; do
433 if [ -f "${FILE}" ]; then
435 pushd "${REPO}/patched-src/" > /dev/null || return 1
436 "${PATCH}" -p0 < "../../${FILE}" || return 1
437 popd > /dev/null || return 1
441 # remove any files marked for removal in local-patch
442 for REMOVE in "${REPO}/local-patch"/*.remove; do
443 if [ -f "${REMOVE}" ]; then
445 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
446 continue # ignore blank lines and comments
448 if [ -e "${REPO}/patched-src/${LINE}" ]; then
449 echo "remove ${LINE}"
450 "${RM}" -rf "${REPO}/patched-src/${LINE}"
464 echo 'Link a fresh checkout with local adjustments.'
465 if [ "${1}" = '--one-line' ]; then return; fi
469 usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
471 Where 'REPO' is the name the dotfiles repository to link. If it
472 is not given, all repositories will be linked.
474 By default, link.sh only replaces missing files and simlinks. You
475 can optionally overwrite any local files by passing the --force
482 FORCE='no' # If 'file', overwrite existing files.
483 # If 'yes', overwrite existing files and dirs.
484 DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
486 while [ "${1::2}" = '--' ]; do
501 echo "ERROR: invalid option to link (${1})" >&2
506 # multi-repo case handled in main() by run_on_all_repos()
507 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
508 maxargs 'link' 1 "${@}" || return 1
509 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
512 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
513 continue # already simlinked
515 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
516 [ "${FORCE}" != 'yes' ]; then
517 echo "use --force to override the existing directory: ${TARGET}/${FILE}"
518 continue # allow unlinked directories
520 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
521 echo "use --force to override the existing target: ${TARGET}/${FILE}"
522 continue # target already exists
524 link_file "${REPO}" "${FILE}" || return 1
526 $(list_files "${DOTFILES_SRC}")
533 COMMANDS+=('disconnect')
535 function disconnect_help()
537 echo 'Freeze dotfiles at their current state.'
538 if [ "${1}" = '--one-line' ]; then return; fi
542 usage: $0 ${COMMAND} [REPO]
544 Where 'REPO' is the name the dotfiles repository to disconnect.
545 If it is not given, all repositories will be disconnected.
547 You're about to give your sysadmin account to some newbie, and
548 they'd just be confused by all this efficiency. This script
549 freezes your dotfiles in their current state and makes everthing
550 look normal. Note that this will delete your dotfiles repository
551 and strip the dotfiles portion from your ~/.bashrc file.
555 function disconnect()
557 # multi-repo case handled in main() by run_on_all_repos()
558 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
559 maxargs 'disconnect' 1 "${@}" || return 1
560 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
562 # See if we've constructed any patched source files that might be
563 # possible link targets
564 if [ ! -d "${DOTFILES_SRC}" ]; then
565 echo 'no installed dotfiles to disconnect'
569 # See if the bashrc file is involved with dotfiles at all
573 if [ "${FILE}" = '.bashrc' ] && [ "$TARGET" -ef "${HOME}" ]; then
576 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
578 echo "de-symlink ${TARGET}/${FILE}"
579 "${RM}" -f "${TARGET}/${FILE}"
580 "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
583 $(list_files "${REPO}/patched-src")
586 if [ "${BASHRC}" == 'yes' ]; then
587 echo 'strip dotfiles section from ~/.bashrc'
588 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
590 # see if the stripped file is any different
591 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
593 if [ "${DIFF_RC}" -eq 0 ]; then
594 echo "no dotfiles section found in ~/.bashrc"
595 "${RM}" -f bashrc_stripped
596 elif [ "${DIFF_RC}" -eq 1 ]; then
597 echo "replace ~/.bashrc with stripped version"
599 "${MV}" bashrc_stripped ~/.bashrc
601 return 1 # diff failed, bail
605 if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
606 echo "remove the ${REPO} repository"
607 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
616 function update_help()
618 echo 'Utility command that runs fetch, patch, and link.'
619 if [ "${1}" = '--one-line' ]; then return; fi
623 usage: $0 ${COMMAND} [REPO]
625 Where 'REPO' is the name the dotfiles repository to update.
626 If it is not given, all repositories will be updateed.
628 Run 'fetch', 'patch', and 'link' sequentially on each repository
629 to bring them in sync with the central repositories. Keeps track
630 of the last update time to avoid multiple fetches in the same
637 # multi-repo case handled in main() by run_on_all_repos()
638 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
639 maxargs 'disconnect' 1 "${@}" || return 1
641 # Update once a week from our remote repository. Mark updates by
642 # touching this file.
643 UPDATE_FILE="${REPO}/updated.$(date +%U)"
645 if [ ! -e "${UPDATE_FILE}" ]; then
646 echo "update ${REPO} dotfiles"
647 "${RM}" -f "${REPO}"/updated.* || return 1
648 "${TOUCH}" "${UPDATE_FILE}" || return 1
649 fetch "${REPO}" || return 1
650 patch "${REPO}" || return 1
651 link "${REPO}" || return 1
652 echo "${REPO} dotfiles updated"
661 echo 'Dotfiles management script.'
662 if [ "${1}" = '--one-line' ]; then return; fi
666 usage: $0 [OPTIONS] COMMAND [ARGS]
669 --help Print this help message and exit.
670 --version Print the $0 version and exit.
671 --dotfiles-dir DIR Directory containing the dotfiles reposotories. Defaults to '.'.
672 --target DIR Directory to install dotfiles into. Defaults to '~'.
676 for COMMAND in "${COMMANDS[@]}"; do
677 echo -en "${COMMAND}\t"
678 "${COMMAND}_help" --one-line
682 To get help on any command, pass the '--help' as the first option
683 to the command. For example:
685 ${0} ${COMMANDS[0]} --help
692 while [ "${1::2}" = '--' ]; do
695 main_help || return 1
711 echo "ERROR: invalid option to ${0} (${1})" >&2
716 COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
719 cd "${DOTFILES_DIR}" || return 1
721 if [ "${1}" = '--help' ]; then
722 "${COMMAND}_help" || return 1
723 elif [ "${COMMAND}" = 'clone' ]; then
724 "${COMMAND}" "${@}" || return 1
727 while [ "${1::2}" = '--' ]; do
731 if [ "${#}" -eq 0 ]; then
732 run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
734 maxargs "${0}" 1 "${@}" || return 1
735 "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
740 main "${@}" || exit 1