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
202 elif [ -n "${ETAG}" ]; then # clear old ETag
203 unset "${REPO_SOURCE_DATA['etag']}"
204 set_repo_source "${REPO}" || return 1
206 echo "extracting ${BUNDLE} to ${REPO}"
207 "${TAR}" -xf "${BUNDLE}" -C "${REPO}" --strip-components 1 --overwrite || return 1
208 "${RM}" -f "${BUNDLE}" || return 1
210 echo "already downloaded the ETag=${ETAG} version of ${URL}"
215 # usage: link_file REPO FILE
217 # Create the symbolic link to the version of FILE in the REPO
218 # repository, overriding the target if it exists.
221 REPO=$(nonempty_option 'link_file' 'REPO' "${1}") || return 1
222 FILE=$(nonempty_option 'link_file' 'FILE' "${2}") || return 1
223 if [ "${BACKUP}" = 'yes' ]; then
224 if [ -e "${TARGET}/${FILE}" ] || [ -h "${TARGET}/${FILE}" ]; then
225 if [ "${DRY_RUN}" = 'yes' ]; then
226 echo "move ${TARGET}/${FILE} to ${TARGET}/${FILE}.bak"
229 mv -v "${TARGET}/${FILE}" "${TARGET}/${FILE}.bak" || return 1
233 if [ "${DRY_RUN}" = 'yes' ]; then
234 echo "rm ${TARGET}/${FILE}"
236 "${RM}" -fv "${TARGET}/${FILE}"
239 if [ "${DRY_RUN}" = 'yes' ]; then
240 echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
243 "${LN}" -sv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
250 # An array of available commands
258 CLONE_TRANSFERS=('git' 'wget')
260 function clone_help()
262 echo 'Create a new dotfiles repository.'
263 if [ "${1}" = '--one-line' ]; then return; fi
267 usage: $0 ${COMMAND} REPO TRANSFER URL
269 Where 'REPO' is the name the dotfiles repository to create,
270 'TRANSFER' is the transfer mechanism, and 'URL' is the URL for the
271 remote repository. Valid TRANSFERs are:
273 ${CLONE_TRANSFERS[@]}
277 $0 clone public wget http://example.com/public-dotfiles.tar.gz
278 $0 clone private git ssh://example.com/~/private-dotfiles.git
284 REPO=$(nonempty_option 'clone' 'REPO' "${1}") || return 1
285 TRANSFER=$(nonempty_option 'clone' 'TRANSFER' "${2}") || return 1
286 URL=$(nonempty_option 'clone' 'URL' "${3}") || return 1
287 maxargs 'clone' 3 "${@}" || return 1
288 TRANSFER=$(get_selection "${TRANSFER}" "${CLONE_TRANSFERS[@]}") || return 1
289 if [ -e "${REPO}" ]; then
290 echo "ERROR: destination path (${REPO}) already exists." >&2
295 case "${TRANSFER}" in
299 "${GIT}" clone "${URL}" "${REPO}" || return 1
305 echo "PROGRAMMING ERROR: add ${TRANSFER} support to clone command" >&2
308 if [ "${CACHE_SOURCE}" = 'yes' ]; then
309 REPO_SOURCE_DATA=(['transfer']="${TRANSFER}" ['url']="${URL}")
310 set_repo_source "${REPO}" || return 1
312 if [ "${FETCH}" = 'yes' ]; then
313 fetch "${REPO}" || return 1
322 function fetch_help()
324 echo 'Get the current dotfiles from the server.'
325 if [ "${1}" = '--one-line' ]; then return; fi
329 usage: $0 ${COMMAND} [REPO]
331 Where 'REPO' is the name the dotfiles repository to fetch. If it
332 is not given, all repositories will be fetched.
338 # multi-repo case handled in main() by run_on_all_repos()
339 REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
340 maxargs 'fetch' 1 "${@}" || return 1
341 if [ "${BASH_MAJOR}" -ge 4 ]; then
342 get_repo_source "${REPO}" || return 1
343 TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
345 echo "WARNING: Bash version < 4.0, assuming all repos use git transfer" >&2
348 if [ "${TRANSFER}" = 'git' ]; then
349 git_fetch "${REPO}" || return 1
350 elif [ "${TRANSFER}" = 'wget' ]; then
351 wget_fetch "${REPO}" || return 1
353 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
365 echo 'Show differences between targets and dotfiles repositories.'
366 if [ "${1}" = '--one-line' ]; then return; fi
370 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
372 Where 'REPO' is the name the dotfiles repository to query. If it
373 is not given, all repositories will be queried.
375 By default, ${COMMAND} will list differences between files that
376 exist in both the target location and the dotfiles repository (as
377 a patch that could be applied to the dotfiles source).
379 With the '--removed' option, ${COMMAND} will list files that
380 should be removed from the dotfiles source in order to match the
383 With the '--local-patch' option, ${COMMAND} will create files in
384 list files that should be removed from the dotfiles source in
385 order to match the target.
392 while [ "${1::2}" = '--' ]; do
401 echo "ERROR: invalid option to diff (${1})" >&2
406 # multi-repo case handled in main() by run_on_all_repos()
407 REPO=$(nonempty_option 'diff' 'REPO' "${1}") || return 1
408 maxargs 'diff' 1 "${@}" || return 1
410 if [ "${MODE}" = 'local-patch' ]; then
411 mkdir -p "${REPO}/local-patch" || return 1
413 exec 3<&1 # save stdout to file descriptor 3
414 echo "save local patches to ${REPO}/local-patch/000-local.patch"
415 exec 1>"${REPO}/local-patch/000-local.patch" # redirect stdout
417 exec 1<&3 # restore old stdout
418 exec 3<&- # close temporary fd 3
420 exec 3<&1 # save stdout to file descriptor 3
421 echo "save local removed to ${REPO}/local-patch/000-local.remove"
422 exec 1>"${REPO}/local-patch/000-local.remove" # redirect stdout
423 diff --removed "${REPO}"
424 exec 1<&3 # restore old stdout
425 exec 3<&- # close temporary fd 3
430 if [ "${MODE}" = 'removed' ]; then
431 if [ ! -e "${TARGET}/${FILE}" ]; then
434 elif [ -f "${TARGET}/${FILE}" ]; then
435 (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
438 $(list_files "${REPO}/src")
447 function patch_help()
449 echo 'Patch a fresh checkout with local adjustments.'
450 if [ "${1}" = '--one-line' ]; then return; fi
454 usage: $0 ${COMMAND} [REPO]
456 Where 'REPO' is the name the dotfiles repository to patch. If it
457 is not given, all repositories will be patched.
463 # multi-repo case handled in main() by run_on_all_repos()
464 REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
465 maxargs 'patch' 1 "${@}" || return 1
467 echo "copy clean checkout into ${REPO}/patched-src"
468 "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
470 # apply all the patches in local-patch/
471 for FILE in "${REPO}/local-patch"/*.patch; do
472 if [ -f "${FILE}" ]; then
474 pushd "${REPO}/patched-src/" > /dev/null || return 1
475 "${PATCH}" -p1 < "../../${FILE}" || return 1
476 popd > /dev/null || return 1
480 # remove any files marked for removal in local-patch
481 for REMOVE in "${REPO}/local-patch"/*.remove; do
482 if [ -f "${REMOVE}" ]; then
485 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
486 continue # ignore blank lines and comments
488 if [ -e "${REPO}/patched-src/${LINE}" ]; then
489 echo "remove ${LINE}"
490 "${RM}" -rf "${REPO}/patched-src/${LINE}"
504 echo 'Link a fresh checkout with local adjustments.'
505 if [ "${1}" = '--one-line' ]; then return; fi
509 usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
511 Where 'REPO' is the name the dotfiles repository to link. If it
512 is not given, all repositories will be linked.
514 By default, link.sh only replaces missing files and simlinks. You
515 can optionally overwrite any local files by passing the --force
522 FORCE='no' # If 'file', overwrite existing files.
523 # If 'yes', overwrite existing files and dirs.
524 DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
526 while [ "${1::2}" = '--' ]; do
541 echo "ERROR: invalid option to link (${1})" >&2
546 # multi-repo case handled in main() by run_on_all_repos()
547 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
548 maxargs 'link' 1 "${@}" || return 1
549 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
552 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
553 continue # already simlinked
555 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
556 [ "${FORCE}" != 'yes' ]; then
557 echo "use --force to override the existing directory: ${TARGET}/${FILE}"
558 continue # allow unlinked directories
560 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
561 echo "use --force to override the existing target: ${TARGET}/${FILE}"
562 continue # target already exists
564 link_file "${REPO}" "${FILE}" || return 1
566 $(list_files "${DOTFILES_SRC}")
573 COMMANDS+=('disconnect')
575 function disconnect_help()
577 echo 'Freeze dotfiles at their current state.'
578 if [ "${1}" = '--one-line' ]; then return; fi
582 usage: $0 ${COMMAND} [REPO]
584 Where 'REPO' is the name the dotfiles repository to disconnect.
585 If it is not given, all repositories will be disconnected.
587 You're about to give your sysadmin account to some newbie, and
588 they'd just be confused by all this efficiency. This script
589 freezes your dotfiles in their current state and makes everthing
590 look normal. Note that this will delete your dotfiles repository
591 and strip the dotfiles portion from your ~/.bashrc file.
595 function disconnect()
597 # multi-repo case handled in main() by run_on_all_repos()
598 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
599 maxargs 'disconnect' 1 "${@}" || return 1
600 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
602 # See if we've constructed any patched source files that might be
603 # possible link targets
604 if [ ! -d "${DOTFILES_SRC}" ]; then
605 echo 'no installed dotfiles to disconnect'
609 # See if the bashrc file is involved with dotfiles at all
613 if [ "${FILE}" = '.bashrc' ] && [ "$TARGET" -ef "${HOME}" ]; then
616 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
618 echo "de-symlink ${TARGET}/${FILE}"
619 "${RM}" -f "${TARGET}/${FILE}"
620 "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
623 $(list_files "${REPO}/patched-src")
626 if [ "${BASHRC}" == 'yes' ]; then
627 echo 'strip dotfiles section from ~/.bashrc'
628 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
630 # see if the stripped file is any different
631 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
633 if [ "${DIFF_RC}" -eq 0 ]; then
634 echo "no dotfiles section found in ~/.bashrc"
635 "${RM}" -f bashrc_stripped
636 elif [ "${DIFF_RC}" -eq 1 ]; then
637 echo "replace ~/.bashrc with stripped version"
639 "${MV}" bashrc_stripped ~/.bashrc
641 return 1 # diff failed, bail
645 if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
646 echo "remove the ${REPO} repository"
647 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
656 function update_help()
658 echo 'Utility command that runs fetch, patch, and link.'
659 if [ "${1}" = '--one-line' ]; then return; fi
663 usage: $0 ${COMMAND} [REPO]
665 Where 'REPO' is the name the dotfiles repository to update.
666 If it is not given, all repositories will be updateed.
668 Run 'fetch', 'patch', and 'link' sequentially on each repository
669 to bring them in sync with the central repositories. Keeps track
670 of the last update time to avoid multiple fetches in the same
677 # multi-repo case handled in main() by run_on_all_repos()
678 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
679 maxargs 'disconnect' 1 "${@}" || return 1
681 # Update once a week from our remote repository. Mark updates by
682 # touching this file.
683 UPDATE_FILE="${REPO}/updated.$(date +%U)"
685 if [ ! -e "${UPDATE_FILE}" ]; then
686 echo "update ${REPO} dotfiles"
687 "${RM}" -f "${REPO}"/updated.* || return 1
688 "${TOUCH}" "${UPDATE_FILE}" || return 1
689 fetch "${REPO}" || return 1
690 patch "${REPO}" || return 1
691 link "${REPO}" || return 1
692 echo "${REPO} dotfiles updated"
701 echo 'Dotfiles management script.'
702 if [ "${1}" = '--one-line' ]; then return; fi
706 usage: $0 [OPTIONS] COMMAND [ARGS]
709 --help Print this help message and exit.
710 --version Print the $0 version and exit.
711 --dotfiles-dir DIR Directory containing the dotfiles reposotories. Defaults to '.'.
712 --target DIR Directory to install dotfiles into. Defaults to '~'.
716 for COMMAND in "${COMMANDS[@]}"; do
717 echo -en "${COMMAND}\t"
718 "${COMMAND}_help" --one-line
722 To get help on any command, pass the '--help' as the first option
723 to the command. For example:
725 ${0} ${COMMANDS[0]} --help
732 while [ "${1::2}" = '--' ]; do
735 main_help || return 1
751 echo "ERROR: invalid option to ${0} (${1})" >&2
756 COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
759 cd "${DOTFILES_DIR}" || return 1
761 if [ "${1}" = '--help' ]; then
762 "${COMMAND}_help" || return 1
763 elif [ "${COMMAND}" = 'clone' ]; then
764 "${COMMAND}" "${@}" || return 1
767 while [ "${1::2}" = '--' ]; do
771 if [ "${#}" -eq 0 ]; then
772 run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
774 maxargs "${0}" 1 "${@}" || return 1
775 "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
780 main "${@}" || exit 1