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
168 REPO=$(nonempty_option 'git_fetch' 'REPO' "${1}") || return 1
169 REMOTES=$(cd "${REPO}" && "${GIT}" remote) || return 1
170 if [ -n "${REMOTES}" ]; then
171 (cd "${REPO}" && "${GIT}" pull) || return 1
173 echo "no remote repositories found for ${REPO}"
177 function wget_fetch()
179 REPO=$(nonempty_option 'wget_fetch' 'REPO' "${1}") || return 1
180 # get_repo_source() was just called on this repo in fetch()
181 TRANSFER=$(nonempty_option 'wget_fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
182 URL=$(nonempty_option 'wget_fetch' 'URL' "${REPO_SOURCE_DATA['url']}") || return 1
183 ETAG="${REPO_SOURCE_DATA['etag']}"
185 HEAD=$("${WGET}" --server-response --spider "${URL}" 2>&1) || return 1
186 SERVER_ETAG=$(echo "${HEAD}" | "${SED}" -n 's/^ *etag: *"\(.*\)"/\1/ip') || return 1
187 if [ "${CHECK_WGET_TYPE_AND_ENCODING}" = 'yes' ]; then
188 TYPE=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-type: *//ip') || return 1
189 ENCODING=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-encoding: *//ip') || return 1
190 if [ "${TYPE}" != 'application/x-gzip' ] || [ "${ENCODING}" != 'x-gzip' ]; then
191 echo "ERROR: invalid content type (${TYPE}) or encoding (${ENCODING})." >&2
192 echo "while fetching ${URL}" >&2
196 if [ -z "${ETAG}" ] || [ "${SERVER_ETAG}" != "${ETAG}" ]; then
197 # Previous ETag not known, or ETag changed. Download new copy.
198 "${WGET}" --output-document "${BUNDLE}" "${URL}" || return 1
199 if [ -n "${SERVER_ETAG}" ]; then # store new ETag
200 REPO_SOURCE_DATA['etag']="${SERVER_ETAG}"
201 set_repo_source "${REPO}" || return 1
203 if [ -n "${ETAG}" ]; then # clear old ETag
204 unset "${REPO_SOURCE_DATA['etag']}"
205 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
437 if [ -f "${TARGET}/${FILE}" ]; then
438 (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
442 $(list_files "${REPO}/src")
451 function patch_help()
453 echo 'Patch a fresh checkout with local adjustments.'
454 if [ "${1}" = '--one-line' ]; then return; fi
458 usage: $0 ${COMMAND} [REPO]
460 Where 'REPO' is the name the dotfiles repository to patch. If it
461 is not given, all repositories will be patched.
467 # multi-repo case handled in main() by run_on_all_repos()
468 REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
469 maxargs 'patch' 1 "${@}" || return 1
471 echo "copy clean checkout into ${REPO}/patched-src"
472 "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
474 # apply all the patches in local-patch/
475 for FILE in "${REPO}/local-patch"/*.patch; do
476 if [ -f "${FILE}" ]; then
478 pushd "${REPO}/patched-src/" > /dev/null || return 1
479 "${PATCH}" -p0 < "../../${FILE}" || return 1
480 popd > /dev/null || return 1
484 # remove any files marked for removal in local-patch
485 for REMOVE in "${REPO}/local-patch"/*.remove; do
486 if [ -f "${REMOVE}" ]; then
489 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
490 continue # ignore blank lines and comments
492 if [ -e "${REPO}/patched-src/${LINE}" ]; then
493 echo "remove ${LINE}"
494 "${RM}" -rf "${REPO}/patched-src/${LINE}"
508 echo 'Link a fresh checkout with local adjustments.'
509 if [ "${1}" = '--one-line' ]; then return; fi
513 usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
515 Where 'REPO' is the name the dotfiles repository to link. If it
516 is not given, all repositories will be linked.
518 By default, link.sh only replaces missing files and simlinks. You
519 can optionally overwrite any local files by passing the --force
526 FORCE='no' # If 'file', overwrite existing files.
527 # If 'yes', overwrite existing files and dirs.
528 DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
530 while [ "${1::2}" = '--' ]; do
545 echo "ERROR: invalid option to link (${1})" >&2
550 # multi-repo case handled in main() by run_on_all_repos()
551 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
552 maxargs 'link' 1 "${@}" || return 1
553 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
556 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
557 continue # already simlinked
559 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
560 [ "${FORCE}" != 'yes' ]; then
561 echo "use --force to override the existing directory: ${TARGET}/${FILE}"
562 continue # allow unlinked directories
564 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
565 echo "use --force to override the existing target: ${TARGET}/${FILE}"
566 continue # target already exists
568 link_file "${REPO}" "${FILE}" || return 1
570 $(list_files "${DOTFILES_SRC}")
577 COMMANDS+=('disconnect')
579 function disconnect_help()
581 echo 'Freeze dotfiles at their current state.'
582 if [ "${1}" = '--one-line' ]; then return; fi
586 usage: $0 ${COMMAND} [REPO]
588 Where 'REPO' is the name the dotfiles repository to disconnect.
589 If it is not given, all repositories will be disconnected.
591 You're about to give your sysadmin account to some newbie, and
592 they'd just be confused by all this efficiency. This script
593 freezes your dotfiles in their current state and makes everthing
594 look normal. Note that this will delete your dotfiles repository
595 and strip the dotfiles portion from your ~/.bashrc file.
599 function disconnect()
601 # multi-repo case handled in main() by run_on_all_repos()
602 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
603 maxargs 'disconnect' 1 "${@}" || return 1
604 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
606 # See if we've constructed any patched source files that might be
607 # possible link targets
608 if [ ! -d "${DOTFILES_SRC}" ]; then
609 echo 'no installed dotfiles to disconnect'
613 # See if the bashrc file is involved with dotfiles at all
617 if [ "${FILE}" = '.bashrc' ] && [ "$TARGET" -ef "${HOME}" ]; then
620 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
622 echo "de-symlink ${TARGET}/${FILE}"
623 "${RM}" -f "${TARGET}/${FILE}"
624 "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
627 $(list_files "${REPO}/patched-src")
630 if [ "${BASHRC}" == 'yes' ]; then
631 echo 'strip dotfiles section from ~/.bashrc'
632 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
634 # see if the stripped file is any different
635 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
637 if [ "${DIFF_RC}" -eq 0 ]; then
638 echo "no dotfiles section found in ~/.bashrc"
639 "${RM}" -f bashrc_stripped
640 elif [ "${DIFF_RC}" -eq 1 ]; then
641 echo "replace ~/.bashrc with stripped version"
643 "${MV}" bashrc_stripped ~/.bashrc
645 return 1 # diff failed, bail
649 if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
650 echo "remove the ${REPO} repository"
651 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
660 function update_help()
662 echo 'Utility command that runs fetch, patch, and link.'
663 if [ "${1}" = '--one-line' ]; then return; fi
667 usage: $0 ${COMMAND} [REPO]
669 Where 'REPO' is the name the dotfiles repository to update.
670 If it is not given, all repositories will be updateed.
672 Run 'fetch', 'patch', and 'link' sequentially on each repository
673 to bring them in sync with the central repositories. Keeps track
674 of the last update time to avoid multiple fetches in the same
681 # multi-repo case handled in main() by run_on_all_repos()
682 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
683 maxargs 'disconnect' 1 "${@}" || return 1
685 # Update once a week from our remote repository. Mark updates by
686 # touching this file.
687 UPDATE_FILE="${REPO}/updated.$(date +%U)"
689 if [ ! -e "${UPDATE_FILE}" ]; then
690 echo "update ${REPO} dotfiles"
691 "${RM}" -f "${REPO}"/updated.* || return 1
692 "${TOUCH}" "${UPDATE_FILE}" || return 1
693 fetch "${REPO}" || return 1
694 patch "${REPO}" || return 1
695 link "${REPO}" || return 1
696 echo "${REPO} dotfiles updated"
705 echo 'Dotfiles management script.'
706 if [ "${1}" = '--one-line' ]; then return; fi
710 usage: $0 [OPTIONS] COMMAND [ARGS]
713 --help Print this help message and exit.
714 --version Print the $0 version and exit.
715 --dotfiles-dir DIR Directory containing the dotfiles reposotories. Defaults to '.'.
716 --target DIR Directory to install dotfiles into. Defaults to '~'.
720 for COMMAND in "${COMMANDS[@]}"; do
721 echo -en "${COMMAND}\t"
722 "${COMMAND}_help" --one-line
726 To get help on any command, pass the '--help' as the first option
727 to the command. For example:
729 ${0} ${COMMANDS[0]} --help
736 while [ "${1::2}" = '--' ]; do
739 main_help || return 1
755 echo "ERROR: invalid option to ${0} (${1})" >&2
760 COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
763 cd "${DOTFILES_DIR}" || return 1
765 if [ "${1}" = '--help' ]; then
766 "${COMMAND}_help" || return 1
767 elif [ "${COMMAND}" = 'clone' ]; then
768 "${COMMAND}" "${@}" || return 1
771 while [ "${1::2}" = '--' ]; do
775 if [ "${#}" -eq 0 ]; then
776 run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
778 maxargs "${0}" 1 "${@}" || return 1
779 "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
784 main "${@}" || exit 1