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}'s set_repo_source requires Bash version >= 4.0" >&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}'s get_repo_source() requires Bash version >= 4.0" >&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 if [ "${BASH_MAJOR}" -ge 4 ]; then
332 get_repo_source "${REPO}" || return 1
333 TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
335 echo "WARNING: Bash version < 4.0, assuming all repos use git transfer" >&2
338 if [ "${TRANSFER}" = 'git' ]; then
339 (cd "${REPO}" && "${GIT}" pull) || return 1
340 elif [ "${TRANSFER}" = 'wget' ]; then
341 wget_fetch "${REPO}" || return 1
343 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
355 echo 'Show differences between targets and dotfiles repositories.'
356 if [ "${1}" = '--one-line' ]; then return; fi
360 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
362 Where 'REPO' is the name the dotfiles repository to query. If it
363 is not given, all repositories will be queried.
365 By default, ${COMMAND} will list differences between files that
366 exist in both the target location and the dotfiles repository (as
367 a patch that could be applied to the dotfiles source).
369 With the '--removed' option, ${COMMAND} will list files that
370 should be removed from the dotfiles source in order to match the
373 With the '--local-patch' option, ${COMMAND} will create files in
374 list files that should be removed from the dotfiles source in
375 order to match the target.
382 while [ "${1::2}" = '--' ]; do
391 echo "ERROR: invalid option to diff (${1})" >&2
396 # multi-repo case handled in main() by run_on_all_repos()
397 REPO=$(nonempty_option 'diff' 'REPO' "${1}") || return 1
398 maxargs 'diff' 1 "${@}" || return 1
400 if [ "${MODE}" = 'local-patch' ]; then
401 mkdir -p "${REPO}/local-patch" || return 1
403 exec 3<&1 # save stdout to file descriptor 3
404 echo "save local patches to ${REPO}/local-patch/000-local.patch"
405 exec 1>"${REPO}/local-patch/000-local.patch" # redirect stdout
407 exec 1<&3 # restore old stdout
408 exec 3<&- # close temporary fd 3
410 exec 3<&1 # save stdout to file descriptor 3
411 echo "save local removed to ${REPO}/local-patch/000-local.remove"
412 exec 1>"${REPO}/local-patch/000-local.remove" # redirect stdout
413 diff --removed "${REPO}"
414 exec 1<&3 # restore old stdout
415 exec 3<&- # close temporary fd 3
420 if [ "${MODE}" = 'removed' ]; then
421 if [ ! -e "${TARGET}/${FILE}" ]; then
425 if [ -f "${TARGET}/${FILE}" ]; then
426 (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
430 $(list_files "${REPO}/src")
439 function patch_help()
441 echo 'Patch a fresh checkout with local adjustments.'
442 if [ "${1}" = '--one-line' ]; then return; fi
446 usage: $0 ${COMMAND} [REPO]
448 Where 'REPO' is the name the dotfiles repository to patch. If it
449 is not given, all repositories will be patched.
455 # multi-repo case handled in main() by run_on_all_repos()
456 REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
457 maxargs 'patch' 1 "${@}" || return 1
459 echo "copy clean checkout into ${REPO}/patched-src"
460 "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
462 # apply all the patches in local-patch/
463 for FILE in "${REPO}/local-patch"/*.patch; do
464 if [ -f "${FILE}" ]; then
466 pushd "${REPO}/patched-src/" > /dev/null || return 1
467 "${PATCH}" -p0 < "../../${FILE}" || return 1
468 popd > /dev/null || return 1
472 # remove any files marked for removal in local-patch
473 for REMOVE in "${REPO}/local-patch"/*.remove; do
474 if [ -f "${REMOVE}" ]; then
477 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
478 continue # ignore blank lines and comments
480 if [ -e "${REPO}/patched-src/${LINE}" ]; then
481 echo "remove ${LINE}"
482 "${RM}" -rf "${REPO}/patched-src/${LINE}"
496 echo 'Link a fresh checkout with local adjustments.'
497 if [ "${1}" = '--one-line' ]; then return; fi
501 usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
503 Where 'REPO' is the name the dotfiles repository to link. If it
504 is not given, all repositories will be linked.
506 By default, link.sh only replaces missing files and simlinks. You
507 can optionally overwrite any local files by passing the --force
514 FORCE='no' # If 'file', overwrite existing files.
515 # If 'yes', overwrite existing files and dirs.
516 DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
518 while [ "${1::2}" = '--' ]; do
533 echo "ERROR: invalid option to link (${1})" >&2
538 # multi-repo case handled in main() by run_on_all_repos()
539 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
540 maxargs 'link' 1 "${@}" || return 1
541 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
544 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
545 continue # already simlinked
547 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
548 [ "${FORCE}" != 'yes' ]; then
549 echo "use --force to override the existing directory: ${TARGET}/${FILE}"
550 continue # allow unlinked directories
552 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
553 echo "use --force to override the existing target: ${TARGET}/${FILE}"
554 continue # target already exists
556 link_file "${REPO}" "${FILE}" || return 1
558 $(list_files "${DOTFILES_SRC}")
565 COMMANDS+=('disconnect')
567 function disconnect_help()
569 echo 'Freeze dotfiles at their current state.'
570 if [ "${1}" = '--one-line' ]; then return; fi
574 usage: $0 ${COMMAND} [REPO]
576 Where 'REPO' is the name the dotfiles repository to disconnect.
577 If it is not given, all repositories will be disconnected.
579 You're about to give your sysadmin account to some newbie, and
580 they'd just be confused by all this efficiency. This script
581 freezes your dotfiles in their current state and makes everthing
582 look normal. Note that this will delete your dotfiles repository
583 and strip the dotfiles portion from your ~/.bashrc file.
587 function disconnect()
589 # multi-repo case handled in main() by run_on_all_repos()
590 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
591 maxargs 'disconnect' 1 "${@}" || return 1
592 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
594 # See if we've constructed any patched source files that might be
595 # possible link targets
596 if [ ! -d "${DOTFILES_SRC}" ]; then
597 echo 'no installed dotfiles to disconnect'
601 # See if the bashrc file is involved with dotfiles at all
605 if [ "${FILE}" = '.bashrc' ] && [ "$TARGET" -ef "${HOME}" ]; then
608 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
610 echo "de-symlink ${TARGET}/${FILE}"
611 "${RM}" -f "${TARGET}/${FILE}"
612 "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
615 $(list_files "${REPO}/patched-src")
618 if [ "${BASHRC}" == 'yes' ]; then
619 echo 'strip dotfiles section from ~/.bashrc'
620 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
622 # see if the stripped file is any different
623 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
625 if [ "${DIFF_RC}" -eq 0 ]; then
626 echo "no dotfiles section found in ~/.bashrc"
627 "${RM}" -f bashrc_stripped
628 elif [ "${DIFF_RC}" -eq 1 ]; then
629 echo "replace ~/.bashrc with stripped version"
631 "${MV}" bashrc_stripped ~/.bashrc
633 return 1 # diff failed, bail
637 if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
638 echo "remove the ${REPO} repository"
639 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
648 function update_help()
650 echo 'Utility command that runs fetch, patch, and link.'
651 if [ "${1}" = '--one-line' ]; then return; fi
655 usage: $0 ${COMMAND} [REPO]
657 Where 'REPO' is the name the dotfiles repository to update.
658 If it is not given, all repositories will be updateed.
660 Run 'fetch', 'patch', and 'link' sequentially on each repository
661 to bring them in sync with the central repositories. Keeps track
662 of the last update time to avoid multiple fetches in the same
669 # multi-repo case handled in main() by run_on_all_repos()
670 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
671 maxargs 'disconnect' 1 "${@}" || return 1
673 # Update once a week from our remote repository. Mark updates by
674 # touching this file.
675 UPDATE_FILE="${REPO}/updated.$(date +%U)"
677 if [ ! -e "${UPDATE_FILE}" ]; then
678 echo "update ${REPO} dotfiles"
679 "${RM}" -f "${REPO}"/updated.* || return 1
680 "${TOUCH}" "${UPDATE_FILE}" || return 1
681 fetch "${REPO}" || return 1
682 patch "${REPO}" || return 1
683 link "${REPO}" || return 1
684 echo "${REPO} dotfiles updated"
693 echo 'Dotfiles management script.'
694 if [ "${1}" = '--one-line' ]; then return; fi
698 usage: $0 [OPTIONS] COMMAND [ARGS]
701 --help Print this help message and exit.
702 --version Print the $0 version and exit.
703 --dotfiles-dir DIR Directory containing the dotfiles reposotories. Defaults to '.'.
704 --target DIR Directory to install dotfiles into. Defaults to '~'.
708 for COMMAND in "${COMMANDS[@]}"; do
709 echo -en "${COMMAND}\t"
710 "${COMMAND}_help" --one-line
714 To get help on any command, pass the '--help' as the first option
715 to the command. For example:
717 ${0} ${COMMANDS[0]} --help
724 while [ "${1::2}" = '--' ]; do
727 main_help || return 1
743 echo "ERROR: invalid option to ${0} (${1})" >&2
748 COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
751 cd "${DOTFILES_DIR}" || return 1
753 if [ "${1}" = '--help' ]; then
754 "${COMMAND}_help" || return 1
755 elif [ "${COMMAND}" = 'clone' ]; then
756 "${COMMAND}" "${@}" || return 1
759 while [ "${1::2}" = '--' ]; do
763 if [ "${#}" -eq 0 ]; then
764 run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
766 maxargs "${0}" 1 "${@}" || return 1
767 "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
772 main "${@}" || exit 1