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
97 elif [ -f "${REPO}" ]; then
98 continue # repositories are directories
100 "${COMMAND}" "${@}" "${REPO}" || return 1
106 function list_files()
108 DIR=$(nonempty_option 'list_files' 'DIR' "${1}") || return 1
110 if [ "${FILE}" = '.' ]; then
113 FILE="${FILE:2}" # strip the leading './'
115 done < <(cd "${DIR}" && find .)
118 # Global variable to allow passing associative arrats between functions
120 if [ "${BASH_MAJOR}" -ge 4 ]; then
121 declare -A REPO_SOURCE_DATA
124 function set_repo_source()
126 if [ "${BASH_MAJOR}" -lt 4 ]; then
127 echo "ERROR: ${0}'s set_repo_source requires Bash version >= 4.0" >&2
128 echo "you're running ${BASH}, which doesn't support associative arrays" >&2
131 REPO=$(nonempty_option 'set_repo_source' 'REPO' "${1}") || return 1
132 > "${REPO}/source_cache" || return 1
133 for KEY in "${!REPO_SOURCE_DATA[@]}"; do
134 echo "${KEY}=${REPO_SOURCE_DATA[${KEY}]}" >> "${REPO}/source_cache" || return 1
138 # usage: get_repo_source REPO
139 function get_repo_source()
141 if [ "${BASH_MAJOR}" -lt 4 ]; then
142 echo "ERROR: ${0}'s get_repo_source() requires Bash version >= 4.0" >&2
143 echo "you're running ${BASH}, which doesn't support associative arrays" >&2
146 REPO=$(nonempty_option 'get_repo_source' 'REPO' "${1}") || return 1
148 if [ -f "${REPO}/source_cache" ]; then
152 REPO_SOURCE_DATA["${KEY}"]="${VALUE}"
153 done < "${REPO}/source_cache"
155 # autodetect verson control system
157 REPO_SOURCE_DATA['repo']="${REPO}"
158 if [ -d "${REPO}/.git" ]; then
159 REPO_SOURCE_DATA['transfer']='git'
161 echo "ERROR: no source location found for ${REPO}" >&2
164 # no need to get further fields for these transfer mechanisms
170 REPO=$(nonempty_option 'git_fetch' 'REPO' "${1}") || return 1
171 REMOTES=$(cd "${REPO}" && "${GIT}" remote) || return 1
172 if [ -n "${REMOTES}" ]; then
173 (cd "${REPO}" && "${GIT}" pull) || return 1
175 echo "no remote repositories found for ${REPO}"
179 function wget_fetch()
181 REPO=$(nonempty_option 'wget_fetch' 'REPO' "${1}") || return 1
182 # get_repo_source() was just called on this repo in fetch()
183 TRANSFER=$(nonempty_option 'wget_fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
184 URL=$(nonempty_option 'wget_fetch' 'URL' "${REPO_SOURCE_DATA['url']}") || return 1
185 ETAG="${REPO_SOURCE_DATA['etag']}"
187 HEAD=$("${WGET}" --server-response --spider "${URL}" 2>&1) || return 1
188 SERVER_ETAG=$(echo "${HEAD}" | "${SED}" -n 's/^ *etag: *"\(.*\)"/\1/ip') || return 1
189 if [ "${CHECK_WGET_TYPE_AND_ENCODING}" = 'yes' ]; then
190 TYPE=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-type: *//ip') || return 1
191 ENCODING=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-encoding: *//ip') || return 1
192 if [ "${TYPE}" != 'application/x-gzip' ] || [ "${ENCODING}" != 'x-gzip' ]; then
193 echo "ERROR: invalid content type (${TYPE}) or encoding (${ENCODING})." >&2
194 echo "while fetching ${URL}" >&2
198 if [ -z "${ETAG}" ] || [ "${SERVER_ETAG}" != "${ETAG}" ]; then
199 # Previous ETag not known, or ETag changed. Download new copy.
200 "${WGET}" --output-document "${BUNDLE}" "${URL}" || return 1
201 if [ -n "${SERVER_ETAG}" ]; then # store new ETag
202 REPO_SOURCE_DATA['etag']="${SERVER_ETAG}"
203 set_repo_source "${REPO}" || return 1
204 elif [ -n "${ETAG}" ]; then # clear old ETag
205 unset "${REPO_SOURCE_DATA['etag']}"
206 set_repo_source "${REPO}" || return 1
208 echo "extracting ${BUNDLE} to ${REPO}"
209 "${TAR}" -xf "${BUNDLE}" -C "${REPO}" --strip-components 1 --overwrite || return 1
210 "${RM}" -f "${BUNDLE}" || return 1
212 echo "already downloaded the ETag=${ETAG} version of ${URL}"
217 # usage: link_file REPO FILE
219 # Create the symbolic link to the version of FILE in the REPO
220 # repository, overriding the target if it exists.
223 REPO=$(nonempty_option 'link_file' 'REPO' "${1}") || return 1
224 FILE=$(nonempty_option 'link_file' 'FILE' "${2}") || return 1
225 if [ "${BACKUP}" = 'yes' ]; then
226 if [ -e "${TARGET}/${FILE}" ] || [ -h "${TARGET}/${FILE}" ]; then
227 if [ "${DRY_RUN}" = 'yes' ]; then
228 echo "move ${TARGET}/${FILE} to ${TARGET}/${FILE}.bak"
231 mv -v "${TARGET}/${FILE}" "${TARGET}/${FILE}.bak" || return 1
235 if [ "${DRY_RUN}" = 'yes' ]; then
236 echo "rm ${TARGET}/${FILE}"
238 "${RM}" -fv "${TARGET}/${FILE}"
241 if [ "${DRY_RUN}" = 'yes' ]; then
242 echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
245 "${LN}" -sv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
252 # An array of available commands
260 CLONE_TRANSFERS=('git' 'wget')
262 function clone_help()
264 echo 'Create a new dotfiles repository.'
265 if [ "${1}" = '--one-line' ]; then return; fi
269 usage: $0 ${COMMAND} REPO TRANSFER URL
271 Where 'REPO' is the name the dotfiles repository to create,
272 'TRANSFER' is the transfer mechanism, and 'URL' is the URL for the
273 remote repository. Valid TRANSFERs are:
275 ${CLONE_TRANSFERS[@]}
279 $0 clone public wget http://example.com/public-dotfiles.tar.gz
280 $0 clone private git ssh://example.com/~/private-dotfiles.git
286 REPO=$(nonempty_option 'clone' 'REPO' "${1}") || return 1
287 TRANSFER=$(nonempty_option 'clone' 'TRANSFER' "${2}") || return 1
288 URL=$(nonempty_option 'clone' 'URL' "${3}") || return 1
289 maxargs 'clone' 3 "${@}" || return 1
290 TRANSFER=$(get_selection "${TRANSFER}" "${CLONE_TRANSFERS[@]}") || return 1
291 if [ -e "${REPO}" ]; then
292 echo "ERROR: destination path (${REPO}) already exists." >&2
297 case "${TRANSFER}" in
301 "${GIT}" clone "${URL}" "${REPO}" || return 1
307 echo "PROGRAMMING ERROR: add ${TRANSFER} support to clone command" >&2
310 if [ "${CACHE_SOURCE}" = 'yes' ]; then
311 REPO_SOURCE_DATA=(['transfer']="${TRANSFER}" ['url']="${URL}")
312 set_repo_source "${REPO}" || return 1
314 if [ "${FETCH}" = 'yes' ]; then
315 fetch "${REPO}" || return 1
324 function fetch_help()
326 echo 'Get the current dotfiles from the server.'
327 if [ "${1}" = '--one-line' ]; then return; fi
331 usage: $0 ${COMMAND} [REPO]
333 Where 'REPO' is the name the dotfiles repository to fetch. If it
334 is not given, all repositories will be fetched.
340 # multi-repo case handled in main() by run_on_all_repos()
341 REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
342 maxargs 'fetch' 1 "${@}" || return 1
343 if [ "${BASH_MAJOR}" -ge 4 ]; then
344 get_repo_source "${REPO}" || return 1
345 TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
347 echo "WARNING: Bash version < 4.0, assuming all repos use git transfer" >&2
350 if [ "${TRANSFER}" = 'git' ]; then
351 git_fetch "${REPO}" || return 1
352 elif [ "${TRANSFER}" = 'wget' ]; then
353 wget_fetch "${REPO}" || return 1
355 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
367 echo 'Show differences between targets and dotfiles repositories.'
368 if [ "${1}" = '--one-line' ]; then return; fi
372 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
374 Where 'REPO' is the name the dotfiles repository to query. If it
375 is not given, all repositories will be queried.
377 By default, ${COMMAND} will list differences between files that
378 exist in both the target location and the dotfiles repository (as
379 a patch that could be applied to the dotfiles source).
381 With the '--removed' option, ${COMMAND} will list files that
382 should be removed from the dotfiles source in order to match the
385 With the '--local-patch' option, ${COMMAND} will create files in
386 list files that should be removed from the dotfiles source in
387 order to match the target.
394 while [ "${1::2}" = '--' ]; do
403 echo "ERROR: invalid option to diff (${1})" >&2
408 # multi-repo case handled in main() by run_on_all_repos()
409 REPO=$(nonempty_option 'diff' 'REPO' "${1}") || return 1
410 maxargs 'diff' 1 "${@}" || return 1
412 if [ "${MODE}" = 'local-patch' ]; then
413 mkdir -p "${REPO}/local-patch" || return 1
415 exec 3<&1 # save stdout to file descriptor 3
416 echo "save local patches to ${REPO}/local-patch/000-local.patch"
417 exec 1>"${REPO}/local-patch/000-local.patch" # redirect stdout
419 exec 1<&3 # restore old stdout
420 exec 3<&- # close temporary fd 3
422 exec 3<&1 # save stdout to file descriptor 3
423 echo "save local removed to ${REPO}/local-patch/000-local.remove"
424 exec 1>"${REPO}/local-patch/000-local.remove" # redirect stdout
425 diff --removed "${REPO}"
426 exec 1<&3 # restore old stdout
427 exec 3<&- # close temporary fd 3
432 if [ "${MODE}" = 'removed' ]; then
433 if [ ! -e "${TARGET}/${FILE}" ]; then
436 elif [ -f "${TARGET}/${FILE}" ]; then
437 (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
440 $(list_files "${REPO}/src")
449 function patch_help()
451 echo 'Patch a fresh checkout with local adjustments.'
452 if [ "${1}" = '--one-line' ]; then return; fi
456 usage: $0 ${COMMAND} [REPO]
458 Where 'REPO' is the name the dotfiles repository to patch. If it
459 is not given, all repositories will be patched.
465 # multi-repo case handled in main() by run_on_all_repos()
466 REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
467 maxargs 'patch' 1 "${@}" || return 1
469 echo "copy clean checkout into ${REPO}/patched-src"
470 "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
472 # apply all the patches in local-patch/
473 for FILE in "${REPO}/local-patch"/*.patch; do
474 if [ -f "${FILE}" ]; then
476 pushd "${REPO}/patched-src/" > /dev/null || return 1
477 "${PATCH}" -p1 < "../../${FILE}" || return 1
478 popd > /dev/null || return 1
482 # remove any files marked for removal in local-patch
483 for REMOVE in "${REPO}/local-patch"/*.remove; do
484 if [ -f "${REMOVE}" ]; then
487 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
488 continue # ignore blank lines and comments
490 if [ -e "${REPO}/patched-src/${LINE}" ]; then
491 echo "remove ${LINE}"
492 "${RM}" -rf "${REPO}/patched-src/${LINE}"
506 echo 'Link a fresh checkout with local adjustments.'
507 if [ "${1}" = '--one-line' ]; then return; fi
511 usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
513 Where 'REPO' is the name the dotfiles repository to link. If it
514 is not given, all repositories will be linked.
516 By default, link.sh only replaces missing files and simlinks. You
517 can optionally overwrite any local files by passing the --force
524 FORCE='no' # If 'file', overwrite existing files.
525 # If 'yes', overwrite existing files and dirs.
526 DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
528 while [ "${1::2}" = '--' ]; do
543 echo "ERROR: invalid option to link (${1})" >&2
548 # multi-repo case handled in main() by run_on_all_repos()
549 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
550 maxargs 'link' 1 "${@}" || return 1
551 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
554 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
555 continue # already simlinked
557 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
558 [ "${FORCE}" != 'yes' ]; then
559 echo "use --force to override the existing directory: ${TARGET}/${FILE}"
560 continue # allow unlinked directories
562 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
563 echo "use --force to override the existing target: ${TARGET}/${FILE}"
564 continue # target already exists
566 link_file "${REPO}" "${FILE}" || return 1
568 $(list_files "${DOTFILES_SRC}")
575 COMMANDS+=('disconnect')
577 function disconnect_help()
579 echo 'Freeze dotfiles at their current state.'
580 if [ "${1}" = '--one-line' ]; then return; fi
584 usage: $0 ${COMMAND} [REPO]
586 Where 'REPO' is the name the dotfiles repository to disconnect.
587 If it is not given, all repositories will be disconnected.
589 You're about to give your sysadmin account to some newbie, and
590 they'd just be confused by all this efficiency. This script
591 freezes your dotfiles in their current state and makes everthing
592 look normal. Note that this will delete your dotfiles repository
593 and strip the dotfiles portion from your ~/.bashrc file.
597 function disconnect()
599 # multi-repo case handled in main() by run_on_all_repos()
600 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
601 maxargs 'disconnect' 1 "${@}" || return 1
602 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
604 # See if we've constructed any patched source files that might be
605 # possible link targets
606 if [ ! -d "${DOTFILES_SRC}" ]; then
607 echo 'no installed dotfiles to disconnect'
611 # See if the bashrc file is involved with dotfiles at all
615 if [ "${FILE}" = '.bashrc' ] && [ "$TARGET" -ef "${HOME}" ]; then
618 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
620 echo "de-symlink ${TARGET}/${FILE}"
621 "${RM}" -f "${TARGET}/${FILE}"
622 "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
625 $(list_files "${REPO}/patched-src")
628 if [ "${BASHRC}" == 'yes' ]; then
629 echo 'strip dotfiles section from ~/.bashrc'
630 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
632 # see if the stripped file is any different
633 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
635 if [ "${DIFF_RC}" -eq 0 ]; then
636 echo "no dotfiles section found in ~/.bashrc"
637 "${RM}" -f bashrc_stripped
638 elif [ "${DIFF_RC}" -eq 1 ]; then
639 echo "replace ~/.bashrc with stripped version"
641 "${MV}" bashrc_stripped ~/.bashrc
643 return 1 # diff failed, bail
647 if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
648 echo "remove the ${REPO} repository"
649 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
658 function update_help()
660 echo 'Utility command that runs fetch, patch, and link.'
661 if [ "${1}" = '--one-line' ]; then return; fi
665 usage: $0 ${COMMAND} [REPO]
667 Where 'REPO' is the name the dotfiles repository to update.
668 If it is not given, all repositories will be updateed.
670 Run 'fetch', 'patch', and 'link' sequentially on each repository
671 to bring them in sync with the central repositories. Keeps track
672 of the last update time to avoid multiple fetches in the same
679 # multi-repo case handled in main() by run_on_all_repos()
680 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
681 maxargs 'disconnect' 1 "${@}" || return 1
683 # Update once a week from our remote repository. Mark updates by
684 # touching this file.
685 UPDATE_FILE="${REPO}/updated.$(date +%U)"
687 if [ ! -e "${UPDATE_FILE}" ]; then
688 echo "update ${REPO} dotfiles"
689 "${RM}" -f "${REPO}"/updated.* || return 1
690 "${TOUCH}" "${UPDATE_FILE}" || return 1
691 fetch "${REPO}" || return 1
692 patch "${REPO}" || return 1
693 link "${REPO}" || return 1
694 echo "${REPO} dotfiles updated"
703 echo 'Dotfiles management script.'
704 if [ "${1}" = '--one-line' ]; then return; fi
708 usage: $0 [OPTIONS] COMMAND [ARGS]
711 --help Print this help message and exit.
712 --version Print the $0 version and exit.
713 --dotfiles-dir DIR Directory containing the dotfiles reposotories. Defaults to '.'.
714 --target DIR Directory to install dotfiles into. Defaults to '~'.
718 for COMMAND in "${COMMANDS[@]}"; do
719 echo -en "${COMMAND}\t"
720 "${COMMAND}_help" --one-line
724 To get help on any command, pass the '--help' as the first option
725 to the command. For example:
727 ${0} ${COMMANDS[0]} --help
734 while [ "${1::2}" = '--' ]; do
737 main_help || return 1
753 echo "ERROR: invalid option to ${0} (${1})" >&2
758 COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
761 cd "${DOTFILES_DIR}" || return 1
763 if [ "${1}" = '--help' ]; then
764 "${COMMAND}_help" || return 1
765 elif [ "${COMMAND}" = 'clone' ]; then
766 "${COMMAND}" "${@}" || return 1
769 while [ "${1::2}" = '--' ]; do
773 if [ "${#}" -eq 0 ]; then
774 run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
776 maxargs "${0}" 1 "${@}" || return 1
777 "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
782 main "${@}" || exit 1