3 # Dotfiles management script. For details, run
9 CHECK_WGET_TYPE_AND_ENCODING='no'
29 BASH="${BASH_VERSION%.*}"
30 BASH_MAJOR="${BASH%.*}"
31 BASH_MINOR="${BASH#*.}"
33 # usage: nonempty_option LOC NAME VALUE
34 function nonempty_option()
39 if [ -z "${VALUE}" ]; then
40 echo "ERROR: empty value for ${NAME} in ${LOC}" >&2
46 # usage: maxargs LOC MAX "${@}"
48 # Print and error and return 1 if there are more than MAX arguments.
54 if [ "${#}" -gt "${MAX}" ]; then
55 echo "ERROR: too many arguments (${#} > ${MAX}) in ${LOC}" >&2
60 # usage: get_selection CHOICE OPTION ...
62 # Check that CHOICE is one of the valid options listed in OPTION. If
63 # it is, echo the choice and return 0, otherwise print an error to
64 # stderr and return 1.
65 function get_selection()
70 if [ "${OPT}" = "${CHOICE}" ]; then
75 echo "ERROR: invalid selection (${CHOICE})" >&2
76 echo "valid choices: ${@}" >&2
80 function run_on_all_repos()
84 if [ -z "${REPO}" ]; then # run on all repositories
86 if [ "${REPO}" = '*' ]; then
87 break # no known repositories
89 "${COMMAND}" "${@}" "${REPO}" || return 1
97 DIR=$(nonempty_option 'list_files' 'DIR' "${1}") || return 1
99 if [ "${FILE}" = '.' ]; then
102 FILE="${FILE:2}" # strip the leading './'
104 done < <(cd "${DIR}" && find .)
107 # Global variable to allow passing associative arrats between functions
108 declare -A REPO_SOURCE_DATA
110 function set_repo_source()
112 REPO=$(nonempty_option 'set_repo_source' 'REPO' "${1}") || return 1
113 > "${REPO}/source_cache" || return 1
114 for KEY in "${!REPO_SOURCE_DATA[@]}"; do
115 echo "${KEY}=${REPO_SOURCE_DATA[${KEY}]}" >> "${REPO}/source_cache" || return 1
119 # usage: get_repo_source REPO
120 function get_repo_source()
122 REPO=$(nonempty_option 'get_repo_source' 'REPO' "${1}") || return 1
124 if [ -f "${REPO}/source_cache" ]; then
128 REPO_SOURCE_DATA["${KEY}"]="${VALUE}"
129 done < "${REPO}/source_cache"
131 # autodetect verson control system
133 REPO_SOURCE_DATA['repo']="${REPO}"
134 if [ -d "${REPO}/.git" ]; then
135 REPO_SOURCE_DATA['transfer']='git'
137 echo "ERROR: no source location found for ${REPO}" >&2
140 # no need to get further fields for these transfer mechanisms
144 function wget_fetch()
146 if [ "${BASH_MAJOR}" -lt 4 ]; then
147 echo "ERROR: ${0} requires Bash version >= 4.0 for wget support" >&2
148 echo "you're running ${BASH}, which doesn't support associative arrays" >&2
152 REPO=$(nonempty_option 'wget_fetch' 'REPO' "${1}") || return 1
153 # get_repo_source() was just called on this repo in fetch()
154 TRANSFER=$(nonempty_option 'wget_fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
155 URL=$(nonempty_option 'wget_fetch' 'URL' "${REPO_SOURCE_DATA['url']}") || return 1
156 ETAG="${REPO_SOURCE_DATA['etag']}"
158 HEAD=$("${WGET}" --server-response --spider "${URL}" 2>&1) || return 1
159 SERVER_ETAG=$(echo "${HEAD}" | "${SED}" -n 's/^ *etag: *"\(.*\)"/\1/ip') || return 1
160 if [ "${CHECK_WGET_TYPE_AND_ENCODING}" = 'yes' ]; then
161 TYPE=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-type: *//ip') || return 1
162 ENCODING=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-encoding: *//ip') || return 1
163 if [ "${TYPE}" != 'application/x-gzip' ] || [ "${ENCODING}" != 'x-gzip' ]; then
164 echo "ERROR: invalid content type (${TYPE}) or encoding (${ENCODING})." >&2
165 echo "while fetching ${URL}" >&2
169 if [ -z "${ETAG}" ] || [ "${SERVER_ETAG}" != "${ETAG}" ]; then
170 # Previous ETag not known, or ETag changed. Download new copy.
171 "${WGET}" --output-document "${BUNDLE}" "${URL}" || return 1
172 if [ -n "${SERVER_ETAG}" ]; then # store new ETag
173 REPO_SOURCE_DATA['etag']="${SERVER_ETAG}"
174 set_repo_source "${REPO}" || return 1
176 if [ -n "${ETAG}" ]; then # clear old ETag
177 unset "${REPO_SOURCE_DATA['etag']}"
178 set_repo_source "${REPO}" || return 1
181 echo "extracting ${BUNDLE} to ${REPO}"
182 "${TAR}" -xf "${BUNDLE}" -C "${REPO}" --strip-components 1 --overwrite || return 1
183 "${RM}" -f "${BUNDLE}" || return 1
185 echo "already downloaded the ETag=${ETAG} version of ${URL}"
189 # usage: link_file REPO FILE
191 # Create the symbolic link to the version of FILE in the REPO
192 # repository, overriding the target if it exists.
195 REPO=$(nonempty_option 'link_file' 'REPO' "${1}") || return 1
196 FILE=$(nonempty_option 'link_file' 'FILE' "${2}") || return 1
197 if [ "${BACKUP}" = 'yes' ]; then
198 if [ -e "${TARGET}/${FILE}" ] || [ -h "${TARGET}/${FILE}" ]; then
199 if [ "${DRY_RUN}" = 'yes' ]; then
200 echo "move ${TARGET}/${FILE} to ${TARGET}/${FILE}.bak"
203 mv -v "${TARGET}/${FILE}" "${TARGET}/${FILE}.bak" || return 1
207 if [ "${DRY_RUN}" = 'yes' ]; then
208 echo "rm ${TARGET}/${FILE}"
210 "${RM}" -fv "${TARGET}/${FILE}"
213 if [ "${DRY_RUN}" = 'yes' ]; then
214 echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
217 "${LN}" -sv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
224 # An array of available commands
232 CLONE_TRANSFERS=('git' 'wget')
234 function clone_help()
236 echo 'Create a new dotfiles repository.'
237 if [ "${1}" = '--one-line' ]; then return; fi
241 usage: $0 ${COMMAND} REPO TRANSFER URL
243 Where 'REPO' is the name the dotfiles repository to create,
244 'TRANSFER' is the transfer mechanism, and 'URL' is the URL for the
245 remote repository. Valid TRANSFERs are:
247 ${CLONE_TRANSFERS[@]}
251 $0 clone public wget http://example.com/public-dotfiles.tar.gz
252 $0 clone private git ssh://example.com/~/private-dotfiles.git
258 REPO=$(nonempty_option 'clone' 'REPO' "${1}") || return 1
259 TRANSFER=$(nonempty_option 'clone' 'TRANSFER' "${2}") || return 1
260 URL=$(nonempty_option 'clone' 'URL' "${3}") || return 1
261 maxargs 'clone' 3 "${@}" || return 1
262 TRANSFER=$(get_selection "${TRANSFER}" "${CLONE_TRANSFERS[@]}") || return 1
263 if [ -e "${REPO}" ]; then
264 echo "ERROR: destination path (${REPO}) already exists." >&2
269 case "${TRANSFER}" in
273 "${GIT}" clone "${URL}" "${REPO}" || return 1
279 echo "PROGRAMMING ERROR: add ${TRANSFER} support to clone command" >&2
282 if [ "${CACHE_SOURCE}" = 'yes' ]; then
283 REPO_SOURCE_DATA=(['transfer']="${TRANSFER}" ['url']="${URL}")
284 set_repo_source "${REPO}" || return 1
286 if [ "${FETCH}" = 'yes' ]; then
287 fetch "${REPO}" || return 1
296 function fetch_help()
298 echo 'Get the current dotfiles from the server.'
299 if [ "${1}" = '--one-line' ]; then return; fi
303 usage: $0 ${COMMAND} [REPO]
305 Where 'REPO' is the name the dotfiles repository to fetch. If it
306 is not given, all repositories will be fetched.
312 # multi-repo case handled in main() by run_on_all_repos()
313 REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
314 maxargs 'fetch' 1 "${@}" || return 1
315 get_repo_source "${REPO}" || return 1
316 TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
317 if [ "${TRANSFER}" = 'git' ]; then
318 "${GIT}" --git-dir "${REPO}/.git" pull || return 1
319 elif [ "${TRANSFER}" = 'wget' ]; then
320 wget_fetch "${REPO}" || return 1
322 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
334 echo 'Show differences between targets and dotfiles repositories.'
335 if [ "${1}" = '--one-line' ]; then return; fi
339 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
341 Where 'REPO' is the name the dotfiles repository to query. If it
342 is not given, all repositories will be queried.
344 By default, ${COMMAND} will list differences between files that
345 exist in both the target location and the dotfiles repository (as
346 a patch that could be applied to the dotfiles source).
348 With the '--removed' option, ${COMMAND} will list files that
349 should be removed from the dotfiles source in order to match the
352 With the '--local-patch' option, ${COMMAND} will create files in
353 list files that should be removed from the dotfiles source in
354 order to match the target.
361 while [ "${1::2}" = '--' ]; do
370 echo "ERROR: invalid option to diff (${1})" >&2
375 # multi-repo case handled in main() by run_on_all_repos()
376 REPO=$(nonempty_option 'diff' 'REPO' "${1}") || return 1
377 maxargs 'diff' 1 "${@}" || return 1
379 if [ "${MODE}" = 'local-patch' ]; then
380 mkdir -p "${REPO}/local-patch" || return 1
382 exec 3<&1 # save stdout to file descriptor 3
383 echo "save local patches to ${REPO}/local-patch/000-local.patch"
384 exec 1>"${REPO}/local-patch/000-local.patch" # redirect stdout
386 exec 1<&3 # restore old stdout
387 exec 3<&- # close temporary fd 3
389 exec 3<&1 # save stdout to file descriptor 3
390 echo "save local removed to ${REPO}/local-patch/000-local.remove"
391 exec 1>"${REPO}/local-patch/000-local.remove" # redirect stdout
392 diff --removed "${REPO}"
393 exec 1<&3 # restore old stdout
394 exec 3<&- # close temporary fd 3
399 if [ "${MODE}" = 'removed' ]; then
400 if [ ! -e "${TARGET}/${FILE}" ]; then
404 if [ -f "${TARGET}/${FILE}" ]; then
405 (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
409 $(list_files "${REPO}/src")
418 function patch_help()
420 echo 'Patch a fresh checkout with local adjustments.'
421 if [ "${1}" = '--one-line' ]; then return; fi
425 usage: $0 ${COMMAND} [REPO]
427 Where 'REPO' is the name the dotfiles repository to patch. If it
428 is not given, all repositories will be patched.
434 # multi-repo case handled in main() by run_on_all_repos()
435 REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
436 maxargs 'patch' 1 "${@}" || return 1
438 echo "copy clean checkout into ${REPO}/patched-src"
439 "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
441 # apply all the patches in local-patch/
442 for FILE in "${REPO}/local-patch"/*.patch; do
443 if [ -f "${FILE}" ]; then
445 pushd "${REPO}/patched-src/" > /dev/null || return 1
446 "${PATCH}" -p0 < "../../${FILE}" || return 1
447 popd > /dev/null || return 1
451 # remove any files marked for removal in local-patch
452 for REMOVE in "${REPO}/local-patch"/*.remove; do
453 if [ -f "${REMOVE}" ]; then
455 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
456 continue # ignore blank lines and comments
458 if [ -e "${REPO}/patched-src/${LINE}" ]; then
459 echo "remove ${LINE}"
460 "${RM}" -rf "${REPO}/patched-src/${LINE}"
474 echo 'Link a fresh checkout with local adjustments.'
475 if [ "${1}" = '--one-line' ]; then return; fi
479 usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
481 Where 'REPO' is the name the dotfiles repository to link. If it
482 is not given, all repositories will be linked.
484 By default, link.sh only replaces missing files and simlinks. You
485 can optionally overwrite any local files by passing the --force
492 FORCE='no' # If 'file', overwrite existing files.
493 # If 'yes', overwrite existing files and dirs.
494 DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
496 while [ "${1::2}" = '--' ]; do
511 echo "ERROR: invalid option to link (${1})" >&2
516 # multi-repo case handled in main() by run_on_all_repos()
517 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
518 maxargs 'link' 1 "${@}" || return 1
519 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
522 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
523 continue # already simlinked
525 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
526 [ "${FORCE}" != 'yes' ]; then
527 echo "use --force to override the existing directory: ${TARGET}/${FILE}"
528 continue # allow unlinked directories
530 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
531 echo "use --force to override the existing target: ${TARGET}/${FILE}"
532 continue # target already exists
534 link_file "${REPO}" "${FILE}" || return 1
536 $(list_files "${DOTFILES_SRC}")
543 COMMANDS+=('disconnect')
545 function disconnect_help()
547 echo 'Freeze dotfiles at their current state.'
548 if [ "${1}" = '--one-line' ]; then return; fi
552 usage: $0 ${COMMAND} [REPO]
554 Where 'REPO' is the name the dotfiles repository to disconnect.
555 If it is not given, all repositories will be disconnected.
557 You're about to give your sysadmin account to some newbie, and
558 they'd just be confused by all this efficiency. This script
559 freezes your dotfiles in their current state and makes everthing
560 look normal. Note that this will delete your dotfiles repository
561 and strip the dotfiles portion from your ~/.bashrc file.
565 function disconnect()
567 # multi-repo case handled in main() by run_on_all_repos()
568 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
569 maxargs 'disconnect' 1 "${@}" || return 1
570 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
572 # See if we've constructed any patched source files that might be
573 # possible link targets
574 if [ ! -d "${DOTFILES_SRC}" ]; then
575 echo 'no installed dotfiles to disconnect'
579 # See if the bashrc file is involved with dotfiles at all
583 if [ "${FILE}" = '.bashrc' ] && [ "$TARGET" -ef "${HOME}" ]; then
586 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
588 echo "de-symlink ${TARGET}/${FILE}"
589 "${RM}" -f "${TARGET}/${FILE}"
590 "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
593 $(list_files "${REPO}/patched-src")
596 if [ "${BASHRC}" == 'yes' ]; then
597 echo 'strip dotfiles section from ~/.bashrc'
598 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
600 # see if the stripped file is any different
601 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
603 if [ "${DIFF_RC}" -eq 0 ]; then
604 echo "no dotfiles section found in ~/.bashrc"
605 "${RM}" -f bashrc_stripped
606 elif [ "${DIFF_RC}" -eq 1 ]; then
607 echo "replace ~/.bashrc with stripped version"
609 "${MV}" bashrc_stripped ~/.bashrc
611 return 1 # diff failed, bail
615 if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
616 echo "remove the ${REPO} repository"
617 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
626 function update_help()
628 echo 'Utility command that runs fetch, patch, and link.'
629 if [ "${1}" = '--one-line' ]; then return; fi
633 usage: $0 ${COMMAND} [REPO]
635 Where 'REPO' is the name the dotfiles repository to update.
636 If it is not given, all repositories will be updateed.
638 Run 'fetch', 'patch', and 'link' sequentially on each repository
639 to bring them in sync with the central repositories. Keeps track
640 of the last update time to avoid multiple fetches in the same
647 # multi-repo case handled in main() by run_on_all_repos()
648 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
649 maxargs 'disconnect' 1 "${@}" || return 1
651 # Update once a week from our remote repository. Mark updates by
652 # touching this file.
653 UPDATE_FILE="${REPO}/updated.$(date +%U)"
655 if [ ! -e "${UPDATE_FILE}" ]; then
656 echo "update ${REPO} dotfiles"
657 "${RM}" -f "${REPO}"/updated.* || return 1
658 "${TOUCH}" "${UPDATE_FILE}" || return 1
659 fetch "${REPO}" || return 1
660 patch "${REPO}" || return 1
661 link "${REPO}" || return 1
662 echo "${REPO} dotfiles updated"
671 echo 'Dotfiles management script.'
672 if [ "${1}" = '--one-line' ]; then return; fi
676 usage: $0 [OPTIONS] COMMAND [ARGS]
679 --help Print this help message and exit.
680 --version Print the $0 version and exit.
681 --dotfiles-dir DIR Directory containing the dotfiles reposotories. Defaults to '.'.
682 --target DIR Directory to install dotfiles into. Defaults to '~'.
686 for COMMAND in "${COMMANDS[@]}"; do
687 echo -en "${COMMAND}\t"
688 "${COMMAND}_help" --one-line
692 To get help on any command, pass the '--help' as the first option
693 to the command. For example:
695 ${0} ${COMMANDS[0]} --help
702 while [ "${1::2}" = '--' ]; do
705 main_help || return 1
721 echo "ERROR: invalid option to ${0} (${1})" >&2
726 COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
729 cd "${DOTFILES_DIR}" || return 1
731 if [ "${1}" = '--help' ]; then
732 "${COMMAND}_help" || return 1
733 elif [ "${COMMAND}" = 'clone' ]; then
734 "${COMMAND}" "${@}" || return 1
737 while [ "${1::2}" = '--' ]; do
741 if [ "${#}" -eq 0 ]; then
742 run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
744 maxargs "${0}" 1 "${@}" || return 1
745 "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
750 main "${@}" || exit 1