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 REPO=$(nonempty_option 'set_repo_source' 'REPO' "${1}") || return 1
125 > "${REPO}/source_cache" || return 1
126 for KEY in "${!REPO_SOURCE_DATA[@]}"; do
127 echo "${KEY}=${REPO_SOURCE_DATA[${KEY}]}" >> "${REPO}/source_cache" || return 1
131 # usage: get_repo_source REPO
132 function get_repo_source()
134 REPO=$(nonempty_option 'get_repo_source' 'REPO' "${1}") || return 1
136 if [ -f "${REPO}/source_cache" ]; then
140 REPO_SOURCE_DATA["${KEY}"]="${VALUE}"
141 done < "${REPO}/source_cache"
143 # autodetect verson control system
145 REPO_SOURCE_DATA['repo']="${REPO}"
146 if [ -d "${REPO}/.git" ]; then
147 REPO_SOURCE_DATA['transfer']='git'
149 echo "ERROR: no source location found for ${REPO}" >&2
152 # no need to get further fields for these transfer mechanisms
156 function wget_fetch()
158 if [ "${BASH_MAJOR}" -lt 4 ]; then
159 echo "ERROR: ${0} requires Bash version >= 4.0 for wget support" >&2
160 echo "you're running ${BASH}, which doesn't support associative arrays" >&2
164 REPO=$(nonempty_option 'wget_fetch' 'REPO' "${1}") || return 1
165 # get_repo_source() was just called on this repo in fetch()
166 TRANSFER=$(nonempty_option 'wget_fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
167 URL=$(nonempty_option 'wget_fetch' 'URL' "${REPO_SOURCE_DATA['url']}") || return 1
168 ETAG="${REPO_SOURCE_DATA['etag']}"
170 HEAD=$("${WGET}" --server-response --spider "${URL}" 2>&1) || return 1
171 SERVER_ETAG=$(echo "${HEAD}" | "${SED}" -n 's/^ *etag: *"\(.*\)"/\1/ip') || return 1
172 if [ "${CHECK_WGET_TYPE_AND_ENCODING}" = 'yes' ]; then
173 TYPE=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-type: *//ip') || return 1
174 ENCODING=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-encoding: *//ip') || return 1
175 if [ "${TYPE}" != 'application/x-gzip' ] || [ "${ENCODING}" != 'x-gzip' ]; then
176 echo "ERROR: invalid content type (${TYPE}) or encoding (${ENCODING})." >&2
177 echo "while fetching ${URL}" >&2
181 if [ -z "${ETAG}" ] || [ "${SERVER_ETAG}" != "${ETAG}" ]; then
182 # Previous ETag not known, or ETag changed. Download new copy.
183 "${WGET}" --output-document "${BUNDLE}" "${URL}" || return 1
184 if [ -n "${SERVER_ETAG}" ]; then # store new ETag
185 REPO_SOURCE_DATA['etag']="${SERVER_ETAG}"
186 set_repo_source "${REPO}" || return 1
188 if [ -n "${ETAG}" ]; then # clear old ETag
189 unset "${REPO_SOURCE_DATA['etag']}"
190 set_repo_source "${REPO}" || return 1
193 echo "extracting ${BUNDLE} to ${REPO}"
194 "${TAR}" -xf "${BUNDLE}" -C "${REPO}" --strip-components 1 --overwrite || return 1
195 "${RM}" -f "${BUNDLE}" || return 1
197 echo "already downloaded the ETag=${ETAG} version of ${URL}"
201 # usage: link_file REPO FILE
203 # Create the symbolic link to the version of FILE in the REPO
204 # repository, overriding the target if it exists.
207 REPO=$(nonempty_option 'link_file' 'REPO' "${1}") || return 1
208 FILE=$(nonempty_option 'link_file' 'FILE' "${2}") || return 1
209 if [ "${BACKUP}" = 'yes' ]; then
210 if [ -e "${TARGET}/${FILE}" ] || [ -h "${TARGET}/${FILE}" ]; then
211 if [ "${DRY_RUN}" = 'yes' ]; then
212 echo "move ${TARGET}/${FILE} to ${TARGET}/${FILE}.bak"
215 mv -v "${TARGET}/${FILE}" "${TARGET}/${FILE}.bak" || return 1
219 if [ "${DRY_RUN}" = 'yes' ]; then
220 echo "rm ${TARGET}/${FILE}"
222 "${RM}" -fv "${TARGET}/${FILE}"
225 if [ "${DRY_RUN}" = 'yes' ]; then
226 echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
229 "${LN}" -sv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
236 # An array of available commands
244 CLONE_TRANSFERS=('git' 'wget')
246 function clone_help()
248 echo 'Create a new dotfiles repository.'
249 if [ "${1}" = '--one-line' ]; then return; fi
253 usage: $0 ${COMMAND} REPO TRANSFER URL
255 Where 'REPO' is the name the dotfiles repository to create,
256 'TRANSFER' is the transfer mechanism, and 'URL' is the URL for the
257 remote repository. Valid TRANSFERs are:
259 ${CLONE_TRANSFERS[@]}
263 $0 clone public wget http://example.com/public-dotfiles.tar.gz
264 $0 clone private git ssh://example.com/~/private-dotfiles.git
270 REPO=$(nonempty_option 'clone' 'REPO' "${1}") || return 1
271 TRANSFER=$(nonempty_option 'clone' 'TRANSFER' "${2}") || return 1
272 URL=$(nonempty_option 'clone' 'URL' "${3}") || return 1
273 maxargs 'clone' 3 "${@}" || return 1
274 TRANSFER=$(get_selection "${TRANSFER}" "${CLONE_TRANSFERS[@]}") || return 1
275 if [ -e "${REPO}" ]; then
276 echo "ERROR: destination path (${REPO}) already exists." >&2
281 case "${TRANSFER}" in
285 "${GIT}" clone "${URL}" "${REPO}" || return 1
291 echo "PROGRAMMING ERROR: add ${TRANSFER} support to clone command" >&2
294 if [ "${CACHE_SOURCE}" = 'yes' ]; then
295 REPO_SOURCE_DATA=(['transfer']="${TRANSFER}" ['url']="${URL}")
296 set_repo_source "${REPO}" || return 1
298 if [ "${FETCH}" = 'yes' ]; then
299 fetch "${REPO}" || return 1
308 function fetch_help()
310 echo 'Get the current dotfiles from the server.'
311 if [ "${1}" = '--one-line' ]; then return; fi
315 usage: $0 ${COMMAND} [REPO]
317 Where 'REPO' is the name the dotfiles repository to fetch. If it
318 is not given, all repositories will be fetched.
324 # multi-repo case handled in main() by run_on_all_repos()
325 REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
326 maxargs 'fetch' 1 "${@}" || return 1
327 get_repo_source "${REPO}" || return 1
328 TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
329 if [ "${TRANSFER}" = 'git' ]; then
330 (cd "${REPO}" && "${GIT}" pull) || return 1
331 elif [ "${TRANSFER}" = 'wget' ]; then
332 wget_fetch "${REPO}" || return 1
334 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
346 echo 'Show differences between targets and dotfiles repositories.'
347 if [ "${1}" = '--one-line' ]; then return; fi
351 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
353 Where 'REPO' is the name the dotfiles repository to query. If it
354 is not given, all repositories will be queried.
356 By default, ${COMMAND} will list differences between files that
357 exist in both the target location and the dotfiles repository (as
358 a patch that could be applied to the dotfiles source).
360 With the '--removed' option, ${COMMAND} will list files that
361 should be removed from the dotfiles source in order to match the
364 With the '--local-patch' option, ${COMMAND} will create files in
365 list files that should be removed from the dotfiles source in
366 order to match the target.
373 while [ "${1::2}" = '--' ]; do
382 echo "ERROR: invalid option to diff (${1})" >&2
387 # multi-repo case handled in main() by run_on_all_repos()
388 REPO=$(nonempty_option 'diff' 'REPO' "${1}") || return 1
389 maxargs 'diff' 1 "${@}" || return 1
391 if [ "${MODE}" = 'local-patch' ]; then
392 mkdir -p "${REPO}/local-patch" || return 1
394 exec 3<&1 # save stdout to file descriptor 3
395 echo "save local patches to ${REPO}/local-patch/000-local.patch"
396 exec 1>"${REPO}/local-patch/000-local.patch" # redirect stdout
398 exec 1<&3 # restore old stdout
399 exec 3<&- # close temporary fd 3
401 exec 3<&1 # save stdout to file descriptor 3
402 echo "save local removed to ${REPO}/local-patch/000-local.remove"
403 exec 1>"${REPO}/local-patch/000-local.remove" # redirect stdout
404 diff --removed "${REPO}"
405 exec 1<&3 # restore old stdout
406 exec 3<&- # close temporary fd 3
411 if [ "${MODE}" = 'removed' ]; then
412 if [ ! -e "${TARGET}/${FILE}" ]; then
416 if [ -f "${TARGET}/${FILE}" ]; then
417 (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
421 $(list_files "${REPO}/src")
430 function patch_help()
432 echo 'Patch a fresh checkout with local adjustments.'
433 if [ "${1}" = '--one-line' ]; then return; fi
437 usage: $0 ${COMMAND} [REPO]
439 Where 'REPO' is the name the dotfiles repository to patch. If it
440 is not given, all repositories will be patched.
446 # multi-repo case handled in main() by run_on_all_repos()
447 REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
448 maxargs 'patch' 1 "${@}" || return 1
450 echo "copy clean checkout into ${REPO}/patched-src"
451 "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
453 # apply all the patches in local-patch/
454 for FILE in "${REPO}/local-patch"/*.patch; do
455 if [ -f "${FILE}" ]; then
457 pushd "${REPO}/patched-src/" > /dev/null || return 1
458 "${PATCH}" -p0 < "../../${FILE}" || return 1
459 popd > /dev/null || return 1
463 # remove any files marked for removal in local-patch
464 for REMOVE in "${REPO}/local-patch"/*.remove; do
465 if [ -f "${REMOVE}" ]; then
468 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
469 continue # ignore blank lines and comments
471 if [ -e "${REPO}/patched-src/${LINE}" ]; then
472 echo "remove ${LINE}"
473 "${RM}" -rf "${REPO}/patched-src/${LINE}"
487 echo 'Link a fresh checkout with local adjustments.'
488 if [ "${1}" = '--one-line' ]; then return; fi
492 usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
494 Where 'REPO' is the name the dotfiles repository to link. If it
495 is not given, all repositories will be linked.
497 By default, link.sh only replaces missing files and simlinks. You
498 can optionally overwrite any local files by passing the --force
505 FORCE='no' # If 'file', overwrite existing files.
506 # If 'yes', overwrite existing files and dirs.
507 DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
509 while [ "${1::2}" = '--' ]; do
524 echo "ERROR: invalid option to link (${1})" >&2
529 # multi-repo case handled in main() by run_on_all_repos()
530 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
531 maxargs 'link' 1 "${@}" || return 1
532 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
535 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
536 continue # already simlinked
538 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
539 [ "${FORCE}" != 'yes' ]; then
540 echo "use --force to override the existing directory: ${TARGET}/${FILE}"
541 continue # allow unlinked directories
543 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
544 echo "use --force to override the existing target: ${TARGET}/${FILE}"
545 continue # target already exists
547 link_file "${REPO}" "${FILE}" || return 1
549 $(list_files "${DOTFILES_SRC}")
556 COMMANDS+=('disconnect')
558 function disconnect_help()
560 echo 'Freeze dotfiles at their current state.'
561 if [ "${1}" = '--one-line' ]; then return; fi
565 usage: $0 ${COMMAND} [REPO]
567 Where 'REPO' is the name the dotfiles repository to disconnect.
568 If it is not given, all repositories will be disconnected.
570 You're about to give your sysadmin account to some newbie, and
571 they'd just be confused by all this efficiency. This script
572 freezes your dotfiles in their current state and makes everthing
573 look normal. Note that this will delete your dotfiles repository
574 and strip the dotfiles portion from your ~/.bashrc file.
578 function disconnect()
580 # multi-repo case handled in main() by run_on_all_repos()
581 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
582 maxargs 'disconnect' 1 "${@}" || return 1
583 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
585 # See if we've constructed any patched source files that might be
586 # possible link targets
587 if [ ! -d "${DOTFILES_SRC}" ]; then
588 echo 'no installed dotfiles to disconnect'
592 # See if the bashrc file is involved with dotfiles at all
596 if [ "${FILE}" = '.bashrc' ] && [ "$TARGET" -ef "${HOME}" ]; then
599 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
601 echo "de-symlink ${TARGET}/${FILE}"
602 "${RM}" -f "${TARGET}/${FILE}"
603 "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
606 $(list_files "${REPO}/patched-src")
609 if [ "${BASHRC}" == 'yes' ]; then
610 echo 'strip dotfiles section from ~/.bashrc'
611 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
613 # see if the stripped file is any different
614 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
616 if [ "${DIFF_RC}" -eq 0 ]; then
617 echo "no dotfiles section found in ~/.bashrc"
618 "${RM}" -f bashrc_stripped
619 elif [ "${DIFF_RC}" -eq 1 ]; then
620 echo "replace ~/.bashrc with stripped version"
622 "${MV}" bashrc_stripped ~/.bashrc
624 return 1 # diff failed, bail
628 if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
629 echo "remove the ${REPO} repository"
630 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
639 function update_help()
641 echo 'Utility command that runs fetch, patch, and link.'
642 if [ "${1}" = '--one-line' ]; then return; fi
646 usage: $0 ${COMMAND} [REPO]
648 Where 'REPO' is the name the dotfiles repository to update.
649 If it is not given, all repositories will be updateed.
651 Run 'fetch', 'patch', and 'link' sequentially on each repository
652 to bring them in sync with the central repositories. Keeps track
653 of the last update time to avoid multiple fetches in the same
660 # multi-repo case handled in main() by run_on_all_repos()
661 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
662 maxargs 'disconnect' 1 "${@}" || return 1
664 # Update once a week from our remote repository. Mark updates by
665 # touching this file.
666 UPDATE_FILE="${REPO}/updated.$(date +%U)"
668 if [ ! -e "${UPDATE_FILE}" ]; then
669 echo "update ${REPO} dotfiles"
670 "${RM}" -f "${REPO}"/updated.* || return 1
671 "${TOUCH}" "${UPDATE_FILE}" || return 1
672 fetch "${REPO}" || return 1
673 patch "${REPO}" || return 1
674 link "${REPO}" || return 1
675 echo "${REPO} dotfiles updated"
684 echo 'Dotfiles management script.'
685 if [ "${1}" = '--one-line' ]; then return; fi
689 usage: $0 [OPTIONS] COMMAND [ARGS]
692 --help Print this help message and exit.
693 --version Print the $0 version and exit.
694 --dotfiles-dir DIR Directory containing the dotfiles reposotories. Defaults to '.'.
695 --target DIR Directory to install dotfiles into. Defaults to '~'.
699 for COMMAND in "${COMMANDS[@]}"; do
700 echo -en "${COMMAND}\t"
701 "${COMMAND}_help" --one-line
705 To get help on any command, pass the '--help' as the first option
706 to the command. For example:
708 ${0} ${COMMANDS[0]} --help
715 while [ "${1::2}" = '--' ]; do
718 main_help || return 1
734 echo "ERROR: invalid option to ${0} (${1})" >&2
739 COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
742 cd "${DOTFILES_DIR}" || return 1
744 if [ "${1}" = '--help' ]; then
745 "${COMMAND}_help" || return 1
746 elif [ "${COMMAND}" = 'clone' ]; then
747 "${COMMAND}" "${@}" || return 1
750 while [ "${1::2}" = '--' ]; do
754 if [ "${#}" -eq 0 ]; then
755 run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
757 maxargs "${0}" 1 "${@}" || return 1
758 "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
763 main "${@}" || exit 1