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()
79 if [ -z "${REPO}" ]; then # run on all repositories
81 if [ "${REPO}" = '*' ]; then
82 break # no known repositories
84 "${COMMAND}" "${REPO}" || return 1
92 DIR=$(nonempty_option 'list_files' 'DIR' "${1}") || return 1
94 if [ "${FILE}" = '.' ]; then
97 FILE="${FILE:2}" # strip the leading './'
99 done < <(cd "${DIR}" && find .)
102 # Global variable to allow passing associative arrats between functions
103 declare -A REPO_SOURCE_DATA
105 function set_repo_source()
107 REPO=$(nonempty_option 'set_repo_source' 'REPO' "${1}") || return 1
108 > "${REPO}/source_cache" || return 1
109 for KEY in "${!REPO_SOURCE_DATA[@]}"; do
110 echo "${KEY}=${REPO_SOURCE_DATA[${KEY}]}" >> "${REPO}/source_cache" || return 1
114 # usage: get_repo_source REPO
115 function get_repo_source()
117 REPO=$(nonempty_option 'get_repo_source' 'REPO' "${1}") || return 1
119 if [ -f "${REPO}/source_cache" ]; then
123 REPO_SOURCE_DATA["${KEY}"]="${VALUE}"
124 done < "${REPO}/source_cache"
126 # autodetect verson control system
128 REPO_SOURCE_DATA['repo']="${REPO}"
129 if [ -d "${REPO}/.git" ]; then
130 REPO_SOURCE_DATA['transfer']='git'
132 echo "ERROR: no source location found for ${REPO}" >&2
135 # no need to get further fields for these transfer mechanisms
139 function wget_fetch()
141 REPO=$(nonempty_option 'wget_fetch' 'REPO' "${1}") || return 1
142 # get_repo_source() was just called on this repo in fetch()
143 TRANSFER=$(nonempty_option 'wget_fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
144 URL=$(nonempty_option 'wget_fetch' 'URL' "${REPO_SOURCE_DATA['url']}") || return 1
145 ETAG="${REPO_SOURCE_DATA['etag']}"
147 HEAD=$("${WGET}" --server-response --spider "${URL}" 2>&1) || return 1
148 SERVER_ETAG=$(echo "${HEAD}" | "${SED}" -n 's/^ *etag: *"\(.*\)"/\1/ip') || return 1
149 if [ "${CHECK_WGET_TYPE_AND_ENCODING}" = 'yes' ]; then
150 TYPE=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-type: *//ip') || return 1
151 ENCODING=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-encoding: *//ip') || return 1
152 if [ "${TYPE}" != 'application/x-gzip' ] || [ "${ENCODING}" != 'x-gzip' ]; then
153 echo "ERROR: invalid content type (${TYPE}) or encoding (${ENCODING})." >&2
154 echo "while fetching ${URL}" >&2
158 if [ -z "${ETAG}" ] || [ "${SERVER_ETAG}" != "${ETAG}" ]; then
159 # Previous ETag not known, or ETag changed. Download new copy.
160 "${WGET}" --output-document "${BUNDLE}" "${URL}" || return 1
161 if [ -n "${SERVER_ETAG}" ]; then # store new ETag
162 REPO_SOURCE_DATA['etag']="${SERVER_ETAG}"
163 set_repo_source "${REPO}" || return 1
165 if [ -n "${ETAG}" ]; then # clear old ETag
166 unset "${REPO_SOURCE_DATA['etag']}"
167 set_repo_source "${REPO}" || return 1
170 echo "extracting ${BUNDLE} to ${REPO}"
171 "${TAR}" -xf "${BUNDLE}" -C "${REPO}" --strip-components 1 --overwrite || return 1
172 "${RM}" -f "${BUNDLE}" || return 1
174 echo "already downloaded the ETag=${ETAG} version of ${URL}"
178 # usage: link_file REPO FILE
180 # Create the symbolic link to the version of FILE in the REPO
181 # repository, overriding the target if it exists.
184 REPO=$(nonempty_option 'link_file' 'REPO' "${1}") || return 1
185 FILE=$(nonempty_option 'link_file' 'FILE' "${2}") || return 1
186 if [ "${BACKUP}" = 'yes' ]; then
187 if [ -e "${TARGET}/${FILE}" ] || [ -h "${TARGET}/${FILE}" ]; then
188 if [ "${DRY_RUN}" = 'yes' ]; then
189 echo "move ${TARGET}/${FILE} to ${TARGET}/${FILE}.bak"
192 mv -v "${TARGET}/${FILE}" "${TARGET}/${FILE}.bak" || return 1
196 if [ "${DRY_RUN}" = 'yes' ]; then
197 echo "rm ${TARGET}/${FILE}"
199 "${RM}" -fv "${TARGET}/${FILE}"
202 if [ "${DRY_RUN}" = 'yes' ]; then
203 echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
206 "${LN}" -sv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
213 # An array of available commands
221 CLONE_TRANSFERS=('git' 'wget')
223 function clone_help()
225 echo 'Create a new dotfiles repository.'
226 if [ "${1}" = '--one-line' ]; then return; fi
230 usage: $0 ${COMMAND} REPO TRANSFER URL
232 Where 'REPO' is the name the dotfiles repository to create,
233 'TRANSFER' is the transfer mechanism, and 'URL' is the URL for the
234 remote repository. Valid TRANSFERs are:
236 ${CLONE_TRANSFERS[@]}
240 $0 clone public wget http://example.com/public-dotfiles.tar.gz
241 $0 clone private git ssh://example.com/~/private-dotfiles.git
247 REPO=$(nonempty_option 'clone' 'REPO' "${1}") || return 1
248 TRANSFER=$(nonempty_option 'clone' 'TRANSFER' "${2}") || return 1
249 URL=$(nonempty_option 'clone' 'URL' "${3}") || return 1
250 maxargs 'clone' 3 "${@}" || return 1
251 TRANSFER=$(get_selection "${TRANSFER}" "${CLONE_TRANSFERS[@]}") || return 1
252 if [ -e "${REPO}" ]; then
253 echo "ERROR: destination path (${REPO}) already exists." >&2
259 case "${TRANSFER}" in
263 "${GIT}" clone "${URL}" "${REPO}" || return 1
268 echo "PROGRAMMING ERROR: add ${TRANSFER} support to clone command" >&2
271 if [ "${CACHE_SOURCE}" = 'yes' ]; then
272 REPO_SOURCE_DATA=(['transfer']="${TRANSFER}" ['url']="${URL}")
273 set_repo_source "${REPO}" || return 1
275 if [ "${FETCH}" = 'yes' ]; then
276 fetch "${REPO}" || return 1
285 function fetch_help()
287 echo 'Get the current dotfiles from the server.'
288 if [ "${1}" = '--one-line' ]; then return; fi
292 usage: $0 ${COMMAND} [REPO]
294 Where 'REPO' is the name the dotfiles repository to fetch. If it
295 is not given, all repositories will be fetched.
301 # multi-repo case handled in main() by run_on_all_repos()
302 REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
303 maxargs 'fetch' 1 "${@}" || return 1
304 get_repo_source "${REPO}" || return 1
305 TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
306 if [ "${TRANSFER}" = 'git' ]; then
307 "${GIT}" --git-dir "${REPO}/.git" pull || return 1
308 elif [ "${TRANSFER}" = 'wget' ]; then
309 wget_fetch "${REPO}" || return 1
311 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
323 echo 'Show differences between targets and dotfiles repositories.'
324 if [ "${1}" = '--one-line' ]; then return; fi
328 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
330 Where 'REPO' is the name the dotfiles repository to query. If it
331 is not given, all repositories will be queried.
333 By default, ${COMMAND} will list differences between files that
334 exist in both the target location and the dotfiles repository (as
335 a patch that could be applied to the dotfiles source).
337 With the '--removed' option, ${COMMAND} will list files that
338 should be removed from the dotfiles source in order to match the
341 With the '--local-patch' option, ${COMMAND} will create files in
342 list files that should be removed from the dotfiles source in
343 order to match the target.
350 while [ "${1::2}" = '--' ]; do
359 echo "ERROR: invalid option to diff (${1})" >&2
364 # multi-repo case handled in main() by run_on_all_repos()
365 REPO=$(nonempty_option 'diff' 'REPO' "${1}") || return 1
366 maxargs 'diff' 1 "${@}" || return 1
368 if [ "${MODE}" = 'local-patch' ]; then
369 mkdir -p "${REPO}/local-patch" || return 1
371 exec 3<&1 # save stdout to file descriptor 3
372 echo "save local patches to ${REPO}/local-patch/000-local.patch"
373 exec 1>"${REPO}/local-patch/000-local.patch" # redirect stdout
375 exec 1<&3 # restore old stdout
376 exec 3<&- # close temporary fd 3
378 exec 3<&1 # save stdout to file descriptor 3
379 echo "save local removed to ${REPO}/local-patch/000-local.remove"
380 exec 1>"${REPO}/local-patch/000-local.remove" # redirect stdout
381 diff "${REPO}" --removed
382 exec 1<&3 # restore old stdout
383 exec 3<&- # close temporary fd 3
388 if [ "${MODE}" = 'removed' ]; then
389 if [ ! -e "${TARGET}/${FILE}" ]; then
393 if [ -f "${TARGET}/${FILE}" ]; then
394 (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
398 $(list_files "${REPO}/src")
407 function patch_help()
409 echo 'Patch a fresh checkout with local adjustments.'
410 if [ "${1}" = '--one-line' ]; then return; fi
414 usage: $0 ${COMMAND} [REPO]
416 Where 'REPO' is the name the dotfiles repository to patch. If it
417 is not given, all repositories will be patched.
423 # multi-repo case handled in main() by run_on_all_repos()
424 REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
425 maxargs 'patch' 1 "${@}" || return 1
427 echo "copy clean checkout into ${REPO}/patched-src"
428 "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
430 # apply all the patches in local-patch/
431 for FILE in "${REPO}/local-patch"/*.patch; do
432 if [ -f "${FILE}" ]; then
434 pushd "${REPO}/patched-src/" > /dev/null || return 1
435 "${PATCH}" -p0 < "../../${FILE}" || return 1
436 popd > /dev/null || return 1
440 # remove any files marked for removal in local-patch
441 for REMOVE in "${REPO}/local-patch"/*.remove; do
442 if [ -f "${REMOVE}" ]; then
444 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
445 continue # ignore blank lines and comments
447 if [ -e "${REPO}/patched-src/${LINE}" ]; then
448 echo "remove ${LINE}"
449 "${RM}" -rf "${REPO}/patched-src/${LINE}"
463 echo 'Link a fresh checkout with local adjustments.'
464 if [ "${1}" = '--one-line' ]; then return; fi
468 usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
470 Where 'REPO' is the name the dotfiles repository to link. If it
471 is not given, all repositories will be linked.
473 By default, link.sh only replaces missing files and simlinks. You
474 can optionally overwrite any local files by passing the --force
481 FORCE='no' # If 'file', overwrite existing files.
482 # If 'yes', overwrite existing files and dirs.
483 DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
485 while [ "${1::2}" = '--' ]; do
500 echo "ERROR: invalid option to link (${1})" >&2
505 # multi-repo case handled in main() by run_on_all_repos()
506 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
507 maxargs 'link' 1 "${@}" || return 1
508 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
511 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
512 continue # already simlinked
514 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
515 [ "${FORCE}" != 'yes' ]; then
516 echo "use --force to override the existing directory: ${TARGET}/${FILE}"
517 continue # allow unlinked directories
519 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
520 echo "use --force to override the existing target: ${TARGET}/${FILE}"
521 continue # target already exists
523 link_file "${REPO}" "${FILE}" || return 1
525 $(list_files "${DOTFILES_SRC}")
532 COMMANDS+=('disconnect')
534 function disconnect_help()
536 echo 'Freeze dotfiles at their current state.'
537 if [ "${1}" = '--one-line' ]; then return; fi
541 usage: $0 ${COMMAND} [REPO]
543 Where 'REPO' is the name the dotfiles repository to disconnect.
544 If it is not given, all repositories will be disconnected.
546 You're about to give your sysadmin account to some newbie, and
547 they'd just be confused by all this efficiency. This script
548 freezes your dotfiles in their current state and makes everthing
549 look normal. Note that this will delete your dotfiles repository
550 and strip the dotfiles portion from your ~/.bashrc file.
554 function disconnect()
556 # multi-repo case handled in main() by run_on_all_repos()
557 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
558 maxargs 'disconnect' 1 "${@}" || return 1
559 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
561 # See if we've constructed any patched source files that might be
562 # possible link targets
563 if [ ! -d "${DOTFILES_SRC}" ]; then
564 echo 'no installed dotfiles to disconnect'
568 # See if the bashrc file is involved with dotfiles at all
572 if [ "${FILE}" = '.bashrc' ] && [ "$TARGET" -ef "${HOME}" ]; then
575 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
577 echo "de-symlink ${TARGET}/${FILE}"
578 "${RM}" -f "${TARGET}/${FILE}"
579 "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
582 $(list_files "${REPO}/patched-src")
585 if [ "${BASHRC}" == 'yes' ]; then
586 echo 'strip dotfiles section from ~/.bashrc'
587 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
589 # see if the stripped file is any different
590 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
592 if [ "${DIFF_RC}" -eq 0 ]; then
593 echo "no dotfiles section found in ~/.bashrc"
594 "${RM}" -f bashrc_stripped
595 elif [ "${DIFF_RC}" -eq 1 ]; then
596 echo "replace ~/.bashrc with stripped version"
598 "${MV}" bashrc_stripped ~/.bashrc
600 return 1 # diff failed, bail
604 if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
605 echo "remove the ${REPO} repository"
606 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
615 function update_help()
617 echo 'Utility command that runs fetch, patch, and link.'
618 if [ "${1}" = '--one-line' ]; then return; fi
622 usage: $0 ${COMMAND} [REPO]
624 Where 'REPO' is the name the dotfiles repository to update.
625 If it is not given, all repositories will be updateed.
627 Run 'fetch', 'patch', and 'link' sequentially on each repository
628 to bring them in sync with the central repositories. Keeps track
629 of the last update time to avoid multiple fetches in the same
636 # multi-repo case handled in main() by run_on_all_repos()
637 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
638 maxargs 'disconnect' 1 "${@}" || return 1
640 # Update once a week from our remote repository. Mark updates by
641 # touching this file.
642 UPDATE_FILE="${REPO}/updated.$(date +%U)"
644 if [ ! -e "${UPDATE_FILE}" ]; then
645 echo "update ${REPO} dotfiles"
646 "${RM}" -f "${REPO}"/updated.* || return 1
647 "${TOUCH}" "${UPDATE_FILE}" || return 1
648 fetch "${REPO}" || return 1
649 patch "${REPO}" || return 1
650 link "${REPO}" || return 1
651 echo "${REPO} dotfiles updated"
660 echo 'Dotfiles management script.'
661 if [ "${1}" = '--one-line' ]; then return; fi
665 usage: $0 [OPTIONS] COMMAND [ARGS]
668 --help Print this help message and exit.
669 --version Print the $0 version and exit.
670 --dotfiles-dir DIR Directory containing the dotfiles reposotories. Defaults to '.'.
671 --target DIR Directory to install dotfiles into. Defaults to '~'.
675 for COMMAND in "${COMMANDS[@]}"; do
676 echo -en "${COMMAND}\t"
677 "${COMMAND}_help" --one-line
681 To get help on any command, pass the '--help' as the first option
682 to the command. For example:
684 ${0} ${COMMANDS[0]} --help
691 while [ "${1::2}" = '--' ]; do
694 main_help || return 1
710 echo "ERROR: invalid option to ${0} (${1})" >&2
715 COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
718 cd "${DOTFILES_DIR}" || return 1
720 if [ "${1}" = '--help' ]; then
721 "${COMMAND}_help" || return 1
722 elif [ "${COMMAND}" = 'clone' ]; then
723 "${COMMAND}" "${@}" || return 1
726 while [ "${1::2}" = '--' ]; do
730 if [ "${#}" -eq 0 ]; then
731 run_on_all_repos "${COMMAND}" "$OPTIONS[@]" || return 1
733 maxargs "${0}" 1 "${@}" || return 1
734 "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
739 main "${@}" || exit 1