3 # Dotfiles management script. For details, run
9 CHECK_WGET_TYPE_AND_ENCODING='no'
27 # Compatibility checks
29 BASH="${BASH_VERSION%.*}"
30 BASH_MAJOR="${BASH%.*}"
31 BASH_MINOR="${BASH#*.}"
33 if [ "${BASH_MAJOR}" -eq 3 ] && [ "${BASH_MINOR}" -eq 0 ]; then
34 echo "ERROR: ${0} requires Bash version >= 3.1" >&2
35 echo "you're running ${BASH}, which doesn't support += array assignment" >&2
42 # usage: nonempty_option LOC NAME VALUE
43 function nonempty_option()
48 if [ -z "${VALUE}" ]; then
49 echo "ERROR: empty value for ${NAME} in ${LOC}" >&2
55 # usage: maxargs LOC MAX "${@}"
57 # Print and error and return 1 if there are more than MAX arguments.
63 if [ "${#}" -gt "${MAX}" ]; then
64 echo "ERROR: too many arguments (${#} > ${MAX}) in ${LOC}" >&2
69 # usage: get_selection CHOICE OPTION ...
71 # Check that CHOICE is one of the valid options listed in OPTION. If
72 # it is, echo the choice and return 0, otherwise print an error to
73 # stderr and return 1.
74 function get_selection()
79 if [ "${OPT}" = "${CHOICE}" ]; then
84 echo "ERROR: invalid selection (${CHOICE})" >&2
85 echo "valid choices: ${@}" >&2
89 function run_on_all_repos()
93 if [ -z "${REPO}" ]; then # run on all repositories
95 if [ "${REPO}" = '*' ]; then
96 break # no known repositories
98 "${COMMAND}" "${@}" "${REPO}" || return 1
104 function list_files()
106 DIR=$(nonempty_option 'list_files' 'DIR' "${1}") || return 1
108 if [ "${FILE}" = '.' ]; then
111 FILE="${FILE:2}" # strip the leading './'
113 done < <(cd "${DIR}" && find .)
116 # Global variable to allow passing associative arrats between functions
118 if [ "${BASH_MAJOR}" -ge 4 ]; then
119 declare -A REPO_SOURCE_DATA
122 function set_repo_source()
124 if [ "${BASH_MAJOR}" -lt 4 ]; then
125 echo "ERROR: ${0} requires Bash version >= 4.0 for source_cache" >&2
126 echo "you're running ${BASH}, which doesn't support associative arrays" >&2
129 REPO=$(nonempty_option 'set_repo_source' 'REPO' "${1}") || return 1
130 > "${REPO}/source_cache" || return 1
131 for KEY in "${!REPO_SOURCE_DATA[@]}"; do
132 echo "${KEY}=${REPO_SOURCE_DATA[${KEY}]}" >> "${REPO}/source_cache" || return 1
136 # usage: get_repo_source REPO
137 function get_repo_source()
139 if [ "${BASH_MAJOR}" -lt 4 ]; then
140 echo "ERROR: ${0} requires Bash version >= 4.0 for source_cache support" >&2
141 echo "you're running ${BASH}, which doesn't support associative arrays" >&2
144 REPO=$(nonempty_option 'get_repo_source' 'REPO' "${1}") || return 1
146 if [ -f "${REPO}/source_cache" ]; then
150 REPO_SOURCE_DATA["${KEY}"]="${VALUE}"
151 done < "${REPO}/source_cache"
153 # autodetect verson control system
155 REPO_SOURCE_DATA['repo']="${REPO}"
156 if [ -d "${REPO}/.git" ]; then
157 REPO_SOURCE_DATA['transfer']='git'
159 echo "ERROR: no source location found for ${REPO}" >&2
162 # no need to get further fields for these transfer mechanisms
166 function wget_fetch()
168 REPO=$(nonempty_option 'wget_fetch' 'REPO' "${1}") || return 1
169 # get_repo_source() was just called on this repo in fetch()
170 TRANSFER=$(nonempty_option 'wget_fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
171 URL=$(nonempty_option 'wget_fetch' 'URL' "${REPO_SOURCE_DATA['url']}") || return 1
172 ETAG="${REPO_SOURCE_DATA['etag']}"
174 HEAD=$("${WGET}" --server-response --spider "${URL}" 2>&1) || return 1
175 SERVER_ETAG=$(echo "${HEAD}" | "${SED}" -n 's/^ *etag: *"\(.*\)"/\1/ip') || return 1
176 if [ "${CHECK_WGET_TYPE_AND_ENCODING}" = 'yes' ]; then
177 TYPE=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-type: *//ip') || return 1
178 ENCODING=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-encoding: *//ip') || return 1
179 if [ "${TYPE}" != 'application/x-gzip' ] || [ "${ENCODING}" != 'x-gzip' ]; then
180 echo "ERROR: invalid content type (${TYPE}) or encoding (${ENCODING})." >&2
181 echo "while fetching ${URL}" >&2
185 if [ -z "${ETAG}" ] || [ "${SERVER_ETAG}" != "${ETAG}" ]; then
186 # Previous ETag not known, or ETag changed. Download new copy.
187 "${WGET}" --output-document "${BUNDLE}" "${URL}" || return 1
188 if [ -n "${SERVER_ETAG}" ]; then # store new ETag
189 REPO_SOURCE_DATA['etag']="${SERVER_ETAG}"
190 set_repo_source "${REPO}" || return 1
192 if [ -n "${ETAG}" ]; then # clear old ETag
193 unset "${REPO_SOURCE_DATA['etag']}"
194 set_repo_source "${REPO}" || return 1
197 echo "extracting ${BUNDLE} to ${REPO}"
198 "${TAR}" -xf "${BUNDLE}" -C "${REPO}" --strip-components 1 --overwrite || return 1
199 "${RM}" -f "${BUNDLE}" || return 1
201 echo "already downloaded the ETag=${ETAG} version of ${URL}"
205 # usage: link_file REPO FILE
207 # Create the symbolic link to the version of FILE in the REPO
208 # repository, overriding the target if it exists.
211 REPO=$(nonempty_option 'link_file' 'REPO' "${1}") || return 1
212 FILE=$(nonempty_option 'link_file' 'FILE' "${2}") || return 1
213 if [ "${BACKUP}" = 'yes' ]; then
214 if [ -e "${TARGET}/${FILE}" ] || [ -h "${TARGET}/${FILE}" ]; then
215 if [ "${DRY_RUN}" = 'yes' ]; then
216 echo "move ${TARGET}/${FILE} to ${TARGET}/${FILE}.bak"
219 mv -v "${TARGET}/${FILE}" "${TARGET}/${FILE}.bak" || return 1
223 if [ "${DRY_RUN}" = 'yes' ]; then
224 echo "rm ${TARGET}/${FILE}"
226 "${RM}" -fv "${TARGET}/${FILE}"
229 if [ "${DRY_RUN}" = 'yes' ]; then
230 echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
233 "${LN}" -sv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
240 # An array of available commands
248 CLONE_TRANSFERS=('git' 'wget')
250 function clone_help()
252 echo 'Create a new dotfiles repository.'
253 if [ "${1}" = '--one-line' ]; then return; fi
257 usage: $0 ${COMMAND} REPO TRANSFER URL
259 Where 'REPO' is the name the dotfiles repository to create,
260 'TRANSFER' is the transfer mechanism, and 'URL' is the URL for the
261 remote repository. Valid TRANSFERs are:
263 ${CLONE_TRANSFERS[@]}
267 $0 clone public wget http://example.com/public-dotfiles.tar.gz
268 $0 clone private git ssh://example.com/~/private-dotfiles.git
274 REPO=$(nonempty_option 'clone' 'REPO' "${1}") || return 1
275 TRANSFER=$(nonempty_option 'clone' 'TRANSFER' "${2}") || return 1
276 URL=$(nonempty_option 'clone' 'URL' "${3}") || return 1
277 maxargs 'clone' 3 "${@}" || return 1
278 TRANSFER=$(get_selection "${TRANSFER}" "${CLONE_TRANSFERS[@]}") || return 1
279 if [ -e "${REPO}" ]; then
280 echo "ERROR: destination path (${REPO}) already exists." >&2
285 case "${TRANSFER}" in
289 "${GIT}" clone "${URL}" "${REPO}" || return 1
295 echo "PROGRAMMING ERROR: add ${TRANSFER} support to clone command" >&2
298 if [ "${CACHE_SOURCE}" = 'yes' ]; then
299 REPO_SOURCE_DATA=(['transfer']="${TRANSFER}" ['url']="${URL}")
300 set_repo_source "${REPO}" || return 1
302 if [ "${FETCH}" = 'yes' ]; then
303 fetch "${REPO}" || return 1
312 function fetch_help()
314 echo 'Get the current dotfiles from the server.'
315 if [ "${1}" = '--one-line' ]; then return; fi
319 usage: $0 ${COMMAND} [REPO]
321 Where 'REPO' is the name the dotfiles repository to fetch. If it
322 is not given, all repositories will be fetched.
328 # multi-repo case handled in main() by run_on_all_repos()
329 REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
330 maxargs 'fetch' 1 "${@}" || return 1
331 get_repo_source "${REPO}" || return 1
332 TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
333 if [ "${TRANSFER}" = 'git' ]; then
334 (cd "${REPO}" && "${GIT}" pull) || return 1
335 elif [ "${TRANSFER}" = 'wget' ]; then
336 wget_fetch "${REPO}" || return 1
338 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
350 echo 'Show differences between targets and dotfiles repositories.'
351 if [ "${1}" = '--one-line' ]; then return; fi
355 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
357 Where 'REPO' is the name the dotfiles repository to query. If it
358 is not given, all repositories will be queried.
360 By default, ${COMMAND} will list differences between files that
361 exist in both the target location and the dotfiles repository (as
362 a patch that could be applied to the dotfiles source).
364 With the '--removed' option, ${COMMAND} will list files that
365 should be removed from the dotfiles source in order to match the
368 With the '--local-patch' option, ${COMMAND} will create files in
369 list files that should be removed from the dotfiles source in
370 order to match the target.
377 while [ "${1::2}" = '--' ]; do
386 echo "ERROR: invalid option to diff (${1})" >&2
391 # multi-repo case handled in main() by run_on_all_repos()
392 REPO=$(nonempty_option 'diff' 'REPO' "${1}") || return 1
393 maxargs 'diff' 1 "${@}" || return 1
395 if [ "${MODE}" = 'local-patch' ]; then
396 mkdir -p "${REPO}/local-patch" || return 1
398 exec 3<&1 # save stdout to file descriptor 3
399 echo "save local patches to ${REPO}/local-patch/000-local.patch"
400 exec 1>"${REPO}/local-patch/000-local.patch" # redirect stdout
402 exec 1<&3 # restore old stdout
403 exec 3<&- # close temporary fd 3
405 exec 3<&1 # save stdout to file descriptor 3
406 echo "save local removed to ${REPO}/local-patch/000-local.remove"
407 exec 1>"${REPO}/local-patch/000-local.remove" # redirect stdout
408 diff --removed "${REPO}"
409 exec 1<&3 # restore old stdout
410 exec 3<&- # close temporary fd 3
415 if [ "${MODE}" = 'removed' ]; then
416 if [ ! -e "${TARGET}/${FILE}" ]; then
420 if [ -f "${TARGET}/${FILE}" ]; then
421 (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
425 $(list_files "${REPO}/src")
434 function patch_help()
436 echo 'Patch a fresh checkout with local adjustments.'
437 if [ "${1}" = '--one-line' ]; then return; fi
441 usage: $0 ${COMMAND} [REPO]
443 Where 'REPO' is the name the dotfiles repository to patch. If it
444 is not given, all repositories will be patched.
450 # multi-repo case handled in main() by run_on_all_repos()
451 REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
452 maxargs 'patch' 1 "${@}" || return 1
454 echo "copy clean checkout into ${REPO}/patched-src"
455 "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
457 # apply all the patches in local-patch/
458 for FILE in "${REPO}/local-patch"/*.patch; do
459 if [ -f "${FILE}" ]; then
461 pushd "${REPO}/patched-src/" > /dev/null || return 1
462 "${PATCH}" -p0 < "../../${FILE}" || return 1
463 popd > /dev/null || return 1
467 # remove any files marked for removal in local-patch
468 for REMOVE in "${REPO}/local-patch"/*.remove; do
469 if [ -f "${REMOVE}" ]; then
472 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
473 continue # ignore blank lines and comments
475 if [ -e "${REPO}/patched-src/${LINE}" ]; then
476 echo "remove ${LINE}"
477 "${RM}" -rf "${REPO}/patched-src/${LINE}"
491 echo 'Link a fresh checkout with local adjustments.'
492 if [ "${1}" = '--one-line' ]; then return; fi
496 usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
498 Where 'REPO' is the name the dotfiles repository to link. If it
499 is not given, all repositories will be linked.
501 By default, link.sh only replaces missing files and simlinks. You
502 can optionally overwrite any local files by passing the --force
509 FORCE='no' # If 'file', overwrite existing files.
510 # If 'yes', overwrite existing files and dirs.
511 DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
513 while [ "${1::2}" = '--' ]; do
528 echo "ERROR: invalid option to link (${1})" >&2
533 # multi-repo case handled in main() by run_on_all_repos()
534 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
535 maxargs 'link' 1 "${@}" || return 1
536 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
539 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
540 continue # already simlinked
542 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
543 [ "${FORCE}" != 'yes' ]; then
544 echo "use --force to override the existing directory: ${TARGET}/${FILE}"
545 continue # allow unlinked directories
547 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
548 echo "use --force to override the existing target: ${TARGET}/${FILE}"
549 continue # target already exists
551 link_file "${REPO}" "${FILE}" || return 1
553 $(list_files "${DOTFILES_SRC}")
560 COMMANDS+=('disconnect')
562 function disconnect_help()
564 echo 'Freeze dotfiles at their current state.'
565 if [ "${1}" = '--one-line' ]; then return; fi
569 usage: $0 ${COMMAND} [REPO]
571 Where 'REPO' is the name the dotfiles repository to disconnect.
572 If it is not given, all repositories will be disconnected.
574 You're about to give your sysadmin account to some newbie, and
575 they'd just be confused by all this efficiency. This script
576 freezes your dotfiles in their current state and makes everthing
577 look normal. Note that this will delete your dotfiles repository
578 and strip the dotfiles portion from your ~/.bashrc file.
582 function disconnect()
584 # multi-repo case handled in main() by run_on_all_repos()
585 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
586 maxargs 'disconnect' 1 "${@}" || return 1
587 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
589 # See if we've constructed any patched source files that might be
590 # possible link targets
591 if [ ! -d "${DOTFILES_SRC}" ]; then
592 echo 'no installed dotfiles to disconnect'
596 # See if the bashrc file is involved with dotfiles at all
600 if [ "${FILE}" = '.bashrc' ] && [ "$TARGET" -ef "${HOME}" ]; then
603 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
605 echo "de-symlink ${TARGET}/${FILE}"
606 "${RM}" -f "${TARGET}/${FILE}"
607 "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
610 $(list_files "${REPO}/patched-src")
613 if [ "${BASHRC}" == 'yes' ]; then
614 echo 'strip dotfiles section from ~/.bashrc'
615 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
617 # see if the stripped file is any different
618 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
620 if [ "${DIFF_RC}" -eq 0 ]; then
621 echo "no dotfiles section found in ~/.bashrc"
622 "${RM}" -f bashrc_stripped
623 elif [ "${DIFF_RC}" -eq 1 ]; then
624 echo "replace ~/.bashrc with stripped version"
626 "${MV}" bashrc_stripped ~/.bashrc
628 return 1 # diff failed, bail
632 if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
633 echo "remove the ${REPO} repository"
634 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
643 function update_help()
645 echo 'Utility command that runs fetch, patch, and link.'
646 if [ "${1}" = '--one-line' ]; then return; fi
650 usage: $0 ${COMMAND} [REPO]
652 Where 'REPO' is the name the dotfiles repository to update.
653 If it is not given, all repositories will be updateed.
655 Run 'fetch', 'patch', and 'link' sequentially on each repository
656 to bring them in sync with the central repositories. Keeps track
657 of the last update time to avoid multiple fetches in the same
664 # multi-repo case handled in main() by run_on_all_repos()
665 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
666 maxargs 'disconnect' 1 "${@}" || return 1
668 # Update once a week from our remote repository. Mark updates by
669 # touching this file.
670 UPDATE_FILE="${REPO}/updated.$(date +%U)"
672 if [ ! -e "${UPDATE_FILE}" ]; then
673 echo "update ${REPO} dotfiles"
674 "${RM}" -f "${REPO}"/updated.* || return 1
675 "${TOUCH}" "${UPDATE_FILE}" || return 1
676 fetch "${REPO}" || return 1
677 patch "${REPO}" || return 1
678 link "${REPO}" || return 1
679 echo "${REPO} dotfiles updated"
688 echo 'Dotfiles management script.'
689 if [ "${1}" = '--one-line' ]; then return; fi
693 usage: $0 [OPTIONS] COMMAND [ARGS]
696 --help Print this help message and exit.
697 --version Print the $0 version and exit.
698 --dotfiles-dir DIR Directory containing the dotfiles reposotories. Defaults to '.'.
699 --target DIR Directory to install dotfiles into. Defaults to '~'.
703 for COMMAND in "${COMMANDS[@]}"; do
704 echo -en "${COMMAND}\t"
705 "${COMMAND}_help" --one-line
709 To get help on any command, pass the '--help' as the first option
710 to the command. For example:
712 ${0} ${COMMANDS[0]} --help
719 while [ "${1::2}" = '--' ]; do
722 main_help || return 1
738 echo "ERROR: invalid option to ${0} (${1})" >&2
743 COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
746 cd "${DOTFILES_DIR}" || return 1
748 if [ "${1}" = '--help' ]; then
749 "${COMMAND}_help" || return 1
750 elif [ "${COMMAND}" = 'clone' ]; then
751 "${COMMAND}" "${@}" || return 1
754 while [ "${1::2}" = '--' ]; do
758 if [ "${#}" -eq 0 ]; then
759 run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
761 maxargs "${0}" 1 "${@}" || return 1
762 "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
767 main "${@}" || exit 1