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
117 declare -A REPO_SOURCE_DATA
119 function set_repo_source()
121 REPO=$(nonempty_option 'set_repo_source' 'REPO' "${1}") || return 1
122 > "${REPO}/source_cache" || return 1
123 for KEY in "${!REPO_SOURCE_DATA[@]}"; do
124 echo "${KEY}=${REPO_SOURCE_DATA[${KEY}]}" >> "${REPO}/source_cache" || return 1
128 # usage: get_repo_source REPO
129 function get_repo_source()
131 REPO=$(nonempty_option 'get_repo_source' 'REPO' "${1}") || return 1
133 if [ -f "${REPO}/source_cache" ]; then
137 REPO_SOURCE_DATA["${KEY}"]="${VALUE}"
138 done < "${REPO}/source_cache"
140 # autodetect verson control system
142 REPO_SOURCE_DATA['repo']="${REPO}"
143 if [ -d "${REPO}/.git" ]; then
144 REPO_SOURCE_DATA['transfer']='git'
146 echo "ERROR: no source location found for ${REPO}" >&2
149 # no need to get further fields for these transfer mechanisms
153 function wget_fetch()
155 if [ "${BASH_MAJOR}" -lt 4 ]; then
156 echo "ERROR: ${0} requires Bash version >= 4.0 for wget support" >&2
157 echo "you're running ${BASH}, which doesn't support associative arrays" >&2
161 REPO=$(nonempty_option 'wget_fetch' 'REPO' "${1}") || return 1
162 # get_repo_source() was just called on this repo in fetch()
163 TRANSFER=$(nonempty_option 'wget_fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
164 URL=$(nonempty_option 'wget_fetch' 'URL' "${REPO_SOURCE_DATA['url']}") || return 1
165 ETAG="${REPO_SOURCE_DATA['etag']}"
167 HEAD=$("${WGET}" --server-response --spider "${URL}" 2>&1) || return 1
168 SERVER_ETAG=$(echo "${HEAD}" | "${SED}" -n 's/^ *etag: *"\(.*\)"/\1/ip') || return 1
169 if [ "${CHECK_WGET_TYPE_AND_ENCODING}" = 'yes' ]; then
170 TYPE=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-type: *//ip') || return 1
171 ENCODING=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-encoding: *//ip') || return 1
172 if [ "${TYPE}" != 'application/x-gzip' ] || [ "${ENCODING}" != 'x-gzip' ]; then
173 echo "ERROR: invalid content type (${TYPE}) or encoding (${ENCODING})." >&2
174 echo "while fetching ${URL}" >&2
178 if [ -z "${ETAG}" ] || [ "${SERVER_ETAG}" != "${ETAG}" ]; then
179 # Previous ETag not known, or ETag changed. Download new copy.
180 "${WGET}" --output-document "${BUNDLE}" "${URL}" || return 1
181 if [ -n "${SERVER_ETAG}" ]; then # store new ETag
182 REPO_SOURCE_DATA['etag']="${SERVER_ETAG}"
183 set_repo_source "${REPO}" || return 1
185 if [ -n "${ETAG}" ]; then # clear old ETag
186 unset "${REPO_SOURCE_DATA['etag']}"
187 set_repo_source "${REPO}" || return 1
190 echo "extracting ${BUNDLE} to ${REPO}"
191 "${TAR}" -xf "${BUNDLE}" -C "${REPO}" --strip-components 1 --overwrite || return 1
192 "${RM}" -f "${BUNDLE}" || return 1
194 echo "already downloaded the ETag=${ETAG} version of ${URL}"
198 # usage: link_file REPO FILE
200 # Create the symbolic link to the version of FILE in the REPO
201 # repository, overriding the target if it exists.
204 REPO=$(nonempty_option 'link_file' 'REPO' "${1}") || return 1
205 FILE=$(nonempty_option 'link_file' 'FILE' "${2}") || return 1
206 if [ "${BACKUP}" = 'yes' ]; then
207 if [ -e "${TARGET}/${FILE}" ] || [ -h "${TARGET}/${FILE}" ]; then
208 if [ "${DRY_RUN}" = 'yes' ]; then
209 echo "move ${TARGET}/${FILE} to ${TARGET}/${FILE}.bak"
212 mv -v "${TARGET}/${FILE}" "${TARGET}/${FILE}.bak" || return 1
216 if [ "${DRY_RUN}" = 'yes' ]; then
217 echo "rm ${TARGET}/${FILE}"
219 "${RM}" -fv "${TARGET}/${FILE}"
222 if [ "${DRY_RUN}" = 'yes' ]; then
223 echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
226 "${LN}" -sv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
233 # An array of available commands
241 CLONE_TRANSFERS=('git' 'wget')
243 function clone_help()
245 echo 'Create a new dotfiles repository.'
246 if [ "${1}" = '--one-line' ]; then return; fi
250 usage: $0 ${COMMAND} REPO TRANSFER URL
252 Where 'REPO' is the name the dotfiles repository to create,
253 'TRANSFER' is the transfer mechanism, and 'URL' is the URL for the
254 remote repository. Valid TRANSFERs are:
256 ${CLONE_TRANSFERS[@]}
260 $0 clone public wget http://example.com/public-dotfiles.tar.gz
261 $0 clone private git ssh://example.com/~/private-dotfiles.git
267 REPO=$(nonempty_option 'clone' 'REPO' "${1}") || return 1
268 TRANSFER=$(nonempty_option 'clone' 'TRANSFER' "${2}") || return 1
269 URL=$(nonempty_option 'clone' 'URL' "${3}") || return 1
270 maxargs 'clone' 3 "${@}" || return 1
271 TRANSFER=$(get_selection "${TRANSFER}" "${CLONE_TRANSFERS[@]}") || return 1
272 if [ -e "${REPO}" ]; then
273 echo "ERROR: destination path (${REPO}) already exists." >&2
278 case "${TRANSFER}" in
282 "${GIT}" clone "${URL}" "${REPO}" || return 1
288 echo "PROGRAMMING ERROR: add ${TRANSFER} support to clone command" >&2
291 if [ "${CACHE_SOURCE}" = 'yes' ]; then
292 REPO_SOURCE_DATA=(['transfer']="${TRANSFER}" ['url']="${URL}")
293 set_repo_source "${REPO}" || return 1
295 if [ "${FETCH}" = 'yes' ]; then
296 fetch "${REPO}" || return 1
305 function fetch_help()
307 echo 'Get the current dotfiles from the server.'
308 if [ "${1}" = '--one-line' ]; then return; fi
312 usage: $0 ${COMMAND} [REPO]
314 Where 'REPO' is the name the dotfiles repository to fetch. If it
315 is not given, all repositories will be fetched.
321 # multi-repo case handled in main() by run_on_all_repos()
322 REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
323 maxargs 'fetch' 1 "${@}" || return 1
324 get_repo_source "${REPO}" || return 1
325 TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
326 if [ "${TRANSFER}" = 'git' ]; then
327 (cd "${REPO}" && "${GIT}" pull) || return 1
328 elif [ "${TRANSFER}" = 'wget' ]; then
329 wget_fetch "${REPO}" || return 1
331 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
343 echo 'Show differences between targets and dotfiles repositories.'
344 if [ "${1}" = '--one-line' ]; then return; fi
348 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
350 Where 'REPO' is the name the dotfiles repository to query. If it
351 is not given, all repositories will be queried.
353 By default, ${COMMAND} will list differences between files that
354 exist in both the target location and the dotfiles repository (as
355 a patch that could be applied to the dotfiles source).
357 With the '--removed' option, ${COMMAND} will list files that
358 should be removed from the dotfiles source in order to match the
361 With the '--local-patch' option, ${COMMAND} will create files in
362 list files that should be removed from the dotfiles source in
363 order to match the target.
370 while [ "${1::2}" = '--' ]; do
379 echo "ERROR: invalid option to diff (${1})" >&2
384 # multi-repo case handled in main() by run_on_all_repos()
385 REPO=$(nonempty_option 'diff' 'REPO' "${1}") || return 1
386 maxargs 'diff' 1 "${@}" || return 1
388 if [ "${MODE}" = 'local-patch' ]; then
389 mkdir -p "${REPO}/local-patch" || return 1
391 exec 3<&1 # save stdout to file descriptor 3
392 echo "save local patches to ${REPO}/local-patch/000-local.patch"
393 exec 1>"${REPO}/local-patch/000-local.patch" # redirect stdout
395 exec 1<&3 # restore old stdout
396 exec 3<&- # close temporary fd 3
398 exec 3<&1 # save stdout to file descriptor 3
399 echo "save local removed to ${REPO}/local-patch/000-local.remove"
400 exec 1>"${REPO}/local-patch/000-local.remove" # redirect stdout
401 diff --removed "${REPO}"
402 exec 1<&3 # restore old stdout
403 exec 3<&- # close temporary fd 3
408 if [ "${MODE}" = 'removed' ]; then
409 if [ ! -e "${TARGET}/${FILE}" ]; then
413 if [ -f "${TARGET}/${FILE}" ]; then
414 (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
418 $(list_files "${REPO}/src")
427 function patch_help()
429 echo 'Patch a fresh checkout with local adjustments.'
430 if [ "${1}" = '--one-line' ]; then return; fi
434 usage: $0 ${COMMAND} [REPO]
436 Where 'REPO' is the name the dotfiles repository to patch. If it
437 is not given, all repositories will be patched.
443 # multi-repo case handled in main() by run_on_all_repos()
444 REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
445 maxargs 'patch' 1 "${@}" || return 1
447 echo "copy clean checkout into ${REPO}/patched-src"
448 "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
450 # apply all the patches in local-patch/
451 for FILE in "${REPO}/local-patch"/*.patch; do
452 if [ -f "${FILE}" ]; then
454 pushd "${REPO}/patched-src/" > /dev/null || return 1
455 "${PATCH}" -p0 < "../../${FILE}" || return 1
456 popd > /dev/null || return 1
460 # remove any files marked for removal in local-patch
461 for REMOVE in "${REPO}/local-patch"/*.remove; do
462 if [ -f "${REMOVE}" ]; then
465 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
466 continue # ignore blank lines and comments
468 if [ -e "${REPO}/patched-src/${LINE}" ]; then
469 echo "remove ${LINE}"
470 "${RM}" -rf "${REPO}/patched-src/${LINE}"
484 echo 'Link a fresh checkout with local adjustments.'
485 if [ "${1}" = '--one-line' ]; then return; fi
489 usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
491 Where 'REPO' is the name the dotfiles repository to link. If it
492 is not given, all repositories will be linked.
494 By default, link.sh only replaces missing files and simlinks. You
495 can optionally overwrite any local files by passing the --force
502 FORCE='no' # If 'file', overwrite existing files.
503 # If 'yes', overwrite existing files and dirs.
504 DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
506 while [ "${1::2}" = '--' ]; do
521 echo "ERROR: invalid option to link (${1})" >&2
526 # multi-repo case handled in main() by run_on_all_repos()
527 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
528 maxargs 'link' 1 "${@}" || return 1
529 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
532 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
533 continue # already simlinked
535 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
536 [ "${FORCE}" != 'yes' ]; then
537 echo "use --force to override the existing directory: ${TARGET}/${FILE}"
538 continue # allow unlinked directories
540 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
541 echo "use --force to override the existing target: ${TARGET}/${FILE}"
542 continue # target already exists
544 link_file "${REPO}" "${FILE}" || return 1
546 $(list_files "${DOTFILES_SRC}")
553 COMMANDS+=('disconnect')
555 function disconnect_help()
557 echo 'Freeze dotfiles at their current state.'
558 if [ "${1}" = '--one-line' ]; then return; fi
562 usage: $0 ${COMMAND} [REPO]
564 Where 'REPO' is the name the dotfiles repository to disconnect.
565 If it is not given, all repositories will be disconnected.
567 You're about to give your sysadmin account to some newbie, and
568 they'd just be confused by all this efficiency. This script
569 freezes your dotfiles in their current state and makes everthing
570 look normal. Note that this will delete your dotfiles repository
571 and strip the dotfiles portion from your ~/.bashrc file.
575 function disconnect()
577 # multi-repo case handled in main() by run_on_all_repos()
578 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
579 maxargs 'disconnect' 1 "${@}" || return 1
580 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
582 # See if we've constructed any patched source files that might be
583 # possible link targets
584 if [ ! -d "${DOTFILES_SRC}" ]; then
585 echo 'no installed dotfiles to disconnect'
589 # See if the bashrc file is involved with dotfiles at all
593 if [ "${FILE}" = '.bashrc' ] && [ "$TARGET" -ef "${HOME}" ]; then
596 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
598 echo "de-symlink ${TARGET}/${FILE}"
599 "${RM}" -f "${TARGET}/${FILE}"
600 "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
603 $(list_files "${REPO}/patched-src")
606 if [ "${BASHRC}" == 'yes' ]; then
607 echo 'strip dotfiles section from ~/.bashrc'
608 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
610 # see if the stripped file is any different
611 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
613 if [ "${DIFF_RC}" -eq 0 ]; then
614 echo "no dotfiles section found in ~/.bashrc"
615 "${RM}" -f bashrc_stripped
616 elif [ "${DIFF_RC}" -eq 1 ]; then
617 echo "replace ~/.bashrc with stripped version"
619 "${MV}" bashrc_stripped ~/.bashrc
621 return 1 # diff failed, bail
625 if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
626 echo "remove the ${REPO} repository"
627 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
636 function update_help()
638 echo 'Utility command that runs fetch, patch, and link.'
639 if [ "${1}" = '--one-line' ]; then return; fi
643 usage: $0 ${COMMAND} [REPO]
645 Where 'REPO' is the name the dotfiles repository to update.
646 If it is not given, all repositories will be updateed.
648 Run 'fetch', 'patch', and 'link' sequentially on each repository
649 to bring them in sync with the central repositories. Keeps track
650 of the last update time to avoid multiple fetches in the same
657 # multi-repo case handled in main() by run_on_all_repos()
658 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
659 maxargs 'disconnect' 1 "${@}" || return 1
661 # Update once a week from our remote repository. Mark updates by
662 # touching this file.
663 UPDATE_FILE="${REPO}/updated.$(date +%U)"
665 if [ ! -e "${UPDATE_FILE}" ]; then
666 echo "update ${REPO} dotfiles"
667 "${RM}" -f "${REPO}"/updated.* || return 1
668 "${TOUCH}" "${UPDATE_FILE}" || return 1
669 fetch "${REPO}" || return 1
670 patch "${REPO}" || return 1
671 link "${REPO}" || return 1
672 echo "${REPO} dotfiles updated"
681 echo 'Dotfiles management script.'
682 if [ "${1}" = '--one-line' ]; then return; fi
686 usage: $0 [OPTIONS] COMMAND [ARGS]
689 --help Print this help message and exit.
690 --version Print the $0 version and exit.
691 --dotfiles-dir DIR Directory containing the dotfiles reposotories. Defaults to '.'.
692 --target DIR Directory to install dotfiles into. Defaults to '~'.
696 for COMMAND in "${COMMANDS[@]}"; do
697 echo -en "${COMMAND}\t"
698 "${COMMAND}_help" --one-line
702 To get help on any command, pass the '--help' as the first option
703 to the command. For example:
705 ${0} ${COMMANDS[0]} --help
712 while [ "${1::2}" = '--' ]; do
715 main_help || return 1
731 echo "ERROR: invalid option to ${0} (${1})" >&2
736 COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
739 cd "${DOTFILES_DIR}" || return 1
741 if [ "${1}" = '--help' ]; then
742 "${COMMAND}_help" || return 1
743 elif [ "${COMMAND}" = 'clone' ]; then
744 "${COMMAND}" "${@}" || return 1
747 while [ "${1::2}" = '--' ]; do
751 if [ "${#}" -eq 0 ]; then
752 run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
754 maxargs "${0}" 1 "${@}" || return 1
755 "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
760 main "${@}" || exit 1